天天看点

JDK8 Fork/Join Work Stealing

java 8 已经发布一段时间了,许多开发者已经开始使用 java 8。本文也将讨论最新发布在 jdk 中的并发功能更新。事实上,jdk 中已经有多处<code>java.util.concurrent</code> 改动,但本文重点将是 fork-join 框架的改进。我们将讨论一点 fork-join,然后实现一个简单的基准测试以比较 fj 在 java 7 和java 8 中的性能。

JDK8 Fork/Join Work Stealing

forkjoin 是一个通常用于并行计算递归任务的框架。它最早被引入java 7 中,从那时起它就能很好地完成目标任务。原因在于,许多大型任务本质上都可以递归表示。

使 forkjoinpool 不同于其他 executorservices 的是,在当下并不执行任务的工作线程会检查其伙伴的工作状态,并向他们借取任务。这种技术称为 work-stealing 。那么,work-stealing 有什么妙用呢?

JDK8 Fork/Join Work Stealing

work-stealing 是一种分散式的工作量管理方法,无需将工作单元分配给所有可用的工作线程,而是每个线程自己管理其任务队列。关键在于高效地管理这些队列。

关于让每个工作进程处理自己的队列,有两个主要问题:

外部提交的任务去哪里了?

我们怎样组织 work-stealing 以有效访问队列

本质上来说,在执行大型任务时,外部提交任务和由工作线程创建的任务之间区别不大。他们都有类似的执行要求并提供结果。然而,运作方式是不同的。最主要的区别在于由工作进程创建的任务可以被窃取。这意味着即便被放进了一个工作进程的任务队列中,他们仍可能被其他工作进程执行。

forkjoin 框架处理它的方法很简单,每个工作线程都有2个任务队列,一个用于外部任务,另一个用于实现窃取工作进程的运作。当外部提交任务时,会将任务添加至随机的工作队列中。当一个任务被分为更小的任务时,工作线程将他们添加到自己的任务队列中,并希望其他工作线程来帮忙。

窃取任务的想法基于以下事实:工作线程在它任务队列末尾添加任务。在正常的执行过程中,每个工作线程试着去从任务队列的队首拿任务,当其个人队列的任务为空时,这一操作就会失败,转而窃取别的工作线程的任务队列末尾的任务。这有效避免了多数任务队列的互锁问题,提高了性能。

另一个使 forkjoin 池工作更快的诀窍是当一个工作线程窃取任务时,它留下了它在哪里取得任务的线索,这样原始的工作线程可以找到它并且帮助该工作线程,因此父任务的的工作进展会更快。

总而言之,这是一套极其复杂的系统,需要大量的背景知识使其顺利运行。并且,系统的属性和性能与具体实现的方式关系很大。因此笔者怀疑,若不进行重大的重构,系统会彻底改变。

增加了 forkjoinpools 的功能并提高其性能,使其应用在用户希望的日益广泛的应用中,且效率更高。新特性包括对最适于 io-bound 使用的 completion-based 设计的支持等。
当大量的用户提交大量任务时,吞吐量能大幅度提高。其原理是将外部提交者与工作线程相似地对待——均使用随机任务队列和窃取任务。当所有任务都为异步,且被提交至 pool 而不是 forked 时,能极大地提高吞吐量。

然而找出究竟什么被改变了、哪些场景被影响了并不简单。因此,让我们换一种方式解决。笔者会创建一个基准测试程序以模仿简单的 forkjoin 计算,并测量 forkjoin 处理任务与单个线程依次完成任务各自所需时间,希望这种方法能帮我们找出改善的具体内容。

jmh 还附带了 maven 原型项目。因此,将一切设置好其实很简单。

在写本文时,jmh core 的最新版本是 0.4.1 ,包括了 @param 注释,可用一系列的参数化输入运行基准测试程序。这减轻了手动重复执行相同基准测试的痛苦,并简化了获取结果的流程。

现在,每个基准测试迭代会获得自己的 forkjoinpool 实例,这也减少了常用 forkjoinpool 实例化在 java 8 与其之前版本中的区别。

<code>sin</code> 、<code>cos</code> 和 <code>tan</code> 是 <code>recursivetask</code> 的实例,实际上 sin 和 cos 并不递归,但会分别计算 <code>math.sin(input)</code> 和 <code>math.cos(input)</code> 的值 。tan 的任务实际上会递归为一组 sin 和 cos ,并返回两者的除法结果。

jmh 处理项目的代码并从标有 <code>@generatemicrobenchmark</code> 注释的方法处生成基准测试程序。你在该类上方看到的其他注释指定了基准测试的选项:迭代次数,计入最终结果的迭代次数,是否 fork 另一个 jvm 进程用于基准测试以及测量哪些值。测量值可以是代码的吞吐量,或这些方法在一段时间内的执行次数。

<code>@param</code> 指定运行基准测试程序时几个输入的大小。总而言之,jmh非常简单,创建基准测试程序不需要手动处理迭代、定时或整理结果。

用 java 7 和 8 运行该基准测试得到以下结果。笔者分别使用的是1.7.0_40 and 1.8.0.版本。

为了便于查看结果,下面以图表形式进行展示。

JDK8 Fork/Join Work Stealing

我们可以看到 jdk 7 与 8 间的基线结果(直接用同一线程运行程序的吞吐量)差异并不大。然而,若加入管理递归任务的时间,使用 forkjoin 来执行,则 java 8 的速度更快。这个简单的基准测试表明,在最新版的 java 中,管理 forkjoin 任务的效率有了 35% 左右的性能提高。

基线和 fj 计算之间的结果差异是因为我们刻意创建的递归任务非常单薄。该任务实质上只是调用一个优化后的数学类。因此,直接进行数学运算会快得多。一个更强壮的任务必将改变这一情况,但是它们会减轻 forkjoin 管理的开销,而这是我们起初就想测量的目标。不过,一般而言,执行递归任务比多次执行同个方法调用要高效得多。

同时,java 7 和 java 8 的基线测试结果也有略微的不同。这个差异是可以忽视的,但很可能不是因为 java 7 和 8 中数学类的实现差异造成的。而是一个测量假象,jmh 努力抵消却还是无法避免。

JDK8 Fork/Join Work Stealing

免责声明:当然,这些结果是模拟所得的,你应该持保留态度。然而,除了讨论 java 性能,笔者也想展示 jmh 创建基准测试程序是如何简单,且能避免一些常见基准测试问题,比如没有提前预热 jvm 。如果基准测试本身存在缺陷,热身也无济于事,但是肯定还是有所裨益。因此,如果你看到以上代码中的逻辑缺陷,请一定告诉笔者。

JDK8 Fork/Join Work Stealing

然而,这些类的文档丰富,并且包含许多内部注释。它也可能学习挖掘jdk最有趣的地方。

另一个相关的发现是 forkjoinpool 在 java8 中的性能更好,至少在一些用例中是这样的。虽然笔者不能精确地描述这背后的原因,但如果我在代码中用到 forkjoin ,我一定会升级 java 版本。