为什么JDK钟爱ForkJoinPool?

2,092 阅读11分钟

Fork/Join Pool

推荐阅读

ForkJoinPool 最适合的是计算密集型的任务,如果存在 I/O,线程间同步,sleep() 等会造成线程长时间阻塞的情况时,最好配合使用 ManagedBlocker。

有哪些JDK源码中使用了Fork/Join Pool?

  1. VirtualThread
  2. ParallelStream
  3. CompletableFuture
  4. Arrays.parallelSort
  5. ConcurrentHashMap.foreach 等等

什么是ForkJoinPool

ForkJoinPool是自java7开始,jvm提供的一个用于并行执行的任务框架。其主旨是将大任务分成若干小任务,之后再并行对这些小任务进行计算,最终汇总这些任务的结果。得到最终的结果。其广泛用在java8的stream中。

这个描述实际上比较接近于单机版的map-reduce。都是采用了分治算法,将大的任务拆分到可执行的任务,之后并行执行,最终合并结果集。区别就在于ForkJoin机制可能只能在单个jvm上运行,而map-reduce则是在集群上执行。此外,ForkJoinPool采取工作窃取算法,以避免工作线程由于拆分了任务之后的join等待过程。这样处于空闲的工作线程将从其他工作线程的队列中主动去窃取任务来执行。这里涉及到的两个基本知识点是分治法和工作窃取。

有多个任务队列,所以在 ForkJoinPool 中就有一个数组形式的成员变量 WorkQueue[]。那问题又来了

任务队列有多个,提交的任务放到哪个队列中呢?(上图中的 Router Rule 部分)

这就需要一套路由规则,从上面的代码 Demo 中可以理解,提交的任务主要有两种:

  • 有外部直接提交的(submission task)
  • 也有任务自己 fork 出来的(worker task)

为了进一步区分这两种 task,Doug Lea 就设计一个简单的路由规则:

  • 将 submission task 放到 WorkQueue 数组的「偶数」下标中
  • 将 worker task 放在 WorkQueue 的「奇数」下标中,并且只有奇数下标才有线程( worker )与之相对

工作窃取(work-stealing)

工作窃取(work-stealing)算法是指某个线程从其他队列里窃取任务来执行。那么,为什么需要使用工作窃取算法呢? 假如我们需要做一个比较大的任务,可以把这个任务分割为若干互不依赖的子任务,为了减少线程间的竞争,把这些子任务分别放到不同的队列里,并为每个队列创建一个单独的线程来执行队列里的任务,线程和队列一一对应。比如A线程负责处理A队列里的任务。但是,有的线程会先把自己队列里的任务干完,而其他线程对应的队列里还有任务等待处理。干完活的线程与其等着,不如去帮其他线程干活,于是它就去其他线程的队列里窃取一个任务来执行。而在这时它们会访问同一个队列,所以为了减少窃取任务线程和被窃取任务线程之间的竞争,通常会使用双端队列,被窃取任务线程永远从双端队列的头部拿任务执行,而窃取任务的线程永远从双端队列的尾部拿任务执行。

  1. 每个工作线程都有自己的工作队列WorkQueue;
  2. 这是一个双端队列,它是线程私有的;
  3. ForkJoinTask中fork的子任务,将放入运行该任务的工作线程的队头,工作线程将以LIFO的顺序来处理工作队列中的任务;
  4. 为了最大化地利用CPU,空闲的线程将从其它线程的队列中“窃取”任务来执行;
  5. 从工作队列的尾部窃取任务,以减少竞争;
  6. 双端队列的操作:push()/pop()仅在其所有者工作线程中调用,poll()是由其它线程窃取任务时调用的;
  7. 当只剩下最后一个任务时,还是会存在竞争,是通过CAS来实现的;

工作窃取算法的优点:充分利用线程进行并行计算,减少了线程间的竞争。

工作窃取算法的缺点:在某些情况下还是存在竞争,比如双端队列里只有一个任务时。并且该算法会消耗了更多的系统资源,比如创建多个线程和多个双端队列。

ForkJoinTask<V>

ForkJoinTask 是我们任务的基类。实际中,我们应该继承它的三个子类:无返回值的 RecursiveAction 和带返回值的 RecursiveTask<V>,CountedCompleter<T>。两者都有一个抽象方法 compute(),在里面实现我们的任务执行逻辑。

两个类里面都定义了一个抽象方法 compute() ,需要子类重写实现具体逻辑

if(任务小到不用继续拆分){
    直接计算得到结果
}else{
    拆分子任务
    调用子任务的fork()进行计算
    调用子任务的join()合并计算结果
}

状态

  • STARTED
  • STOP
  • TERMINATED
  • SHUTDOWN
  • RSLOCK
  • RSIGNAL

源码分析

  • ForkJoinWorkerThread(继承 Thread):就是我们上面说的线程(Worker)
  • WorkQueue:双向的任务队列
  • ForkJoinTask:Worker 执行的对象

invokeAll()方法的源码就可以发现,invokeAll()的N个任务中,其中N-1个任务会使用fork()交给其它线程执行,但是,它还会留一个任务自己执行,这样,就充分利用了线程池,保证没有空闲的不干活的线程。

构造函数参数

JDK21

ForkJoinPool(
                int parallelism,
                ForkJoinWorkerThreadFactory factory,
                UncaughtExceptionHandler handler,
                boolean asyncMode,
                int corePoolSize,
                int maximumPoolSize,
                int minimumRunnable,
                Predicate<? super ForkJoinPool> saturate,
                long keepAliveTime,
                TimeUnit unit
            )
  1. parallelism:指定线程池的并行度,即允许多少个并行线程执行任务。这个值通常设置为 CPU 核心数,以提高并行计算效率。
  2. factory:用于生成 ForkJoinWorkerThread 实例的线程工厂。通过自定义工厂,可以创建有特定设置的线程,比如自定义线程名或设置线程优先级。
  3. handler:未捕获异常的处理器 (UncaughtExceptionHandler)。当某个任务线程抛出异常且未被捕获时,使用该处理器来处理异常。
  4. asyncMode:指定线程池的任务模式。true 表示线程池优先采用异步模式,即任务队列按照“FIFO”(先进先出)原则处理任务;false 则表示按照“LIFO”(后进先出)模式处理任务。
  5. corePoolSize:设置线程池的核心线程数,即在没有任务时仍然保留在池中的最小线程数。当有新任务提交时,线程池将首先尝试利用这些核心线程。
  6. maximumPoolSize:线程池允许的最大线程数。如果任务量增大,线程池可以超出核心线程数,创建更多的线程处理任务,但不会超过这个最大线程数。
  7. minimumRunnable:指定最小可运行的线程数,即在高并发情况下,保持至少该数量的线程处于“就绪”状态。设置这个值可以避免任务阻塞。
  8. saturate:一个 Predicate 类型的断言,用于判断线程池的负载是否饱和。返回 true 表示线程池达到饱和状态,此时可能触发某些策略,比如拒绝任务。
  9. keepAliveTime:当线程数超过 corePoolSize 时,多余的线程在空闲状态下保持存活的时间。当空闲时间超过该值后,非核心线程会被回收。
  10. unit:指定 keepAliveTime 的时间单位(如秒、毫秒等)。

asyncMode

java - What is ForkJoinPool Async mode - Stack Overflow

在Java的ForkJoinPool中,asyncMode参数用于控制工作队列的工作模式。具体来说:

  • asyncMode参数影响的是从未从其工作队列加入的分叉任务的处理顺序。当使用ForkJoinPool来实现最初的设计目的,即递归fork/join任务分解时,asyncMode不起作用。只有当工作线程不参与实际的fork/join处理时,它才会执行异步任务,并且只有在那时才会实际查询asyncMode标志
  • FIFO模式(先进先出):当asyncMode设置为true时,ForkJoinPool将使用FIFO(先进先出)队列模式。这意味着,工作线程会按照任务被提交的顺序来处理它们。这种模式适用于那些任务之间没有太多依赖关系,或者依赖关系可以通过任务的提交顺序来自然解决的场景。(关注于工作窃取
  • LIFO模式(后进先出):当asyncMode设置为false(默认值),ForkJoinPool将使用LIFO(后进先出)队列模式。在这种模式下,工作线程会优先处理最近提交的任务。

parallelism,corePoolSize,maximumPoolSize,minimumRunnable

  • parallelism和corePoolSize:parallelism定义了期望的并行度,而corePoolSize则定义了池中核心线程数的下限。corePoolSize可以大于parallelism,这在任务较多且阻塞任务频繁时更有效。
  • maximumPoolSize与parallelism:maximumPoolSize允许超出parallelism的线程数,但不能小于parallelism。通常用于应对极端情况下阻塞任务较多的情况。
  • minimumRunnable:它确保在阻塞任务较多时,池中始终有足够的活跃线程来执行任务,从而避免整个池被阻塞。通过在parallelism基础上设定minimumRunnable的数量,可以提高任务执行的连贯性。

为什么CompletableFuture,VirtualThread,ParallelStream 等没有分治需求的api都是用ForkJoin pool不用ThreadPool呢

CompletableFutureVirtual Threads 虽然没有直接的分治需求,但使用 ForkJoinPool 而不是 ThreadPool 主要出于以下几个原因:

1. 任务分解和并行处理模型

尽管 CompletableFutureVirtualThreads 不总是用于分治任务,但 ForkJoinPool 的设计使得它在处理大量小型、短生命周期的任务时非常高效。这对于 CompletableFuture 等异步任务管理来说尤为重要,因为 CompletableFuture 的典型任务链包括许多小任务(如完成回调、错误处理等)。ForkJoinPool 利用了工作窃取机制,使线程池在处理这些细粒度任务时具有更好的吞吐量和负载均衡。

2. 工作窃取 (Work-Stealing)

ForkJoinPool 使用工作窃取算法(work-stealing),使得空闲的线程可以从其他忙碌线程的任务队列中窃取任务执行,减少了线程等待时间,从而提升了并发执行的效率。对于 CompletableFuture,多个依赖任务之间可能并不均衡,但通过工作窃取,可以有效地避免线程饥饿和不必要的等待。相比之下,普通的 ThreadPoolExecutor 任务分配方式较为固定,难以有效平衡任务负载。

3. 减少线程上下文切换

ForkJoinPool 在分配和管理线程方面更为轻量,高效处理大量短生命周期的任务。CompletableFuture 的任务通常比较短暂,而使用 ForkJoinPool 能降低线程频繁创建和销毁带来的性能开销。Virtual Threads 结合 ForkJoinPool 也可以更好地利用这一优势,减少硬件线程上的阻塞和切换,从而提升整体吞吐量。

4. 提升资源利用效率

ForkJoinPool 会根据系统的并行度(一般为 CPU 核心数)自动调整线程的并行执行数。相比 ThreadPoolExecutor 的固定线程分配机制,ForkJoinPool 可以更智能地利用系统资源,以避免在低负载情况下浪费资源。对于 CompletableFutureVirtual Threads 这些使用频繁、任务负载不确定的应用场景,ForkJoinPool 的自适应负载分配更具优势。

5. Java 标准库的优化选择

CompletableFuture 以及 VirtualThreads 是 Java 8 及以后的增强功能。ForkJoinPool 被设计成 Java 标准库中适合并行计算的通用池,因此也是 Java 标准库默认的并行池,开发者可以在不显式定义线程池的情况下利用这个默认池。这不仅简化了并发编程,还可以减少资源消耗。

基准测试

环境

JDK21

jmh 1.23

Runtime.getRuntime().availableProcessors() = 32

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;
import java.util.function.Consumer;
import java.util.function.Function;

import lombok.SneakyThrows;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

@BenchmarkMode(Mode.AverageTime)
@State(Scope.Thread)
//@Fork(1)
//@OutputTimeUnit(TimeUnit.MILLISECONDS)
//@Warmup(iterations = 3)
//@Measurement(iterations = 5)
public class JmhHello {
    int m(int x){
        if(x<=1)return 1;
        return x * m(x-1);
    }


    @SneakyThrows
    @Benchmark
    public void testExecutor() {
        ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
        List<Future<Integer>> futures=new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            futures.add(executorService.submit(()->m(5000)));
        }
        for (Future<Integer> future : futures) {
            future.get();
        }
        executorService.shutdown();
        executorService.close();
    }

    @SneakyThrows
    @Benchmark
    public void testForkJoin() {
        //asyncMode=true
        ExecutorService executorService = Executors.newWorkStealingPool();
        List<Future<Integer>> futures=new ArrayList<>();
        for (int i = 0; i < 20; i++) {
            futures.add(executorService.submit(()->m(5000)));
        }
        for (Future<Integer> future : futures) {
            future.get();
        }
        executorService.shutdown();
        executorService.close();
    }

    public static void main(String[] args) throws RunnerException {
        Options opt = new OptionsBuilder()
            .include(JmhHello.class.getSimpleName())
            .build();
        new Runner(opt).run();
    }
}

结果如下:

Benchmark              Mode  Cnt   Score    Error  Units
JmhHello.testExecutor  avgt   25   0.002 ±  0.001   s/op
JmhHello.testForkJoin  avgt   25  ≈ 10⁻³            s/op

总结

ForkJoinPool 并不是只为分治算法而设计的,它是 Java 并发库中一种高效且适用广泛的线程池实现。它不仅能够处理分治任务,也非常适合 CompletableFutureVirtualThreads 等需要短时间、小粒度并行执行的任务,因为它的设计(工作窃取)可以平衡高负载、灵活调度和高效资源利用。