Java 21 Virtual Thread 마스터 노트 시리즈 3편. Virtual Thread 생성 4가지 방법(직접·startVirtualThread·Builder·ExecutorService), Thread.Builder API의 세밀한 제어, ThreadFactory로 스타일 통일, Executors.newVirtualThreadPerTaskExecutor의 try-with-resources 패턴, Pool vs Per-Task의 결정적 차이까지.
이 글은 Java 21 Virtual Thread 마스터 노트 시리즈의 세 번째 편입니다. 2편(내부)에서 Carrier·Mount를 봤다면, 이번엔 사용자 API — Virtual Thread 만드는 4가지 방법.
직접 생성·startVirtualThread·Builder·ExecutorService. 어느 게 어디? 일반 = ExecutorService, 세밀 제어 = Builder.
처음 API가 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, 생성 방법 4가지가 한 번에 등장합니다. 둘째, Pool vs Per-Task 차이가 막연합니다.
해결법은 한 가지예요. "일반 = ExecutorService, 단순 = startVirtualThread, 세밀 제어 = Builder" 한 줄. 이 매핑만 잡으면 끝.
1. Thread.startVirtualThread() — 가장 단순
Thread vt = Thread.startVirtualThread(() -> {
System.out.println("Hello from " + Thread.currentThread());
});
vt.join();
한 줄로 시작. 데모·간단 작업에 적합.
2. Thread.ofVirtual() — Builder 패턴
Thread vt = Thread.ofVirtual()
.name("worker-1")
.uncaughtExceptionHandler((t, e) -> log.error("Failed", e))
.start(() -> {
// 작업
});
vt.join();
세밀 제어. 이름·예외 핸들러 등.
unstarted
Thread vt = Thread.ofVirtual()
.name("worker-1")
.unstarted(() -> {});
// 나중에 시작
vt.start();
생성과 시작 분리.
name with counter
Thread.Builder.OfVirtual builder = Thread.ofVirtual().name("worker-", 0);
Thread vt1 = builder.unstarted(() -> {}); // worker-0
Thread vt2 = builder.unstarted(() -> {}); // worker-1
Thread vt3 = builder.unstarted(() -> {}); // worker-2
자동 카운터.
3. ThreadFactory
ThreadFactory factory = Thread.ofVirtual().factory();
ExecutorService executor = Executors.newThreadPerTaskExecutor(factory);
// 같은 효과
ExecutorService executor2 = Executors.newVirtualThreadPerTaskExecutor();
Factory로 명시 제어. ExecutorService가 자동 사용.
4. ExecutorService — 가장 일반적
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10000; i++) {
executor.submit(() -> {
String data = httpClient.get("/api");
return process(data);
});
}
} // try-with-resources가 모든 작업 완료 대기
가장 권장. try-with-resources 패턴.
여기서 정말 중요한 시험 함정 — newVirtualThreadPerTaskExecutor는 풀 X. 각 작업마다 새 Virtual Thread. 풀링 비효율 — 만들고 버리는 게 더 쌈.
try-with-resources의 의미
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task1);
executor.submit(task2);
}
// close() 자동 호출
// 모든 작업 완료까지 대기
// 그 후 다음 코드 실행
ExecutorService (Java 19+)는 AutoCloseable 구현. close = shutdown() + awaitTermination.
Future·CompletableFuture
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> f1 = executor.submit(() -> fetchData());
Future<String> f2 = executor.submit(() -> fetchOther());
String r1 = f1.get(); // 블로킹 — 그러나 Virtual Thread면 unmount
String r2 = f2.get();
}
기존 Future 패턴 그대로. Virtual Thread가 효율 처리.
CompletableFuture와 결합
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<String> f1 = CompletableFuture.supplyAsync(
() -> fetchData(), executor
);
CompletableFuture<String> f2 = CompletableFuture.supplyAsync(
() -> fetchOther(), executor
);
String combined = f1.thenCombine(f2, (a, b) -> a + b).join();
}
CompletableFuture에 executor 명시. Virtual Thread가 백엔드.
여기서 시험 함정이 하나 있어요. CompletableFuture 기본 = ForkJoinPool.commonPool(). Virtual Thread 명시 안 하면 일반 풀. 명시 권장.
동일 작업 — 4 방법 비교
// 1. startVirtualThread
Thread.startVirtualThread(this::doWork);
// 2. Builder
Thread.ofVirtual().start(this::doWork);
// 3. ThreadFactory
ThreadFactory f = Thread.ofVirtual().factory();
f.newThread(this::doWork).start();
// 4. ExecutorService
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
ex.submit(this::doWork);
}
| 방법 | 사용처 |
|---|---|
| 1. startVirtualThread | 데모·단순 |
| 2. Builder | 이름·핸들러 등 세밀 제어 |
| 3. ThreadFactory | ExecutorService 통합 |
| 4. ExecutorService | 운영 표준 |
ScheduledExecutorService
// Virtual Thread + 스케줄?
// Java 21에선 직접 지원 X
// Workaround:
ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor();
ExecutorService vtExecutor = Executors.newVirtualThreadPerTaskExecutor();
scheduler.scheduleAtFixedRate(() -> {
vtExecutor.submit(() -> {
// 실제 작업 — Virtual Thread에서
});
}, 0, 1, TimeUnit.SECONDS);
스케줄러는 Platform Thread, 작업은 Virtual Thread.
Pool vs Per-Task
// Per-Task (Virtual Thread 표준)
Executors.newVirtualThreadPerTaskExecutor();
// 각 작업마다 새 VT, 끝나면 GC
// Pool (Platform Thread만)
Executors.newFixedThreadPool(200);
// 200 스레드 재사용
여기서 정말 중요한 시험 함정 — Virtual Thread를 풀에 두면 의미 X. 풀의 목적 = 비싼 스레드 재사용. VT는 가벼움 = 풀 불필요. Per-Task가 표준.
ExecutorService 메서드
try (var ex = Executors.newVirtualThreadPerTaskExecutor()) {
// submit — Future 반환
Future<String> f = ex.submit(() -> "result");
// execute — Future X
ex.execute(() -> {});
// invokeAll — 모두 끝까지 대기
List<Future<String>> all = ex.invokeAll(tasks);
// invokeAny — 가장 빠른 하나
String first = ex.invokeAny(tasks);
}
Java 21+ 표준 메서드. 모두 Virtual Thread 위에서.
Daemon — Virtual Thread 항상 true
Thread vt = Thread.ofVirtual().start(() -> {});
vt.isDaemon(); // true (강제)
// non-daemon 시도 X
// Thread.ofVirtual().daemon(false); // 컴파일 에러
Virtual Thread는 daemon만. main 종료 시 즉시 종료.
Thread Group
ThreadGroup group = Thread.currentThread().getThreadGroup();
// VirtualThreads (Virtual Thread 그룹)
모든 Virtual Thread는 VirtualThreads 그룹. 사용자 정의 그룹 X.
Priority — 무시
Thread.ofVirtual()
.priority(Thread.MAX_PRIORITY) // 무시됨
.start(() -> {});
Virtual Thread는 priority 의미 X. 모든 VT 동등.
Interrupt
Thread vt = Thread.ofVirtual().start(() -> {
while (!Thread.currentThread().isInterrupted()) {
// 작업
}
});
vt.interrupt(); // 정상 동작
Platform Thread와 같음.
호환성 — 기존 Thread API
Thread vt = Thread.ofVirtual().start(task);
// 기존 Thread API 그대로
vt.join();
vt.interrupt();
vt.getName();
vt.getId();
vt.getState();
여기서 시험 함정이 하나 있어요. Virtual Thread = Thread 클래스 그대로. 별도 클래스 X. 기존 코드 호환.
ScopedValue (Preview)
final static ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "alice").run(() -> {
Thread.startVirtualThread(() -> {
// VT 안에서도 USER 접근
System.out.println(USER.get()); // alice
});
});
ThreadLocal 대안. Virtual Thread 친화. 6편에서 자세히.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 3편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- 생성 4 방법 —
startVirtualThread(단순) /ofVirtual()(Builder) / ThreadFactory /newVirtualThreadPerTaskExecutor(운영) - 운영 표준 = ExecutorService + try-with-resources
- close() = shutdown + awaitTermination
Thread.ofVirtual().name("worker-", 0)— 자동 카운터unstarted(...)— 생성·시작 분리- ThreadFactory 통합 —
newThreadPerTaskExecutor(factory) - Future·CompletableFuture 그대로 사용 OK
- CompletableFuture executor 명시 권장 (기본 = commonPool)
- Virtual Thread를 풀에 X — Per-Task 표준
- ExecutorService — submit·execute·invokeAll·invokeAny
- Scheduled = Platform Thread + VT 작업 결합
- Virtual Thread 항상 daemon (강제)
- non-daemon 시도 X
- 모든 VT는
VirtualThreads그룹 - priority 무시 (모두 동등)
- interrupt 정상 동작
- Thread 클래스 그대로 호환 (별도 클래스 X)
- 기존 join·interrupt·getName·getState OK
ScopedValue= ThreadLocal 대안 (Preview, Java 21+)
시리즈 다른 편
- 1편 — 동시성 기초·Java Thread
- 2편 — Carrier·Mount·Unmount
- 3편 — API·Builder·ExecutorService (현재 글)
- 4편 — Pinning·synchronized·ReentrantLock
- 5편 — Spring Boot 통합
- 6편 — Structured Concurrency
- 7편 — Performance·JFR·메모리
- 8편 — Patterns·실전·안티패턴
공식 문서: Virtual Threads API 에서 더 깊이.
다음 글(4편)에서는 Pinning — Virtual Thread의 가장 큰 함정, synchronized·JNI 한계, ReentrantLock 대안까지 풀어 갑니다.