자바 언어·플랫폼 용어 사전
동시성Virtual Thread · Loom · pinning · JEP 491

가상 스레드

블로킹 시 OS 스레드를 놓아 주는 JVM 관리 스레드. 블로킹 코드 그대로 수만 동시성을 얻는다.

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 에 무거운 객체를 캐싱하는 코드가 있는 곳
    • 먼저 걷어낸다

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 동시성