Skip to main content

Command Palette

Search for a command to run...

Java Virtual Threads 동작 원리 — Continuation, Carrier, 그리고 JEP 491 이후

Updated
11 min read

이 글은 Java 21에서 정식으로 도입된 Virtual Threads(JEP 444)와 Java 24에서 핀닝 문제를 정리한 JEP 491의 내부 동작을 들여다봅니다. Thread-per-request 모델로 수십만 동시 요청을 처리할 수 있게 된 배경과, 그 아래에서 Continuation과 Carrier thread가 어떻게 협력하는지를 설명합니다.

왜 Virtual Threads가 필요했나

Java 1.0부터 java.lang.Thread는 OS 스레드를 1:1로 감쌌습니다. 코드는 단순했지만, OS 스레드 한 개의 비용이 만만치 않았습니다.

  • 기본 스택 크기 약 1MB(64bit) — 만 개를 띄우면 스택만 10GB
  • 컨텍스트 스위치 시 커널 모드 전환과 캐시/TLB 무효화
  • OS 자원이므로 프로세스당 수만 개 수준이 한계

대부분의 서버 워크로드는 CPU를 적극적으로 쓰지 않고 DB, 외부 API, 캐시의 응답을 기다리는 시간이 절대적입니다. 그런데 OS 스레드가 비싸니, 동시 요청이 많아지면 스레드를 풀(pool)에 묶어 재활용하거나, NIO/리액티브 프로그래밍으로 콜백을 깎아 쓰거나 둘 중 하나였습니다.

스레드 풀은 요청보다 스레드가 적을 때 큐 대기로 응답이 늘어집니다. 리액티브는 코드를 콜백/체인 형태로 비틀어야 해서 디버깅·스택트레이스·기존 라이브러리와의 궁합이 모두 나빠집니다.

Virtual Thread는 OS 스레드와 무관하게 JVM이 직접 만들고 스케줄링하는 가벼운 스레드입니다. 표준 java.lang.Thread의 인스턴스이므로 기존 API(스레드 로컬, 스택 트레이스, 디버거)를 그대로 쓸 수 있고, 블로킹 호출을 그대로 적어도 OS 스레드를 잡아먹지 않습니다.

요청 한 건당 가상 스레드 한 개를 만들고, 코드는 평범한 동기 스타일로 작성합니다. 이게 가능한 이유를 아래에서 풀어 봅니다.


Platform Thread vs Virtual Thread

Java 21부터 Thread는 두 종류로 갈라집니다.

  • Platform thread: 기존의 OS 스레드를 1:1로 감싼 스레드. new Thread(...)Thread.ofPlatform()로 만듭니다.
  • Virtual thread: JVM이 관리하는 사용자 모드 스레드. Thread.ofVirtual(), Thread.startVirtualThread(Runnable), Executors.newVirtualThreadPerTaskExecutor()로 만듭니다.

겉에서 보면 둘 다 Thread 클래스 인스턴스이고 메서드 시그니처도 같습니다. 차이는 다음에 있습니다.

항목Platform threadVirtual thread
매핑OS 스레드 1:1다대일(M:N) — Carrier에 마운트
스택미리 잡힌 고정 크기(기본 1MB)힙 위에 가변 크기, 필요할 때만 잡음
생성 비용비쌈(시스템콜)매우 쌈(자바 객체 + 힙 청크)
적정 개수수천 개수십만~수백만 개
스케줄러OS 커널JVM의 ForkJoinPool
daemon 여부선택항상 daemon, 우선순위 NORM

가상 스레드는 항상 daemon 스레드이고 우선순위는 변경되지 않습니다. JVM이 메인 스레드 외 가상 스레드 종료를 기다리지 않으므로, 종료 전 join이나 ExecutorService close()가 필요합니다.


기초가 되는 개념: Continuation

가상 스레드의 핵심은 Continuation입니다. Continuation은 "지금까지의 호출 스택과 지역 변수를 통째로 보존해 두었다가, 나중에 그 시점부터 다시 실행할 수 있게 해주는 객체"입니다. JDK 내부에는 jdk.internal.vm.Continuation 클래스로 존재하고, Project Loom의 가장 기본 빌딩 블록입니다.

Continuation은 두 개의 동작을 가집니다.

  • run(): 진입점부터 코드를 실행합니다. 끝까지 가거나 yield까지 갑니다.
  • yield(scope): 현재까지의 스택을 통째로 떼어내서 보존하고, run()을 호출했던 쪽으로 제어를 돌립니다.

가상 스레드는 자기 안에 Continuation을 하나 들고 있습니다. 가상 스레드 위에서 실행되는 모든 코드는 결국 그 Continuation의 run() 안에서 흘러갑니다. 블로킹 동작이 발생할 때 JDK는 yield를 호출해서 가상 스레드의 스택을 통째로 힙으로 옮기고, 진행 중이던 OS 스레드를 풀어 줍니다.

이게 synchronizedwait/notify 같은 자바 언어 차원의 메커니즘과 별개로, JVM 내부의 user-mode 스택 스위칭으로 동작한다는 점이 중요합니다. 커널이 개입하지 않으므로 컨텍스트 스위치 비용이 한 자릿수 마이크로초 수준까지 떨어집니다.


Mount와 Unmount

가상 스레드가 실제 CPU에서 코드를 돌리려면 누군가의 OS 스레드 위에 올라타야 합니다. 이 OS 스레드를 Carrier thread라고 부르고, 올라타는 행위를 Mount, 내려오는 행위를 Unmount라고 합니다.

수명 흐름은 이렇습니다.

  1. 스케줄러가 가상 스레드를 큐에서 꺼내 임의의 Carrier에 mount합니다.
  2. Carrier는 가상 스레드의 Continuation을 run()해서 코드를 실행합니다.
  3. 가상 스레드가 블로킹 동작을 만나면 Continuation이 yield하면서 unmount됩니다. 가상 스레드의 스택은 JVM 힙의 stack chunk로 옮겨지고, Carrier는 다른 가상 스레드를 실행할 수 있게 풀려납니다.
  4. 블로킹이 풀리면 가상 스레드는 다시 스케줄러 큐에 들어갑니다. 다시 마운트될 때 같은 Carrier로 돌아온다는 보장은 없습니다.

여기서 "블로킹 동작"이란 JDK 내부에서 가상 스레드 친화적으로 재작성된 동작들입니다. 대표적으로 다음이 모두 unmount를 트리거합니다.

  • Thread.sleep(...)
  • LockSupport.park*
  • BlockingQueue.take/put
  • java.util.concurrent.locksReentrantLock.lock
  • Socket/SocketChannel/DatagramChannel/Pipe/Selector의 블로킹 I/O
  • java.nio.file 비동기 채널

JDK 내부 라이브러리들이 이미 가상 스레드를 인지하도록 다시 짜였기 때문에, 사용자 코드는 그냥 평범한 동기 코드를 쓰면 됩니다.

Stack chunk — 진짜로 가벼운 이유

가상 스레드의 스택은 OS 스레드처럼 1MB 같은 큰 가상 메모리 영역을 통째로 잡아두지 않습니다. 대신 실제로 사용 중인 프레임만 힙 위 stack chunk 객체에 담깁니다. 이 청크는 JVM의 GC가 관리하므로, 가상 스레드가 끝나면 그냥 GC 대상이 됩니다.

Mount할 때는 청크의 프레임이 Carrier의 OS 스택 위로 복사되어 실행되고, Unmount할 때는 다시 청크로 돌아옵니다. 즉 OS 스택은 마운트되어 있는 동안만 점유합니다.


스케줄러 — ForkJoinPool, FIFO 모드

가상 스레드를 Carrier에 분배하는 스케줄러는 ForkJoinPool 입니다. 단, ForkJoinPool.commonPool()과는 다른 별도의 인스턴스이고, FIFO(asyncMode=true) 모드로 동작합니다.

기본 병렬도(parallelism)는 Runtime.availableProcessors()이고, 다음 시스템 프로퍼티로 조정합니다.

-Djdk.virtualThreadScheduler.parallelism=N      # carrier 개수
-Djdk.virtualThreadScheduler.maxPoolSize=M      # 동시에 살아 있는 carrier 최대치
-Djdk.virtualThreadScheduler.minRunnable=K      # 최소 동시 실행 보장

ForkJoinPool은 work-stealing 알고리즘을 씁니다. 각 Carrier는 자기 큐가 비면 다른 Carrier의 큐에서 작업을 훔쳐 옵니다. FIFO 모드인 이유는 가상 스레드를 짧은 task처럼 빠르게 돌려막기 위해서입니다(공정성과 캐시 지역성의 균형).

Carrier 풀은 평소엔 parallelism 개수만 쓰지만, 핀닝(아래 참고)이나 ManagedBlocker 같은 특수 상황에서는 maxPoolSize까지 일시적으로 늘어날 수 있습니다.

스케줄러를 직접 갈아끼우는 공식 API는 아직 없습니다. 다만 Thread.ofVirtual().scheduler(...)는 비공식·실험적이고, 운영 환경에서는 시스템 프로퍼티로만 조절합니다.


Pinning — 자유로운 unmount를 막는 상황

가상 스레드가 Carrier에서 떨어지지 못하고 그 자리에 못박혀 버리는 상황을 Pinning이라고 부릅니다. 핀이 박힌 동안은 Carrier가 다른 가상 스레드를 실행할 수 없으므로, 동시성을 그만큼 잃어버립니다. 심하면 모든 Carrier가 핀에 잡혀 굶주림(starvation)이나 데드락이 생깁니다.

JDK 21~23까지의 핀닝 조건은 두 가지였습니다.

  1. synchronized 블록·메서드 안에서 블로킹 동작을 하는 경우
  2. 네이티브 메서드(JNI)나 외부 함수(FFM) 안에서 블로킹 동작을 하는 경우

특히 1번이 골치 아팠습니다. 표준 라이브러리, JDBC 드라이버, 풀 라이브러리 곳곳에 synchronized가 박혀 있었기 때문입니다. 그래서 Java 21~23에서는 synchronized 대신 ReentrantLock을 쓰라는 권고가 흔했습니다.

synchronized만 핀닝됐나

JVM의 모니터(객체 헤더 + ObjectMonitor)는 소유자를 JavaThread*, 즉 platform thread의 포인터로 기록합니다. 가상 스레드가 synchronized로 모니터를 잡으면, 그 시점의 Carrier가 소유자로 박힙니다. 만약 가상 스레드가 unmount되어 다른 Carrier에서 재개되면 소유자가 바뀐 것처럼 보여 모니터의 일관성이 깨집니다. 그래서 JVM은 unmount 자체를 막는 쪽을 택했습니다 — 그게 핀닝입니다.

진단: jdk.tracePinnedThreads

JDK 21~23에서는 다음 옵션으로 핀닝 발생 위치를 출력할 수 있었습니다.

-Djdk.tracePinnedThreads=full    # 전체 스택과 모니터 보유 프레임 표시
-Djdk.tracePinnedThreads=short   # 문제 프레임만

이 플래그는 JDK 24에서 제거되었습니다. JEP 491이 핀닝 자체를 거의 없앴기 때문입니다.


JEP 491 — JDK 24에서 synchronized 핀닝 제거

JEP 491 "Synchronize Virtual Threads without Pinning"은 JDK 24(2025년 3월)에서 정식으로 들어갔습니다. 핵심 아이디어는 단순합니다.

모니터의 소유자를 JavaThread*(carrier) 대신 java.lang.Thread.tid(virtual thread) 로 기록한다.

이 한 줄의 변경으로 가상 스레드가 모니터를 들고 unmount할 수 있게 됩니다. 정확히는 다음이 모두 가능해졌습니다.

  1. synchronized 블록 안에서 I/O를 기다리며 unmount
  2. synchronized 진입을 기다리며 unmount
  3. Object.wait() / wait(timeout)에서 unmount

JEP 491을 적용한 뒤 가상 스레드 구현은 모니터 표현을 두 갈래로 들고 갑니다.

  • Lightweight (thin) lock: 보통은 객체 헤더의 비트 패턴으로 처리. 빠른 경로.
  • Inflated monitor: 경합이나 wait()이 있을 때 ObjectMonitor로 부풀려짐. 이 경로의 _owner 필드가 이제 JavaThread*가 아니라 Thread.tid를 들고 있음.

운영 관점에서의 결과는 큽니다. JDBC, 로깅, 커넥션 풀의 synchronized가 더 이상 핀을 박지 않습니다. Java 21에서 synchronized를 모두 ReentrantLock으로 바꾸느라 진땀을 뺐던 코드라면, Java 24+에서는 그런 부담이 거의 사라집니다.

그래도 남는 핀닝

JEP 491 이후에도 다음 경로는 여전히 핀을 박습니다.

  • 네이티브 메서드(JNI): 네이티브 프레임을 가로질러 스택을 옮길 수 없음
  • 외부 함수(Foreign Function & Memory API): 같은 이유
  • 클래스 초기화(<clinit>) 안에서의 블로킹: 초기화 동기화 메커니즘이 별도
  • 클래스 초기화를 기다리는 다른 스레드: 위와 짝
  • 클래스 로딩 중 심볼릭 참조 해석: 드물지만 존재

대부분의 애플리케이션 코드에서는 거의 부딪힐 일이 없습니다. 다만 JNI로 네이티브 라이브러리를 자주 호출하는 코드(예: 일부 압축, 암호화, GPU 계산 라이브러리)는 여전히 가상 스레드와 궁합이 좋지 않을 수 있습니다.


ThreadLocal과 InheritableThreadLocal

가상 스레드는 표준 Thread이므로 ThreadLocal을 그대로 지원합니다. JEP 444 시절 preview에서는 "thread-local을 비활성화한 가상 스레드"를 만들 수 있었지만, 정식 릴리스에서는 항상 지원으로 정리됐습니다.

다만 한 번 생각해 볼 점은 있습니다. 가상 스레드는 수십만 개가 살 수 있는데, 각 가상 스레드가 ThreadLocal에 큰 객체를 매달면 메모리가 그만큼 부풀어 오릅니다. 또한 풀이 아니므로 "쓰고 비우기"의 라이프사이클이 짧다는 점은 장점이지만, 라이브러리가 InheritableThreadLocal로 부모 컨텍스트를 자식에게 복사한다면 가상 스레드 폭발과 함께 사본도 폭발합니다.

JEP 446 "Scoped Values"가 이 문제의 후속 답으로 진행 중이지만, 현재 LTS인 Java 21·25에서는 여전히 ThreadLocal이 표준입니다.


API 사용

// 1) 한 번에 만들고 시작
Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("hello from " + Thread.currentThread());
});

// 2) Builder로 이름 지정
Thread named = Thread.ofVirtual()
        .name("worker-", 0)            // worker-0, worker-1 ...
        .start(() -> doWork());

// 3) ExecutorService — try-with-resources로 자동 종료 대기
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 10_000; i++) {
        executor.submit(() -> handleRequest());
    }
}   // close() 안에서 모든 task 종료까지 대기

newVirtualThreadPerTaskExecutor()는 풀이 아니라 요청마다 새 가상 스레드를 만드는 팩토리입니다. 그래서 "스레드 풀 크기를 어떻게 잡을까"라는 질문이 통째로 사라집니다.

스프링 부트 3.2+, 톰캣, Jetty, Netty 등 주요 컨테이너는 가상 스레드를 요청 처리에 쓸 수 있게 옵션을 열어 두었습니다. 스프링 부트는 spring.threads.virtual.enabled=true 한 줄로 톰캣 워커, @Async, 스케줄러 등을 가상 스레드로 돌립니다.

평범하게 동기 코드처럼 작성

가상 스레드의 가장 큰 가치는 코드가 보이는 모습 그대로 동작한다는 점입니다. 외부 시스템 세 곳을 직렬로 부르고 결과를 합치는 흔한 패턴이라면 다음 코드를 그대로 적습니다.

public Order enrichOrder(long orderId) {
    Order order = orderRepository.findById(orderId);     // blocking JDBC
    Customer cx = customerClient.fetch(order.customerId()); // blocking HTTP
    Inventory inv = inventoryClient.check(order.items());   // blocking HTTP
    return order.with(cx, inv);
}

가상 스레드 위에서 이 코드는 세 번의 블로킹마다 unmount되어 Carrier를 풀어 줍니다. 동시 요청이 5만 개여도 OS 스레드는 parallelism 개수(보통 CPU 코어 수)만 사용됩니다. 같은 동작을 리액티브로 적으려면 Mono.zip, flatMap, subscribeOn이 필요한데, 디버거에서 호출 스택이 끊어지고 예외 추적이 어려워집니다.


한 발 더 — Structured Concurrency

가상 스레드 위에서 자주 마주하는 패턴은 "여러 비동기 작업을 동시에 시작하고 모두의 결과를 모아 처리"입니다. 이 패턴을 안전하게 표현하기 위해 OpenJDK는 Structured Concurrency를 별도 JEP로 진행 중입니다.

// JDK 21~25 preview 기준 (StructuredTaskScope API는 계속 다듬어지는 중)
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
    Subtask<User> user = scope.fork(() -> userClient.fetch(id));
    Subtask<Order> order = scope.fork(() -> orderClient.recent(id));
    scope.join().throwIfFailed();
    return new Profile(user.get(), order.get());
}

scope가 종료될 때 fork된 모든 가상 스레드가 정리되고, 한쪽 실패 시 형제 작업을 자동 취소합니다. 다만 이 API는 JDK 21 preview, JDK 22(JEP 462), JDK 23(JEP 480), JDK 24(JEP 499), JDK 25(JEP 505)까지 계속 모양이 바뀌고 있어서, 운영 코드에 넣기엔 아직 이릅니다. 가상 스레드만 LTS(Java 21)로 우선 도입하는 것이 안전한 선택입니다.


어떤 워크로드에 맞는가

잘 맞는 경우

  • Thread-per-request 형태의 서버. 한 요청이 여러 외부 호출(DB, REST, 메시지큐)을 직렬·병렬로 호출하는 패턴
  • 동시 IO 호출 수가 크지만 각 호출의 CPU 시간이 짧은 경우
  • 기존 동기 코드를 콜백/리액티브로 바꾸지 않고 처리량을 끌어올리고 싶을 때

안 맞는 경우

  • 장시간 CPU 바운드 작업. 가상 스레드는 코드를 끝낼 때까지 Carrier를 점유합니다. CPU만 굴리는 작업은 다른 가상 스레드를 굶깁니다. 이런 작업은 ForkJoinPool이나 별도 platform thread pool로 분리해야 합니다.
  • JNI/FFM 비중이 매우 큰 코드. 핀닝이 빈번하면 가상 스레드의 이득이 사라집니다.
  • 풀링이 의미를 가지는 자원과 1:1로 묶인 스레드. 예: 한 스레드가 영구적으로 한 커넥션을 점유하는 모델. 가상 스레드를 풀링하지 마세요 — Executors.newVirtualThreadPerTaskExecutor()는 풀이 아니고, 가상 스레드 자체를 풀링하는 것은 안티패턴입니다.

"Don't pool virtual threads." JEP 444가 명시적으로 권고하는 문구입니다.


관측과 디버깅

jcmd Thread.dump_to_file

가상 스레드는 수가 많으므로 기존 jstack은 실용적이지 않습니다. JDK 21부터 jcmd로 JSON·plain 형식의 가상 스레드 친화적 덤프를 뜰 수 있습니다.

jcmd <pid> Thread.dump_to_file -format=json /tmp/threads.json

각 가상 스레드의 상태, 스택, 그리고 어떤 Carrier에 마운트돼 있는지가 함께 찍힙니다.

JFR 이벤트

다음 JDK Flight Recorder 이벤트가 가상 스레드와 직접 관련됩니다.

  • jdk.VirtualThreadStart / jdk.VirtualThreadEnd
  • jdk.VirtualThreadPinned — 핀이 박힌 채로 일정 시간 이상 머무른 경우
  • jdk.VirtualThreadSubmitFailed — 스케줄러 거부

JDK 24부터 synchronized로 인한 VirtualThreadPinned는 거의 사라지지만, JNI/FFM/<clinit> 케이스 추적용으로 여전히 유용합니다.

스택트레이스가 평범하다

리액티브 코드를 다뤄 본 사람이라면 가상 스레드의 가장 큰 운영 이점이 무엇인지 바로 압니다. 예외 스택트레이스가 평범한 동기 코드처럼 보입니다. 콜백 체인이 끊어져서 "어디에서 시작된 일인지" 모를 일이 없습니다.


정리

  • 가상 스레드는 OS 스레드 1:1 모델을 다대일(M:N)로 바꿉니다. JVM의 ForkJoinPool이 Carrier 위에 가상 스레드를 mount/unmount하며 돌립니다.
  • 그 핵심 메커니즘은 Continuation입니다. 블로킹이 발생하면 가상 스레드의 스택을 힙으로 옮기고 Carrier를 풀어 줍니다.
  • Java 21에서 정식 도입(JEP 444)되었지만, synchronized 핀닝이 약점이었습니다. Java 24의 JEP 491이 모니터 소유자를 가상 스레드 ID로 바꾸면서 이 문제를 거의 해결했습니다.
  • 그래도 JNI/FFM/<clinit>는 여전히 핀이 박힙니다. CPU 바운드 작업은 가상 스레드에 어울리지 않습니다.
  • 운영에서는 jcmd Thread.dump_to_file과 JFR VirtualThreadPinned 이벤트를 함께 쓰면 진단이 쉽습니다.

Thread-per-request라는, 한때 "비현실적"이라 평가됐던 모델이 다시 상식이 되어가는 중입니다. 가상 스레드는 마법이 아니라 스택을 힙으로 옮길 수 있게 한 작은 일 위에 쌓아 올린 일관된 결과물입니다.


참고자료

  • JEP 444: Virtual Threads — https://openjdk.org/jeps/444
  • JEP 491: Synchronize Virtual Threads without Pinning — https://openjdk.org/jeps/491
  • Oracle Java 21 — Virtual Threads 가이드 — https://docs.oracle.com/en/java/javase/21/core/virtual-threads.html
  • java.lang.Thread Javadoc (Java 21) — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/lang/Thread.html
  • java.util.concurrent.Executors Javadoc (Java 21) — https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/concurrent/Executors.html
  • JEP 446: Scoped Values — https://openjdk.org/jeps/446
  • JEP 480: Structured Concurrency (Third Preview) — https://openjdk.org/jeps/480
  • OpenJDK Bug — JDK-8338383 / JDK-8338813 (JEP 491 구현) — https://bugs.openjdk.org/browse/JDK-8338383

More from this blog

LangChain, LangGraph, LangSmith — 첫 AI 에이전트를 만들기 전에 알아야 하는 세 도구의 역할

처음으로 AI 에이전트를 만들어 보려는 백엔드 개발자를 대상으로 합니다. 이름이 비슷한 세 도구 — LangChain, LangGraph, LangSmith — 가 각각 어떤 문제를 풀려고 등장했는지, 어떤 관계로 묶여 있는지, 그리고 어디서부터 손을 대야 하는지를 한 흐름으로 정리합니다. 함께 자주 등장하는 단어(Tool, ReAct, Function C

May 14, 202613 min read

Spring @Transactional 동작 원리 — 프록시, 트랜잭션 매니저, 전파와 롤백의 진실

메서드 위에 @Transactional 한 줄을 붙이면 Spring은 그 호출을 가로채 트랜잭션을 시작하고, 예외가 나면 롤백하고, 정상 종료되면 커밋합니다. 이 글은 그 한 줄 뒤에서 일어나는 일을 코드 흐름과 함께 풀어냅니다. 프록시가 어떻게 메서드를 가로채는지, TransactionInterceptor가 어떤 순서로 매니저를 호출하는지, 전파 속성과

May 14, 202612 min read

끄적끄적 테크 블로그

47 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ