JVM이 직접 스케줄링하는 스레드(Java 21 정식). 블로킹을 만나면 OS 스레드를 놓아 준다.
무엇이 싸졌나
플랫폼 스레드 (OS 1:1)
- 스택 약 1MB 예약 · 생성/전환에 커널 개입
- 수천 개가 한계 → 스레드 풀 크기가 곧 동시성 상한
가상 스레드
- 스택이 힙에 놓인 평범한 자바 객체 → 필요한 만큼만 쓴다
- 수십만 개도 부담 없다
마운트·언마운트
가상 스레드 A ── 마운트 ──→ 캐리어 스레드(진짜 OS 스레드)
← 언마운트 ─ 블로킹을 만나면 스택을 힙으로 옮기고 캐리어를 놓는다
가상 스레드 B ── 마운트 ──→ 같은 캐리어
캐리어 개수 = 기본값 CPU 코어 수
쓰는 법 — 풀을 만들지 않는다
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (var id : ids) executor.submit(() -> repository.find(id)); // 평범한 블로킹 코드
}
스레드 풀의 존재 이유는 '생성이 비싸서 재사용' 이었다 가상 스레드는 생성이 싸므로 재사용할 이유가 없다 오히려 풀로 묶으면 풀 크기가 다시 상한이 되어 이점을 스스로 없앤다
pinning — 버전을 아는지 가르는 지점
synchronized (lock) { jdbc.query(...); } // 여기서 블로킹하면?
[Java 21~23] 모니터 소유권이 캐리어의 정체성에 묶여 있었다
-
언마운트 불가 → 캐리어에 고정(pinning)
-
캐리어는 코어 수뿐이라 몇 개만 물려도 전체가 멈춘다
-
당시 처방: ReentrantLock 으로 교체
-
[Java 24+] — JEP 491 이 모니터 소유권을 '가상 스레드 자신' 으로 추적하게 바꿨다
- 제거됐다. 리팩터링 불필요
남은 pinning은 셋뿐이다 — 네이티브 프레임(JNI·FFM), 클래스 로딩·정적 초기화, 리눅스 로컬 파일 I/O.
진짜 요점 — 병목이 옮겨간다
JDBC 워크로드 벤치마크 (동시 500, 60초)
처리량 624 → 1,539 req/s (+147%)
에러율 37% → 37% (그대로!)
- 원인은 커넥션 풀 포화
- 스레드 모델의 천장을 없앴을 뿐, 병목을 '옮긴' 것이다
// 스레드 풀이 해 주던 암묵적 상한을 자원 앞에 직접 건다
private static final Semaphore DB = new Semaphore(50);
ThreadLocal이 손해가 된다
플랫폼 스레드는 풀에서 재사용되니 ThreadLocal 캐시도 스레드 수만큼만 생긴다 가상 스레드는 요청마다 만들고 버리므로 캐시가 요청마다 재생성된다
같은 60초 워크로드에서 초기화 200회 → 443,267회 (약 2,200배) 측정 예외도 경고도 없이 GC 압력만 는다 → Scoped Values(Java 25) 로 대체한다
면접 함정
- ❌ "가상 스레드 = 비동기" → 반대다. 동기 블로킹 코드를 그대로 쓰기 위한 기능이다.
- ❌ "CPU 바운드도 빨라진다" → 코어 수가 한계다. I/O 대기 비용만 없앤다.
확인하고 관측하기
Thread.ofVirtual().start(() -> {
System.out.println(Thread.currentThread());
// VirtualThread[#21]/runnable@ForkJoinPool-1-worker-3
// ^^^^^^^^^^^^^^^^^^^^^ 지금 올라탄 캐리어
});
Thread.currentThread().isVirtual(); // Java 21+
-Djdk.virtualThreadScheduler.parallelism=16 # 캐리어 수 (기본 = 코어 수)
-Djdk.tracePinnedThreads=full # Java 21~23 에서 pinning 추적
스레드 덤프가 달라진다
jcmd <pid> Thread.dump_to_file -format=json dump.json
# 기존 jstack 은 가상 스레드를 보여주지 않는다 (수십만 개를 나열할 수도 없다)
# JSON 덤프가 가상 스레드까지 포함한다
언제 안 쓰나
- CPU 바운드 작업 — 코어 수가 한계다. 오히려 컨텍스트 관리 비용만 는다
- 스레드 풀로 동시성을 '제한' 하는 것이 목적이었던 곳
- 그 상한을 Semaphore 로 명시적으로 옮겨야 한다
- ThreadLocal 에 무거운 객체를 캐싱하는 코드가 있는 곳
- 먼저 걷어낸다