
Node.js, Python을 만지작거리다 정신차려보니 자바의 새로운 LTS인 Java 21이 나온 지도 1년이 넘어버렸습니다... 아직도 자바 8 ~ 17 어디쯤 멈춰있는 제 뇌를 최근 LTS인 Java 21과 동기화해보려고 합니다…
21버전에서도 많은 사람들의 관심을 끌고 있는 가상 스레드가 현재의 스레드 구조와 어떤 차이를 가지고 성능상 이점을 이끌어내고 있는지 알아봅시다.
자바의 스레드는 모델은 Native Thread 모델입니다.
Native Thread는 OS 스레드와 VM의 스레드가 매핑되어 실행되는 스레드 모델로 JVM에서는 이를 위해 JNI(Java Native Interface)를 통해 커널 영역의 시스템 콜을 호출해 실제 스레드를 동작시킵니다.
이런 특징 때문에 자바에서 스레드를 사용하는 것은 OS 스레드를 사용하는 것과 동일한데, 때문에 문맥 교환, 생성 등 시간적 비용이 컸고, 프로세스에 비해 작다고는 해도 최대 2MB의 메모리를 점유해 4GB 환경에서 아무리 크게 잡아도 4000개의 스레드만을 생성할 수 있었습니다. (공간적 비용도 저렴하지 않았습니다.)
설명에 앞서 Java의 대표적인 프레임워크인 스프링 MVC는 기본적으로 Thread Per Request 방식을 사용하는데 즉, 하나의 요청당 하나의 스레드를 사용하는 방식입니다.
다만, 이런 구조에서는 급격히 증가하는 요청을 수용하기 위해 더 많은 수의 스레드가 필요했지만, 제한적인 스레드만 생성 가능한 자바의 스레드로 인해 새로운 방식을 고려해야했습니다.
때문에 자바 개발자들은 Event-Loop 기반으로 Non-Blocking I/O를 구현한 Spring WebFlux, 경량 스레드의 일종인 코루틴을 통해 위의 제한사항을 해결해왔습니다.
다만, 기존 자바 스레드를 사용하는 것에 비해 높은 학습곡선을 가지는 기술들이고 때문에 자바에서는 이런 불편함을 해결하기 위해 JDK 21부터 Project Room에서 처음 모습을 드러냈던 가상 스레드를 출시해 위 제한사항을 해결하고 있습니다.
가상 스레드는 운영 체제의 스레드에 자바의 스레드를 매핑하는 대신, JVM Thread Pool에 저장된 Platform 스레드 위에서 실행되는 경량 스레드입니다.
가상 스레드의 가장 큰 특징은 가볍운 용량, 빠른 문맥 교환, 더 높은 처리량이라고 생각합니다.
가상 스레드에서는 continuation, JVM 스케줄러를 통해 Non-Blocking I/O 작업을 지원하는데 덕분에 I/O 작업이 빈번한 요청에서 개선된 처리량을 보일 수 있고,
OS 스레드가 아닌 Platform 스레드에 매핑되어 더 가볍운 용량은 가질 수 있고, 문맥 교환시에도 더 빠르게 동작 수행이 가능합니다.
코드에서도 보여지듯 기존 스레드는 native 메서드인 park를 사용해 I/O 요청등이 들어오면 실제 스레드를 블락한다면,
가상 스레드는 더이상 native가 함수를 통해 스레드를 블락하는 것이 아닌 yeildContinuation()을 통해 Continuation을 대기 상태로 전환하기 때문에 문맥 교환 비용이 훨씬 저렴합니다.
가상 스레드가 non-blocking I/O를 구현하는 방식을 이해하기 위해서는 Continuation을 이해해야합니다.
Continuation은 실행가능한 작업 흐름을 말하는데, 특징으로는 중단 가능하다, 중단 지점으로부터 재실행 가능하다라는 특징을 가집니다.
즉, 하나의 함수처럼 한번 실행되면 끝날 때까지 종료시키지 못하는 것이 아닌, 함수 실행 시에도 특정 지점에 중단하고 재실행 가능한 작업의 흐름을 말합니다.
continuation은 본인의 작업이 중단(yeild)된다면 스택 영역에서 실행 중이던 상태(stack pointer)를 Continuation 객체에 저장 후 Heap 메모리로 이동합니다.
그리고, 대기 중이던 Continuation을 heap → stack 으로 이동시켜 기록되어있는 스택 포인터를 기준으로 다음 코드를 실행합니다.
가상 스레드는 이런 Continuation의 특징을 이용해 non-blocking I/O를 구현하는데, I/O 요청이 들어오면 더 이상 스레드를 블락하지 않습니다.
I/O요청을 받은 Continuation의 실행 흐름을 중단시키고 Work Queue에서 해당 Continuation을 제거합니다.
이제 Carrier Thread는 블락되지 않기 때문에 큐에서 대기 중인 또 다른 Continuation을 실행시키도록 구현되어 I/O 이벤트가 발생해도 또 다른 Continuation을 실행하고 Non-Blocking I/O로 동작할 수 있습니다.
- 예제 코드
/** Result: Flow A is Running - 1 Flow B is Running - 1 Flow A is Running - 2 Flow B is Running - 2 **/ public class Main { public static void main(String[] args) { ContinuationScope scope = new ContinuationScope("Virtual Thread"); Continuation flowA = new Continuation(scope, () -> { System.out.println("Flow A is Running - 1"); Continuation.yield(scope); System.out.println("Flow A is Running - 2"); }); Continuation flowB = new Continuation(scope, () -> { System.out.println("Flow B is Running - 1"); Continuation.yield(scope); System.out.println("Flow B is Running - 2"); }); flowA.run(); flowB.run(); flowA.run(); flowB.run(); } }

이제 가상 스레드가 뭔지, 가상 스레드에서 continuation이 뭔지 알아봤으니 가상 스레드가 실행되고 종료될 때까지의 동작 방식을 알아봅시다.
앞서 알아본 것 처럼 virtual thread는 OS 스레드와 분리되어 동작해 생성, 문맥 교환등의 작업을 내부 스케줄러를 통해 수행시켜 시스템 콜 오버헤드 시간을 개선했으며, 더 가벼운 메모리 공간을 차지해 스레드 풀에서 관리되지 않고 작업 종료 후 삭제됩니다.
또한, Continuation을 통해 Non-Blocking I/O를 지원하는데 덕분에 I/O 작업 성능이 개선되었는데 기존 스레드와 비교를 통해 장/단점을 알아보겠습니다.
public class VirtualThreadPerformanceTest {
public static void main(String[] args) throws InterruptedException {
System.out.println("Performance Test with Virtual Threads and Platform Threads");
System.out.println("\nCase 1: Sleep-based Blocking Tasks");
System.out.println("Platform Threads:");
measurePerformance(false, VirtualThreadPerformanceTest::blockingTask);
System.out.println("Virtual Threads:");
measurePerformance(true, VirtualThreadPerformanceTest::blockingTask);
System.out.println("\nCase 2: CPU-Intensive Tasks");
System.out.println("Platform Threads:");
measurePerformance(false, VirtualThreadPerformanceTest::cpuIntensiveTask);
System.out.println("Virtual Threads:");
measurePerformance(true, VirtualThreadPerformanceTest::cpuIntensiveTask);
}
private static void measurePerformance(boolean useVirtualThreads, Runnable task) throws InterruptedException {
ExecutorService executor;
if (useVirtualThreads) {
executor = Executors.newVirtualThreadPerTaskExecutor();
} else {
executor = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
}
Instant start = Instant.now();
for (int i = 0; i < 1_000_000; i++) {
executor.submit(task);
}
executor.shutdown();
boolean finished = executor.awaitTermination(10, java.util.concurrent.TimeUnit.MINUTES);
if (!finished) {
System.err.println("Tasks did not complete in time!");
}
Instant end = Instant.now();
System.out.println("Time taken: " + Duration.between(start, end).toMillis() + " ms");
}
// Case 1: Blocking Task (Sleep-based)
private static void blockingTask() {
try {
Thread.sleep(100); // Simulate blocking task
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
// Case 2: CPU-Intensive Task
private static void cpuIntensiveTask() {
long sum = 0;
for (int i = 0; i < 10_000_000; i++) {
sum += i; // Simulate CPU-intensive computation
}
}
}
해당 테스트에서 확인 가능하듯이 Blocking이 발생하는 이벤트의 경우 non-blocking I/O 덕분에 가상 스레드가 성능상 이점을 가집니다.
반면 CPU Bound 작업은 오히려 일반 스레드가 성능상 이점을 가졌는데, CPU Bound 작업에서는 Virtual Thread가 Switching 되지 않고 Platform Thread 사용 비용뿐만 아니라 Virtual Thread 생성 및 스케줄링 비용까지 포함되어 성능 낭비가 발생되기 때문입니다.
이상으로 virtual thread의 원리와 장/단점을 알아보았습니다.
virtual thread는 기존 스레드의 인터페이스를 상속하기 때문에 거의 완벽하게 기존 시스템과 호환되어 학습 곡선이 기존 다른 시스템보다 훨씬 낮고, 제한된 환경에서 보이는 빠른 처리량과 높은 처리량이 장점인 기술입니다.
Virtual Thread가 대부분의 환경에서 적합한가에는 아직 물음표가 붙을 수 있는 낯선 기술이지만, 결국 Java가 다른 언어와 차이를 만들어 낼 수 있는 기술이라고 생각이 들 정도로 흥미로운 기술이라 생각합니다.