티스토리 뷰

가상 스레드와 ForkJoinPool

가상 스레드는 고성능의 동시성 애플리케이션을 지원합니다. 이 가상 스레드 구현을 이해하기 위해서는 Work-Stealing 알고리즘을 사용하는 포크조인 풀(ForkJoinPool)과 JDK 19에 가상 스레드와 함께 JDK 내부에 추가된 컨티뉴에이션(Continuation)을 알아야 합니다.

해당 글에서는 포크조인 풀에 대해 알아보겠습니다.

 

Virtual Thread 스케줄러 아키텍처

가상 스레드는 ForkJoinPool에 제출 -> 캐리어에 마운트 -> 블로킹하면 unmount되어 힙에서 대기하다 준비되면 다시 제출

위 그림은 가상 스레드 스케줄러 아키텍처로, 스케줄러가 두 층으로 나뉘어 있음을 보여줍니다. JDK 스케줄러는 가상 스레드를 캐리어에 배정하고, 운영체제 스케줄러는 캐리어를 코어에 배정합니다. 캐리어는 평범한 플랫폼 스레드라 운영체제와 1:1로 대응하고, M:N 매핑은 맨 윗단 JVM 상에서 일어납니다. 그것을 가능하게 하는 것이 unmount로 블로킹 지점에서 가상 스레드가 캐리어를 반납하기 때문에 적은 수의 캐리어로 동시에 많은 가상 스레드를 소화할 수 있습니다.

 

플랫폼 스레드(Platform Thread)

자바가 처음 만들어졌을 때부터 존재하던 네이티브 스레드(native thread)입니다. JDK에서 공싱적인 이름은 플랫폼 스래드로 운영체제에 의해 실행되는 무거운 스레드이며 스케줄링과 관리를 운영체제에 의존하고, 자바 스레드와 커널 스레드 사이에 1:1 관계를 유지합니다. 즉, 자바 코드는 기반이 되는 OS 스레드 위에 실행되어 운영체제의 스케줄링과 컨텍스트 전환 메커니즘을 활용합니다. 사용 가능한 플랫폼 스레드의 수는 OS 스레드의 수에 의해 제한됩니다. 또한, 플랫폼 스레드는 일반적으로 큰 스레드 스택과 운영체제가 관리하는 기타 리소스를 가지고 있습니다.

가상 스레드는 위 그림처럼 이 플랫폼 스레드 내부에서 실행되며, 이렇게 플랫폼 스레드가 가상 스레드를 실행해 주는 역할을 '캐리어 스레드(carrier thread)'라고 합니다.

 

가상 스레드(Virtual Thread)

프로젝트 룸(Loom)은 자바 및 JVM 생태계의 최신 프로젝트로 기존 자바 동시성 모델의 한계를 해결하기 위해 가상 스레드가 JDK 21부터 새로 도입된 자바의 동시성 모델로 도입되었습니다. 전적으로 JVM에 의해 관리되어 직접적으로 커널 스레드와 매핑되지 않고 다수의 가상 스레드가 플랫폼 스레드(캐리어 스레드) 풀을 공유합니다. 이 덕분에 JVM이 상대적으로 적은 운영체제 자원으로도 많은 수의 가상 스레드를 효율적으로 다중화(multiplex)할 수 있습니다.

가상 스레드는 플랫폼 스레드와 아래와 같은 차이를 가지고 있습니다.

  • 가벼움
    • 수백만 개의 가상 스레드를 생성하더라도 시스템 자원이 고갈되지 않을 정도로 플랫폼 스레드에 비해 훨씬 적은 양의 메모리를 사용하고 시스템 자원을 더 적게 소모합니다.
  • 스케줄링
    • JVM에 의해 스케줄링되므로 CPU 사이클을 낭비 없이 더 잘 사용할 수 있고, 운영체제 스레드 스케줄링에서 발생하는 오버헤드를 피할 수 있습니다.
  • 블로킹 허용 능력
    • 블로킹 연산 수행 시 시스템 자원을 점유하지 않고 제어권을 캐리어 스레드에게 넘겨 다른 가상 스레드들이 계속 실행될 수 있습니다.

 

ForkJoinPool

포크조인풀은 Java 7에 도입된 특수한 스레드 풀로, 일반 스레드 풀과 달리 워커마다 개인 작업 큐(데크, deque)를 두고, 할 일이 없는 워커가 바쁜 워커의 큐에서 작업을 훔쳐오는(work-stealing) 방식으로 동작합니다. 원래는 분할 정복 병렬 연산용으로 설계되었지만, 현재는 병렬 스트림, CompletableFuture를 넘어 Virtual Thread 스케줄러의 구현체를 담당하고 있습니다.

ThreadPoolExecutor 같은 일반 풀은 모든 워커가 하나의 공유 큐에서 작업을 꺼내 워커가 많아지면 락 경합 지점이 될 수 있는 구조적 문제를 해결하기 위해 설계되었습니다.

 

ForkJoinPool 동작 방식

워커마다 개인 데크(deque, 양방향 큐)를 가집니다. 워커가 작업을 실행하다가 fork()로 하위 작업을 만들면 자기 데크의 한쪽 끝(head)에 넣고, 다음 작업도 같은 끝(head)에서 꺼냅니다(LIFO 방향). 자기 큐를 자기만 만지는 동안에는 경합이 거의 없고, 방금 넣은 작업을 바로 꺼내므로 CPU 캐시 지역성도 좋습니다.

데크가 빈 워커는 놀지 않고 다른 워커의 작업을 훔치는 Work-Stealing 기반으로 동작합니다. 다른 워커의 데크 반대쪽 끝(tail)에서 작업을 가져갑니다(FIFO 방향). 이 덕분에 충돌할 확률을 최소화할 수 있고, tail에 있는 작업은 가장 먼저 fork된 "큰 덩어리"일 가능성이 높아, 한 번 훔치면 오래 일할 수 있어 훔치는 빈도 자체가 줄어듭니다. 부하 분산과 낮은 경합을 제공할 수 있습니다.

외부 제출은 별도 제출 큐로 들어옵니다. 풀 밖에서 submit()된 작업은 공유 큐(실제로는 해시 분산된 여러 큐)에 들어가고, 워커들이 자기 데크가 비면 여기서도 가져갑니다.

 

ForkJoinPool 사용

public class FibonacciNumberWithForkJoinPool {

    private static final Map<Integer, Long> cache = new ConcurrentHashMap<>(
            Map.of(0, 0L, 1, 1L));

    static class FibonacciTask extends RecursiveTask<Long> { // 1
        private final int n;

        public FibonacciTask(int n) {
            this.n = n;
        }

        @Override
        protected Long compute() {
            if (cache.containsKey(n)) {
                return cache.get(n);
            }

            FibonacciTask f1 = new FibonacciTask(n - 1);
            f1.fork(); // 2
            FibonacciTask f2 = new FibonacciTask(n - 2);
            long result = f2.compute() + f1.join(); // 3
            cache.put(n, result);
            return result;
        }
    }

    public static void main(String[] args) {
        try (var pool = new ForkJoinPool(2)) {
            Long result = pool.invoke(new FibonacciTask(5));
            System.out.println("Fibonacci number is: " + result);
        }
    }
}

ForkJoinPool은 결과가 있는 경우 RecursiveTask<V>, 결과가 없는 경우 RecursiveAction을 상속해 compute() 안에서 충분히 작으면 직접 계산, 크면 반으로 쪼개 fork() 후 join()을 구현합니다. 위 코드는 ForkJoinPool의 분할(fork) / 직접 계산과 합류(compute + join) / work-stealing을 하나로 확인하면서 피보나치 계산을 수행하는 대표 예시 중 하나입니다. 

 

static class FibonacciTask extends RecursiveTask<Long> {

RecursiveTask<V>는 위에서 설명했듯이 결과를 반환하는 ForkJoinTask입니다. 태스크는 스레드가 아닌 상태를 가진 객체이고, 어떤 워커가 이 객체의 compute()를 자기 콜 스택에서 호출하게 됩니다. 그래서 FibonacciTask(4)(줄여서 F(4)) 태스크 객체는 worker-1가 만들었지만 worker-2의 스택에서 실행될 수 있습니다.

 

FibonacciTask f1 = new FibonacciTask(n - 1);
f1.fork(); // F(4)를 내 데크 head에 push

fork()는 현재 워커의 데크 head(top)에 push하고 바로 리턴하는 메서드입니다.

 

FibonacciTask f2 = new FibonacciTask(n - 2);
long result = f2.compute() + f1.join();

두 하위 작업을 모두 fork할 수도 있지만, 그러면 워커 자신은 push만 하고 놀게 되기 떄문에 큐를 거치는 오버헤드 없이 하나는 즉시 처리하는 방식입니다. 또한, compute과 join 순서가 중요한데 f1.join() + f2.compute()으로 진행하게 되면 자기 일을 시작하기 전에 join 대기부터 하게 되어 병렬성을 스스로 깎아먹게 됩니다.

 

F(5) 실행 시 worker-1의 덱이 채워지고, 유휴 worker-2가 tail에서 가장 큰 태스크를 훔쳐가는 과정

위 코드를 실행 시의 시퀀스 다이어그램으로, pool.invoke(new FibonacciTask(5))가 호출되면 F(5)라는 하나의 큰 작업이 worker-1에게 배정되고, worker-1은 이를 F(4)와 F(3)으로 분할합니다. 이때, worker-1은 fork()를 호출해서 자기 데크에 F(4)를 push하고, 유휴worker-2 워커 스레드가 그것을 훔쳐갑니다.

 

 

while (taskB.status != DONE) {
    ① taskB가 아직 내 데크에 있나? → 있으면 내가 직접 꺼내서(exec) 실행
    ② 도둑맞았나? → 도둑의 데크에서 관련 태스크를 가져와 대신 실행 (helping)
    ③ 도울 것도 없나? → 그때 비로소 보상 후 진짜 park
}


그리고 join()을 실행 시 join 대상을 도둑 맞았다고 하더라도 해당 메서드는 "스레드를 재우는 명령"이 아니라 상태 확인 루프가 있는 일반 메서드 호출입니다. 태스크의 실행이란 결국 워커 스레드 스택 위에서 exec() 메서드를 호출하는 것뿐이므로, join 지점에서 워커는 자기 스택 위에 다른 태스크의 프레임을 중첩으로 쌓아 실행(helping)할 수 있습니다.

즉, ForkJoinTask는 상태 필드(volatile status)를 가진 객체이고, join을 대기 중인 태스크는 위 다이어그램 및 루프처럼 스택 아래쪽 프레임에 정지된 채 그대로 보존되고, 중첩 실행이 반환되어 내려오면 완료 여부를 재확인하고 재개됩니다.

 

Virtual Thread와의 관계

// java.lang.VirtualThread.java
final class VirtualThread extends BaseVirtualThread {
    private static final Unsafe U = Unsafe.getUnsafe();
    private static final ContinuationScope VTHREAD_SCOPE = new ContinuationScope("VirtualThreads");
    private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
    
    // ...
    
    private static ForkJoinPool createDefaultScheduler() {
        ForkJoinWorkerThreadFactory factory = pool -> new CarrierThread(pool);
        int parallelism, maxPoolSize, minRunnable;
        String parallelismValue = System.getProperty("jdk.virtualThreadScheduler.parallelism");
        String maxPoolSizeValue = System.getProperty("jdk.virtualThreadScheduler.maxPoolSize");
        String minRunnableValue = System.getProperty("jdk.virtualThreadScheduler.minRunnable");
        if (parallelismValue != null) {
            parallelism = Integer.parseInt(parallelismValue);
        } else {
            parallelism = Runtime.getRuntime().availableProcessors();
        }
        if (maxPoolSizeValue != null) {
            maxPoolSize = Integer.parseInt(maxPoolSizeValue);
            parallelism = Integer.min(parallelism, maxPoolSize);
        } else {
            maxPoolSize = Integer.max(parallelism, 256);
        }
        if (minRunnableValue != null) {
            minRunnable = Integer.parseInt(minRunnableValue);
        } else {
            minRunnable = Integer.max(parallelism / 2, 1);
        }
        Thread.UncaughtExceptionHandler handler = (t, e) -> { };
        boolean asyncMode = true; // FIFO
        return new ForkJoinPool(parallelism, factory, handler, asyncMode,
                     0, maxPoolSize, minRunnable, pool -> true, 30, SECONDS);
    }

    // ...
    
}

위 코드는 JDK 25의 java.lang.VirtualThread.java로 가상 스레드의 기본 스케줄러는 ForkJoinPool 인스턴스를 사용하고 있는 것을 확인할 수 있다. 하지만, JVM 전체가 공유하는 기본 인스턴스인 ForkJoinPoo.commonPool()과 달리 별개의 ForkJoinPool 전용 인스턴스로 두 가지 중요한 차이가 있습니다.

FIFO 모드(asyncMode)로 동작합니다. 일반적인 ForkJoinPool은 방금 쪼갠 하위 작업 먼저 진행하는 분할 정복용 LIFO 방식이지만, 수십만 개의 독립적인 요청을 처리하는 서버에서 LIFO를 쓰면 먼저 온 요청이 한없이 밀리는 불공정이 생길 수 있습니다. 따라서 도착 순서 공정성을 제공하기 위해 FIFO 방식으로 자기 데크에서도 오래된 것부터 꺼내 대기 시간을 고르게 만듭니다.

워커(carrier thread) 수가 기본적으로 CPU 코어 수와 같습니다. 가상 스레드는 위에서 살펴본 RecuriveTask와 달리 Continuation이라는 실행 태스크(다음 블로킹 지점까지 진행시키는 단위)를 가지고 가상 스레드가 시작되거나 unpark될 때마다 태스크가 하나 제출되고, 실행 중 블로킹을 만나면 태스크는 unmount되어 carrier를 반납하고, 힙에서 대기하게 됩니다. 이 덕분에 적은 수의 carrier로도 방대한 가상 스레드를 소화할 수 있습니다.

 

기존 ThreadPoolExecutor의 한계

가상 스레드는 1:1 매핑이 아닌 다수의 경량 실행 단위를 소수의 OS 스레드 위에서 돌리는 M:N 모델을 택했습니다. 그러면 다른 ThreadPoolExecutor가 있는데도 불구하고 ForkJoinPool을 선택했을까요? 이는 기존 스레드 스케줄러가 가진 한계를 알아봐야 합니다.

기존 ThreadPoolExecutor는 모든 워커가 하나의 BlockingQueue에서 take()를 경쟁합니다. 즉, 테스크를 공유 큐(shared queue)에 넣고, 유휴 스레드가 태스크를 가져가기 위해 경쟁하게 되고 오버헤드가 발생할 수 있습니다. 요청 수천 개 수준에서는 문제가 없을 수 있지만, 가상 스레드처럼 mount/unmount가 초당 수백만 번 일어나는 환경에서는 그 큐의 head가 전 시스템의 병목 지점이 될 수 있습니다.

 

ForkJoinPool을 선택한 이유

M:N 모델을 택하기 위해서는 누가 어떤 코어에서 실행할지를 커널 대신 런타임이 직접 결정해야 합니다. 이 배정을 하나의 공유 큐로 처리하면 그 큐의 head가 전 시스템의 경합 지점이 되므로, 배정 자체를 코어별로 분산해야 합니다. 따라서 분산, 균형, 저비용을 동시에 만족시키는 답이 코어별 로컬 큐 + work-stealing이며, 자바에는 이미 그 자료구조를 20여 년간 다듬어온 ForkJoinPool이 있었습니다.

실제로, Go, Kotlin 코루틴, Rust Tokio 등 주요 런타임들도 M:N 모델을 지원하기 위해 서로 독립적으로 코어별 로컬 큐 + work-stealing이라는 같은 자료구조를 사용하고 있습니다. 마찬가지로 Loom은 그 자료구조를 취하고 위에서 살펴봤듯이 공정성을 위해 FIFO로 바뀌었고, 워커는 CarrierThread로 교체되었으며, 캐리어 보상이라는 서버 워크로드용 정책만 새로 얹었습니다.

 

 

참고

Java's Virtual vs. Platform Threads and What's New in JDK 24

Virtual Threads 가상 스레드

모던 자바 동시성 프로그래밍

Java ForkJoinPool의 Work-Stealing 알고리즘 완벽 가이드: 고성능 병렬 처리의 비밀

Java 병렬 처리 ForkJoinPool 기본 작동 원리

Overview Of Fork-Join Framework - Core of Parallelism in Java

 

 

 

 

 

 

 

 

 

 

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/08   »
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
글 보관함