← Go Back
Computer Science/Operating System JavaJVMAsync

Java Virtual Thread

Virtual Thread 란?

**버추얼 스레드(Virtual Thread)**는 Java 21에서 정식화된, 동시성 처리 방식을 근본적으로 바꾼 기능이다.

기존 Java의 스레드 모델은 Native Thread로, Java의 유저 스레드를 만들면 Java Native Interface(JNI)를 통해 커널 영역을 호출하여 OS가 커널 영역을 생성하고 1:1로 매핑하여 작업을 수행하는 형태였다.

문제는 이게 비싸다는 점인데, 하나당 메모리 ~2MB, 생성시간 ~1ms, 컨텍스트 스위칭 시간 ~100μs가까이 나온다. 때문에 수천 개씩 못 만들고 스레드 풀로 개수를 제한해야 한다.

근데 전형적인 서버 작업(DB 조회, 외부 API 호출)은 대부분의 시간을 I/O 대기(블로킹)로 보내고, 스레드가 응답을 기다리는 동안 아무 일도 안하고 자원만 점유한다. 이에 대한 해결책은 두 가지이다.

  • 리액티브/비동기 (WebFlux, CompletableFuture) - 처리량은 좋지만 콜백 지옥, 디버깅 지옥, 함수 색칠
  • Virtual Thread - 동기식 블로킹 코드를 그대로 쓰면서 처리량을 확보

Virtual Thread는 기존 Java의 스레드 모델과 달리, 플랫폼 스레드와 가상 스레드로 나뉜다. 플랫폼 스레드 위에서 여러 Virtual Thread가 번갈아 가며 실행되는 형태로 동작한다. 마치 커널 스레드와 유저 스레드가 매핑되는 형태와 비슷하다.

여기서 가장 큰 특징은 Virtual Thread는 컨텍스트 스위칭 비용이 저렴하다는 것. Virtual Thread는 메모리 ~10KB, 생성시간 ~1μs, 컨텍스트 스위칭 시간 ~10μs 이다.

[!note]+ 스레드 계층 1. 네이티브 스레드 = OS 스레드 = 커널 스레드 운영체제 커널이 직접 만들고 스케줄링하는 “진짜” 스레드. Linux의 pthread가 이것. CPU 코어 위에서 실제로 돌아가는 실행단위이고, 커널이 관리하기 때문에 비싸다.

2. 플랫폼 스레드 (Platform Thread) java.lang.Thread가 네이티브 스레드를 1:1로 얇게 감싼 것. 자바에서 new Thread()로 만들던 그 스레드가 전부 플랫폼 스레드다.

  • Q. 그럼 왜 갑자기 “플랫폼 스레드”라는 이름이 생겼지?
  • A. Java 21에서 버추얼 스레드가 등장하면서 Thread가 두 종류로 나뉘었기 때문에 구분 목적.

3. 캐리어 스레드 (Carrier Thread) - 타입이 아니라 역할 별도의 스레드 종류가 아니라, 버추얼 스레드를 실행 중인 플랫폼 스레드를 부르는 이름. VT를 등에 업고(carry) 있어서 캐리어. VT가 언마운트되면 그 플랫폼 스레드는 캐리어 역할을 잠시 내려놓고 다른 VT를 태운다.

4. 버추얼 스레드 (Virtual Thread) JVM이 관리하는 경량 스레드. 네이티브 스레드와 1:1이 아니라 소수의 캐리어 위에 M:N으로 얹힌다. 타입은 여전히 java.lang.Thread라서 코드 호환이 되고, 스택이 힙에 저장되기에 수십만 개 생성이 가능하다.

Virtual Thread의 구조

우선 Platform Thread의 기본 스케줄러는 ForkJoinPool을 사용하는데, 스케줄러는 platform thread pool을 관리하고, Virtual Thread의 작업 분배 역할을 한다.

디버거를 통해 런타임의 Virtual Thread를 살펴보면

  • VirtualThread는 carrierThread를 가지고 있다. 실제로 작업을 수행시키는 platform thread를 의미한다. carrierThreadworkQueue를 가지고 있다.
  • VirtualThread는 scheduler라는 ForkJoinPool을 가지고 있다. carrier thread의 pool 역할을 하고, 가상 스레드의 작업 스케줄링을 담당한다.
  • VirtualThread는 runContinuation이라는 실제 작업 내용(Runnable)을 가지고 있다.

Virtual Thread의 동작 원리

  1. 실행 대기 Runnable Queue의 runContinuation을 carrier thread의 Work Queue에 마운트(unpark()) 한다.
  2. Work Queue의 runContinuation들은 ForkJoinPool에 의해 work stealing(각 캐리어가 자기 작업 큐를 갖고, 놀고 있으면 다른 캐리어 큐에서 작업을 훔쳐옴) 방식으로 Carrier Thread에 의해 처리된다.
  3. 처리되던 runContinuation들은 블로킹 작업(I/O), Sleep으로 인한 interrupt나 작업 완료 시, Work Queue에서 언마운트(pop)되어, park() 과정에 의해 다시 힙 메모리로 되돌아간다.
  4. carrier는 즉시 다른 버추얼 스레드를 실행한다.
  5. I/O가 끝나면 그 Virtual Thread가 아무 carrier에나 다시 마운트돼서 이어 실행된다.

-> 그래서 Virtual Thread의 스택은 OS 스레드처럼 고정 크기가 아니라 힙에서 필요한 만큼만 차지한다. 이게 수십만 개를 만들 수 있는 이유이다.

스레드 모델 간 비교

다음은 Ngrinder 기반 성능 테스트 결과이다.  

테스트는 애플리케이션 스펙을 최소 사양으로 두고, 256MB의 힙 사이즈를 사용하도록 설정했으며, 300ms를 sleep하는 API를 3번 호출하는 Request I/O Bound 작업, 0~300000000까지 합을 3번 계산하는 CPU Bound 작업으로 진행되었다.

Thread vs Virtual Thread

public String ioBound() {
        requestSleep().block(); //Thread.sleep(300) API 호출
        requestSleep().block();
        requestSleep().block();

    return "ok";
}

public Integer cpuBound() {
        IntStream.range(0, 300000000).reduce(0, Integer::sum);
        IntStream.range(0, 300000000).reduce(0, Integer::sum);
        return IntStream.range(0, 300000000).reduce(0, Integer::sum);
}

I/O Bound 작업에서 Virtual Thread의 성능은 Thread 모델에 비해 약 51% 이상 향상되었다.

우선 적절한 vuser 수를 설정하기 위해 테스트를 해보았는데, Ngrinder의 동시 요청 수를 계속해서 늘리다 보니 vuser(가상 사용자 수)가 250이 넘어가는 시점부터 Thread 모델에서는 서버가 죽고 응답을 정상적으로 주지 못하는 상황이 발생하였다. 반면에 virtual thread를 사용하는 서버에서는 동일한 vuser수에도 장애 없이 정상 처리한 모습.

반면 CPU Bound 작업에서는 일반 스레드 모델이 성능상 우위를 보였다. 경량 스레드가 결국 플랫폼 스레드 위에서 동작하기 때문에, CPU Bound 작업과 같은 Virtual Thread가 Switching 되지 않는 경우에는 Platform Thread 사용 비용뿐만 아니라 Virtual Thread 생성 및 스케줄링 비용까지 포함되어 성능 낭비가 발생되기 때문이다.

It is more expensive to run a task in a virtual thread than running it in a platform thread. (Java Youtube, Java 21 new feature: Virtual Threads)

Virtual Thread vs Kotlin Coroutine

fun ioBound(): String? {
    return CoroutineScope(Dispatchers.IO).async {
        requestSleep().awaitFirstOrNull() // api call
        requestSleep().awaitFirstOrNull()
        requestSleep().awaitFirstOrNull()
    }.await();
}

코루틴 모델은 앞서 테스트한 스레드 모델보다 더 많은 처리량을 가지기 때문에, 이전 테스트의 vuser의 4배인 510으로 두고 I/O bound 요청 테스트를 진행하였다. 성능테스트 결과 Virtual Thread의 성능이 Kotlin coroutine에 비해 37% 좋은 성능을 보였다.

[!note]+ 코루틴 Kotline Coroutine은 virtual thread이 JDK 자체적으로 지원하는 것과는 다르게 Kotlin 컴파일러의 마법으로 가능하다. Coroutine은 중단(suspend)했다가 나중에 그 지점부터 재개(resume)할 수 있는 함수로, 협력적 멀티태스킹(cooperative multitasking)의 단위이다.

간단하게 코루틴의 동작 원리를 살펴보면,

  1. suspend 함수를 Continuation과 지역변수를 가진 클래스로 만든다.
  2. 첫 번째 그림처럼 suspend 메서드 내에 호출하고 있는 suspend 함수가 2개의 지점이 있다면, suspend 함수 호출 부분을 기점으로 suspend point로 지정한다.
  3. 각각의 suspend point를 기준으로 label(L0, L1, L2)을 나눠 switch(when)문으로 finite state machine 처럼 코드를 Generate 한다.
  4. fetchUser(), fetchProfile() 과 같은 park/unpark가 필요한 I/O 발생지점과 같은 부분은 위에서 보이는 .await()와 같은 Kotlin 확장함수를 통해 park/unpark를 가능하게 한다. Virtual Thread는 기존의 Thread 방식을 완전히 대체하기 때문에, TaskExecutor를 교체하여 어플리케이션 전체에 적용할 수 있습니다. 반면 Coroutine은 메서드 단위로 원하는 곳에만 경량스레드를 적용할 수 있다는 장점이 있습니다.

코루틴은 JDK21 이전의 버전에서도 경량스레드를 적용할 수 있다는 장점을 가지고 있다. 이는 JDK의 최신버전을 바로 적용하기 어려운 상황에서 최선의 선택이 될 수 있다.

그러나 Coroutine은 suspend 진입 전 플로우는 경량스레드가 아닌 일반 Thread로 처리되고, I/O Block 이나 sleep 같은 Thread park/unpark(컨텍스트 스위칭)가 필요한 순간마다 Kotlin이 만들어놓은 suspend 확장함수를 사용해야 하므로 프로덕션 코드에 변경이 필요하다.

그리고 Reactive Streams 패러다임과 마찬가지로, suspend function들은 역시 전염성이 있어서 suspend가 전파될 수 있다는 함수의 색 문제가 있다. 전파를 막으려면 특정 지점에서 runBlocking을 사용하거나 suspend Controller로 만들어야 하는데, 이는 응답을 reactive 응답으로 전환하게 된다.

Virtual Thread vs Reactive Programming

public Mono<String> ioBound() {
      return requestSleep()
          .flatMap(it -> requestSleep())
          .flatMap(it -> requestSleep())
}

리액티브 스레드모델 또한, vuser를 앞서 테스트한것의 4배인 510으로 두고 I/O bound 요청을 통해 진행하였다. 성능테스트 결과 Virtual Thread의 성능이 Reactive에 비해 111% 좋은 성능을 보였다.

pring의 Reactive 프로그래밍 모델인 WebFlux는 Netty의 event loop 기반으로 동작한다. Event loop가 중심에서 모든 요청을 처리하고, 요청 처리 구간을 callback으로 등록해놓고 worker 스레드 풀이 작업들을 처리하는 형태이다. Worker 스레드가 작업을 처리하는 과정에서 I/O를 마주치게 되면 작업이 park 되면서 컨텍스트 스위칭이 발생한다.

위 코드를 보면 동기로 짜여있던 코드는 직관적이었던 반면 reactive 프로그래밍으로 짜인 코드는 다소 파편화 되어 있는 모습이다. if문이나 try/catch 구문들이 모두 메서드 단위로 분리되어 있기 때문인데, 이는 Java의 기본적인 syntax를 활용하기 어렵게 하여, 코드의 흐름을 이해하기 어렵게 만들 수 있다.

또한 reactive 프로그래밍은 함수의 색 문제를 가지고 있어, park/unpark 사용되는 부분마다 Reactive가 적용되어야 하고, 이는 플로우 전체에서 reactive streams를 사용해야하는 문제점이 존재한다.

마지막으로 컨텍스트 스위칭시 실제 스레드를 switch 하기 때문에, 경량스레드 switch에 비해 성능 낭비가 존재하고, 스레드의 컨텍스트를 상실하기 때문에 스택 트레이스가 유실된다는 단점도 존재한다. 이는 디버그를 어렵게 만들 수 있다.

주의사항

  • No pooling
    • Virtual Thread는 값싼 일회용품이라고 보시면 된다. 생성비용이 작기 때문에 스레드 풀을 만드는 행위 자체가 낭비가 될 수 있습니다. 필요할 때마다 생성하고 GC(Garbage Collector)에 의해 소멸되도록 방치해버리시는게 좋습니다. (실생활의 일회용품엔 GC가 없으니 방치하지 말아주세요!😊
  • CPU bound 작업엔 비효율
    • 앞선 테스트에서 봤듯이 IO 작업 없이 CPU 작업만 수행하는것은, 플랫폼 스레드만 사용하는것보다 성능이 떨어진다. 컨텍스트 스위칭이 빈번하지 않은 환경이라면, 기존 스레드모델을 사용하시는것이 이득이다.
  • Pinned issue
    • Virtual thread 내에서 synchronized 나 parallelStream 혹은 네이티브 메서드를 쓰면 virtual thread가 carrier thread에 park 될 수 없는 상태가 되어버린다. 이를 Pinned(고정된) 상태라고 하는데, 이는 예상한 virtual thread의 성능저하를 유발할 수 있다. 그래서 21에서는 ReentrantLock을 쓰라고 권장했었다. 근데 Java 24/25(JEP 491)에서 synchronized 피닝이 해결돼서, 25 기준으로는 모니터 관련 피닝은 거의 사라지고 네이티브 호출 같은 일부 코너 케이스만 남았다. 그래도 JNI 네이티브 블로킹은 여전히 피닝되니 주의.
  • Thread local
    • Virtual Thread는 수시로 생성되고 소멸되며 스위칭된다. 백만개의 스레드를 운용할 수 있도록 설계되었기 때문에, 항상 크기를 작게 유지하는게 좋다.

Reference

← Go Back