본문 바로가기

Java and Spring

Virtual Thread란?

이번 포스팅에서는 Virtual Thread에 대해서 소개해보려고 합니다.

자바 21에서 Virtual Thread가 공식적으로 Feature 되었다는 소식을 듣고 기존 Thread와 어떤 차이점이 있고 어떻게 동작하는지 원리를 알아보도록 하겠습니다.

1. Virtual Thread 소개

Virtual Thread는 기존 자바 Thread와 다른 방식으로 동작하는 경량 쓰레드입니다.

간단하게 기존 Thread는 OS의 Thread와 1:1 맵핑을 하여 작업을 처리하는 방식이라면, Virtual Thread는 OS의 Thread를 사용하지 않고, JVM 내부 스케줄링을 통해 맵핑이 되는 구조라서 좀 더 빠르고, 생성시 비용적인 측면에서 효율적인 장점이 있습니다.

 

그럼 간단하게 Virtual Thread 생성시 비용적인 측면에서 효율적인지 코드를 통해 테스트를 해보도록 하겠습니다.

먼저, 전통적인 Thread입니다.

public static void main(String[] args) {
    long start = System.currentTimeMillis();
    List<Thread> threads = IntStream.range(1, 10000)
            .mapToObj(i -> new Thread(() -> {}))
            .toList();

    threads.forEach(Thread::start);
    long end = System.currentTimeMillis();
    System.out.println(end - start);
}

 

자바 21에서 새로 추가된 Virtual Thread입니다.

public static void main(String[] args) {
    long start = System.currentTimeMillis();
    List<Thread> threads = IntStream.range(1, 10000)
            .mapToObj(i -> Thread.ofVirtual().unstarted(() -> {}))
            .toList();

    threads.forEach(Thread::start);
    long end = System.currentTimeMillis();
    System.out.println(end - start);
}

 

쓰레드 구현부를 비워두고 순수하게 생성하는 속도로만 보더라도 Virtual Thread가 훨씬 빠르다는 것을 볼 수 있습니다.

이러한 이유는 기존 Thread와 Virtual Thread의 동작방식이 다르기 때문인데 아래에서 그 이유를 자세하게 알아보도록 하게습니다.

 

2. 기존 Thread와 가상 Thread의 구조 차이

기존 Thread 같은 경우 어플리케이션에서 Thread를 사용하면 OS의 쓰레드와 연결이 되게 됩니다.

이 과정에서 JNI(Java Native Interface)를 통해 작업을 처리하기 때문에 System Call 과정에서 시간을 많이 사용하게 됩니다.

반면에 Virtual Thread 같은 경우 기존 Java 쓰레드와 달리 플랫폼 쓰레드와 가상 쓰레드로 나뉘게 되고 여러개의 가상 쓰레드가 플랫폼 쓰레드에서 작업을 하게 됩니다.

바로 이런 구조로 인해서 기존 Java 쓰레드와 달리 가상 쓰레드는 컨텍스트 스위칭 비용이 저렴합니다.

기존 Java 쓰레드는 최대 2MB의 스택 사이즈를 가지고 있기 때문에 메모리 이동량이 크고, 쓰레드 생성시 JNI을 통해여 커널에 접근해야 하기 때문에 System Call시 비용이 많이 들게 됩니다.

 

반면에 가상 쓰레드는 스택 사이즈도 작고, JVM내부에서 스케줄링 되고 JNI을 통한 System Call이 없기 때문에 기존 Thread에 비해 컨텍스트 스위칭 비용이 적어지게 됩니다.

 

3. 가상 Thread 동작 원리

이번에는 가상 Thread가 어떻게 동작하는지 2가지 관점에서 알아보도록 하겠습니다.

3 - 1. 객체 생성 관점

먼저 Thread.start를 하게되면 VirturalThread는 Thread를 상속받고 있기 때문에 @Override한 start()가 실행되게 됩니다.

submitRunContinuation()은 JVM내부적으로 스케줄링 하는 기능을 실행하는 부분입니다.

 

submitRunContinuation에 들어가보면 scheduler가 있는데 이게 가상 쓰레드의 스케줄러라고 생각하시면 됩니다.

 

그럼 scheduler 변수를 알아보기 위해 클래스 상위의 변수 선언부를 보면 scheduler 변수가 선언되어 있고, 생성자를 통해  DEFAULT_SCHEDULER값으로 초기화가 되고 있다는걸 알 수 있습니다.

 

그럼 다시 DEFAULT_SCHEDULER 값을 알아보기 위해 클래스 상위의 변수 선언부를 보면, DEFAULT_SCHEDULER는 ForkJoinPool타입으로 스케줄링을 하는것을 알 수 있고, ForkJoinPool을 상속받은 CarrierThread를 사용하는 것을 알 수 있습니다.

 

지금까지 객체 생성 시 가상 쓰레드가 효율이 좋은 이유를 코드로 알아보았습니다.

아래 이미지와 같이 JVM 내에서 ForkJoinPool을 사용하여 스케줄링을 하고 커널 영역에 접근하지 않아 System Call 오버헤드가 발생하지 않고, 위의 1. Virtual Thread 소개에서 객체 생성 테스트를 할 때, 기존 Java 쓰레드보다 빠르게 객체 생성 작업을 처리 할 수 있었던 이유입니다.

 

3 - 2. 작업 단위 관점

가상 쓰레드는 non blocking I/O를 제공해 주기 때문에 기존의 컨텍스트 스위칭이 많이 발생하던 작업에 대해서 효율적으로 작업을 처리 할 수 있도록 도와주고 있습니다. 가상 쓰레드는 이러한 작업단위를 Continuation을 통해 관리하고 있습니다.

그렇다면, Continuation과 Runnable이 생성자를 통해 어떻게 초기화가 되고 있는지 확인해보도록 하겠습니다.

cont는 VThreadContinuation으로 초기화가 되고 runContinuation은 내부 메소드를 통해 Continuation을 실행시키고 있습니다.

 

그리고 아까 위에서 보았던 start() 메소드가 실행되었을 때, submitRunContinuation()이 실행되게 되는데, 이 때 스케줄러를 통해 Continuation을 실행시키게 됩니다.

 

스케줄을 통해 작업단위가 실행되게 되면 아래와 같은 방식으로 작업이 진행되게 됩니다.

  1. 가상 쓰레드의 작업 단위인 runContinuation이 CarrierThread의 WorkQuere에 들어가게 됩니다.
  2. 처리중이던 runContinuation들이 I/O 혹은 다른이유로 blocking이 된다면 park() 메소드를 통해 중단을 하고 힙메모리로 이동하게 됩니다.
  3. CarrierThread가 비어있다면, CarrierThreaad와 VirtualThread는 아무런 관계가 없기 때문에 다른 VirtualThread의 작업을 가져와 진행하게 됩니다.

이러한 이유와 구조로 가상 쓰레드는 기존의 Java 쓰레드보다 작업 처리 속도가 빠르다는 것을 알 수 있습니다.

4. 그래서 결국 가상 Thread는 언제 사용할까??

결론적으로 저도 이 부분이 궁금하여 공부하게 되었던 것 같습니다.

가상 쓰레드의 장점은 적은 컨텍스트 비용입니다. 만약 실무에서 I/O 블록킹으로 인해 System Call이 자주 발생하는 시스템이라면 가상 쓰레드를 적용하는게 좋을 것으로 보입니다.

반면에 자바의 기존 Thread를 사용하더라도 Thread가 부족하지 않고, I/O 블록킹이 자주 일어나지 않는 상황이라면 굳이 가상 Thread를 사용할 필요는 없다고 생각합니다.

그 이유는 I/O 블록킹이 자주 일어나지 않으면 가상 쓰레드의 장점을 살릴 수 없을 뿐만아니라 가상 쓰레드를 도입하기 위해 코드가 복잡해지거나 불필요한 러닝커브가 발생하기 때문입니다.