한 줄 정의

분할 정복(divide-and-conquer) 병렬 처리에 최적화된 스레드 풀. 각 워커 스레드가 자체 작업 큐(deque)를 가지며, work-stealing 알고리즘으로 스레드 간 부하를 자동으로 균형 맞춘다.

쉽게 말하면

일반 스레드 풀이 “하나의 공유 접수대에서 번호표를 뽑는 구조”라면, ForkJoinPool은 “각 직원이 자기 책상에 서류 더미를 갖고 있는 구조”입니다. 자기 서류를 다 처리한 직원이 옆 직원 책상에서 서류를 가져다 처리하는 것이 work-stealing입니다. 공유 큐 하나를 놓고 경쟁하지 않으니 lock 충돌이 적고, 유휴 스레드가 알아서 일감을 찾아가므로 코어 활용률이 높습니다.

왜 이걸 알아야 하는가?

  • Virtual Thread의 스케줄러: Java Virtual Thread의 캐리어 스레드 풀이 바로 ForkJoinPool입니다. Virtual Thread가 어떻게 캐리어에 배정되고 work-stealing으로 부하가 분산되는지를 이해하려면 ForkJoinPool의 동작 원리를 알아야 합니다.
  • parallelStream의 내부 동작: list.parallelStream().map(...)을 호출하면 ForkJoinPool.commonPool에서 실행됩니다. commonPool의 스레드 수가 코어 수 - 1이라는 사실을 모르면, “왜 parallelStream이 기대만큼 빠르지 않은지” 설명할 수 없습니다.
  • ThreadPoolExecutor와의 차이: 둘 다 스레드 풀이지만 내부 구조가 근본적으로 다릅니다. 어떤 워크로드에 어떤 풀이 적합한지 판단하려면 각각의 큐잉 전략과 스케줄링 방식을 알아야 합니다.

왜 이렇게 설계했는가?

  • 해결하는 문제: 재귀적으로 쪼개지는 병렬 작업을 ThreadPoolExecutor에서 처리하면 두 가지 문제가 발생합니다. 첫째, 부모 태스크가 자식 태스크의 완료를 기다리면서 스레드를 점유하므로 데드락에 가까운 상황(thread starvation)이 생길 수 있습니다. 둘째, 하나의 공유 큐에 모든 스레드가 접근하므로 lock 경합이 병목이 됩니다.
  • 이게 없다면: 분할 정복을 병렬로 실행하려면 직접 작업 분배와 합류를 관리해야 하고, 스레드 간 부하 불균형이 생겨도 수동으로 재분배해야 합니다.
  • 핵심 설계 원칙: work-stealing — 유휴 스레드가 바쁜 스레드의 큐에서 작업을 가져가서 자동으로 부하를 균형 맞춥니다. per-thread deque — 스레드마다 독립적인 양방향 큐를 두어 공유 큐의 lock 경합을 제거합니다.

어떻게 동작하는가?

Fork/Join 패러다임

큰 작업을 작은 단위로 **fork(분할)**하고, 결과를 **join(합류)**하는 분할 정복 패턴입니다.

class SumTask extends RecursiveTask<Long> {
    private final int[] arr;
    private final int lo, hi;
 
    protected Long compute() {
        if (hi - lo <= THRESHOLD) {
            return sequentialSum(arr, lo, hi);  // 충분히 작으면 직접 계산
        }
        int mid = (lo + hi) / 2;
        SumTask left = new SumTask(arr, lo, mid);
        SumTask right = new SumTask(arr, mid, hi);
        left.fork();                             // 왼쪽을 다른 스레드에 위임
        long rightResult = right.compute();      // 오른쪽은 현재 스레드에서 실행
        long leftResult = left.join();           // 왼쪽 결과 대기
        return leftResult + rightResult;
    }
}

fork()는 현재 스레드의 deque에 작업을 넣고, join()은 결과가 준비될 때까지 다른 작업을 처리하면서 기다립니다. 단순히 블로킹하는 것이 아니라 대기 시간을 활용하는 것이 핵심입니다.

Work-Stealing 알고리즘

ForkJoinPool의 핵심이자 ThreadPoolExecutor와의 가장 큰 차이입니다.

구조:

  • 각 워커 스레드가 자신만의 **deque(양방향 큐)**를 갖고 있습니다.
  • 자기 작업은 deque의 **top(LIFO)**에서 꺼냅니다. 가장 최근에 fork한 작업을 먼저 처리하므로 캐시 지역성이 좋습니다.
  • 다른 스레드의 작업을 훔칠 때는 deque의 **bottom(FIFO)**에서 가져갑니다. 오래된(큰) 작업을 가져가므로 훔친 작업에서 다시 fork가 일어나 스스로 일감을 만들어낼 수 있습니다.

왜 LIFO + FIFO 조합인가:

  • 자기 작업 = LIFO: 최근 fork한 작업일수록 데이터가 캐시에 남아있을 확률이 높습니다.
  • stealing = FIFO: 오래된 작업은 보통 더 큰 단위이므로, 훔친 스레드가 다시 재귀적으로 분할하면서 자체 일감을 확보할 수 있습니다. 작은 작업을 훔치면 금방 끝나고 또 훔쳐야 하므로 stealing 오버헤드가 커집니다.

lock-free 접근: 자기 deque의 top에서 push/pop하는 것은 단일 스레드만 접근하므로 lock이 필요 없습니다. stealing은 bottom에서 발생하고 CAS(Compare-And-Swap) 연산으로 처리하므로, 대부분의 경우 lock 경합 없이 동작합니다.

ThreadPoolExecutor vs ForkJoinPool 내부 구조

구분ThreadPoolExecutorForkJoinPool
작업 큐공유 BlockingQueue 1개워커당 deque 1개
작업 가져오기모든 스레드가 공유 큐에서 경쟁자기 deque에서 LIFO pop
유휴 시큐에서 대기 (blocking)다른 워커의 deque에서 stealing
lock 경합공유 큐 접근 시 발생거의 없음 (lock-free deque)
적합한 작업독립적인 태스크 (I/O, 요청 처리)재귀적 분할 정복, 부하 불균형

commonPool

ForkJoinPool.commonPool()은 JVM 전체에서 공유하는 싱글턴 ForkJoinPool 인스턴스입니다.

  • 스레드 수: 기본값 = Runtime.getRuntime().availableProcessors() - 1. 호출자 스레드도 작업에 참여하므로 -1입니다.
  • 사용처: parallelStream(), CompletableFuture.supplyAsync() (executor 미지정 시) 등이 기본으로 사용합니다.
  • 주의점: commonPool은 JVM 전체가 공유하므로, 한 곳에서 블로킹 작업을 돌리면 다른 곳의 parallelStream까지 느려집니다. 블로킹이 필요하면 별도의 ForkJoinPool 인스턴스를 생성하거나 ThreadPoolExecutor를 사용해야 합니다.

Virtual Thread 스케줄러로서의 ForkJoinPool

Java Virtual Thread의 캐리어 스레드 풀은 ForkJoinPool이지만, 일반적인 fork/join 용도와는 다르게 설정됩니다.

구분일반 ForkJoinPoolVirtual Thread용 ForkJoinPool
작업 순서LIFO (캐시 지역성)FIFO (공정한 스케줄링)
용도분할 정복 병렬 처리Virtual Thread 스케줄링
스레드 수코어 수 - 1코어 수
work-stealing큰 작업을 훔쳐 재분할대기 중인 VT를 훔쳐 실행

FIFO로 전환한 이유는 Virtual Thread는 재귀적 분할이 아니라 독립적인 요청들이므로, 먼저 들어온 요청을 먼저 처리해야 공정하기 때문입니다. LIFO로 하면 나중에 들어온 요청이 먼저 처리되어 초기 요청의 응답 시간이 불예측적으로 길어집니다.

시각화

Work-Stealing 동작

Worker 1 deque       Worker 2 deque       Worker 3 deque
┌─────────┐         ┌─────────┐         ┌─────────┐
│ Task A  │ ← pop   │         │         │ Task F  │ ← pop
│ Task B  │         │  (비어있음) │         │ Task G  │
│ Task C  │         │         │         │ Task H  │
│ Task D  │         │         │         │ Task I  │
└─────────┘         └─────────┘         └─────────┘
              steal →  ↑ bottom                ↑ bottom
              Worker 2가 Worker 1의
              bottom에서 Task D를 훔침

Fork/Join 실행 흐름

graph TD
    Big["배열 합계 (0~1000)"] --> L1["fork: 합계 (0~500)"]
    Big --> R1["compute: 합계 (500~1000)"]
    L1 --> L2["fork: 합계 (0~250)"]
    L1 --> R2["compute: 합계 (250~500)"]
    R1 --> L3["fork: 합계 (500~750)"]
    R1 --> R3["compute: 합계 (750~1000)"]

    L2 -.->|"join"| L1
    R2 -.->|"결과"| L1
    L3 -.->|"join"| R1
    R3 -.->|"결과"| R1
    L1 -.->|"join"| Big
    R1 -.->|"결과"| Big

정리

기준ThreadPoolExecutorForkJoinPoolVirtual Thread Executor
큐 구조공유 BlockingQueue워커당 deque + stealing없음 (즉시 VT 생성)
스케줄링FIFO (큐 순서)LIFO (자기) + FIFO (steal)FIFO (ForkJoinPool 위임)
최적 워크로드독립적 I/O 태스크재귀적 분할 정복I/O-bound 대량 동시성
부하 분산없음 (큐 하나)자동 (work-stealing)자동 (work-stealing)
블로킹 작업스레드 점유compensate (스레드 추가 생성)unmount (캐리어 반환)

실무에서 만난 사례

  • parallelStream 성능 함정: list.parallelStream().map(item -> blockingApiCall(item))처럼 블로킹 호출을 parallelStream에서 돌리면, commonPool의 스레드(코어 수 - 1개)가 전부 블로킹에 묶여서 같은 JVM의 다른 parallelStream까지 멈추게 됩니다. 블로킹 작업은 별도 ThreadPoolExecutor에서 처리하거나, Virtual Thread를 사용하는 것이 맞습니다.
  • CompletableFuture의 기본 풀: CompletableFuture.supplyAsync(() -> ...)는 executor를 지정하지 않으면 commonPool을 사용합니다. parallelStream과 같은 풀을 공유하므로 서로 영향을 줄 수 있고, 이것이 운영 환경에서 executor를 명시적으로 지정해야 하는 이유입니다.
  • ManagedBlocker: ForkJoinPool 안에서 블로킹이 불가피한 경우 ManagedBlocker 인터페이스를 구현하면, 풀이 보상 스레드(compensating thread)를 추가 생성하여 parallelism을 유지합니다. Virtual Thread의 unmount가 이 메커니즘을 활용합니다.

관련 개념

출처

  • Doug Lea, “A Java Fork/Join Framework” (2000)
  • JSR 166: Concurrency Utilities
  • JEP 444: Virtual Threads
  • Brian Goetz, “Java Concurrency in Practice” Chapter 8