Java ForkJoin 的API层面的逻辑与ExecutorService没有本质区别:fork()等于submit(), join()等于future.get(). 实际上ForkJoinTask实现了Future接口。

但是ForkJoin在任务的调度上采用了无blocking的更加轻量的模型,从而比ExecutorService的BlockingQueue+多线程拉取模式更加高效。主要的优化点有:

  • 每个任务线程worker都维护一个自己的非线程安全的双端队列deque,从而以无锁的方式添加和拉取任务
  • 每个worker在自己任务执行完成后会从全局的deques中偷取别的worker的任务
  • 注意woker在从自己的deque中拉取任务时是从尾部拉取的,而偷取任务则是从别的worker的队列的头部拉取,从而减少了对相同的临界资源的并发访问。

jdk19中ForkJoin的一些实现细节:

  • 每个worker(ForkJoinWorkerThread)也就是一个线程,会维护一个非线程安全的deque存储属于自己的任务。
  • 每个worker会循环从自己deque的尾部(LIFO)拉取任务并执行。
  • 在subTask执行fork()的时候会做以下几件事情:
  • 把自己放入当前线程(worker)的deque里。因为是worker线程操作自己的deque,所以没有线程安全问题。
  • 尝试让forkJoinPool启动一个新的worker.
  • 如果启动了新的worker. 那么新的worker会从全局偷取任务,极有可能偷取到刚刚fork()的那个任务。
  • 每个worker在执行完自己deque里的任务后,就会尝试去偷取任务。偷取从每个队列的头部take(FIFO),从而避免了与deque的拥有者worker产生并发冲突。
  • 偷取的代码位于:forkJoinPool.scan().
  • 偷取的线程安全问题使用了乐观的 U.compareAndSetReference() 来解决
  • 具体见WorkQueue.casSlotToNull(a, k, t),尝试原子跟新数组a里下标k的引用为null
  • 当woker执行join()操作时,会尝试获取其他任务并执行。


ForkJoin的调度策略是否可以用于改进默认ExecutorService的阻塞式调度模式,从而提高性能?

答案是肯定的。实际上ForkJoinPool已经实现了ExecutorService接口,在提交任务时,ForkJoinPool会随机的挑选一个worker,并将任务放入该worker的deque。因为有多个deque,所以添加任务时产生并发冲突的概率很低。ForkJoinPool同样采用了乐观策略解决多个线程随机到访问同一个worker的问题。

  • 随机挑选woker/deque的代码位于:ForkJoinPool.submissionQueue()
  • 添加任务到选中的队列的代码位于:ForkJoinPool.WorkQueue.push()


ForkJoin的实现算法为我们提高并发程序的性能提供了一个思路:那就是将临界资源拆分成多个子资源,这样锁的粒度就会更低,甚至直接让临界资源对应到每个线程。实际上ConcurrentHashmap也用了类似的思路,将临界资源拆分到了单个桶的粒度。


该论文中提供了更多ForkJoin框架的细节:https://dl.acm.org/doi/pdf/10.1145/337449.337465