ForkJoinPool이란
ForkJoinPool은 Java에서 제공하는 특수한 스레드풀으로서, 대규모 계산 작업을 처리하는 데 최적화되어 있습니다. 이 스레드풀의 핵심 개념은 작업을 여러 개의 작은 하위 작업으로 분할하고, 각 하위 작업을異なる 스레드에서 병렬로 실행한 후, 결과를 다시 하나로 합치는 것입니다.
작업 도용(Work Stealing) 메커니즘
ForkJoinPool의 가장 중요한 특징은 작업 도용 알고리즘입니다. 각 스레드마다 독립적인 작업 대기열이 존재하며,某个 스레드가 자신의 대기열에 있는 작업을 모두 완료하거나 유휴 상태가 되면, 다른 스레드의 작업 대기열에서 작업을 가져와 실행합니다. 이를 통해 모든 스레드가 최대한 바쁘게 유지되어 시스템 자원이 효율적으로 활용됩니다.
동작 방식 비교
전통적인 ThreadPoolExecutor
전통적인 스레드풀의 동작 방식을 살펴보겠습니다. 다음 예제는 2개의 코어 스레드, 最大 4개 스레드,容量 10인阻塞 대기열을 사용하는 설정입니다.
public class TraditionalPoolExample {
public static void main(String[] args) {
ExecutorService executor = new ThreadPoolExecutor(
2, // 코어 스레드 수
4, // 최대 스레드 수
60L, // 유휴 시간
TimeUnit.SECONDS, // 시간 단위
new ArrayBlockingQueue<>(10), // 작업 대기열
new ThreadPoolExecutor.CallerRunsPolicy()
);
for (int i = 0; i < 10; i++) {
final int taskId = i;
executor.submit(() -> {
try {
Thread.sleep(500);
System.out.println("Task " + taskId + " completed");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
executor.shutdown();
}
}
동작 흐름은 다음과 같습니다:
- 작업이 제출되면 코어 스레드 수 이하일 경우 새 스레드를 생성
- 코어 스레드가 모두 사용 중이면 작업이 대기열에 저장
- 대기열이 가득 찰 경우 최대 스레드 수까지 새 스레드 생성
- 모든 자원이 고갈되면 거부 정책 적용
ForkJoinPool의 동작 방식
ForkJoinPool은 근본적으로 다른 구조를 가집니다. 각 스레드가 자체 대기열을 보유하며, 작업 도용을 통해 부하를 분산합니다.
public class ForkJoinPoolExample {
public static void main(String[] args) {
ForkJoinPool pool = new ForkJoinPool(4);
for (int i = 0; i < 10; i++) {
final int taskId = i;
pool.submit(() -> {
try {
Thread.sleep(500);
System.out.println("Task " + taskId + " executed by " +
Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
}
pool.shutdown();
}
}
동작 흐름:
- 초기화 시 스레드 수에 맞춰 스레드 및 개별 대기열 생성
- 각 스레드는 자신의 대기열에서 작업을 가져와 실행
- 자신의 대기열이 비면 다른 스레드에서 작업을 도용
대규모 작업의 분할 처리
ForkJoinPool의 진정한 강점은RecursiveTask 또는 RecursiveAction을 사용하여 작업을 분할할 수 있다는 점입니다. 1부터 100만까지의 합을 계산하는 예제를 살펴보겠습니다.
public class SumCalculation {
public static void main(String[] args) {
ForkJoinPool forkJoinPool = new ForkJoinPool();
long result = forkJoinPool.invoke(new SumTask(1, 1_000_000));
System.out.println("Total sum: " + result);
forkJoinPool.shutdown();
}
private static class SumTask extends RecursiveTask<Long> {
private static final int CHUNK_SIZE = 10_000;
private final long start;
private final long end;
SumTask(long start, long end) {
this.start = start;
this.end = end;
}
@Override
protected Long compute() {
long range = end - start;
if (range <= CHUNK_SIZE) {
return calculateDirectly();
}
long middle = start + (range / 2);
SumTask leftTask = new SumTask(start, middle);
SumTask rightTask = new SumTask(middle + 1, end);
leftTask.fork();
rightTask.fork();
return leftTask.join() + rightTask.join();
}
private long calculateDirectly() {
long sum = 0;
for (long i = start; i <= end; i++) {
sum += i;
}
return sum;
}
}
}
작업 분할 전략: 10,000개 이하의 범위는 더 이상 분할하지 않고 직접 계산하고, 그 이상은 중간 지점을 기준으로 두 개의 하위 작업으로 나눕니다. 각 하위 작업은 fork()를 통해 비동기로 실행되며, join()으로 결과를 병합합니다. 이때 스레드는 다른 스레드의 대기열에서 작업을 도용할 수 있습니다.
실제 활용 시나리오
ForkJoinPool은 여러 곳에서 내부적으로 활용됩니다. Java 스트림 API의 parallelStream()은 기본적으로 ForkJoinPool.commonPool()을 사용합니다. 또한 Java 21에서 도입된 가상 스레드(Virtual Thread)도 내부적으로 ForkJoinPool을 활용하여 구현되었습니다. 계산 집약적인 작업을 병렬로 처리해야 하는 경우 ForkJoinPool이 효과적인 선택입니다.