Java 21 Virtual Thread 마스터 노트 시리즈 2편. Carrier Thread가 Virtual Thread를 운반하는 메커니즘, mount/unmount의 결정적 동작, ForkJoinPool 기반 캐리어 풀, Continuation API의 역할, Virtual Thread 생명주기, 메모리 사용 비교, JVM 내부 동작까지.
이 글은 Java 21 Virtual Thread 마스터 노트 시리즈의 두 번째 편입니다. 1편(기초)에서 Virtual Thread 등장 배경을 봤다면, 이번엔 그 내부 동작 — Carrier·Mount·Unmount.
JVM이 어떻게 수백만 Virtual Thread를 처리하나? Carrier Thread 위에 mount/unmount하며. 이 메커니즘이 곧 Virtual Thread의 본질.
처음 내부 동작이 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, Carrier Thread·Mount·Unmount 단어가 낯섭니다. 둘째, OS 스레드와 어떻게 다른지 막연합니다.
해결법은 한 가지예요. "Virtual Thread = 작업, Carrier Thread = 작업자". 작업이 일하다 잠깐 쉬면(I/O 대기) 작업자는 다른 작업으로. 깨어나면 (어쩌면) 다른 작업자에게. 이 그림이 잡히면 모든 동작이 따라옵니다.
큰 그림
[Application Code]
↓ 코드 실행
[Virtual Thread] (수백만 개 가능)
↓ mount
[Carrier Thread] (CPU 코어 수만큼, ForkJoinPool)
↓ 실제 실행
[OS Kernel Thread]
Virtual Thread는 JVM 내부 객체. 실제 실행은 Carrier Thread (Platform Thread)가.
Mount / Unmount
Mount — 캐리어에 올라타기
Virtual Thread VT-1이 실행 시작
↓
캐리어 스레드 C-1에 mount
↓
C-1이 VT-1의 코드 실행
Unmount — 캐리어에서 내리기
VT-1: I/O 호출 (Thread.sleep·DB·HTTP)
↓ JDK가 자동 unmount
C-1은 다른 Virtual Thread (VT-2) 받음
↓ C-1이 VT-2 실행
VT-1 I/O 완료 (수백 ms 후)
↓
VT-1이 다시 (어쩌면 다른) 캐리어에 mount
여기서 정말 중요한 시험 함정 — mount 후 캐리어가 같다는 보장 X. ForkJoinPool 워크 스틸링. 같은 코드라도 다른 캐리어에서 재개 가능. ThreadLocal 주의.
Carrier Thread Pool
// 기본 = ForkJoinPool, 코어 수만큼
// 시스템 속성으로 변경 가능
-Djdk.virtualThreadScheduler.parallelism=8
-Djdk.virtualThreadScheduler.maxPoolSize=256
특성:
- ForkJoinPool 기반
- 워크 스틸링 (놀고 있는 캐리어가 다른 캐리어 큐에서 작업 훔침)
- CPU 코어 수와 비슷한 규모
Continuation — 핵심 메커니즘
Virtual Thread의 unmount/mount = Continuation API.
Virtual Thread = Continuation + Scheduler
실행 중 I/O 호출:
Continuation.yield() ← 현재 상태 저장
스택·로컬 변수 모두 힙에 저장
재개 시:
Continuation.run() ← 저장된 상태 복원
멈춘 자리부터 계속
여기서 정말 중요한 시험 함정 — Continuation = JVM 내부 API. 사용자가 직접 안 다룸. Virtual Thread가 자동 활용. JEP 444 핵심.
스택 메모리
Platform Thread:
스택 = OS 메모리 ~2MB 고정 할당
Virtual Thread:
스택 = JVM 힙에 동적 할당
Unmount 시 힙에 보관 (~수 KB)
Mount 시 캐리어 스택으로 복원
여기서 정말 중요한 시험 함정 — Virtual Thread 스택 = 힙 안 객체. GC 대상. 메모리 효율 ↑·관리 자동.
메모리 비교
| 종류 | 메모리 | 1GB로 만들 수 |
|---|---|---|
| Platform Thread | ~2MB | ~500개 |
| Virtual Thread (idle) | ~수 KB | ~수십만 개 |
| Virtual Thread (running) | 캐리어 스택 사용 | 캐리어 수만큼만 |
idle = 대부분 시간. running = 잠시. 평균 = 수 KB.
생명주기
NEW
↓ start()
RUNNABLE (mounted on carrier)
↓ I/O 호출
WAITING (unmounted, heap)
↓ I/O 완료
RUNNABLE (mounted again)
↓ run() 끝
TERMINATED
여기서 시험 함정이 하나 있어요. Virtual Thread 상태 = Platform Thread와 같은 enum. Thread.State 사용. BLOCKED·WAITING·TIMED_WAITING 모두 의미.
JDK 자동 통합 — 어떤 호출이 unmount하나
✓ Thread.sleep()
✓ I/O 호출 (java.io·java.nio)
✓ DB JDBC (대부분)
✓ HTTP Client (java.net.http)
✓ LockSupport.park()
✓ Object.wait() (Java 24+)
✓ Reentrant·Stamped Lock
✗ synchronized 블록 안 (4편)
✗ JNI (네이티브 코드)
✗ CPU 집약 코드
대부분 표준 JDK API = 자동 unmount. 코드 변경 X.
ForkJoinPool 워크 스틸링
Carrier 1: queue [VT-1, VT-2, VT-3]
Carrier 2: queue [] (놀고 있음)
↓
Carrier 2가 Carrier 1의 큐에서 VT-3 훔침
↓
Carrier 2: queue [VT-3]
부하 자동 분산. 명시 작업 분배 X.
ThreadLocal 함정
ThreadLocal<String> userTl = new ThreadLocal<>();
userTl.set("alice");
// I/O 호출 → unmount → 다른 캐리어에서 재개
String user = userTl.get(); // 여전히 "alice" (Virtual Thread 단위)
여기서 정말 중요한 시험 함정 — ThreadLocal은 Virtual Thread 단위로 작동. 캐리어 변경되어도 OK. 다만 수백만 Virtual Thread = 수백만 ThreadLocal = 메모리 폭주 위험.
해결 — ScopedValue (Java 21+, Preview):
final static ScopedValue<String> USER = ScopedValue.newInstance();
ScopedValue.where(USER, "alice").run(() -> {
String user = USER.get();
// ...
});
ScopedValue는 명확한 스코프. 자동 정리.
Daemon vs Non-Daemon
Thread vt = Thread.ofVirtual().unstarted(() -> {});
// Virtual Thread는 항상 daemon
vt.isDaemon(); // true
여기서 시험 함정이 하나 있어요. Virtual Thread는 항상 daemon. JVM이 main 종료 후 즉시 종료. non-daemon 설정 시도 = IllegalArgumentException.
// ExecutorService로 종료 대기
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(task);
} // try-with-resources가 모든 작업 완료 대기
getName · getId
Thread vt = Thread.ofVirtual().start(() -> {});
vt.getName(); // "" (기본 빈)
vt.threadId(); // 고유 ID
여기서 시험 함정이 하나 있어요. Virtual Thread 이름 기본 빈 문자열. 디버깅엔 명시 필요:
Thread.ofVirtual()
.name("worker-", 0) // worker-0, worker-1, ...
.start(() -> {});
Thread.currentThread()
Thread.startVirtualThread(() -> {
Thread current = Thread.currentThread();
System.out.println(current);
// VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
});
#21 = Virtual Thread ID, ForkJoinPool-1-worker-1 = 현재 캐리어.
Stack Trace 표시
java.lang.RuntimeException
at MyClass.method (MyClass.java:10)
at java.util.concurrent.VirtualThreadTask.run (...)
...
Virtual Thread도 정상 stack trace. 디버깅 OK.
isVirtual()
Thread t = Thread.currentThread();
if (t.isVirtual()) {
// Virtual Thread
}
Java 21+ — 현재 스레드가 Virtual인지 확인.
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 2편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Virtual Thread = JVM 내부 객체 / Carrier Thread = 실제 실행 (Platform Thread)
- Mount = 캐리어에 올라타기
- Unmount = I/O 시 캐리어에서 내림
- 다음 mount는 다른 캐리어 가능 (워크 스틸링)
- Carrier Pool = ForkJoinPool, CPU 코어 수
- 시스템 속성 —
jdk.virtualThreadScheduler.parallelism - Continuation API = unmount/mount 핵심 메커니즘 (JVM 내부)
- 스택 = JVM 힙 (idle 시 ~수 KB)
- 메모리 — Platform 2MB / Virtual ~수 KB
- 생명주기 — NEW → RUNNABLE → WAITING ↔ RUNNABLE → TERMINATED
- 자동 unmount —
Thread.sleep·JDK I/O·JDBC·HttpClient·LockSupport·Reentrant Lock - synchronized·JNI = unmount X (4편 Pinning)
- 워크 스틸링 = 놀고 있는 캐리어가 작업 훔침
- ThreadLocal = VT 단위 (캐리어 변경 OK)
- 수백만 ThreadLocal = 메모리 위험 → ScopedValue 권장
- ScopedValue = 명확한 스코프·자동 정리
- Virtual Thread 항상 daemon
- main 종료 시 즉시 종료
- ExecutorService try-with-resources로 대기
- 이름 기본 빈 → 명시 권장
Thread.currentThread().isVirtual()(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·실전·안티패턴
공식 문서: JEP 444 — Virtual Threads / JEP 446 — Scoped Values 에서 더 깊이.
다음 글(3편)에서는 Virtual Thread API — Thread.ofVirtual·Executors.newVirtualThreadPerTaskExecutor·ThreadFactory·Builder 패턴까지 풀어 갑니다.