Home Virtual threads
Post
Cancel

Virtual threads

가상 쓰레드(Virtual Thread)에 대해 학습하며 작성된 글입니다. 학습 과정에서 작성된 글이기 때문에 잘못된 내용이 있을 수 있으며, 이에 대한 지적이나 피드백은 언제든 환영입니다.

image









1 등장 배경


가상 쓰레드(Virtual threads)가 등장한 이유를 이해하기 위해서는 기존 자바 쓰레드 매핑 방식의 문제점과 Project Loom이 등장하게 된 배경을 이해해야 합니다. 이에 대해 살펴보겠습니다.

Virtual threads are lightweight threads that reduce the effort of writing, maintaining, and debugging high-throughput concurrent applications.






1-1. 초기 자바의 쓰레드 매핑

자바 초기 버전에는 Green threads 모델을 채택했는데, 이는 JVM이 쓰레드 스케줄링을 담당했습니다. 하지만 이는 JVM에서 관리되는 쓰레드가 실제 OS의 쓰레드와 직접적으로 매핑되지 않았기 때문에 다음과 같은 문제가 있었습니다.

  1. JVM에 의존적: Green threads는 JVM에 의해 관리되었기 때문에 OS보다 효율적으로 스케줄링을 관리할 수 없었습니다.
  2. 멀티 코어 활용: 여러 쓰레드가 동시에 다른 CPU 코어에서 실행되지 않기 때문에 OS의 병렬 처리 장점을 활용하지 못했습니다.
  3. 블로킹 이슈: 하나의 Green threads가 블로킹 상태가 되면, 해당 OS 쓰레드에서 실행되는 모든 Green threads가 블로킹 상태로 되었습니다.







특히 블로킹 이슈가 가장 문제가 됐는데, 이는 OS가 JVM 프로세스 내의 여러 Green threads를 하나의 쓰레드로 인식했기 때문입니다. 따라서 특정 Green thread가 I/O 작업을 요청하면 해당 OS 쓰레드는 블로킹 상태로 전환되며, 연산이 완료될 때까지 JVM 내의 모든 Green threads가 대기 상태가 됐습니다.

image

쉽게 말해 하나의 OS 쓰레드와 여러 Green threads가 매핑되기 때문에 OS 쓰레드 하나가 블로킹 되면 모든 Green thread가 블로킹 됐습니다.







1-2. Native threads

JVM은 이런 문제를 해결하기 위해 Green threads가 아닌 Native threads 모델을 도입했습니다. 이는 운영 체제의 쓰레드를 직접 활용하기 때문에 멀티코어나 멀티 프로세서 환경에서도 효율적으로 동작했습니다. 이를 통해 JVM은 운영 체제의 스케줄링 및 기타 쓰레드 관리 기능을 직접 활용할 수 있게 되었으며, 블로킹 이슈 또한 해결했습니다.

image

또한 Native threads는 JNI(Java Native Interface)를 통해 C나 C++ 같은 네이티브 코드와 더 원활하게 작업할 수 있습니다.







하지만 이런 Native threads도 오버헤드, 메모리 사용 등과 같은 많은 리소스를 소모한다는 단점을 가지고 있습니다. OS 각각의 쓰레드는 고정된 스택 메모리를 차지하기 때문에 많은 쓰레드가 생성될 경우 메모리 오버헤드가 발생할 수 있기 때문입니다. 또한 쓰레드 간의 컨텍스트 전환 시 CPU와 운영 체제 간 상호 작용이 필요하므로 성능 문제도 존재했습니다.

What Causes java.lang.OutOfMemoryError: unable to create new native thread









1-3. Project Loom

Project Loom은 Native threads가 가진 문제를 해결하기 위해 시작됐습니다. 이는 Java VM의 기능과 API를 개선하여 Java 플랫폼에서 높은 처리량의 경량 동시성과 새로운 프로그래밍 모델을 손쉽게 지원하려는 목적으로 개발되었습니다.

Project Loom is to intended to explore, incubate and deliver Java VM features and APIs built on top of them for the purpose of supporting easy-to-use, high-throughput lightweight concurrency and new programming models on the Java platform.






Project Loom은 화이버(Fiber)라는 경량 쓰레드를 기반으로 동작하는데, 화이버가 바로 가상 쓰레드(Virtual thread) 입니다. 이를 통해 높은 동시성 처리와 성능 오버헤드 최소화를 할 수 있으며, 복잡한 비동기 코드 없이 많은 동시 태스크를 효율적으로 관리할 수 있게 되었습니다. 그 특징은 다음과 같습니다.

  1. Fiber: 가상 또는 경량 쓰레드로, 기존 OS 쓰레드에 비해 훨씬 가볍습니다. 이는 JVM에서 직접 관리되며, 적은 메모리를 사용해 수백만 개의 쓰레드를 생성, 실행하는 것이 가능합니다. 블로킹 연산을 기다리는 동안 다른 화이버의 실행을 진행할 수 있으며, 이를 통해 높은 동시성 처리가 가능합니다.
  2. Continuation: 화이버는 현재 실행 중인 상태를 저장하거나 복원할 수 있으며, 이를 통해 작업을 빠르게 중지하고 재개하는 것이 가능합니다.
  3. Overhead: 화이버는 JVM 수준에서 빠르게 스위치될 수 있기 때문에 오버헤드를 크게 줄일 수 있습니다.









2. 가상 쓰레드가 빠른 이유


가상 쓰레드가 빠른 이유는 많겠지만, 핵심은 JVM에서 이를 직접 관리하기 때문입니다. 크게 오버헤드 감소, 효율적 메모리 사용, 높은 처리량와 같은 장점이 있는데 이에 대해 살펴보겠습니다.

이 외에도 다른 많은 장점이 존재하는데, 이에 대해서는 별도로 학습해보실 것을 권장드립니다.





2-1. 오버헤드 감소

가상 쓰레드는 OS 수준의 컨텍스트 스위칭보다 비용이 적습니다. OS 수준의 컨텍스트 스위칭은 실행 중인 쓰레드의 상태를 저장하고, 메모리 접근 제어, 캐시 무효화 및 파이프라인 플러싱(Pipeline Flushing)과 같은 여러 비용을 필요로 합니다. 반면 가상 쓰레드는 JVM 위에서 동작하기 때문에 JVM이 이를 관리하며, OS 레벨 보다 경량화된 상태로 빠르게 수행됩니다.


image

즉 OS와 JVM 사이에 추가적 인터페이스를 두는 비용을 제거했는데, 이는 가상 쓰레드의 오버헤드를 줄이는 핵심 요인 중 하나입니다.







Native threads 모델에서는 OS의 쓰레드와 1:1로 매핑되었으며, JVM의 각 쓰레드는 실제 운영 체제의 쓰레드에 직접 대응됩니다. 즉, 자바 쓰레드의 라이프사이클이 OS 쓰레드의 라이프사이클에 의존하게 때문에 JVM은 세부적인 쓰레드 관리 작업에 크게 개입하지 않으며, 대부분의 쓰레드 관리를 OS에게 위임하는 것입니다.

예를 들어 JVM 내부에서 쓰레드를 생성하면 운영 체제도 해당 쓰레드에 대응하는 OS 쓰레드를 생성합니다.







또한 쓰레드를 관리하기 위해서는 쓰레드 상태, 우선순위스케줄링 정보 등의 메타데이터가 필요합니다. Native threads 모델에서는 이런 정보를 운영체제 레벨에서 관리했기 때문에, 각 쓰레드가 시스템 자원에 접근하거나 변경할 때마다 OS에 의존하게 되므로 비교적 큰 오버헤드가 발생했습니다. 반면 Project Loom에서는 가상 쓰레드의 상태와 관련된 메타데이터를 JVM이 직접 관리하며, 필요한 정보만을 최적화된 형태로 저장/관리 합니다. 이를 통해 가상 쓰레드는 전통적인 OS 쓰레드에 비해 더 경량화되고 효율적으로 동작할 수 있게 됩니다.

Resizable stack. Virtual threads live in the RAM. Their stack and metadata also live there. The Platform thread has to allocate a fixed stack size (With Java it’s 1MB) and that stack can’t be resized. This means you get stack overflow if you exceed it and waste memory if you don’t use it. Furthermore, the min required memory to bootstrap a Virtual Thread is around 200–300 bytes.









2-2. 효율적 메모리 사용

전통적인 OS 쓰레드와 가상 쓰레드의 주요 차이점 중 하나는 메모리 사용량입니다. 각 OS 쓰레드는 고정된 크기의 스택 메모리(MB)를 할당받는데, 만약 많은 수의 OS 쓰레드를 생성하면 시스템 메모리 사용량이 급격히 증가하게 됩니다. 반면 가상 쓰레드는 시작할 때 작은 스택 크기를 가지며, 필요에 따라 이 크기를 동적으로 조절합니다. 이를 통해 메모리 사용을 최적화하며, 필요한 경우에만 추가 메모리를 할당합니다. 따라서 스택 메모리를 훨씬 더 효율적으로 사용할 수 있습니다.

We dont have to guess how much stack space a thread might need, or make a one-size-fits-all estimate for all threads; the memory footprint for a virtual thread starts out at only a few hundred bytes, and is expanded and shrunk automatically as the call stack expands and shrinks.







일반적으로 스택에는 메서드 호출 정보, 지역 변수, 매개변수와 같은 값들이 저장됩니다. 화이버는 실행 중 필요한 정보만 스택 메모리에 저장하기 때문에 초기에 작은 양의 스택 메모리를 할당받습니다. 반면 전통적 OS 쓰레드는 시작 시 미리 일정량의 스택 메모리를 할당받아 사용합니다. 즉 실제 사용하지 않더라도 일정 크기의 스택을 할당받아 사용하기 때문에 메모리를 비효율적으로 사용하게 됩니다.

It is a temporary storage memory. When the computing task is complete, the memory of the variable will be automatically erased. The stack section mostly contains methods, local variable, and reference variables.







스택 메모리는 프로세스의 주소 공간의 특정 위치에 미리 할당됩니다. 만약 스택의 크기나 메모리를 동적으로 변경한다면, 다른 메모리 영역과 충돌할 가능성이 있습니다. 이를 막기 위해서는 이미 사용중인 메모리를 다른 곳으로 이동하거나 전체 스택을 다른 위치로 복사 등과 같은 작업을 해야 하는데, 이는 CPU 메모리에 추가 작업이 필요하므로 성능 저하가 발생할 수 있습니다.

image









2-3. High-throughput

가상 쓰레드는 코드의 실행 속도를 빠르게 만드는 것이 아닌, 동시에 많은 작업을 처리할 수 있는 확장성을 제공합니다. 이는 특히 많은 동시 요청을 처리하거나, 블로킹 I/O 작업이 많은 애플리케이션에서 강점을 가집니다.

Virtual threads are not faster threads; they do not run code any faster than platform threads. They exist to provide scale (higher throughput), not speed (lower latency).







이에 대해 간략한 실험을 해보겠습니다. 결과는 각 사용자의 환경에 따라 다를 수 있으며, 이를 실험하는 컴퓨터의 스펙은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ system_profiler SPHardwareDataType

Hardware:

    Hardware Overview:

      Model Name: MacBook Pro
      Model Identifier: MacBookPro16,1
      Processor Name: 8-Core Intel Core i9
      Processor Speed: 2.3 GHz
      Number of Processors: 1
      Total Number of Cores: 8
      L2 Cache (per Core): 256 KB
      L3 Cache: 16 MB
      Hyper-Threading Technology: Enabled
      Memory: 32 GB
      System Firmware Version: 1968.100.17.0.0 (iBridge: 20.16.4252.0.0,0)
      OS Loader Version: 577~129
      Serial Number (system): ${SERIAL_NUMBER}
      Hardware UUID: ${HARDWARE_UUID}
      Provisioning UDID: ${PROVISIONING_UUID}
      Activation Lock Status: Enabled









아래 코드는 OS 쓰레드, 가상 쓰레드를 각각 1000개씩 생성해 0.1초의 블로킹을 주고 작업을 합니다. 결과를 보면 가상 쓰레드가 약 50배 정도 빠른 것을 볼 수 있는데, 이는 블로킹 작업 시 가상 쓰레드가 즉시 새로운 쓰레드를 생성해 작업을 하기 때문입니다. 반면 OS 쓰레드는 한 번에 실행 가능한 쓰레드의 수가 시스템의 프로세서 코어 수에 의해 제한됩니다. 따라서, 블로킹 상태에 있는 쓰레드가 다른 작업을 처리할 수 없기 때문에 가상 쓰레드에 비해 작업 속도가 떨어집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class Main {

    private static final int TASK_COUNT = 1_000;

    public static void main(String[] args) throws Exception {
        // 가상 쓰레드 작업
        workWithVirtualThreads();

        // OS 쓰레드 작업
        workWithOSThreads();
    }

    private static void workWithOSThreads() throws InterruptedException {
        long startTime = System.currentTimeMillis();
        CountDownLatch osLatch = new CountDownLatch(TASK_COUNT);

        var fixedThreadPool = Executors
            .newFixedThreadPool(Runtime.getRuntime().availableProcessors());
        work(osLatch, fixedThreadPool);
        fixedThreadPool.shutdown();

        long endTime = System.currentTimeMillis();
        System.out.println("OS Threads: " + (endTime - startTime) + "ms");
    }

    private static void workWithVirtualThreads() throws InterruptedException {
        long startTime = System.currentTimeMillis();
        CountDownLatch virtualLatch = new CountDownLatch(TASK_COUNT);

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            work(virtualLatch, executor);
        }
        
        long endTime = System.currentTimeMillis();
        System.out.println("Virtual Threads: " + (endTime - startTime) + "ms");
    }

    private static void work(
        CountDownLatch countDownLatch,
        ExecutorService executor
    ) throws InterruptedException {
        for (int index = 0; index < TASK_COUNT; index++) {
            executor.submit(() -> {
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    countDownLatch.countDown();
                }
            });
        }
        countDownLatch.await();
    }
}

image









3. 정리


Project Loom이 왜 도입 됐는지, 가상 쓰레드는 무엇인지, 왜 가상 쓰레드가 빠른지에 대해 살펴보았습니다. 이는 JVM에서 쓰레드를 관리하기 때문에 OS 레벨 보다 훨씬 경량화 돼 있으며, 이를 통해 오버헤드 감소, 적은 메모리 사용 등의 이점을 가지고 있습니다.

참고로 Intellij는 2023.11 월 부터 이를 지원합니다.


This post is licensed under CC BY 4.0 by the author.