Fork/Join框架在不同配置下的表现及与其他并发方式对比

点击上方“Java编程精选”,选择“置顶公众号”

Fork/Join框架在不同配置下的表现及与其他并发方式对比

关键时刻,第一时间送达!

Fork/Join框架在不同配置下的表现如何?

如同《星球大战》电影所展现,Java 8的并行流同样褒贬不一。这种并行流(Parallel Stream)的语法特性,宛如预告片中那令人心潮澎湃的新型光剑。目前,Java语言中并发编程的途径众多,我们迫切希望探究采用这种方式能带来的性能飞跃以及潜在的风险。经过260余次实验所收集到的数据,为我们带来了诸多新的认识,现在,我们愿意将这些新发现与大家共同探讨。

ExecutorService与Fork/Join框架以及并行流在并行处理方面各有千秋。ExecutorService提供了灵活的任务提交和线程管理方式;Fork/Join框架专注于递归分解任务,适合于计算密集型任务;而并行流则是Java 8引入的一种新的并行编程抽象,它简化了并行代码的编写。

在遥远的往昔,有一颗遥远的星球。实际上,我想说的是,十年前,Java的并行处理功能尚需借助第三方库来完成。随后,Java 5版本问世,并带来了包含诸多Doug Lea印记的java.util.concurrent包。这个包中的ExecutorService功能,为用户提供了便捷的线程池管理手段。当然,java.util.concurrent包持续得到优化升级,在Java 7版本中,还新增了依托于ExecutorService线程池的Fork/Join框架。对于众多开发者而言,Fork/Join框架依旧显得相当神秘莫测,为此,Java 8的stream API提供了一种更加便捷的方式来利用它。我们来看下这几种方式有什么不同之处。

我们计划通过执行两项任务来检验,一项是针对CPU的高强度任务,另一项则是针对IO的高强度任务。这两个任务虽然功能相同,但将在四种不同的环境下接受测试。在各项实现的对比中,线程的数量扮演着至关重要的角色,因此,这也是我们测试计划中的一个关键目标。测试所用的机器配备了8个核心,故我们决定分别采用4、8、16、32个线程进行测试。针对每一个任务,我们还会对单线程执行版本进行测试;然而,这一部分在图表中并未展示,原因是其所需时间相对较长。若想深入了解这些测试案例的具体执行过程,请参阅文档中关于基础库的最后一节内容。现在,让我们开始吧。

给一段580万行6GB大小的文本建立索引

在本轮测试中,我们成功创建了容量庞大的文本档案,并依照既定流程构建了索引。现在,让我们一探究竟,看看成效如何。

单线程的运行时长为176,267毫秒,换算过来大约是三分钟。需特别注意的是,图中展示的数据是从20,000毫秒处开始的。

1. 线程过少会浪费CPU,而过多则会增加负载

图中首先引人注目的是柱状图的形态——仅凭这4个数据点,便可大致掌握不同实现的表现情况。从8个线程增至16个线程时,图表出现倾斜,这表明部分线程在文件IO环节遭遇了阻塞,因而提升线程数量有助于更高效地利用CPU资源。然而,当线程数量增至32个时,由于额外开销的增加,性能反而有所下降。

并行流执行效率极高。相较于直接采用Fork/Join方法,其速度大约提升了约一秒钟。

并行流所赋予我们的,不仅仅是语法上的便利(这里所说的并非仅限于lambda表达式),其性能亦显著优于Fork/Join框架和ExecutorService。处理完6GB大小的文件仅需24.33秒。请对Java充满信心,它同样能够展现出出色的性能。

然而,并行流的呈现效果尤为不佳;独独它超过了30秒的时长。

并行流的运用为何会降低系统效率,这一点在此为你详细讲解。在那些已运行多线程程序的设备上,这种现象尤为常见。考虑到可用的线程数量本就有限,采用Fork/Join框架进行操作,相较于使用并行流,往往更为高效——两者间的性能差距约为5秒,相当于大约18%的性能损失。

4. 如果涉及到IO操作的话,不要使用默认的线程池大小

在测试中,采用预设的线程池规模(即机器CPU核心数,此处为8核)的并行流处理,其速度比使用16个线程的情况慢了2秒钟。换句话说,若采用默认的线程池大小,速度将降低7%。这种情况的出现主要是由于IO操作的阻塞所引起的。因为存在大量线程处于等待状态,增加线程数量可以更有效地利用CPU资源,避免在其它线程等待调度时造成资源闲置。

若需调整并行流中默认的Fork/Join线程池的规模,您可以通过设置一个JVM参数来实现对公共Fork/Join线程池大小的修改。

设置程序参数-Djava.util.concurrent.ForkJoinPool.common.parallelism为16,以限制并行执行的任务数量。

Fork/Join任务在默认情况下均会共享一个线程池,该线程池的大小与CPU核心数量一致。这一设置的优势在于,当线程处于空闲状态时,它们能够接收并处理其他任务。

或者,你可以尝试运用这一小窍门:通过创建一个定制的Fork/Join线程池来执行并行流操作。这样做将取代系统默认的共享Fork/Join池,并允许你利用个人设置的线程池。虽然这种方法略显狡猾。在测试阶段,我们采用的是公共的线程池。

5. 单线程的性能跟最快的结果相比要慢7.25倍

并发性能可提高至7.25倍,鉴于机器拥有8核处理器,这几乎等同于8倍的提升效果!而未达到的部分,可能是因为线程开销所致。此外,即便是在所有并行版本中表现最差的4线程并行流实现(耗时30.23秒),其速度也快于单线程版本(耗时176.27秒)5.8倍。

如果不考虑IO的话呢?比如判断某个数是否是素数

针对本次测试,我们计划移除I/O环节,以评估判断一个超大整数是否为素数所需的时间。这个整数究竟有多大呢?它有19位,具体数值为1,530,692,068,127,007,263,换言之,即为一百五十三万零六百九十二亿零六百八十一亿两千万七千二百六十三。得,让我先喘口气。我们没有进行任何优化处理,直接计算了它的平方根,为此我们甚至对所有的偶数进行了检验,尽管这个大数实际上并不为2所整除,这样做只是为了延长运算所需的时间。提前透露一下:这确实是一个素数。而且,每个进行运算的过程所经历的次数都是相同的。

下面是测试的结果:

单线程运行耗时为118至127毫秒,换算下来约等于2分钟。需特别注意的是,图中数据是从20000毫秒处开始的。

1. 8个线程与16个线程相差不大

在这与IO测试有所区别的场景中,并未涉及IO操作,故而无论是8线程还是16线程,性能差异并不显著,Fork/Join版本则是个例外。鉴于其异常表现,我们额外进行了多轮测试,以验证结果的准确性,然而,事实依旧,结果保持一致。期待你在下方评论区分享你的观点。

2. 不同实现的最好结果都很接近

观察发现,不论各版本的具体实现,其最优性能的输出时间均保持一致,大约在28秒上下。这些方法尽管在具体操作上有所差异,但最终效果却颇为相似。然而,这并不代表所有方法的效果都毫无二致。下面这一点便能说明问题。

3. 并行流的线程处理开销要优于其它实现

这一点颇具趣味。在本次测试中,我们注意到,采用并行流的16个线程再次展现了优势。不仅如此,无论线程数量如何变化,并行流在此次测试中的表现始终是最出色的。

4. 单线程的版本比最快的结果要慢4.2倍

除此之外,在执行计算密集型任务时,并行版本的优势相较于包含I/O操作的测试减少了约两倍。鉴于这是一个CPU密集型测试,这样的结果也是可以理解的。它并不像之前的测试那样,通过减少CPU等待I/O的时间能够带来额外的收益。

结论

我之前曾向各位推荐过阅读源代码,以便掌握何时适宜运用并行流,并在Java开展并发编程时,避免轻易得出结论。最有效的验证手段是在演示环境中反复执行类似测试案例。在此过程中,需特别关注的是运行测试的硬件条件,以及应用程序所使用的总线程数。涉及公共的Fork/Join线程池,以及团队其他开发者所编写的代码中嵌入的线程。在您着手编写自己的并发处理逻辑之前,务必先审查这些内容,以便对应用程序的整体架构有所把握。

基础库

本次测试是在EC2的c3.2xlarge实例上进行的,该实例配备了8个虚拟CPU核和15GB的内存。由于采用了超线程技术,这里的vCPU实际上仅对应4个物理核心,但每个核心都被模拟为两个。对于操作系统的调度器来说,它将我们视为拥有8个核心。为了确保结果的公正性,我们对每个实现方案都进行了十次运行,并从中选取了第二次至第九次运行的平均耗时作为最终结果。这意味着总共进行了260次运行!此外,处理所需的时间长度同样至关重要。鉴于我们选取的任务运行时间均超过20秒,时间上的微小差异便能够清晰显现,且这种差异不太可能受到外界因素的干扰。

你可能想看:
« 上一篇
下一篇 »

相关推荐

java中什么是冒泡,冒泡算法

2026年09月06日

27阅读

java中冒号,java双冒号

2026年09月06日

26阅读

java中的->,java中的是什么意思

2026年09月06日

24阅读

java中的super用法,Java中super的说明

2026年09月06日

28阅读

java中反射的应用,java中反射的应用场景

2026年09月06日

26阅读

java中开关的定义,Java关键字有哪些

2026年09月06日

26阅读