자바생
article thumbnail
Published 2025. 6. 14. 14:45
virtual thread(jdk21, jdk24) Java
728x90

Java 스레드의 진화

 

초기 Java는 Thread class, Runnable Interface로 OS 스레드와 1:1로 맵핑되는 방식을 사용했다. 자바 스레드는 본질적으로 host OS에서 제공하는 네이티브 스레드와 1:1로 맵핑되는 wrapping 객체이다.

 

이후 Java는 스레드 관리의 효율성을 위해 Executor 프레임워크로 스레드풀 방식을 도입했다. 그리고 JDK 21부터는 가상 스레드가 도입되었다.

 

가상 스레드의 등장

 

가상 스레드의 등장은 'Thread-per-request' 방식의 확장성 문제를 해결하기 위해 시작되었다. 요청 당 스레드 모델은 애플리케이션에서 서로 독립적인 동시 사용자 요청을 처리하는데 적합하며 코드의 직관성과 디버깅 용이성 면에서 장점이 있다.

하지만 이 모델을 확장할 때 성능상의 제약이 발생한다. 애플리케이션의 성능은 리틀의 법칙에 따라 결정되는데, 이 법칙은 처리량과 동시성, 응답 시간 사이의 관계를 설명한다.

리틀의 법칙

처리량(Throughput) (λ)

  • 단위 시간당 완료된 항목(예: 작업, 요청)의 평균 개수

동시성 Concurrency (N)

  • 동시에 처리되는 항목의 평균 개수

응답 시간 Response Time (d)

  • 단일 항목이 처음부터 끝까지 처리되는 데 걸리는 평균 시간

리틀의 법칙에 따르면, 응답 시간이 고정되어 있을 때 처리량을 증가시키려면 동시성(스레드 수)이 증가해야 한다.

하지만 Java에서의 스레드는 운영체제의 wrapper 스레드이기 때문에 사용 가능한 스레드의 수가 제한되어있다. 

 

Oracle 문서에서는 다음과 같이 설명한다:
"OS threads are costly, so we cannot have too many of them, which makes the implementation ill-suited to the thread-per-request style"

OS 스레드는 비용이 많이 들기 때문에 너무 많이 생성할 수 없으며, 이는 요청당 스레드 스타일 구현에 적합하지 않다는 것이다.

 

이러한 한계를 해결하기 위해 등장한 것이 바로 가상 스레드다. 가상 스레드는 요청당 스레드 모델의 직관성은 유지하면서도, OS 스레드의 제약에서 벗어날 수 있는 해결책이다.

 

Oracle 공식 문서에 따르면:
높은 처리량을 요구하는 동시성 애플리케이션에서는 가상 스레드를 사용하세요. 특히 대기 시간이 많은 수많은 동시 작업들로 구성된 애플리케이션에 적합합니다. 서버 애플리케이션이 대표적인 예인데, 이들은 보통 리소스를 가져오는 것과 같은 블로킹 I/O 작업을 수행하는 많은 클라이언트 요청을 처리하기 때문입니다.

 

 

가상 스레드는 기존 Java 코드베이스와 완벽한 호환성을 제공한다. Executor 프레임워크를 이미 사용하고 있다면 Executors.newVirtualThreadPerTaskExecutor()로 간단히 전환할 수 있다.

 

가상 스레드의 동작 원리를 이해하기 위해서는 먼저 플랫폼 스레드와의 차이점을 알아야 한다.

 

플랫폼 스레드 (클래식 스레드)

자바 스레드는 OS 스레드와 1:1 맵핑되며, OS 실제 스레드와 연결되기 때문에 스케일링에 제한이 있다. 전통적인 스레드 모델이다.

 

가상 스레드

플랫폼 스레드와 M:N 맵핑 구조를 가진다. 즉, 다수의 가상 스레드가 적은 수의 플랫폼 스레드에서 실행되며, 플랫폼 스레드는 여전히 OS 스레드와 1:1로 맵핑된다. 관리 주체가 JVM이며, 기존 플랫폼 스레드에 비해 훨씬 많이 생성할 수 있다.

 

가상 스레드는 내부적으로 ForkJoinPool 기반의 캐리어 스레드(플랫폼 스레드) 위에서 실행된다.

 

https://foojay.io/today/unleashing-the-power-of-lightweight-concurrency-a-comprehensive-guide-to-java-virtual-threads-part-1/

 

 

가상 스레드의 장점

 

블로킹 작업

Sleep 또는 I/O 작업과 같은 블로킹 작업을 만나면 자동으로 캐리어 스레드(플랫폼 스레드)에서 언마운트되어 제어권을 반납한다. 이를 통해 해당 캐리어 스레드는 다른 가상 스레드를 즉시 실행할 수 있어 리소스를 최적으로 활용할 수 있다. 블로킹 작업이 완료되면 가상 스레드는 사용 가능한 캐리어 스레드에 다시 마운트된다. 그리고 중단된 지점부터 다시 실행한다.

 

가벼움

메모리 오버헤드가 매우 낮아 수백만 개의 스레드를 생성할 수 있으며 동시성이 높고 확장성이 뛰어나다.

 

사용성

가상 스레드는 기존 코드베이스와 원활하게 통합되도록 설계됐다.

 

 

가상 스레드의 아키텍처와 작동 원리

 

가상 스레드는 경량 스레드이다. 커널 수준 스레드는 플랫폼 스레드와 1:1로 맵핑되어 있고, 가상 스레드는 플랫폼 스레드와 1:N 구조를 가지고 있다. 따라서 커널 수준 스레드가 M개 있을 때 가상 스레드는 M:N 스케줄링을 구현할 수 있다.

운영체제는 가상 스레드를 인식하지 못하고, OS 수준 스케줄링의 단위인 플랫폼 스레드만 인식한다. 그래서 가상 스레드에서 코드를 실행하기 위해 플랫폼 스레드에 마운트한다.

 

마운트 과정

가상 스레드가 실행되려면 먼저 캐리어 스레드에 마운트되어야 한다. 마운트될 때 가상 스레드의 스택 프레임이 힙에서 캐리어 스레드의 실제 스택으로 임시 복사된다. 이는 가상 스레드의 스택은 JVM 힙에 저장되지만, 실제 코드 실행은 운영체제가 인식하는 캐리어 스레드에서 이뤄져야 하기 때문이다. 캐리어 스레드는 말 그대로 가상 스레드의 실행을 '운반'하는 역할을 담당한다.

 

실행 과정

가상 스레드가 실행되어야 할 때, ForkJoinPool에서 관리하는 캐리어 스레드를 하나 빌려온다. 힙에 저장되어 있던 가상 스레드의 스택 프레임 정보를 캐리어 스레드의 실제 스택으로 임시 복사한다. 복사된 정보를 바탕으로 가상 스레드를 실행하고, 실행이 끝나면 캐리어 스레드를 다시 풀로 반환하면서 스택 프레임을 다시 힙으로 복사한다. 풀로 반환된 캐리어 스레드는 다시 다른 가상 스레드가 사용한다.

 

  1. 마운트: ForkJoinPool에서 사용 가능한 캐리어 스레드를 할당받는다
  2. 스택 복사: 힙에 저장된 가상 스레드의 스택 프레임을 캐리어 스레드의 스택으로 복사한다.
  3. 실행: 캐리어 스레드에서 가상 스레드의 코드를 실행한다
  4. 언마운트: 실행이 완료되거나 블로킹되면 스택 정보를 다시 힙으로 복사하고 캐리어 스레드를 반환한다
  5. 재사용: 반환된 캐리어 스레드는 다른 가상 스레드가 사용할 수 있게 된다

 

캐리어 스레드는 플랫폼 스레드와 같은 것인데 여기서 캐리어 스레드라고 표현한 이유는 무엇일까?

실행 과정을 보면 가상 스레드가 실행되어야하기 위해서는 ForkJoinPool에 있는 스레드가 필요하다. 운영체제가 인식하는 스레드는 캐리어 스레드이기 때문이다. 그래서 가상 스레드를 싣는 스레드라는 의미로 캐리어 스레드라고도 한다.

 

가상 스레드와 플랫폼 스레드의 차이점 : 메모리 관점

 

플랫폼 스레드

플랫폼 스레드는 스택 프레임을 운영체제가 할당하는 고정된 메모리 블럭에 저장한다. 스택 크기는 고정적이다. 

 

https://blog.ycrash.io/java-virtual-threads-quick-introduction/

 

 

가상 스레드

가상 스레드는 JVM의 heap에 스택 청크를 저장한다. 또한, 플랫폼 스레드와 다르게 작은 청크로 시작하여 스택의 크기가 동적으로 변하게 되어 메모리를 효율적으로 사용할 수 있다. 메서드 호출이 깊어지면 스택이 커지고, 작업이 끝나면 다시 작아져 메모리를 반납한다.


스택 청크란?

가상 스레드의 스택 데이터를 저장하는 객체이다. 이 스택 객체들은 JVM heap에 저장된다.

 

정리하면 일하는 사람(가상 스레드)이 책상(힙)에서 업무를 메모장(스택 청크)에 적고있다. 라고 이해하면 좋을 것 같다.

: 일하는 사람 -> 가상 스레드, 업무 내용을 적는 메모장 -> 스택 청크, 책상 -> 힙

 

"스택의 크기가 동적이기 때문에 메모리를 효율적으로 사용할 수 있다." 라는게 무슨 뜻일까?

문서에는 이를 설명하기 위해 '스택의 깊이'라는 개념을 사용했다.

 

https://openjdk.org/jeps/425#Using-virtual-threads-Example-2

void handle(Request request, Response response) {
    var url1 = ...
    var url2 = ...
 
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var future1 = executor.submit(() -> fetchURL(url1));
        var future2 = executor.submit(() -> fetchURL(url2));
        response.send(future1.get() + future2.get());
    } catch (ExecutionException | InterruptedException e) {
        response.fail(e);
    }
}
 
String fetchURL(URL url) throws IOException {
    try (var in = url.openStream()) {
        return new String(in.readAllBytes(), StandardCharsets.UTF_8);
    }
}

 

기존 플랫폼 스레드에서는 handle 메서드 실행 전에 이미 5개의 메서드가 호출 스택에 쌓여있다면, fetchURL을 실행할 때 스택 깊이가 더욱 깊어진다. 각 메서드 호출마다 스택 프레임이 누적되어 메모리 사용량이 증가한다.

반면 가상 스레드로 fetchURL을 실행하면, 새로운 가상 스레드는 새로운 스택에서 시작된다. 기존 호출 스택의 영향을 받지 않아 얕은 스택으로 메모리를 훨씬 효율적으로 사용할 수 있다. 이는 플랫폼 스레드의 고정된 스택 크기와 달리, 가상 스레드는 필요한 만큼만 스택을 사용하기 때문이다.

 

가상 스레드와 플랫폼 스레드의 차이점 : GC 관점

 

가상 스레드는 플랫폼 스레드와 달리 GC에 최적화되어있다.

 

가상 스레드 스택은 플랫폼 스레드 스택과 다르게 GC 루트가 아니다. 그래서 수백만 개의 가상 스레드가 있더라도 GC는 그 스택들을 일일이 스캔할 필요가 없기 때문에 stop the world 중지 시간이 크게 줄어든다.

 

가상 스레드는 GC 대상이 된다. 다른 스레드가 가상 스레드나 큐에 대한 참조를 얻을 수 없는 경우 GC가 가능하다. 

 

Unlike platform thread stacks, virtual thread stacks are not GC roots, so the references contained in them are not traversed in a stop-the-world pause by garbage collectors, such as G1, that perform concurrent heap scanning. This also means that if a virtual thread is blocked on, e.g., 
BlockingQueue.take()
, and no other thread can obtain a reference to either the virtual thread or the queue, then the thread can be garbage collected — which is fine, since the virtual thread can never be interrupted or unblocked. Of course, the virtual thread will not be garbage collected if it is running or if it is blocked and could ever be unblocked.

플랫폼 스레드 스택과 달리 가상 스레드 스택은 GC 루트가 아니므로 그 안에 포함된 참조는 동시 힙 스캔을 수행하는 가비지 컬렉터(예: G1)에 의해 일시 중지 상태에서 트래버스되지 않습니다. 이는 또한 가상 스레드가 차단된 경우, 예를 들어 BlockingQueue.take() 를 호출하여 다른 스레드가 가상 스레드나 큐에 대한 참조를 얻을 수 없는 경우 가상 스레드는 중단되거나 차단이 해제되지 않으므로 가비지 수집이 가능합니다. 물론 가상 스레드가 실행 중이거나 차단되어 있고 차단을 해제할 수 있는 경우에는 가비지 수집이 되지 않습니다.

 

문서에서는 위와 같이 설명하고있지만 이해하기 조금 난해하다. 

하기 코드를 보면 쉽게 이해할 수 있다.

// 자동 삭제 상황
// 이 가상 스레드는 GC에 의해 자동 삭제될 수 있음
Thread.startVirtualThread(() -> {
    BlockingQueue<String> queue = new LinkedBlockingQueue<>();
    queue.take(); // 영원히 대기... 누구도 이 큐를 모름
}); 

// 이런 경우는 삭제 안 됨
// 다른 스레드가 큐에 접근 가능하므로 삭제 안 됨
BlockingQueue<String> sharedQueue = new LinkedBlockingQueue<>();
Thread.startVirtualThread(() -> {
    sharedQueue.take(); // 다른 스레드가 여기에 데이터를 넣을 수 있음
});

 

다만 JDK 21의 가상 스레드에는 G1 GC 관련 제한사항이 있다. 가상 스레드의 스택이 깊어져서 스택 청크가 G1 GC의 humongous object 임계값(보통 512KB)을 초과하면 문제가 발생한다. Humongous object는 G1 GC에서 특별히 비효율적으로 처리되기 때문에, 이런 경우 더 이상 스택을 확장하지 못하고 StackOverflowError가 발생할 수 있다. 따라서 매우 깊은 재귀 호출이나 큰 로컬 변수를 사용할 때 주의해야 할 점이다.

 

 

Executor 프레임워크와의 성능 비교

 

가상 스레드의 성능을 확인하기 위해 기존 Executor 프레임워크(newFixedThreadPool)와 간단한 테스트를 진행해보았다.

 

public class VirtualThreadBenchmarkTest {

    public static void main(String[] args) {
        benchmark("Virtual Threads", Executors.newVirtualThreadPerTaskExecutor());
        benchmark("Fixed ThreadPool (100)", Executors.newFixedThreadPool(100));
        benchmark("Fixed ThreadPool (500)", Executors.newFixedThreadPool(500));
        benchmark("Fixed ThreadPool (1000)", Executors.newFixedThreadPool(1000));
    }

    static void benchmark(String title, ExecutorService executor) {
        long start = System.currentTimeMillis();
        AtomicLong completedTasks = new AtomicLong();

        try (executor){
            IntStream.range(0, 10000).forEach(i -> executor.submit(() -> {
                try {
                    Thread.sleep(Duration.ofMillis(500));
                    completedTasks.incrementAndGet();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }));
        }

        long end = System.currentTimeMillis();
        long interval = end - start;

        double throughput = (double) completedTasks.get() / interval * 1000;
        System.out.println(title + " Time: " + interval + "ms, Throughput: " + throughput + "/s");
    }
}

Virtual Threads - Time: 592ms, Throughput: 16891.89189189189/s
Fixed ThreadPool (100) - Time: 50372ms, Throughput: 198.52298896212181/s
Fixed ThreadPool (500) - Time: 10105ms, Throughput: 989.6091044037605/s
Fixed ThreadPool (1000) - Time: 5079ms, Throughput: 1968.8915140775741/s

 

 

다양한 스레드 풀 크기별로 테스트한 결과, 처리량에서 현저한 차이를 확인할 수 있었다. task가 많으면 많을수록 차이는 커질거라 예상된다.

 

https://www.kloia.com/blog/benchmarking-java-virtual-threads-a-comprehensive-analysis

 

장표를 간단하게 해석하면 무거운 연산일수록 실행시간, CPU, 메모리 사용량에서 Virtual Thread이 플랫폼 스레드보다 성능이 좋다는 것을 알 수 있다.

 

가상 스레드의 적용

 

가상 스레드는 CPU 바운드 작업보다는 I/O 바운드 작업에 유리하다. 가상 스레드는 외부 시스템의 지연 시간을 직접 제어할 수 없는 I/O 중심 작업에서 효과적이다.

다만 벤치마크의 결과 및 가상 스레드에 대한 아티클을 보며 가상 스레드가 동시성을 위한 만병통치약이 아니라는 것을 꼭 알아야한다. 각 애플리케이션에 적합한지,  충분한 테스트를 통해 적합성을 확인해야하며 절대 맹목적인 도입은 피해야한다.

 

가상 스레드의 한계(jdk 21)

 

가상 스레드가 뛰어난 성능을 보여주지만, JDK 21에서는 몇 가지 알려진 제한사항이 있다.

 

핀닝(Pinning) 문제

핀닝은 가상 스레드가 특정 캐리어 스레드에 계속 붙어있게 되는 현상을 말한다. 일반적으로 가상 스레드는 블로킹 작업을 만나면 캐리어 스레드에서 분리(언마운트)되어야 한다. 하지만 특정 상황에서는 이 분리가 불가능해져 캐리어 스레드에 '핀닝'되는 현상이 발생한다.

핀닝이 발생하면 가상 스레드가 블로킹 작업중에도 캐리어 스레드에서 마운트 해제되지 않아, 해당 캐리어 스레드를 독점하게 된다.

핀닝은 synchronized된 블록 또는 메서드에서 발생한다. 또한 Native method 또는 foreign functions에서도 발생할 수 있다.

 

핀닝의 영향

가상 스레드의 핵심은 블로킹 작업을 수행할 때 캐리어 스레드에서 마운트 해제할 수 있어 다른 작업을 위해 캐리어 스레드를 확보할 수 있다는 점이다.

핀닝이 발생하면 가상 스레드는 스스로 마운트를 해제할 수 없다. 캐리어 스레드 수는 제한되어 있기 때문에, 많은 가상 스레드가 장시간 핀닝되면 사용 가능한 캐리어 스레드가 부족해진다. 결과적으로 다른 가상 스레드의 실행이 차단되어 가상 스레드가 제공하려던 동시성 이점이 크게 제한된다.

 

핀닝 문제 해결 방안

 

ReentrantLock 사용

핀닝의 영향을 완화하려면 ReentrantLock 사용을 고려해야 한다. 가상 스레드가 차단되었을 때 마운트 해제할 수 있다.

 

ReentrantLock의 중요한 특징

synchronized와의 차이점이 중요하다. synchronized에서 락을 기다리는 스레드는 절대 인터럽트될 수 없다. 즉, Thread.interrupt()를 호출해도 락을 얻을 때까지 계속 대기한다.

ReentrantLock은 락을 얻으려고 할 때 대기하다가 interrupt가 호출되면 락 획득을 위한 대기를 그만두고 InterruptedException이 발생한다.

 

public class SynchronizedPinningExample {

    public static void main(String[] args) {
        List<Thread> threads = IntStream.range(0, 10)
            .mapToObj(i -> Thread.ofVirtual().unstarted(() -> {
                if (i == 0) {
                    System.out.println(Thread.currentThread());
                }
                synchronized (SynchronizedPinningExample.class) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                    }
                }
                if (i == 0) {
                    System.out.println(Thread.currentThread());
                }
            })).toList();

        threads.forEach(Thread::start);

        threads.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
            }
        });
    }
}

VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#22]/runnable@ForkJoinPool-1-worker-1

public class ReentrantLockPinningExample {

    private static final ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {

        List<Thread> threads = IntStream.range(0, 10)
            .mapToObj(i -> Thread.ofVirtual().unstarted(() -> {
                if (i == 0) {
                    System.out.println(Thread.currentThread());
                }

                lock.lock();

                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                } finally {
                    lock.unlock();
                }

                if (i == 0) {
                    System.out.println(Thread.currentThread());
                }
            })).toList();

        threads.forEach(Thread::start);

        threads.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    }
}

VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-8

 

 

VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 는 다음과 같이 해석할 수 있다.

  • 가상 스레드 #21이 실행 가능 상태에 있다
  • 이 가상 스레드는 현재 ForkJoinPool-1-worker-1이라는 캐리어 스레드에 마운트되어있음

ReentrantLock과 synchronized 의 출력을 보면 synchronized는 같은 캐리어 스레드에 마운트되어있고, ReentrantLock은 다른 캐리어 스레드에 마운트 되어있는 것을 알 수 있다.

 

Java 24에서의 synchronized 개선

 

문서에서 핀닝 현상이 아예 해결된 것은 아니지만 synchronized 사용 시 핀닝 현상을 해소했다고 한다. 

확인을 하기 위해 위 코드(Java 21)에서 버전업(Java 24)을 하고 다시 확인해보겠다.

 

public class SynchronizedPinningExample {

    public static void main(String[] args) {
        List<Thread> threads = IntStream.range(0, 10)
            .mapToObj(i -> Thread.ofVirtual().unstarted(() -> {
                if (i == 0) {
                    System.out.println(Thread.currentThread());
                }
                synchronized (SynchronizedPinningExample.class) {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                    }
                }
                if (i == 0) {
                    System.out.println(Thread.currentThread());
                }
            })).toList();

        threads.forEach(Thread::start);

        threads.forEach(t -> {
            try {
                t.join();
            } catch (InterruptedException e) {
            }
        });
    }
}

VirtualThread[#28]/runnable@ForkJoinPool-1-worker-1
VirtualThread[#28]/runnable@ForkJoinPool-1-worker-3

 

기존 Java 21에서는 같은 플랫폼 스레드로 인해 스레드의 값을 출력할 때 같은 스레드였지만 Java 24로 실행할 시 ReentrantLock과 같은 결과가 나온다는 것을 볼 수 있다.

 

정리

 

가상 스레드를 공부하면서 멀티 스레드 개념에도 많은 도움이 됐다. 또한, 내용을 이해하기 위해 ForkJoinPool, stealing work 알고리즘, Java 메모리 아키텍처 등을 찾아보게됐다. 위 개념들을 알아두면 가상 스레드를 학습할 때 더욱 도움이 될 것 같다.

jdk 21, 24에서는 가상 스레드 뿐만 아니라 병렬 실행을 좀 더 세밀하고 안전하게 다루기 위해 scoped value, structured concurrency가 등장하게 되는데 아마 다음 글이 되지 않을까싶다.

 

 

REFERENCES

 

https://openjdk.org/jeps/425

https://openjdk.org/jeps/491

https://docs.oracle.com/en/java/javase/18/gctuning/garbage-first-g1-garbage-collector1.html#GUID-D74F3CC7-CC9F-45B5-B03D-510AEEAC2DAC

https://www.danvega.dev/blog/jdk-24-virtual-threads-without-pinning

 

 

728x90

'Java' 카테고리의 다른 글

try-with-resources 는 왜 사용해야할까?  (0) 2023.03.28
파라미터에 Optional은 왜 안티패턴?  (0) 2023.03.25
unmodifiableList & copyOf & 방어적 복사  (0) 2023.02.21
Generic & Wildcard  (0) 2022.12.29
Generic in Java  (2) 2022.12.17
profile

자바생

@자바생

틀린 부분이 있다면 댓글 부탁드립니다~😀

검색 태그