ForkJoinPool 내부 구조 — Work-Stealing Deque, Common Pool, 그리고 가상 스레드 캐리어
Parallel Stream의 백엔드,
CompletableFuture의 기본 executor, JDK 21 이후 가상 스레드 스케줄러. 자바 동시성 라이브러리의 절반은 같은 풀 위에 서 있습니다. 이 글은 그 풀이 어떤 자료구조와 알고리즘으로 동작하는지를 OpenJDKmaster소스와 공식 javadoc, 그리고 Doug Lea가 2000년에 발표한 원본 논문 기준으로 정리합니다. 자바 동시성 내부 동작에 관심 있는 개발자를 대상으로 합니다.
ExecutorService 인터페이스를 보면 submit(Callable)이 Future를 돌려주는 단순한 모양입니다. ThreadPoolExecutor는 그 위에 큐 하나와 워커 스레드 N개를 두는 정직한 구현입니다. ForkJoinPool은 같은 인터페이스를 구현하지만 안쪽이 완전히 다릅니다. 큐 하나가 아니라 워커마다 자기 deque를 들고, 일이 떨어지면 옆 워커의 deque 끝에서 훔쳐다 씁니다. 이 단순한 한 줄짜리 차이 — 큐 한 개 대 deque N개 — 가 fork/join 알고리즘의 거의 모든 성능 특성을 결정합니다.
왜 deque인가
Doug Lea의 2000년 ACM Java Grande 논문 A Java Fork/Join Framework은 이 디자인의 출발점을 한 문장으로 요약합니다.
- 한 워커가 만든 subtask는 그 워커의 deque에 push 됩니다.
- 워커는 자기 deque에서 LIFO(최신 먼저)로 pop 해서 처리합니다.
- 자기 deque가 비면 다른 워커의 deque에서 FIFO(가장 오래된 것 먼저)로 take(=steal) 합니다.
같은 deque의 양 끝을 owner와 stealer가 나눠 쓰기 때문에 두 쪽이 부딪힐 일이 거의 없습니다. owner가 4개 push해서 3개 pop하는 동안 stealer가 1개를 base에서 가져가도, top과 base는 멀리 떨어진 인덱스라 CAS 충돌이 일어나지 않습니다. 충돌이 일어나는 경우는 deque에 작업이 하나밖에 없을 때뿐인데, 그건 일이 거의 끝나가는 순간이라 그래도 무방하다는 것이 논문의 주장입니다.
LIFO/FIFO 비대칭에는 두 번째 이유가 있습니다. fork/join 알고리즘은 보통 큰 작업을 재귀적으로 둘로 쪼개는 구조라, 먼저 쪼개진 task일수록 일이 더 큽니다. owner는 가장 최근(=가장 작은) task를 LIFO로 가져가 캐시 친화적으로 마무리하고, stealer는 가장 오래된(=가장 큰) task를 FIFO로 훔쳐 가서 한 번에 큰 덩어리를 가져갑니다. 같은 stealing 한 번에 더 많은 일을 옮기니 스틸 빈도가 줄어듭니다.
이 두 가지 — 양 끝 분리로 인한 무경쟁 동작과, 더 큰 일을 훔치게 만드는 비대칭 — 가 work-stealing의 핵심 가치이고, 이후 25년 동안 ForkJoinPool 코드가 아무리 두꺼워져도 이 원칙은 그대로입니다.
WorkQueue 한 장으로 보는 구조
OpenJDK master의 java/util/concurrent/ForkJoinPool.java 안에는 WorkQueue가 static nested class로 들어 있습니다. 핵심 필드만 추리면 다음과 같습니다.
static final class WorkQueue {
volatile int phase; // 큐 id + lock + 버전 카운터
int stackPred; // Treiber 스택의 다음 비활성 워커 phase
int source; // 마지막으로 task를 훔친 큐의 인덱스
volatile int top; // push/pop이 움직이는 owner 측 인덱스
volatile int base; // poll(steal)이 움직이는 stealer 측 인덱스
ForkJoinTask<?>[] array; // 원형 버퍼
final ForkJoinWorkerThread owner;
// ...
}
array는 원형 버퍼입니다. 길이는 항상 2의 거듭제곱이라(top - 1) & (array.length - 1)한 줄로 슬롯 인덱스를 뽑습니다.top은 owner 쪽 포인터.push(t)는array[top & mask] = t; top++형태,pop()은 그 반대.base는 stealer 쪽 포인터.poll()은array[base & mask]에 대해 CAS로 슬롯을 null로 만들고base++.phase는 두 가지 역할을 겸합니다. 외부 제출 큐에서는 짧은 락 역할(짝수면 free, 홀수면 lock held), 워커 큐에서는 큐 id와 활성/비활성 마커를 포함하는 버전 카운터입니다.source는 helping 알고리즘이 쓰는 단서입니다. 내가 join을 기다리는 task의 owner가 어떤 큐에서 일을 훔쳤는지 거꾸로 추적해 그쪽으로 일을 도우러 갑니다.stackPred는 비활성 워커들을 잇는 Treiber 스택의 next 포인터입니다.
이 다섯 필드 위에서 push/pop/poll 세 동작이 어떻게 충돌 없이 굴러가는지가 work-stealing 디자인의 본체입니다. 의사코드 수준으로 보면 단순합니다.
// owner-only push
void push(ForkJoinTask<?> t) {
int s = top;
int mask = array.length - 1;
array[s & mask] = t; // 슬롯 채우기
top = s + 1; // 인덱스 진행
if (s - base <= 1) signalWork(); // 거의 비어 있던 큐였다면 도움 요청
}
// owner-only pop
ForkJoinTask<?> pop() {
int s = top - 1;
int mask = array.length - 1;
int i = s & mask;
ForkJoinTask<?> t = array[i];
if (t != null && CAS(array, i, t, null)) {
top = s; // base와의 경쟁 없이 슬롯을 비움
return t;
}
return null; // 이미 stealer가 가져갔거나 큐가 비었음
}
// stealer-callable poll
ForkJoinTask<?> poll() {
int b = base;
int mask = array.length - 1;
int i = b & mask;
ForkJoinTask<?> t = array[i];
if (t != null && CAS(array, i, t, null)) {
base = b + 1;
return t;
}
return null;
}
owner는 top에서, stealer는 base에서 동작하므로 두 인덱스가 떨어져 있는 한 같은 슬롯을 동시에 손볼 수 없습니다. 두 쪽이 같은 슬롯에 접근하는 유일한 순간은 큐에 task가 하나만 남았을 때이고, 그때는 CAS 한쪽이 이기고 다른 쪽이 깔끔하게 실패합니다. 락이 전혀 등장하지 않는 자료구조이면서도 정합성이 유지되는 이유입니다.
풀 차원에서는 WorkQueue[] queues가 모든 큐를 담는데, 인덱스 배치에 패턴이 있습니다.
flowchart LR
Q0[Q0 submission] --- Q1[Q1 worker]
Q1 --- Q2[Q2 submission]
Q2 --- Q3[Q3 worker]
Q3 --- Q4[Q4 submission]
Q4 --- Q5[Q5 worker]
- 짝수 인덱스는 외부에서 제출한 task가 들어가는 submission queue.
- 홀수 인덱스는 워커가 보유한 worker queue.
외부 스레드(=pool 바깥에서 submit() 호출한 스레드)는 ThreadLocalRandom의 probe로 짝수 슬롯 하나를 골라 거기에 task를 밀어 넣습니다. 워커들은 이 짝수 슬롯과 다른 워커의 홀수 슬롯을 모두 스캔 대상으로 봅니다. 외부 제출과 내부 fork가 동일한 stealing 메커니즘 위에서 처리되도록 한 통일된 디자인입니다.
ctl, 하나의 long에 다 박아 넣은 풀 상태
ForkJoinPool은 volatile long ctl 한 필드로 풀 전체 상태를 표현합니다. 64비트를 네 조각으로 나눠 packed 인코딩합니다.
| 비트 범위 | 의미 |
|---|---|
| 상위 16비트 (RC) | Release Count — 활성 워커 수에 상응하는 16비트 signed 카운터 |
| 그 다음 16비트 (TC) | Total Count — 총 워커 수에 상응하는 16비트 signed 카운터 |
| 하위 32비트 (SP) | Stack Pointer — Treiber 스택 top에 있는 비활성 워커의 phase |
signalWork()는 ctl을 한 번의 CAS로 변경합니다.
- 비활성 워커가 있으면 SP가 가리키는 워커를 깨우고(unpark) SP를 그 워커의
stackPred로 교체. - 비활성 워커가 없으면 RC와 TC를 동시에 증가시키며 새 워커를 생성.
워커가 일을 못 찾고 비활성화될 때는 자기 stackPred에 현 SP를 박고, ctl의 SP를 자기 phase로 바꾸는 CAS를 시도합니다. 활성·비활성 전환이 락 없이 한 번의 CAS로 끝납니다.
MAX_CAP = (1 << 15) - 1 = 32767이 워커 수 상한이고, RC/TC가 모두 16비트 signed라 신호 카운터가 오버플로하지 않게 마스킹을 정확히 해야 합니다. 실제로 JDK 9 이후 TC 감소 시 마스킹이 인접 RC 비트를 침범하는 버그(JDK-8330017, JDK-8351933)가 발견되어 패치된 적이 있을 정도로 비트 연산이 빡빡합니다. 이 한 줄짜리 마스크 실수가 production에서 풀 전체를 멈추게 만드는 종류의 결함이라는 점이, packed encoding이 가진 위험성을 잘 보여 줍니다.
task 한 개의 일생
pool.submit(task)에서 시작해 task.join()이 결과를 돌려줄 때까지의 흐름을 따라가 보면 다음과 같습니다.
flowchart TD
S[submit task from caller] --> EXT{is caller a ForkJoinWorkerThread}
EXT -- no --> EQ[push to submission queue at even index]
EXT -- yes --> WQ[push to own work queue at top]
EQ --> SIG[signalWork]
WQ --> SIG
SIG --> RW[worker runWorker scan loop]
RW --> SCAN[scan queues array randomly]
SCAN --> FOUND{task found}
FOUND -- yes --> RUN[execute task doExec]
RUN --> RW
FOUND -- no --> AWAIT[deactivate and park]
순서대로 보면:
- 제출. 호출자가
ForkJoinWorkerThread면 자기 큐의 top에 push, 아니면 자기 probe로 고른 짝수 슬롯에 push. - 신호.
signalWork()이 ctl을 검사해 비활성 워커가 있으면 깨우고, 없으면 새 워커를 만듭니다(상한 도달까지). - 스캔. 깨어난 워커는
runWorker()에서scan()을 호출. 이 함수는queues배열을 의사난수 순열로 훑으며 base를 읽고, base != top인 큐를 만나면 그 슬롯에 CAS로 null을 박으며 task를 가져옵니다. - 실행. 가져온 task에 대해
task.doExec()를 호출하면 그 안에서compute()가 돌고, 그 안에서 다시 sub-task를fork()하면 자기 큐 top에 push. - 비활성화. 스캔이 한 바퀴 돌아도 빈 큐만 보이면 자기를 비활성 스택에 넣고 park. 누군가
signalWork()로 깨우면 다시 4번으로.
여기서 흥미로운 건 fork된 sub-task가 항상 자기 큐에 LIFO로 push된다는 점입니다. 호출자 입장에서는 그저 함수 호출의 연속처럼 보이지만, 사실 그 사이에 stealer가 끼어들어 옆에서 base의 task를 훔쳐 갈 수 있는 구조입니다. 호출자가 join()을 부르면 다음 문단의 helping 시나리오가 시작됩니다.
RecursiveTask로 보는 실제 흐름
추상적인 설명을 한 번 구체적으로 묶어 봅니다. 배열 구간 합을 fork/join으로 계산하는 가장 흔한 예시입니다.
import java.util.concurrent.RecursiveTask;
import java.util.concurrent.ForkJoinPool;
class SumTask extends RecursiveTask<Long> {
private static final int THRESHOLD = 10_000;
private final long[] data;
private final int lo, hi;
SumTask(long[] data, int lo, int hi) {
this.data = data;
this.lo = lo;
this.hi = hi;
}
@Override
protected Long compute() {
if (hi - lo <= THRESHOLD) {
long sum = 0;
for (int i = lo; i < hi; i++) sum += data[i];
return sum;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(data, lo, mid);
SumTask right = new SumTask(data, mid, hi);
left.fork(); // 1) 왼쪽은 큐에 push
long r = right.compute(); // 2) 오른쪽은 현재 스레드에서 직접 호출
long l = left.join(); // 3) 왼쪽 join — pop이 가능하면 직접, 아니면 도움
return l + r;
}
}
long total = ForkJoinPool.commonPool()
.invoke(new SumTask(input, 0, input.length));
이 작은 코드 안에서 풀의 알고리즘이 거의 다 등장합니다.
left.fork()는 현재 스레드의 큐 top에 task를 push. owner-only push 한 번이고, 이때 큐가 거의 비어 있었다면signalWork()가 다른 워커를 깨웁니다.right.compute()는 같은 스레드에서 곧장 함수 호출. fork/join이 "함수 호출의 일반화"라는 표현은 이 줄에서 가장 잘 보입니다.left.join()시점에 left task가 아직 내 큐 top에 있다면 pop으로 가져와 같은 스레드에서 처리. 이미 stealer가 가져갔다면 위에서 본 helpJoin 경로로 넘어갑니다.
THRESHOLD 위의 큰 SumTask는 stealer 입장에서 매력적인 사냥감입니다. base 쪽에 더 오래된, 그래서 더 큰 task가 있기 때문입니다. 한 번 훔쳐 가면 그 스레드는 다시 그 task 안에서 재귀 분할을 시작하니, 자기 큐가 풍성해지고 또 다른 워커가 그쪽을 노리는 식으로 일이 자연스럽게 퍼집니다.
join이 부른 두 가지 helping
task.join()은 task가 done 상태가 될 때까지 block 해야 합니다. 그런데 fork/join 풀에서 무작정 park 해 버리면, 스레드 수가 parallelism으로 제한된 풀에서 자기가 직접 일을 안 도와 데드락 비슷한 상황이 만들어집니다. 그래서 ForkJoinTask.join()은 그냥 기다리지 않고 일을 돕습니다. 두 가지 helping 경로가 있습니다.
helpJoin — 일반 task
source 필드를 추적해 join 대상 task를 가져간 stealer를 거꾸로 따라갑니다.
- 내가 push한 task
T가 deque에 그대로 있으면 pop해서 직접 실행. fork/join이 동기 함수 호출처럼 동작하는 행복 경로입니다. T를 누가 훔쳐 갔다면, 그 stealer의source필드를 본다. 그 stealer가 다시 누군가의 task를 훔쳤다면 그 큐의 base에서 task를 가져와 실행. 이 과정이 일종의 "linear scan"입니다.- 도울 일이 없으면
ManagedBlocker비슷한 보상 절차를 통해 잠시 park 합니다.
핵심은 join을 기다리는 동안 스레드를 놀게 두지 않는다는 점입니다. 자기 task와 인과적으로 연결된 다른 task를 도우면, join 대상은 결국 빠르게 끝날 가능성이 높아집니다.
helpComplete — CountedCompleter
CountedCompleter는 join 대신 pending count를 0으로 만들면 부모를 호출해 주는 변종 task입니다. completion chain이 위로 올라가는 구조라 helping 로직도 그 체인을 따라 올라가며 부모/형제 task를 찾아 실행합니다. 일반 helpJoin보다 추적이 더 정밀합니다.
helpAsyncBlocker — CompletableFuture
CompletableFuture.get()처럼 unstructured async가 commonPool에서 block 되는 경우, async helper 패턴으로 풀의 다른 task를 우선 실행해 데드락을 피합니다. 자주 부딪히는 시나리오는 commonPool 위에서 돌아가는 CF 체인이 자기 자신을 join 하는 경우인데, 이때 helpAsyncBlocker가 같은 풀의 다른 task를 도와줘 진행 가능성을 회복시킵니다.
ManagedBlocker — 진짜로 block 해야 한다면
work-stealing은 task가 "CPU bound이고 짧다"는 가정 위에서 가장 잘 동작합니다. 그런데 가끔은 어쩔 수 없이 IO나 lock에서 block 해야 할 때가 있습니다. 그대로 park 하면 parallelism이 줄어 풀 전체 처리량이 떨어지죠.
ForkJoinPool.ManagedBlocker 인터페이스가 이걸 해결합니다.
public interface ManagedBlocker {
boolean block() throws InterruptedException;
boolean isReleasable();
}
ForkJoinPool.managedBlock(blocker)을 호출하면 풀이 다음 순서로 협상합니다.
isReleasable()이 true면 즉시 리턴.- 아니면 풀이 임시로 보상 워커를 한 개 더 생성해 parallelism을 보전.
- 그 후
block()호출. 끝나면isReleasable()이 true가 되고 워커가 정상 복귀.
이 패턴 위에서 Phaser, ConcurrentHashMap의 forEach, LinkedTransferQueue 등이 fork/join 풀 안에서 안전하게 block 합니다. 일반 어플리케이션 코드가 직접 쓸 일은 드물지만, 풀이 IO bound 작업을 만나도 죽지 않는 이유가 여기 있습니다.
Common Pool — JDK 8의 묵시적 기본 풀
JDK 8부터 ForkJoinPool.commonPool()이라는 정적 풀이 추가됐습니다. 이 풀이 갖는 의미는 단순한 "기본 인스턴스" 이상입니다.
parallelStream()의 백엔드.CompletableFuture의supplyAsync(Supplier)같은 1-arg 오버로드의 기본 executor.ForkJoinTask.fork()가 풀 밖에서 호출됐을 때 떨어지는 풀.
기본 parallelism은 Runtime.getRuntime().availableProcessors() - 1입니다. 메인 스레드까지 계산에 참여한다고 가정한 값입니다. 0이 되면 모든 task가 호출자 스레드에서 직접 실행되는 caller-runs 모드가 됩니다.
튜닝 가능한 시스템 프로퍼티는 javadoc에 명시된 네 가지입니다.
| 프로퍼티 | 효과 |
|---|---|
java.util.concurrent.ForkJoinPool.common.parallelism |
parallelism 직접 지정. 0이면 caller-runs |
java.util.concurrent.ForkJoinPool.common.threadFactory |
워커 생성 팩토리 클래스 FQCN |
java.util.concurrent.ForkJoinPool.common.exceptionHandler |
미처리 예외 핸들러 클래스 FQCN |
java.util.concurrent.ForkJoinPool.common.maximumSpares |
보상 워커 상한 (기본 256) |
common pool의 워커는 일반 ForkJoinWorkerThread가 아니라 InnocuousForkJoinWorkerThread 입니다. 이 변종은 task 실행 전 ThreadLocal과 ContextClassLoader를 깨끗하게 비웁니다. 풀이 전역이라 ThreadLocal 누수가 곧 다른 코드 경로의 누수가 되기 때문입니다. parallelStream에서 ThreadLocal에 의존하는 코드가 의도와 다르게 동작하는 흔한 함정의 원인이 여기 있습니다.
또 한 가지, common pool은 일반 풀과 달리 shutdown()/awaitTermination()을 무시합니다. JVM 라이프사이클 동안 살아 있는 것이 디자인 의도입니다.
LIFO vs FIFO — 같은 풀의 두 모드
ForkJoinPool 생성자는 asyncMode 파라미터를 받습니다.
public ForkJoinPool(
int parallelism,
ForkJoinWorkerThreadFactory factory,
UncaughtExceptionHandler handler,
boolean asyncMode, // <-- 이 인자
int corePoolSize,
int maximumPoolSize,
int minimumRunnable,
Predicate<? super ForkJoinPool> saturate,
long keepAliveTime,
TimeUnit unit
);
asyncMode = false (commonPool 포함 fork/join 용도)는 LIFO 모드입니다. owner가 자기 큐에서 task를 가져올 때 top에서 pop. 재귀 분할 알고리즘에 최적입니다.
asyncMode = true는 FIFO 모드입니다. owner조차도 자기 큐에서 base 쪽에서 가져옵니다. 모든 task가 동등한 비재귀 단발 task인 경우 (이벤트 처리, 메시지 디스패치 등) 공정한 처리에 유리합니다. 후술할 가상 스레드 스케줄러가 이 모드를 씁니다.
같은 코드가 한 boolean으로 두 가지 큐 동작을 전환한다는 점이 이 디자인의 우아함입니다. 추가 자료구조 없이 push/pop/poll의 인덱스 선택만 바꿉니다.
가상 스레드 스케줄러 — JEP 444 위의 또 다른 ForkJoinPool
JDK 21에서 final이 된 가상 스레드(JEP 444) 스케줄러는 또 하나의 ForkJoinPool 인스턴스입니다. commonPool과는 다음 두 가지가 다릅니다.
- FIFO 모드. 가상 스레드는 단발 task처럼 동작해야 하기 때문에 LIFO로 갓 unpark된 스레드를 먼저 깨우는 동작은 부적절합니다.
- 별도 system property로 분리 설정.
| 프로퍼티 | 의미 |
|---|---|
jdk.virtualThreadScheduler.parallelism |
캐리어 스레드 parallelism. 기본 availableProcessors() |
jdk.virtualThreadScheduler.maxPoolSize |
보상 포함 캐리어 상한. 기본은 availableProcessors, 256으로 cap |
jdk.virtualThreadScheduler.minRunnable |
항상 실행 가능한 캐리어 최소 수 |
가상 스레드가 LockSupport.park()로 멈추면 캐리어 스레드에서 unmount 되어 다른 가상 스레드가 같은 캐리어를 잡아 진행합니다. 그런데 synchronized 블록에 들어간 채 멈추거나, JDK 21 시점에 unmount 미지원 IO 시스템 콜에 들어가면 캐리어가 함께 멈춥니다. 이때 풀은 minRunnable 이상의 실행 가능한 캐리어를 확보하기 위해 임시로 보상 캐리어를 만듭니다. JEP 444 본문도 이 동작을 "temporarily expanding the parallelism of the scheduler"로 명시합니다.
결과적으로 가상 스레드 친화 코드는 동일한 work-stealing 인프라 위에서 돌아가지만 LIFO/FIFO와 보상 정책 두 축에서만 commonPool과 다르게 튜닝된 셈입니다. 자료구조 하나가 두 곳에서 살아 움직이는 좋은 예입니다.
스캔 전체 그림
지금까지 본 조각을 묶으면 풀 한 사이클은 이렇게 흐릅니다.
flowchart TD
Pool[ForkJoinPool] --> Queues[queues array even submission odd worker]
Pool --> Ctl[ctl long RC TC SP]
Pool --> Workers[ForkJoinWorkerThreads]
Workers --> W1[Worker 1 WorkQueue]
Workers --> W2[Worker 2 WorkQueue]
Workers --> W3[Worker 3 WorkQueue]
W1 -->|fork pushes at top| W1
W2 -->|local pop from top LIFO| W2
W3 -->|scan random walks queues| Steal[steal from base FIFO of another queue]
Steal --> W3
Ctl -->|signalWork CAS RC++ or wake SP| Workers
Ctl -->|deactivate worker pushes phase to SP| Workers
세 가지 알고리즘이 이 그림 위에서 동시에 돕니다.
- task 생산자: owner가 자기 큐 top에 push, 필요시
signalWork()로 ctl을 두드림. - task 소비자: 워커는 자기 top에서 LIFO pop, 비면 다른 큐 base를 FIFO로 훔침.
- 활성 협상자: 비활성 워커는 Treiber 스택에 들어가 unpark 신호를 기다림. ctl 한 번의 CAS로 활성/비활성이 뒤집힘.
세 알고리즘 모두 락 없이 CAS만으로 동작하고, 각 알고리즘이 손대는 메모리 영역(자기 deque vs 남의 deque vs ctl)이 대부분 겹치지 않습니다. 이 분리가 work-stealing 구현이 빠른 진짜 이유입니다.
실전 시 흔히 부딪히는 함정
내부 구조를 알고 나면 자주 보고되는 함정들이 자연스럽게 이해됩니다.
- parallelStream에서 ThreadLocal 사용. common pool은 InnocuousForkJoinWorkerThread를 쓰기 때문에 ThreadLocal이 task 사이에 살아남지 않습니다. 다른 큐로 같은 task가 갈 수도 있고, 같은 큐라도 의도와 다르게 초기화돼 동작 자체가 불안정합니다.
- common pool 안에서의 blocking IO. 풀 parallelism이
availableProcessors() - 1이라 워커 두세 개만 IO에 잡혀도 처리량이 0에 가까워집니다.ManagedBlocker를 쓰거나, 아예 자기 풀을 만들어 격리해야 합니다. CompletableFuture자기 자신 join.CompletableFuture.get()을 commonPool의 워커가 호출하면 helpAsyncBlocker가 부분적으로 막아 주지만, 체인이 복잡하면 progress가 멈출 수 있습니다. blocking 경계는 명시적 executor로 격리하는 게 안전합니다.- 가상 스레드 환경에서 commonPool. 가상 스레드용 풀과 commonPool은 별개라,
Thread.startVirtualThread()로 시작한 가상 스레드 안에서parallelStream을 쓰면 여전히 commonPool의 플랫폼 스레드가 일을 합니다. parallelism 산정이 별도이니 캐퍼시티 계산을 합쳐서 하면 안 됩니다. - RC/TC 오버플로(JDK-8330017). JDK 8~18 사이의 일부 버전에서 ctl 마스킹 결함으로 풀이 영구히 멈추는 사례가 있었습니다. JDK 19 이후 별도 parallelism 필드 도입으로 재현이 어려워졌고, JDK 24 시점에는 JDK-8351933 패치로 마스크가 정확해졌습니다. 장수 어플리케이션이라면 JDK 버전을 최신 LTS 또는 그 이후로 올리는 편이 안전합니다.
정리
ForkJoinPool은 한 줄로 요약하기 어렵지만, 그 깊은 곳에는 1999년 Doug Lea가 그린 단순한 그림 한 장이 있습니다. 워커마다 자기 deque, 양 끝을 owner와 stealer가 나눠 쓰고, 일이 없으면 옆에서 훔친다. 25년이 지나 코드가 5천 줄로 늘었지만 핵심 자료구조는 그대로이고, 그 위에 ctl packed encoding, helping 알고리즘, ManagedBlocker 보상, common pool과 InnocuousForkJoinWorkerThread, asyncMode 한 boolean이 만든 가상 스레드 스케줄러까지 얹혀 있습니다.
자바 동시성 라이브러리의 절반이 같은 풀 위에 서 있다는 사실이 우연이 아닙니다. push/pop/poll을 양 끝으로 나눈 단순한 deque 모델이, 그 위에 어떤 task 타입을 얹어도 잘 동작하는 일반성을 가졌기 때문입니다. parallelStream에서 가상 스레드까지, 같은 work-stealing 인프라가 자바의 동시성 모델 전체를 떠받치고 있습니다.
참고자료
- OpenJDK ForkJoinPool source (master)
- Doug Lea, A Java Fork/Join Framework, ACM Java Grande 2000
- ForkJoinPool Javadoc — Java SE 21
- ForkJoinPool Javadoc — Java SE 25 early access
- JEP 444: Virtual Threads
- JDK-8330017: ForkJoinPool stops executing tasks due to ctl Release Count overflow
- JDK-8351933: Inaccurate masking of TC subfield decrement in ForkJoinPool
- ForkJoinTask Javadoc — Java SE 21

