17장. 가상 스레드의 기계 — 마운트, 언마운트, 그리고 pinning

만화로 보는 요약 — 먼저 읽어보세요
1GB 힙에 가상 스레드 140만 개가 떴습니다. 1장에서 커널이 4,068개에 거부했던 그 기계에서요. 개당 값은 800바이트 안팎입니다.
면접 실전 질문: ① 가상 스레드의 mount와 unmount는 무엇인가요? ② carrier thread는 어떤 역할을 하나요? ③ JDK 24 이후에도 pinning이 남아 있는 경우는 무엇인가요?
배경 — 되사올 전제의 값
16장 끝에서 이렇게 예고했습니다. “되사올 전제의 값부터 재야죠. 가상 스레드는 정확히 얼마나 싼가 — 그리고 어디서 안 싸지는가.”
앞 장들이 미뤄둔 숫자가 셋 있습니다. 4장이 “천장이 얼마나 올라가는지는 17장에서 잰다”고 했고, 8장이 “진짜 천장은 17장의 몫”이라고 했고, 13장이 ThreadLocal 부담을 “17장에서 잰다”며 미검증 딱지를 달아뒀죠. 이 셋을 이 장에서 갚습니다. 4장이 꼽은 나머지 하나 — CPU 바운드에는 이득이 없다 — 는 18장에서 다른 방식들과 나란히 놓고 재는 게 나아서 거기로 넘깁니다.
값을 재기 전에 기계가 어떻게 도는지부터 봅시다.
캐리어를 갈아탄다
가상 스레드는 Thread를 상속하지만 커널은 그 존재를 모릅니다. 실제로 CPU 위에서 도는 건 캐리어 스레드(carrier thread) — JVM이 관리하는 진짜 플랫폼 스레드예요. 가상 스레드는 여기 올라타서(mount) 실행되고, 기다릴 일이 생기면 내려옵니다(unmount).
내려온다는 게 말뿐인지 어떻게 확인할까요. 자기 전과 후의 캐리어를 비교하면 될 것 같습니다.
final int N = 200;
String[] before = new String[N], after = new String[N];
for (int i = 0; i < N; i++) {
final int idx = i;
vex.submit(() -> {
before[idx] = carrier(); // VirtualThread[#21]/runnable@ForkJoinPool-1-worker-3 의 @ 뒤
Thread.sleep(50); // ← 여기서 내려온다는 게 가설
after[idx] = carrier();
});
}배열 인덱스로 짝을 고정한 게 중요합니다. 도착 순서대로 리스트에 쌓으면 before[i]와 after[i]가 서로 다른 스레드가 되어버려서, 비교해봐야 아무 의미가 없거든요.
캐리어 종류: 전 8개 / 후 8개
→ 자기 전과 다른 캐리어에서 깨어난 스레드: 178 / 200그런데 이 숫자만으로는 약합니다. “매번 무작위로 배정된다”와 “안 갈아탄다”를 한 번의 전후 비교로 가르기 어려워요. 그래서 반증 가능한 형태로 바꿨습니다. 한 스레드를 스무 번 재우고, 그 스레드가 평생 몇 종류의 캐리어를 밟는지 셉니다. 언마운트가 없다면 이 값은 반드시 1이어야 합니다 — 한 번 올라탄 자리를 못 떠날 테니까요.
한 가상 스레드가 20번 자는 동안 밟은 캐리어 종류 수
최소 5 / 중앙값 8 / 최대 8
→ 끝까지 캐리어 1개만 밟은 스레드: 0 / 200 (언마운트가 없다면 200 여야 한다)✅ 실측 (JDK 25 / Apple M1 8코어 / macOS 26.5, 2026-08. 3회 반복 시 전후 변경 178~185 / 200, 캐리어 1개에 머문 스레드는 세 번 모두 0개. 전체 소스: docs/book/code/async/Ch17VirtualThreadMachinery.java)
200개 전부가 캐리어를 여러 개 밟았습니다. 대부분은 있는 캐리어 여덟 개를 다 밟았고요.
캐리어가 여덟 개인 것도 우연이 아닙니다. 가상 스레드 스케줄러의 기본 병렬도가 코어 수거든요(이 기계가 8코어입니다). 상한은 따로 256이고, 뒤의 실험에서 이 상한을 1로 묶습니다.
그럼 “내려온다”는 건 물리적으로 뭘까요. 자바 스택 프레임을 힙으로 옮겨 담는 것입니다. 가상 스레드가 sleep을 만나면 JVM은 지금까지 쌓인 프레임을 힙 위의 객체로 복사하고 캐리어를 놓아줍니다. 깨어날 땐 반대로 되돌려 놓고 아무 캐리어에나 올라타요(실제로는 한 번에 전부가 아니라 필요한 프레임만 조금씩 되돌립니다). 📄 문서 기반 (JEP 444)
스토리 — 진단 도구가 0건이라고 말했다
내려올 수 없으면 캐리어를 붙잡고 있게 됩니다. 이걸 pinning이라고 하죠.
판별은 쉽습니다. 캐리어를 1개로 묶고 200ms짜리 대기를 64개 던지면 돼요. 언마운트되면 64개가 겹쳐 자니까 200ms, pin되면 한 줄로 서서 자니까 64 × 200 = 12,800ms입니다.
| 무엇을 하는가 | 소요 | 판정 |
|---|---|---|
Thread.sleep(200) — 자바 프레임만 | 208 ms | 언마운트 |
synchronized 안에서 sleep(200) | 205 ms | 언마운트 |
usleep(200_000) — FFM 다운콜 | 13,073 ms | pin |
✅ 실측 (같은 환경, -Djdk.virtualThreadScheduler.maxPoolSize=1, 3회 반복 시 13,037~13,073ms. synchronized는 스레드마다 다른 락이라 경합이 0입니다 — 경합이 있으면 pin이 아니라 그냥 대기가 생겨요. 전체 소스: docs/book/code/async/Ch17Pinning.java)
synchronized는 이제 pin하지 않습니다. 4장에서 확인한 대로 JDK 24의 JEP 491이 걷어냈어요. “가상 스레드 쓸 때 synchronized를 ReentrantLock으로 바꿔라”는 조언은 JDK 23까지의 이야기입니다.
네이티브 프레임은 여전히 pin합니다. 13,073ms는 64개가 완전히 직렬로 잤다는 뜻이죠.
왜 이것만 남았을까요. JEP 444가 말하는 건 여기까지입니다 — 네이티브 메서드나 외부 함수를 실행하는 동안에는 언마운트할 수 없다. 왜 못 하는지는 이렇게 읽힙니다. 언마운트가 자바 스택 프레임을 힙으로 복사하는 일인데, C 함수의 스택 프레임에는 JVM이 해석할 수 없는 값들이 들어 있습니다. 반환 주소, callee-saved 레지스터(호출된 쪽이 보존해야 하는 레지스터), 그리고 그 C 코드가 자기 스택을 가리키며 만든 포인터들이요. 이걸 다른 곳으로 옮기면 그 포인터들이 전부 엉뚱한 데를 가리키게 됩니다. JVM은 자기가 만든 프레임만 옮길 수 있고, 네이티브 프레임은 그 자리에 있어야만 유효한 거죠. 📄 문서 기반 (JEP 444/491) + 기전은 추론
실무에서 이게 어디서 나오냐면 — 네이티브를 물고 있는 JDBC 드라이버(Oracle OCI 같은), JNI 기반 압축·암호화 라이브러리, 네이티브 커넥터를 쓰는 클라이언트들입니다. MySQL Connector/J와 PostgreSQL JDBC는 순수 자바라 pin하지 않아요. FFM(java.lang.foreign)으로 직접 다운콜을 쓰는 코드는 물론 해당됩니다.
그래서 켜봤습니다
pin을 찾으려면 무엇을 켜야 할까요. 흔히 언급되는 -Djdk.tracePinnedThreads부터 확인했습니다.
$ java -Djdk.virtualThreadScheduler.maxPoolSize=1 -Djdk.tracePinnedThreads=full ... Ch17Pinning.java
# pin이 13초 났는데 아무것도 안 찍힘이 플래그는 JDK 24에서 제거됐습니다. 시스템 프로퍼티는 오타를 내도, 없어져도 조용하기 때문에, 이걸 켜두고 “pin이 없구나” 하고 넘어가기 쉽습니다.
대체재는 JFR의 jdk.VirtualThreadPinned 이벤트예요. 그런데 이걸 켜고 위의 usleep 실험을 다시 돌렸더니 — 이벤트가 0건이었습니다. 13초를 pin해놓고요.
가설을 세웠습니다. 이벤트 이름이 “pinned”가 아니라 “pinned 상태로 park했다”는 뜻이라면, 네이티브 안에서 그냥 시간을 보내는 경우는 애초에 park를 안 하니 잡힐 리가 없다.
반증 가능한 형태로 만들어봅시다. 네이티브 프레임을 깔고 앉은 채로 park하는 경우를 만들면 됩니다. libc의 qsort에 자바 비교자를 업콜로 넘기고, 그 비교자 안에서 Thread.sleep을 하면 자바 → 네이티브 → 자바 구조가 되죠.
| 무엇을 하는가 | 소요 | JFR 이벤트 | |
|---|---|---|---|
| A. 다운콜 | 네이티브 안에서 usleep — park 안 함 | 6,629 ms (pin) | 0건 |
| B. 업콜 | 네이티브 프레임 위에서 sleep — park 함 | 6,622 ms (pin) | 32건 |
✅ 실측 (같은 환경, -Djdk.virtualThreadScheduler.maxPoolSize=1, 캐리어 1개 / 가상 스레드 32개 / 200ms, -XX:StartFlightRecording=settings=profile. 전체 소스: docs/book/code/async/Ch17PinDetect.java)
둘은 똑같이 6.6초 pin됐는데, 진단은 B만 봅니다. 같은 JFR 설정에서 B가 32건을 찍었으니 레코딩이 안 켜진 게 아니에요. B의 이벤트는 이렇게 생겼습니다.
jdk.VirtualThreadPinned {
duration = 201 ms
blockingOperation = "LockSupport.park"
pinnedReason = "Native or VM frame on stack"
carrierThread = "ForkJoinPool-1-worker-1" (javaThreadId = 36)
eventThread = "" (javaThreadId = 35, virtual)
stackTrace = [ VirtualThread.parkOnCarrierThread … Thread.sleep ] // 일부 생략
}blockingOperation이 답을 말해줍니다. 이 이벤트는 “park하려는데 못 내려왔다”를 기록하는 것이지 “캐리어를 오래 붙잡았다”를 기록하는 게 아니에요. A는 park를 안 했으니 JVM 입장에서는 그냥 실행 중인 스레드입니다. 오래 도는 계산과 구분할 방법이 없죠.
pinnedReason도 한 가지 알려줍니다. “Native or VM frame” — JDK 자신은 두 종류를 구분하고 있어요. 우리가 잰 것 중에는 네이티브 프레임만 남았지만, VM 프레임(클래스 초기화 중 같은)도 같은 범주에 있습니다. 📄 문서 기반 (미검증)
실무에 옮기면 이렇습니다. jdk.VirtualThreadPinned가 0건이라고 pin이 없는 게 아닙니다. 이 이벤트가 못 보는 구멍은 네이티브 호출이 오래 걸리는 경우 — 정확히 JDBC 드라이버나 네이티브 라이브러리에서 걱정하던 그 경우예요. A형을 잡으려면 캐리어 스레드를 직접 봐야 합니다. jcmd <pid> Thread.print | grep -A5 ForkJoinPool-1-worker를 몇 초 간격으로 두 번 찍어서, 워커들이 같은 네이티브 프레임에 멈춰 있으면 그겁니다.
핵심 — 얼마나 싼가, 그리고 청구서
이제 값을 잽니다. 힙을 1GB로 묶고 대기만 하는 가상 스레드를 벽에 닿을 때까지 만듭니다.
CountDownLatch hold = new CountDownLatch(1);
for (made = 0; made < 5_000_000; made++)
Thread.ofVirtual().start(() -> hold.await());힙 상한 1024 MB. 대기만 하는 가상 스레드를 계속 만들어본다.
200,000개 | 힙 191 MB | 개당 1,001 B | 플랫폼 스레드 15
400,000개 | 힙 340 MB | 개당 891 B | 플랫폼 스레드 15
600,000개 | 힙 474 MB | 개당 828 B | 플랫폼 스레드 15
800,000개 | 힙 632 MB | 개당 828 B | 플랫폼 스레드 15
1,000,000개 | 힙 754 MB | 개당 790 B | 플랫폼 스레드 15
1,200,000개 | 힙 879 MB | 개당 768 B | 플랫폼 스레드 15
1,400,000개 | 힙 982 MB | 개당 735 B | 플랫폼 스레드 15
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"✅ 실측 (같은 환경, -Xmx1g)
세 가지가 보입니다.
첫째, 천장은 힙입니다. 1장에서 4,068개에 걸렸던 OutOfMemoryError는 이름만 메모리였고 실제로는 커널이 pthread_create를 거부한 거였죠. 이번엔 힙이 상한을 채우고(982 / 1,024MB) 죽었습니다. 8장의 예측이 맞았어요. 다만 정확히 몇 개째에서 죽었는지는 못 잡았습니다 — OOM이 터진 뒤엔 실패 메시지를 포맷할 메모리조차 없어서 프로그램의 보고 경로가 아예 안 돕니다. 아는 건 “140만은 넘겼다”까지예요.
둘째, JVM이 만든 플랫폼 스레드는 15개에서 꿈쩍도 안 했습니다. 여기서 15는 Thread.getAllStackTraces()가 세는 자바 스레드입니다. GC 워커나 JIT 컴파일러 스레드는 자바 스레드가 아니라 여기 안 잡히지만 커널은 세죠. 그래서 커널이 보는 수를 따로 재봤습니다 — 가상 스레드 20만 개를 띄운 상태에서 35개였습니다(가상 스레드가 없는 빈 JVM은 21개). ✅ 실측 (ps -M <pid>) 어느 쪽으로 세든 kern.num_taskthreads 4,096과는 자릿수가 다릅니다.
셋째, 개당 735~1,001바이트입니다. 20만에서 140만까지 일곱 배 늘어나는 동안 자릿수가 안 변해요. 표만 보면 계속 내려가는 것처럼 보이지만 600,000과 800,000이 828B로 같고, 다른 기계에서 재면 중간에 반등하기도 합니다. totalMemory() - freeMemory()는 미수거 쓰레기를 포함한 순간값이라 ±20%는 흔들려요. 추세를 읽지 말고 자릿수만 읽으세요.
이제 4장의 빚을 갚을 수 있습니다.
| 플랫폼 스레드 (1장) | 가상 스레드 (이 장) | |
|---|---|---|
| 몇 개까지 | 4,068개 | 1,400,000개 이상 |
| 무엇이 막았나 | 커널 (EAGAIN) | 힙 (-Xmx1g) |
| 개당 값 | RSS 62KB / 주소공간 2MB 예약 | 힙 735~1,001 B |
| 늘리려면 | 커널 파라미터 | -Xmx |
약 340배입니다. 두 값 다 정확한 수가 아니라서 — 1장은 4,067~4,073 사이에서 걸렸고 이 장은 실패 지점을 못 잡았습니다 — 유효숫자 세 자리로 쓸 수 있는 비율은 아니에요.
더 중요한 건 배수가 아니라 벽의 주인이 바뀐 것입니다. 4,068개는 커널이 정한 벽이라 애플리케이션이 어쩔 도리가 없지만, 140만 개는 -Xmx가 정한 벽이라 돈으로 밀 수 있습니다.
한 가지는 정직하게 붙여둡시다. 이건 아무것도 안 하고 대기만 하는 스레드의 값입니다. 실제 요청 처리 스레드는 스택 프레임이 깊고 지역 변수를 들고 있어서 더 듭니다. 800바이트는 하한이지 평균이 아니에요.
ThreadLocal을 얹으면 두 배가 된다
13장에서 이렇게 걱정했습니다 — 가상 스레드는 저마다 ThreadLocalMap을 하나씩 들고 다니니까, 요청마다 ThreadLocal을 열 개씩 채우면 동시 요청 10만 건은 맵 10만 개라고요. 📄 미검증 딱지를 달아뒀죠. 이제 잽니다.
가상 스레드 10만 개를 띄우고, 각각 ThreadLocal에 64바이트짜리 값을 0개·1개·10개 넣습니다.
스레드당 ThreadLocal | 스레드 개당 힙 (3회 측정 범위) |
|---|---|
| 0개 | 1,092 ~ 1,158 B |
| 1개 | 910 ~ 991 B |
| 10개 | 1,863 ~ 1,948 B |
✅ 실측 (같은 환경, -Xmx2g, 가상 스레드 100,000개, System.gc() 후 측정, 3회)
ThreadLocal 10개가 스레드 값을 두 배 가까이 만들었습니다. 10만 스레드 기준 +80~90MB예요.
그런데 1개 행을 보세요. 0개보다 작습니다. 세 번 다 그랬어요. ThreadLocal 하나를 추가했는데 힙이 줄어들 리는 없죠 — 이건 측정 방식의 분해능이 거기까지 못 미친다는 뜻입니다. totalMemory() - freeMemory()는 System.gc() 뒤라도 순간값이라 수십 바이트 차이를 못 봅니다. 그래서 “항목 하나당 몇 바이트”는 이 측정으로 말할 수 없습니다. 살아남는 주장은 “열 개면 대략 두 배” 하나뿐이에요.
절대량으로 보면 파국은 아닙니다. 문제는 프레임워크가 몇 개를 꽂는지 대개 모른다는 거죠. 세어봤습니다.
| 클래스 | ThreadLocal 필드 수 |
|---|---|
TransactionSynchronizationManager (spring-tx 6.1.10) | 6개 |
RequestContextHolder (spring-web 6.1.10) | 2개 |
✅ 실측 (javap -p로 필드 직접 확인)
두 클래스만으로 이미 여덟 개입니다. 여기에 MDC와 보안 컨텍스트가 더 붙죠. 방금 잰 “열 개면 두 배”의 그 열 개 선에, 평범한 Spring 앱은 이미 서 있습니다.
ScopedValue(JDK 25 final, JEP 506)가 이 자리를 노립니다. 값을 스레드에 매다는 대신 호출 범위에 매달아서, 자식 스레드가 복사 없이 공유하게 하거든요. 13장에서 본 그 API예요.
정리
- 가상 스레드는 캐리어를 갈아탑니다. 실측: 200개 중 178~185개가
sleep(50ms)후 다른 캐리어에서 깨어났고, 스무 번 재웠을 때 캐리어 하나에 머문 스레드는 0개였습니다(언마운트가 없다면 200이어야 하는 값). 캐리어는 기본적으로 코어 수만큼이고, 언마운트는 자바 스택 프레임을 힙으로 옮겨 담는 일입니다. - 천장은 커널에서 힙으로 옮겨갔습니다. 실측:
-Xmx1g에 140만 개 이상, 개당 735~1,001바이트, 그동안 자바 플랫폼 스레드는 15개(커널이 보는 수로도 35개) 고정. 1장의 4,068개 대비 약 340배입니다. 대기만 하는 스레드 기준이라는 단서는 붙습니다. - 더 중요한 건 벽의 주인이 바뀐 것입니다. 커널이 정한 벽은 못 밀지만
-Xmx가 정한 벽은 밉니다. ThreadLocal10개가 스레드 값을 두 배 가까이 만듭니다. 실측: 약 1,100 B → 약 1,900 B. 그리고TransactionSynchronizationManager만으로 이미 여섯 개예요. 다만 “항목 하나당 몇 바이트”는 이 측정 방식으로 말할 수 없습니다 — 1개 행이 0개 행보다 작게 나왔거든요.- JDK 25에서 우리가 잰 것 중 pin하는 건 네이티브 프레임뿐입니다. 실측:
synchronized205ms(언마운트) vs FFM 다운콜 13,073ms(pin). JFR이 붙이는 이유가Native or VM frame on stack인 걸 보면 VM 프레임도 같은 범주에 있습니다. - 진단 도구는 pin의 절반만 봅니다.
-Djdk.tracePinnedThreads는 JDK 24에서 제거돼 조용히 무시되고,jdk.VirtualThreadPinned는 park하다 막힌 경우만 기록합니다. 실측: 똑같이 6.6초 pin된 두 경우 중 0건 / 32건. 이벤트가 0건인 게 무죄 증명이 아닙니다.
생각해볼 질문
- 가상 스레드 100만 개가 뜨는 서버에서 CPU 바운드 작업의 처리량은 어떻게 될까요? 캐리어가 코어 수만큼이라는 사실이 답을 정합니다.
jdk.VirtualThreadPinned가 0건인데 처리량이 캐리어 수에 갇혀 있다면, 다음에 무엇을 봐야 할까요?- 1GB에 140만 개가 뜬다고 해서 동시 요청 140만 건을 받아도 될까요? 16장의 어느 결론이 여기에 제동을 걸까요?
3번이 마지막 장의 주제입니다. 스레드가 싸졌다는 건 “얼마든지 받아도 된다”가 아니에요. 4부에서 본 네 문제 중 셋은 전제를 되사오면서 같이 사라지지만, 하나는 스레드 값과 무관하게 그대로 남습니다.
이제 그 하나를 이름 붙이고, 무엇을 언제 쓸지 정할 차례입니다.