개요 - 기존 스레드의 한계
Java의 전통적인 동시성 모델은 Thread per Request 모델로 요청에 대해 하나의 스레드를 할당한다. 해당 요청 처리가 끝날 때 까지 스레드가 점유된다.
문제는 JVM의 스레드(Platform Thread)는 OS 스레드와 1:1로 매핑되며, 스레드 당 메모리를 1~2MB 점유하므로 비용이 비싸다.
서버의 자원은 한정되어 있기에 요청이 늘어난다고 해도, 수천 개 이상의 스레드를 생성하기에는 한계가 있다.
또한 JVM의 스레드는 I/O 블로킹이 발생하면, 작업이 완료될 때까지 다른 요청을 처리할 수 없다. 동시 요청이 스레드 풀 크기를 초과하게 되면 나머지 요청은 대기할 수 밖에 없고 병목이 될 수 있다.
Virtual Thread는 이러한 한계를 극복하고 처리량을 늘릴 수 있도록 하며, Java 21에 정식 릴리즈 되었다.
스레드의 종류

하드웨어 스레드 (CPU 코어)
CPU에서 실제로 명령을 실행할 수 있는 물리적 실행 단위이다.
하이퍼스레딩을 지원하는 경우, 하나의 물리 코어에서 2개 이상의 하드웨어 스레드가 동작할 수 있다.
커널 스레드 (OS Thread)
운영체제가 관리하는 실행 단위이다.
OS 스케줄러에 의해 하드웨어 스레드에 배치되어 실행된다.
유저 스레드 (Platform Thread)
JVM이 관리하는 스레드이다.
커널 스레드와 1:1로 매핑되어 실행된다.
Virtual Thread란?

유저 스레드는 커널 스레드와 1:1로 매핑되어 있다. 하나의 스레드가 하나의 작업을 온전히 처리해야 하며, Blocking이 발생할 경우 해당 스레드는 작업이 완료될 때까지 다른 요청을 처리할 수 없다.
Virtual Thread는 '하나의 스레드가 Blocking으로 대기하는 동안, 다른 작업을 처리할 수 있게 한다면?' 이라는 아이디어에서 기반되었다.
Virtual Thread는 유저 스레드와 달리 OS 스레드에 직접 매핑되지 않는 경량 스레드이다. 하나의 유저 스레드 위에서 여러 개의 Virtual Thread가 JVM 레벨에서 스케줄링되어 실행된다.
기존 유저 스레드는 OS 레벨에서 스케줄링되어 커널 컨텍스트 스위칭 비용이 발생하지만, Virtual Thread는 JVM 레벨에서 스케줄링되어 비용이 훨씬 가볍다. 또한 스레드당 메모리 사용량도 수 KB 수준으로 매우 적기 때문에, 경량 스레드라고 불린다.
이 때, Virtual Thread가 실행되기 위해 올라타는 유저 스레드를 Carrier Thread 라고 한다.
또한 Virtual Thread가 유저 스레드의 올라타서 실행되는 것을 mount라고 하며, Blocking 등으로 내려오는 것을 unmount라고 한다
Virtual Thread 동작 원리 - Work Stealing

Virtual Thread의 스케쥴러는 Fork/Join Pool을 이용한다.
Fork/Join Pool에서 Virtual Thread의 동작은 아래와 같다.
1. Fork/Join Pool 내의 Carrier Thread 수는 기본적으로 CPU 코어 수만큼 존재한다.
2. Virtual Thread의 작업은 Carrier Thread에 mount 되어 실행된다.
3. Carrier Thread는 각각의 Work Queue를 가지며, 그 안에 있는 Virtual Thread 작업을 순차적으로 처리한다.
4. 만약 본인의 Work Queue에 작업이 없다면, 다른 Carrier Thread의 Work Queue에서 작업을 훔쳐와서 실행한다. (Work Stealing 알고리즘)
5. 작업 중 Blocking 발생 시, 현재까지의 실행 정보(스택 프레임)를 Heap에 저장 후 unmount된다. 이때 컨텍스트 스위칭은 JVM 레벨에서 발생한다.
6. Blocking이 끝나면 Virtual Thread는 이전과 다른 Carrier Thread에도 mount될 수 있으며, Heap에 저장해 둔 스택 프레임을 불러와 중단 지점부터 작업을 재개한다.
Virtual Thread는 언제 어떻게 좋은 것인가?
기존의 유저 스레드와 비교했을 때 Virtual Thread의 가장 큰 차이점은 Blocking으로 인한 컨텍스트 스위칭 시 나타난다.
유저 스레드에서 Blocking이 일어나면 커널 수준에서 컨텍스트 스위칭이 일어난다. 반면에 Virtual Thread는 Blocking 시 유저 스레드는 OS 스레드에 계속 할당되어 있고 JVM 레벨에서 컨텍스트 스위칭이 일어난다.
결과적으로 Virtual thread는 Blocking 에 발생하는 컨텍스트 스위칭에 대한 메모리와 시간 등의 비용을 아낀다.
이는 CPU Bound 작업보다는 I/O 블로킹 작업에 더 효율적이며, CPU Bound 작업의 비중이 클 경우 Virtual Thread 이점을 크게 보지 못 할 것이다.
기존 유저 스레드와 비교했을 때 Virtual Thread의 이점은 크게 두 가지이다.
첫째, Blocking 시 컨텍스트 스위칭 비용이 가볍다. 유저 스레드에서 Blocking이 일어나면 커널 수준에서 컨텍스트 스위칭이 일어난다. 반면에 Virtual Thread는 Blocking 시 Carrier Thread는 OS 스레드에 계속 할당되어 있고, JVM 레벨에서 컨텍스트 스위칭이 일어난다. 커널 개입 없이 처리되므로 메모리와 시간 등의 비용이 절감된다.
둘째, 스레드당 메모리 사용량이 적어 훨씬 많은 수의 스레드를 생성할 수 있다. Platform Thread는 스레드당 1~2MB의 메모리를 점유하여 동시에 수천 개 이상 생성하기 어렵다. 반면 Virtual Thread는 수 KB 수준이므로 수십만 개 이상의 스레드를 동시에 운용할 수 있다. 이로 인해 동시 요청이 많은 환경에서 처리량이 크게 향상된다.
이러한 이점은 CPU Bound 작업보다는 I/O Blocking 작업이 많은 환경에서 효과적이다. CPU Bound 작업의 비중이 클 경우, Blocking 자체가 적기 때문에 Virtual Thread의 이점을 크게 보지 못할 것이다.
| Platform 스레드 | Virtual thread | |
| 매핑 | OS 스레드 1 : 1 | OS 스레드 N : M |
| 생성 비용 | 비쌈 | 저렴 |
| 컨텍스트 스위칭 | OS 레벨에서 발생 | JVM 내부에서 스케쥴링 |
| 메모리 사용량 | MB 단위 | KB 단위 |
| 생성 가능 갯수 | 수천개 | 수백만개 |
| Blocking시 동작 | OS 스레드 점유 | Carrier Thread unmount |
중단되었던 작업을 어떻게 재개하는 걸까? - Continuation
중단되었던 Virtual Thread의 작업이 재개될 때, 이전에 중단지점을 어떻게 기억하고 작업을 이어나갈 수 있는걸까?
일반적인 함수 호출은 스택에 쌓이고 return으로 빠져나오며, 중간에 멈췄다가 나중에 이어서 실행하는 것은 불가능하다.
Virtual Thread는 Continuation라는 객체를 통해 중단 시점의 작업 실행 상태를 저장한다

Virtual Thread 객체의 내부를 보면, Continuation 객체가 들어있는 것을 볼 수 있다.

Continuation 객체의 내부를 보면, Runnable 그리고 2개의 Continuation 객체가 있는 것을 볼 수 있다.
Runnable 객체는 Virtual Thread 수행해야 할 실제 작업을 의미한다. Thread에 넘겨주는 람다나 메서드가 Runnable 객체에 할당된다.
또한 ContinuationScope와 부모, 자식 Continuation을 통해 Virtual Thread가 중단된 시점의 작업 실행 상태를 저장하고 있다.
정리하자면, Virtual Thread는 작업이 중단된 시점의 상태를 Continuation이라는 객체를 통해 Heap 영역에 저장한 후, Carrier Thread에서 해제된다.
Blocking이 완료되어서 JVM 스케쥴러를 통해 Carrier Thread에 mount가 된다면, Continuation 객체를 통해 중단된 시점의 작업 실행 상태를 통해 작업을 재개하는 것이다.
Virtual Thread 사용 예시
private int makeRandom() {
//난수를 생성하는데 10초가 걸리는 무거운 작업
sleep(10000);
int value = new Random().nextInt(10);
System.out.println("난수 생성 : " + value);
return value;
}
10초가 걸리는 난수 생성 작업이 있다고 가정하자.
단일 Virtual Thread 생성 및 실행
//단일 Virtual Thread
Thread thread = Thread
.ofVirtual()
.name("myVirtual1")
//바로 실행
.started(() -> makeRandom());
//Virtual Thread 여부 확인 가능
log.info(thread.isVirtual());
=============================================
Thread thread = Thread
.ofVirtual()
.name("myVirtual1")
//바로 실행하지 않음
.unstarted(() -> makeRandom());
thread.start();
thread.join();
Thread.ofVirtual() 메서드를 통해 단일 Virtual Thread를 생성할 수 있다.
isVirtual() 메서드로 Virtual Thread 여부에 대해 확인 가능하다.
Virtual Thread 풀
long startTime = System.currentTimeMillis();
//Virtual Thread 풀 생성
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 10; i++) {
es.submit(() -> makeRandom());
}
}
long endTime = System.currentTimeMillis();
System.out.println("모든 작업 완료 시간 : " + (endTime - startTime));
newVirtualThreadPerTaskExecutor() 메서드를 통해 손쉽게 Virtual Thread 풀을 생성할 수 있다.
//커스텀 설정
ThreadFactory factory = Thread.ofVirtual().name("virtual-thread-pool").factory();
try (ExecutorService es = Executors.newThreadPerTaskExecutor(factory)) {
for (int i = 0; i < 10; i++) {
es.submit(() -> makeRandom());
}
}
Virtual Thread 풀에 이름 등에 커스텀한 설정이 필요할 경우, 직접 인자로 넘겨줄 수도 있다.
Virtual Thread 성능 비교
private static long getUsedMemory() {
Runtime runtime = Runtime.getRuntime();
//전체 메모리 - 남은 메모리 = 사용한 메모리
return runtime.totalMemory() - runtime.freeMemory();
}
메모리 성능 비교를 위해 사용할 메서드이다.
메모리 측정
private static final int THREAD_COUNT = 10000;
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
//유저 스레드 풀
try (ExecutorService es = Executors.newFixedThreadPool(THREAD_COUNT)) {
for (int i = 0; i < THREAD_COUNT; i++) {
es.submit(() -> {
try {
makeRandom();
} finally {
latch.countDown();
}
});
}
}
latch.await();
System.out.println("사용한 메모리 : " + getUsedMemory());
스레드 풀을 10000개로 지정 후, 10초의 Blocking이 있는 작업을 스레드 수 만큼 실행해보았다.

결과는 OutOfMemoryError다..! 메모리를 초과하였다
private static final int THREAD_COUNT = 10000;
CountDownLatch latch = new CountDownLatch(THREAD_COUNT);
//Virtual Thread 풀
try (ExecutorService es = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < THREAD_COUNT; i++) {
es.submit(() -> {
try {
makeRandom();
} finally {
latch.countDown();
}
});
}
}
latch.await();
System.out.println("사용한 메모리 : " + getUsedMemory());
이번엔 Virtual Thread 풀로 변경해서 실행해보았다.

메모리 초과 없이 10000개의 작업을 10초 동안 처리한 것을 볼 수 있다.
단순히 유저 스레드 풀을 Virtual Thread 풀로 변경하기만 하였을 뿐인데 메모리 상에 이점을 보았다.
주의할 점
이렇게 보면 좋은 점 투성이라 이쁜 녀석으로만 보이지만 주의할 점이 있다.
1. pinning - Virtual Thread에서 synchronized 키워드를 사용하지 말자
Virtual Thread에서 synchronized 블록을 통해 동시 접근을 제어하게 된다면, Carrier Thread 수준에서의 모니터 락을 사용하게 된다.
Carrier Thread가 락을 획득한 후 작업을 하다가 Blocking이 발생을 하게 된다면, Virtual Thread는 unmount되지 못하고 컨텍스트 스위칭이 불가능하게 된다.
2. Thread Local에 큰 객체를 저장하지 말자
Virtual Thread 별로 독립적 Thread Local을 사용할 수 있다. 하지만 Virtual Thread는 정말 많이 생성이 가능하다.
각각이 다 Thread Local을 통해 데이터를 관리한다면, 메모리 관련 문제가 발생할 수도 있을 것이다.
가상 스레드에서 Thread Local의 대안으로 Scoped Value라는 것이 나왔다. Java 25에서 정식으로 릴리즈 되었다.
'Language > Java' 카테고리의 다른 글
| [Java] Callable과 Future & CompletableFuture (0) | 2026.02.01 |
|---|---|
| [Java] 자바 스레드 풀과 ExecutorService (0) | 2026.01.27 |
| [Java] ReentrantLock을 통한 스레드 동기화 (0) | 2026.01.02 |
| [Java] 자바 스레드 생명 주기와 메모리 가시성 (0) | 2025.12.30 |
| [Java] Stream Collector, Downstream Collector (0) | 2025.12.26 |