가상 스레드가 실제로 CPU 위에서 돌기 위해 올라타는 진짜 OS 스레드.
가상 스레드는 스스로 CPU를 쓸 수 없다. CPU를 점유할 수 있는 건 커널이 스케줄링하는 OS 스레드뿐이고, 가상 스레드는 그 위에 마운트되어야 비로소 실행된다. 이 받침대 역할을 하는 OS 스레드를 캐리어 스레드라고 부른다.
몇 개나 있나
- 기본값 = 사용 가능한 CPU 코어 수
- 조정 — = -Djdk.virtualThreadScheduler.parallelism=N
- 관리 — = 전용 ForkJoinPool (Fork/Join 방식으로 작업을 나눠 갖는 스레드 풀)
8코어 머신이면 캐리어는 8개뿐이다. 그 위로 가상 스레드 10만 개가 번갈아 오르내린다.
마운트·언마운트의 흐름
가상 스레드 10,000개 캐리어 8개
│ │
실행할 차례 ─── 마운트 ────────→ [코어에서 실행]
│ │
블로킹 만남 ←── 언마운트 ───────┘ ← 스택을 힙에 보관, 캐리어 반납
│
대기 중(힙에 스택만 존재, OS 자원 0)
│
I/O 완료 ─── 재마운트 ────────→ [아무 캐리어 위에서 이어서 실행]
여기서 중요한 성질이 하나 있다 — 언마운트 후 재마운트될 때 같은 캐리어로 돌아간다는 보장이 없다. 그래서 "이 스레드에 값을 붙여 둔다"는 전제의 코드(ThreadLocal을 캐리어 기준으로 생각하는 코드)가 어긋난다.
캐리어가 막히면 — pinning
캐리어의 개수가 코어 수뿐이라는 게 곧 약점이다. 어떤 이유로 가상 스레드가 언마운트하지 못하고 캐리어를 붙잡으면, 그 캐리어는 블로킹이 끝날 때까지 다른 가상 스레드를 못 받는다. 8개 중 8개가 그렇게 되면 애플리케이션 전체가 멈춘다. 이 현상이 pinning이다.
면접 함정
- ❌ "가상 스레드가 많으면 캐리어도 늘어난다" → 아니다. 캐리어는 코어 수로 고정이고, 그래서 pinning이 치명적이었다.
- 관측 팁: 처리량이 캐리어 수 근처에서 정체되면 pinning이나 CPU 포화를 의심한다.
눈으로 확인하기
Thread.ofVirtual().start(() -> {
System.out.println(Thread.currentThread());
// VirtualThread[#21]/runnable@ForkJoinPool-1-worker-3
// ^^^^^^^^^^^^^^^^^^^ 지금 올라탄 캐리어
});
앞부분이 가상 스레드, @ 뒤가 지금 그것을 실행 중인 캐리어다. 블로킹을 만났다가 재개하면 이 뒷부분이 바뀔 수 있다.
개수 조절
java -Djdk.virtualThreadScheduler.parallelism=16 -jar app.jar
기본값은 코어 수다. 늘린다고 처리량이 늘지는 않는다 — 캐리어가 부족해서 막히는 게 아니라면(즉 pinning이나 CPU 포화가 아니라면) 의미가 없다. 오히려 이 값을 만지고 싶어지는 상황이라면 병목이 다른 곳(커넥션 풀·다운스트림)에 있을 가능성이 높다.