Java 21 이전까지 일반적으로 사용하던 Java Thread 모델은 OS Thread 기반이었다.
Java에서 Thread를 생성하면 JVM 내부의 native layer(C/C++ 코드로 작성된 JVM 내부 구현 영역)를 통해 OS Thread가 생성되고, Java Thread와 OS Thread가 거의 1:1로 매핑되어 작업을 수행하는 형태였다.

Java는 초기 버전부터 java.lang.Thread를 제공했다. Java 21 이전까지 기존 Thread 모델은 Native Thread로, JVM native 구현을 통해 OS Thread를 생성했다.
Thread thread = new Thread(() -> {
System.out.println(Thread.currentThread());
});
thread.start();
Virtual Thread가 도입된 이후, Java의 Thread는 크게 두 종류로 구분된다.
java.lang.Thread
├─ Platform Thread
└─ Virtual Thread
Platform Thread는 기존 Java Thread 모델을 설명하기 위한 용어이다.
즉, OS Thread를 얇게 감싼 Wrapper이며, Java 코드 실행은 OS Thread 위에서 수행된다.
Java Thread 객체
↓
Platform Thread
↓
OS Thread
↓
CPU Core
Platform Thread는 생명주기 동안 특정 OS Thread를 점유한다.
따라서 Platform Thread를 많이 만들면 OS Thread 수도 함께 증가한다.
OS Thread는 운영체제가 스케줄링하는 실행 단위이며, 각 OS Thread는 별도의 native stack과 OS 관리 리소스를 가진다.
이러한 구조로 인해 비싼 Platform Thread를 생성하지 않고 재사용 하고자, Platform Thread 기반 서버 애플리케이션은 일반적으로 Thread를 무제한 생성하지 않고 Thread Pool을 사용한다. 하지만 동시에 처리 가능한 작업 수(처리량)가 pool size에 의해 제한되고, pool size를 초과한 나머지 작업들은 queue에서 대기한다.
Virtual Thread는 이러한 기존 Thread 모델의 한계를 완화하기 위해 등장했으며, Platform Thread에 비해 생성 비용과 stack 메모리 부담이 상대적으로 적다고 공식 문서에서는 설명한다.

Virtual Thread는 Java 21에서 정식 도입된 경량 Thread 모델이다.
Virtual Thread는 Java 관점에서는 java.lang.Thread의 한 종류이지만, Platform Thread처럼 OS Thread와 생명주기 동안 1:1로 고정되지 않는다. JEP 444에서도 Virtual Thread는 특정 OS Thread에 묶이지 않는 java.lang.Thread 인스턴스이며, Platform Thread는 OS Thread를 얇게 감싼 Thread 구현이라고 설명한다.
Virtual Thread가 실행될 때 그 Virtual Thread를 실제로 실행하고 있는 Platform Thread를 Carrier Thread라고 부른다.
/**
* A thread that is scheduled by the Java virtual machine rather than the operating
* system.
*/
final class VirtualThread extends BaseVirtualThread {
private static final ContinuationScope VTHREAD_SCOPE = new ContinuationScope("VirtualThreads");
private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
// scheduler and continuation
private final Executor scheduler;
private final Continuation cont;
private final Runnable runContinuation;
// virtual thread state, accessed by VM
private volatile int state;
// parking permit
private volatile boolean parkPermit;
// carrier thread when mounted, accessed by VM
private volatile Thread carrierThread;
}
JDK 21의 java.lang.VirtualThread 구현을 보면 내부적으로 다음 필드들을 가지고 있다.
NEW : 아직 시작 전 상태STARTED : 요청이 들어와 scheduler에 제출될 준비 상태RUNNING : VT가 Carrier Thread에 mount 되어 실행 중인 상태PARKED : VT가 Carrier Thread에서 unmount된 대기 상태UNPARKED : 대기 중이던 Virtual Thread가 다시 실행 가능해진 상태PINNED : 현재 continuation 중단 실패 후 VT가 Carrier Thread에서 내려오지 못한 상태YIELDED : 현재 continuation 중단 시도 후 다시 실행 가능한 상태TERMINATED : 실행이 끝난 상태park, unpark 동작을 제어하기 위한 boolean 값이 구조를 미루어 보았을 때, Virtual Thread는 내부적으로 실행할 작업을 Continuation으로 감싸고, 이를 runContinuation
이라는 실행 단위를 만들어 scheduler에 제출한다.
즉, 구조를 단순화하면 다음과 같다.
VirtualThread
├─ scheduler
│ └─ runContinuation 실행 위치 결정
├─ cont
│ └─ 실제 작업 흐름 저장 및 재개
├─ state
│ └─ NEW, STARTED, RUNNING, PARKED, PINNED 등 상태 관리
├─ parkPermit
│ └─ park/unpark 제어
└─ carrierThread
└─ 현재 mount된 Platform Thread
runContinuation() 메서드는 Virtual Thread가 실제로 실행되거나, 대기 후 재개될 때 호출되는 핵심 메서드다.
아래 소스 코드를 통해 확인할 수 있듯,
1. runContinuation()메서드는 현재 Thread가 VT인지, STARTED/UNPARKED/YIELDED 상태인지 확인하고 상태를 RUNNING으로 변경한다.
2. mount() : 현재 Platform Thread를 carrier thread에 설정하고, carrier thread에 VT를 반환하도록 바꾼다.
3. cont.run() :
- Virtual Thread의 실제 실행 흐름을 담당한다.
- 처음 호출될 때는 사용자가 전달한 Runnable task를 실행하고
- park 또는 blocking 등으로 Continuation이 yield된 이후 다시 호출될 때는 중단되었던 지점부터 실행을 이어간다. 따라서 Virtual Thread는 cont.run()을 통해 실행과 재개를 같은 흐름으로 처리할 수 있다.
4. unmount() : VT와 carrier thread의 연결을 해제한다.
VirtualThread(Executor scheduler, String name, int characteristics, Runnable task) {
super(name, characteristics, /*bound*/ false);
Objects.requireNonNull(task);
// choose scheduler if not specified
if (scheduler == null) {
Thread parent = Thread.currentThread();
if (parent instanceof VirtualThread vparent) {
scheduler = vparent.scheduler;
} else {
scheduler = DEFAULT_SCHEDULER;
}
}
this.scheduler = scheduler;
this.cont = new VThreadContinuation(this, task);
this.runContinuation = this::runContinuation;
}
/**
* The continuation that a virtual thread executes.
*/
private static class VThreadContinuation extends Continuation {
VThreadContinuation(VirtualThread vthread, Runnable task) {
super(VTHREAD_SCOPE, wrap(vthread, task));
}
@Override
protected void onPinned(Continuation.Pinned reason) {
if (TRACE_PINNING_MODE > 0) {
boolean printAll = (TRACE_PINNING_MODE == 1);
VirtualThread vthread = (VirtualThread) Thread.currentThread();
int oldState = vthread.state();
try {
// avoid printing when in transition states
vthread.setState(RUNNING);
PinnedThreadPrinter.printStackTrace(System.out, reason, printAll);
} finally {
vthread.setState(oldState);
}
}
}
private static Runnable wrap(VirtualThread vthread, Runnable task) {
return new Runnable() {
@Hidden
public void run() {
vthread.run(task);
}
};
}
}
@ChangesCurrentThread // 메서드가 실행 중에 Thread.currentThread()의 결과를 바꿀 수 있다는 JVM 내부 힌트
private void runContinuation() {
// the carrier must be a platform thread
if (Thread.currentThread().isVirtual()) {
throw new WrongThreadException();
}
// set state to RUNNING
int initialState = state();
if (initialState == STARTED || initialState == UNPARKED || initialState == YIELDED) {
// newly started or continue after parking/blocking/Thread.yield
if (!compareAndSetState(initialState, RUNNING)) {
return;
}
// consume parking permit when continuing after parking
if (initialState == UNPARKED) {
setParkPermit(false);
}
} else {
// not runnable
return;
}
mount();
try {
cont.run();
} finally {
unmount();
if (cont.isDone()) {
afterDone();
} else {
afterYield();
}
}
}
@ChangesCurrentThread // 메서드가 실행 중에 Thread.currentThread()의 결과를 바꿀 수 있다는 JVM 내부 힌트
@ReservedStackAccess // JVM 내부의 중요한 복구/전환 코드가 스택이 거의 부족한 상황에서도 실행될 수 있게 허용하는 내부 어노테이션
private void mount() {
// notify JVMTI before mount
notifyJvmtiMount(/*hide*/true);
// sets the carrier thread
Thread carrier = Thread.currentCarrierThread(); // 현재 Platform Thread를 가져와서
setCarrierThread(carrier); // carrier Thread로 지정
// sync up carrier thread interrupt status if needed
if (interrupted) { // VT가 interrupt 상태면
carrier.setInterrupt(); // carrier도 interrupt 상태로 맞춰서 상태 동기화
} else if (carrier.isInterrupted()) { // carrier가 interrupt 상태인데
synchronized (interruptLock) { // interruptLock의 락 획득으로 다른 Thread가 VT를 interrupt 하지 못하도록
// need to recheck interrupt status
if (!interrupted) { // VT가 interrupt 상태가 아니라면
carrier.clearInterrupt(); // carrier interrupt 상태를 clear
}
}
}
// set Thread.currentThread() to return this virtual thread
carrier.setCurrentThread(this); // carrier에 VT 마운트
}
@ChangesCurrentThread // 메서드가 실행 중에 Thread.currentThread()의 결과를 바꿀 수 있다는 JVM 내부 힌트
@ReservedStackAccess //
private void unmount() {
// set Thread.currentThread() to return the platform thread
Thread carrier = this.carrierThread;
carrier.setCurrentThread(carrier); // carrier가 다시 Platform thread를 반환하도록 복구
// break connection to carrier thread, synchronized with interrupt
synchronized (interruptLock) {
setCarrierThread(null); // VT의 carrier값을 null로
}
carrier.clearInterrupt();
// notify JVMTI after unmount
notifyJvmtiUnmount(/*hide*/false);
}
참고) Continuation은 실행 중인 Java 코드의 흐름을 특정 지점에서 멈췄다가, 나중에 그 지점부터 다시 이어서 실행할 수 있게 해주는 JVM 내부 실행 단위다.


public class Continuation {
private static final Unsafe U = Unsafe.getUnsafe();
private static final long MOUNTED_OFFSET = U.objectFieldOffset(Continuation.class, "mounted");
private static final boolean PRESERVE_SCOPED_VALUE_CACHE;
private static final JavaLangAccess JLA = SharedSecrets.getJavaLangAccess(); // JDK 내부 코드가 `java.lang.Thread` 같은 내부 상태에 접근하기 위한 비공개 인터페이스. JDK 내부 native 메서드로 우회해서 현재 실행 중인 Java Thread의 threadObj() 반환으로 carrier thread의 java `Thread` 객체를 가져옴.
static {
ContinuationSupport.ensureSupported();
StackChunk.init(); // ensure StackChunk class is initialized
String value = GetPropertyAction.privilegedGetProperty("jdk.preserveScopedValueCache");
PRESERVE_SCOPED_VALUE_CACHE = (value == null) || Boolean.parseBoolean(value);
}
// Continuation.mount()는 이 Continuation이 현재 native stack 위에서 실행 중임을 표시하는 내부 플래그다.
private void mount() {
if (!compareAndSetMounted(false, true))
throw new IllegalStateException("Mounted!!!!");
}
private void unmount() {
setMounted(false);
}
/**
* Mounts and runs the continuation body. If suspended, continues it from the last suspend point.
*/
public final void run() {
while (true) {
mount(); // Continuation 자체를 mounted 상태로 표시함
JLA.setScopedValueCache(scopedValueCache); // 이전에 저장해둔 ScopedValue 캐시를 현재 실행 컨텍스트에 복원
if (done) // 이미 완료된 Continuation은 다시 실행할 수 없음
throw new IllegalStateException("Continuation terminated");
Thread t = currentCarrierThread(); // 현재 실행 중인 carrier thread를 가져와서
if (parent != null) { // 현재 carrier thread에 등록된 기존 continuation을 parent로 설정하거나 검증함
if (parent != JLA.getContinuation(t))
throw new IllegalStateException();
} else
this.parent = JLA.getContinuation(t);
JLA.setContinuation(t, this);
try {
boolean isVirtualThread = (scope == JLA.virtualThreadContinuationScope()); // 해당 continuation이 vt용인지 확인. vt는 VTHREAD_SCOPE라는 전용 continuationScope를 사용
// 첫 실행인지, 이전 yield 이후 재개인지 구분
if (!isStarted()) { // is this the first run? (at this point we know !done)
enterSpecial(this, false, isVirtualThread);
} else {
assert !isEmpty();
enterSpecial(this, true, isVirtualThread);
}
} finally {
fence(); // VM/JIT/CPU 최적화로 메모리 쓰기 순서가 어긋나지 않도록 fence 처리한다.
// 종료된 경우에는 stack이 비어있어야 하므로 done == true이면 continuation stack도 empty여야 한다는 내부 검
try {
assert isEmpty() == done : "empty: " + isEmpty() + " done: " + done + " cont: " + Integer.toHexString(System.identityHashCode(this));
// carrier thread의 현재 continuation을 parent로 되돌림
JLA.setContinuation(currentCarrierThread(), this.parent);
// parent가 있으면 parent.child 연결을 해제함
if (parent != null)
parent.child = null;
// continuation이 완료된 경우 tail stack chunk 참조를 정리!
postYieldCleanup();
// continuation 자체를 unmounted 상태로 설
unmount();
// ScopedValue 캐시 보존 옵션에 따라 현재 캐시를 저장하거나 비움
if (PRESERVE_SCOPED_VALUE_CACHE) {
scopedValueCache = JLA.scopedValueCache();
} else {
scopedValueCache = null;
}
// 현재 carrier thread에 설정된 ScopedValue 캐시를 비움
JLA.setScopedValueCache(null);
} catch (Throwable e) { e.printStackTrace(); System.exit(1); }
}
// we're now in the parent continuation (parent continuation 쪽으로 돌아온 상태)
assert yieldInfo == null || yieldInfo instanceof ContinuationScope;
// parent/yieldInfo를 정리하고 Continuation.run()을 반환
if (yieldInfo == null || yieldInfo == scope) {
this.parent = null;
this.yieldInfo = null;
return;
} else {
// 중첩 continuation에서 더 바깥쪽 scope로 yield해야 하는 경우
// 현재 continuation을 parent의 child로 연결하고,
// parent쪽 yield0를 호출해 yield를 바깥쪽으로 전파
parent.child = this;
parent.yield0((ContinuationScope)yieldInfo, this);
parent.child = null;
}
}
}
}