"가상 스레드는 그냥 가벼운 스레드"라는 문장은 외우기 쉽지만, 막상 왜 가벼운지 — 소켓 read() 하나가 어떻게 OS 스레드를 붙잡지 않고 놓아주는지 — 를 인과 사슬로 설명하려니 막혔다. 게다가 "synchronized 안에서 블로킹하면 성능이 죽는다"는 경고는 자주 들었는데, 그 "pinning"이 정확히 스택의 어느 지점에서 왜 일어나는지는 몰랐다.
그래서 JEP 444와 OpenJDK의 VirtualThread·Continuation 소스(JDK 21 기준)를 따라가며 정리했다. 이 글에서 얻어갈 것은 세 가지다: 가상 스레드가 실제로 무엇의 합성인지, 블로킹 지점에서 스택이 어디로 이동하는지, 그리고 그 이동이 막히는(pinning) 조건이 무엇인지.
가상 스레드는 마법이 아니라 두 조각의 합성이다.
VirtualThread
├─ Continuation ← 재개 가능한 실행 상태 (스택 프레임들)
└─ Scheduler ← 기본값: ForkJoinPool (FIFO async 모드, parallelism = CPU 코어 수)
yield를 호출하면 현재 스택을 접어(freeze) 힙의 stack chunk 객체로 옮기고 제어를 호출자에게 돌려준다. 재개할 때는 그 chunk를 다시 캐리어 스택 위로 펼친다(thaw).즉 가상 스레드가 "실행 중"이라는 건 곧 어떤 캐리어에 마운트되어 있다는 뜻이고, "대기 중"이라는 건 스택이 힙에 접혀 있고 어떤 캐리어에도 올라타 있지 않다는 뜻이다.
가상 스레드 T가 소켓 read()에서 블로킹하려는 순간을 따라가 보자.
[carrier P1] 스택: ...→ read()→ park()
│
│ 1. JDK I/O 코드가 "지금 데이터 없음" 감지 → Continuation.yield()
▼
freeze: T의 스택 프레임을 힙 stack chunk로 복사 ← UNMOUNT
│
▼
[carrier P1] 이제 자유 → FJPool 큐에서 다른 가상 스레드 U를 mount
...
(데이터 도착, Selector가 T를 깨움)
│
▼
thaw: T의 stack chunk를 아무 캐리어 P2 스택으로 복사 → 이어서 실행 ← MOUNT
핵심은 JDK의 블로킹 API가 재작성되어 있다는 점이다. SocketChannel.read(), Thread.sleep(), LockSupport.park(), BlockingQueue.take() 같은 블로킹 지점들은 내부적으로 "지금 가상 스레드에서 실행 중이면 OS를 블로킹하지 말고 continuation을 yield하라"로 바뀌었다. 그래서 애플리케이션 코드는 여전히 익숙한 동기 블로킹 스타일인데도, 실제로는 캐리어를 놓아준다. 언마운트되면 캐리어 스택은 비고 힙에는 T의 접힌 스택만 남으므로, 수만 개의 대기 중 가상 스레드가 소수의 캐리어를 공유할 수 있다.
다시 깨어날 때 T가 원래 캐리어로 돌아갈 필요는 없다는 점도 중요하다. thaw는 힙의 stack chunk를 그 시점에 여유 있는 아무 캐리어 위로 복사하면 되기 때문이다.
경량성의 실체는 스택의 위치에 있다. 플랫폼 스레드는 고정 크기 스택(리눅스 기본 약 1MB)을 OS가 예약한다 — 수만 개면 산술적으로 수십 GB다. 반면 가상 스레드의 스택은 힙에 놓인 stack chunk 객체이고, 실제로 사용한 깊이만큼만 차지하며 GC 대상이 된다. 호출 깊이가 깊어지면 chunk를 늘리고, 얕아지면 회수한다. 이 "쓴 만큼만"이 수만 개를 동시에 띄울 수 있는 근거다.
Continuation.yield()는 현재 캐리어 스택을 통째로 힙으로 옮기는 연산이다. 그런데 스택 프레임 중에 네이티브 상태와 얽힌 것이 있으면 그대로 옮길 수 없다. 대표적으로 두 경우다.
synchronized 모니터를 잡은 채 블로킹 — 모니터는 특정 OS 스레드에 귀속된 상태라, 가상 스레드가 캐리어를 바꾸면 모니터 소유권이 깨진다. 그래서 JDK 21에서는 언마운트를 포기하고 T를 캐리어에 pin한다.pinning이 일어나면 그 캐리어는 T가 블로킹을 끝낼 때까지 다른 가상 스레드를 돌리지 못한다. 대기 가상 스레드가 많은데 캐리어가 전부 pin되면 처리량이 급락한다(캐리어 기아). 상태로 보면 정상 경로와 예외 경로의 차이가 곧 확장성의 차이다.
블로킹 진입 │ yield()
▼
PARKING ──(freeze 성공)──▶ PARKED ← 언마운트됨, 캐리어 자유
│
(freeze 불가: monitor/native)
▼
PINNED ← 캐리어 붙잡은 채 OS 블로킹
PARKED는 스택이 힙에 안전하게 접히고 캐리어는 반납된 이상적 경로,PINNED는 접지 못해 캐리어를 붙든 채 OS 수준에서 블로킹된 예외 경로다.
-Djdk.tracePinnedThreads=short 옵션으로 실행하면 pin이 일어난 스택을 stderr로 덤프해 준다.
// 실행: java -Djdk.tracePinnedThreads=short Main
var lock = new Object();
Thread.ofVirtual().start(() -> {
synchronized (lock) { // 모니터 획득
try { Thread.sleep(100); } // 이 블로킹에서 언마운트 시도 → pin 발생
catch (InterruptedException e) {}
}
}).join();
// stderr 에 "VirtualThread[...] ... <== monitors:1" 형태의 pin 스택이 찍힌다.
Thread.sleep()은 가상 스레드에서 continuation yield로 구현되므로 평소엔 캐리어를 놓아준다. 하지만 synchronized 안이라 언마운트가 거부되고 캐리어에 고정된다. 같은 코드를 ReentrantLock으로 바꾸면 — 락은 캐리어가 아니라 가상 스레드에 귀속되고 continuation과 함께 이동하므로 — pin 로그가 사라진다. 이것이 "Loom 시대엔 synchronized보다 ReentrantLock" 권고의 근거다.
한 가지 덧붙이면, JEP 491(JDK 24)에서 synchronized 블로킹 시에도 언마운트가 가능하도록 모니터 구현이 개선되어 synchronized에 의한 pinning은 사실상 사라진다고 알려져 있다. 위 설명은 그 이전, JDK 21 기준이다.
가상 스레드는 힙에 저장된 재개 가능한 스택(continuation)이고, 블로킹 지점에서 이 스택을 캐리어에서 떼어냈다가(unmount) 나중에 아무 캐리어에나 다시 붙여(mount) 실행을 이어가는 것이 전부다 — 단, 떼어낼 수 없는 프레임이 있으면 캐리어에 고정(pinned)된다.
더 파고들 만한 주제 두 가지: Continuation/StackChunk의 freeze·thaw가 GC(특히 stack chunk의 write barrier)와 어떻게 상호작용하는가, 그리고 JEP 491이 synchronized pinning을 없앤 모니터 재구현의 실제 메커니즘.
java.lang.VirtualThread, jdk.internal.vm.Continuation (JDK 21)