Skip to content

가상스레드

  • 2023년 9월 19일에 릴리즈된 Java 21에서 ‘가상 스레드’ 라는 기능이 추가되었다.

  • 가상 스레드란 기존의 전통적인 Java 스레드보다 가벼운 경량 스레드이다. 자바의 전통적인 스레드는 OS 스레드를 랩핑(wrapping)한 것으로, 플랫폼 스레드라고도 부르는데, 경량 스레드는 OS 스레드를 그대로 사용하지 않고 JVM 자체적으로 내부 스케줄링을 통해서 사용할 수 있도록 한다.는 낭비를 줄여야 할 필요가 있었다. 이 때문에 Non-blocknig 방식의 Reactive Programming이 발전하였다.

  • 비동기 방식의 Reactive 프로그래밍을 사용하면 낭비되었던 스레드를 다른 요청을 처리하는데 사용할 수 있다. 하지만 이런 Reactive 코드는 이해하기 어렵고, 다른 라이브러리를 모두 Reactive 방식에 맞게 새롭게 작성해야하는 문제가 있었다.

    • 가상 스레드는 Non-blocking에 대한 처리를 JVM 레벨에서 담당하기에 기존와 크게 다르지 않은 코드로 비동기 처리를 구현할 수 있다. 일반적인 코드를 작성하더라도 내부에서 가상 스레드가 효율적으로 스케줄링 해주어 처리량을 높일 수 있다.
  • 자바 플랫폼은 전통적으로 스레드를 중심으로 구성되어 있었기에, 비동기 방식에선 디버깅이 어려웠다. Exception, Debugger, Profile(JFR) 등 스레드 호출 스택들이 데이터와 컨텍스트를 연결할 때 기본적으로 thread local을 사용하도록 설계되어 있다. Reactive 스타일로 코드를 작성하면 여러 스레드에서 요청을 처리하기 때문에 스택 추적이 힘들었다.

    • 하지만 가상 스레드는 기존 스레드 구조를 그대로 사용하기 때문에 디버깅, 프로파일링등 기존의 도구를 그대로 사용할 수 있다.

구조

  • 가상 스레드를 사용하면 JVM 자체적으로 가상 스레드를 OS 스레드와 연결(스케줄링)해준다. 이 작업을 mount/unmount라고 하고, 기존에 플랫폼 스레드라고 하던 부분을 Carrier 스레드라고 한다.

  • 한 요청을 하나의 가상 스레드가 처리하고, 가상 스레드에 Blocking이 일어나면 실제 Carrier 스레드에는 다른 가상 스레드를 스케줄링하는 식으로 처리한다. 따라서 Non-blocking이 누리는 장점을 동일하게 누릴 수 있다.

image

  • 이런 방식에서는 가상 스레드의 수가 수 백만개까지 크게 늘어날 수 있기 때문에 기존 스레드보다 더 적은 자원을 사용한다.

    플랫폼 스레드가상 스레드
    메타 데이터 사이즈약 2kb(OS별로 차이있음)
    메모리미리 할당된 Stack 사용
    컨텍스트 스위칭 비용1~10us (커널영역에서 발생하는 작업)

사용 방법

  • 스레드를 생성하기 위해 Thread.ofVirtual(), Thread.ofPlatform(), Thread.Builder라는 신규 API를 사용할 수 있다.

    • Thread.ofPlatform()는 기존 스레드를, Thread.ofVirtual()는 가상 스레드를 생성하는 함수이다.

    • Thread.Builder를 사용하면 단일 Thread 뿐만 아니라 동일한 속성의 여러 스레드를 갖는 ThreadFactory도 생성할 수 있다. 아래는 시작되지 않은 foo라는 이름의 새로운 가상 스레드를 생성하고 실행하는 코드이다.

      Runnable fn = () -> {
      // your code here
      };
      Thread thread = Thread.ofVirtual()
      .name("foo")
      .unstarted(fn);
  • 현재 스레드가 가상 스레드인지 검사하려면 다음의 메소드를 사용할 수 있다.

    boolean isVirtual = Thread.isVirtual();

 

  • Thread.getAllStackTraces()를 호출하면 전체 플랫폼 스레드의 스택 트레이스를 맵으로 제공해준다.

    Map<Thread, StackTraceElement[]> map = Thread.getAllStackTraces();

 

  • Thread API에서 플랫폼 스레드와 가상 스레드의 차이를 정리하면 다음과 같다.
    • Thread 클래스의 퍼블릭 생성자로는 가상 스레드를 만들 수 없다.
    • 가상 스레드는 항상 데몬 스레드이며 Thread.setDaemon(boolean)으로도 비데몬 스레드로 바꿀 수 없다.
    • 가상 스레드의 우선 순위는 Thread.NORM_PRIORITY로 항상 고정되어 있으며, 수정할 수 없다.
    • 가상 스레드는 스레드 그룹의 active member가 아니다. 가상 스레드에서 Thread.getThreadGroup()를 호출하면 “VirtualThreds”라는 이름의 placeholder 스레드 그룹을 반환한다. Thread.Builder API는 가상 스레드의 스레드 그룹을 설정하는 메소드를 갖고 있지 않다.

주의할 점

  • 풀링은 고가의 리소스를 공유하기 위한 것이다. 하지만 가상 스레드는 라이프사이클 동안 하나의 작업만 실행하도록 설계되었으므로 절대 풀링해서는 안된다. 따라서 풀링 없이 항상 새롭게 생성해주면 된다. 동시 요청의 수를 제한하기 위해 스레드 풀(Thread Pool)을 사용하는 코드도 풀링 대신 세마포어 등을 사용하도록 수정해야 한다.

    var executor = Executors.newFixedThreadPool(10)
    var executor = Executors.newVirtualThreadPerTaskExecutor()
  • 스레드 로컬은 스레드의 실행과 연관된 데이터들을 다루는 기법으로 캐싱, 파라미터 숨기기 등 다양한 목적으로 사용된다. 가상 스레드에서도 스레드 로컬 기능을 제공하지만, 다음과 같은 문제 때문에 메모리 누수나 메모리 에러 등이 발생할 수 있다.

    • 명확한 생명주기가 없음(unbounded lifetime)
    • 변경 가능성에 대해서 제약이 없음(unconstrained mutability)
    • 메모리 사용에 대해서 제약이 없음(unconstrained memory usage)
    • 값비싼 상속 기능을 사용하는 InheritableThreadLocal의 성능 문제

참고