【问题标题】:Unexpected Scalability results in Java Fork-Join (Java 8)Java Fork-Join (Java 8) 中的意外可扩展性导致
【发布时间】:2026-01-15 18:40:01
【问题描述】:

最近,我正在使用 Java Fork-Join 进行一些可扩展性实验。在这里,我使用了非默认 ForkJoinPool 构造函数ForkJoinPool(int parallelism),将所需的并行度(#workers)作为构造函数参数传递。

具体来说,使用以下代码:

public static void main(String[] args) throws InterruptedException {
    ForkJoinPool pool = new ForkJoinPool(Integer.parseInt(args[0]));
    pool.invoke(new ParallelLoopTask());    
}

static class ParallelLoopTask extends RecursiveAction {

    final int n = 1000;

    @Override
    protected void compute() {
        RecursiveAction[] T = new RecursiveAction[n];
        for(int p = 0; p < n; p++){
            T[p] = new DummyTask();
            T[p].fork();
        }
        for(int p = 0; p < n; p++){
            T[p].join();
        }
        /*
        //The problem does not occur when tasks are joined in the reverse order, i.e.
        for(int p = n-1; p >= 0; p--){
            T[p].join();
        }
        */
    }
}


static public class DummyTask extends RecursiveAction {
    //performs some dummy work

    final int N = 10000000;

    //avoid memory bus contention by restricting access to cache (which is distributed)
    double val = 1;

    @Override
    protected void compute() {
        for(int j = 0; j < N; j++){
            if(val < 11){
                val *= 1.1;
            }else{
                val = 1;
            }
        }
    }
}

我在具有 4 个物理内核和 8 个逻辑内核的处理器上获得了这些结果(使用 java 8:jre1.8.0_45):

T1:11730

T2:2381(加速:4,93)

T4:2463(加速:4,76)

T8:2418(加速:4,85)

在使用 java 7 (jre1.7.0) 时,我得到了

T1:11938

T2:11843(加速:1,01)

T4:5133(加速:2,33)

T8:2607(加速:4,58)

(其中 TP 是以毫秒为单位的执行时间,使用并​​行级别 P)

虽然这两个结果让我感到惊讶,但我可以理解后者(连接将导致 1 个工作人员(执行循环)阻塞,因为它无法识别它可以在等待时从其本地队列处理其他待处理的虚拟任务)。然而,前者让我感到困惑。

顺便说一句:在计算已启动但尚未完成的虚拟任务的数量时,我发现在某个时间点并行度为 2 的池中最多存在 24 个此类任务......?

编辑:

我使用 JMH (jdk1.8.0_45) 对上述应用程序进行了基准测试 (选项 -bm avgt -f 1)(= 1 个分叉,20+20 次迭代) 结果如下

T1:11,664

11,664 ±(99.9%) 0,044 s/op [Average]
(min, avg, max) = (11,597, 11,664, 11,810), stdev = 0,050
CI (99.9%): [11,620, 11,708] (assumes normal distribution)

T2:4,134(加速:2,82)

4,134 ±(99.9%) 0,787 s/op [Average]
(min, avg, max) = (3,045, 4,134, 5,376), stdev = 0,906
CI (99.9%): [3,348, 4,921] (assumes normal distribution)

T4:2,972(加速:3,92)

2,972 ±(99.9%) 0,212 s/op [Average]
(min, avg, max) = (2,375, 2,972, 3,200), stdev = 0,245
CI (99.9%): [2,759, 3,184] (assumes normal distribution)

T8:2,845(加速:4,10)

2,845 ±(99.9%) 0,306 s/op [Average]
(min, avg, max) = (2,277, 2,845, 3,310), stdev = 0,352
CI (99.9%): [2,540, 3,151] (assumes normal distribution)

乍一看,人们会认为这些可扩展性结果更接近人们的预期,即 T1

  1. Java 7 和 8 之间 T2 的区别。我猜一种解释 将是执行并行循环的工作人员不会在 java 8 中闲置,而是找到其他工作来执行。
  2. 2 个工作人员的超线性加速 (3x)。另外,请注意 T2 似乎随着每次迭代而增加(见下文,请注意这是 情况也是如此,尽管 P = 4,8 的程度较小)。时代在 热身的第一次迭代类似于提到的那些 多于。也许预热期应该更长,但是,执行时间增加不是很奇怪,即我宁愿期望它减少吗?
  3. 最后还是发现观察多了 开始和未完成的虚拟任务比工作线程好奇。

>

Run progress: 0,00% complete, ETA 00:00:40
Fork: 1 of 1
Warmup Iteration   1: 2,365 s/op
Warmup Iteration   2: 2,341 s/op
Warmup Iteration   3: 2,393 s/op
Warmup Iteration   4: 2,323 s/op
Warmup Iteration   5: 2,925 s/op
Warmup Iteration   6: 3,040 s/op
Warmup Iteration   7: 2,304 s/op
Warmup Iteration   8: 2,347 s/op
Warmup Iteration   9: 2,939 s/op
Warmup Iteration  10: 3,083 s/op
Warmup Iteration  11: 3,004 s/op
Warmup Iteration  12: 2,327 s/op
Warmup Iteration  13: 3,083 s/op
Warmup Iteration  14: 3,229 s/op
Warmup Iteration  15: 3,076 s/op
Warmup Iteration  16: 2,325 s/op
Warmup Iteration  17: 2,993 s/op
Warmup Iteration  18: 3,112 s/op
Warmup Iteration  19: 3,074 s/op
Warmup Iteration  20: 2,354 s/op
Iteration   1: 3,045 s/op
Iteration   2: 3,094 s/op
Iteration   3: 3,113 s/op
Iteration   4: 3,057 s/op
Iteration   5: 3,050 s/op
Iteration   6: 3,106 s/op
Iteration   7: 3,080 s/op
Iteration   8: 3,370 s/op
Iteration   9: 4,482 s/op
Iteration  10: 4,325 s/op
Iteration  11: 5,002 s/op
Iteration  12: 4,980 s/op
Iteration  13: 5,121 s/op
Iteration  14: 4,310 s/op
Iteration  15: 5,146 s/op
Iteration  16: 5,376 s/op
Iteration  17: 4,810 s/op
Iteration  18: 4,320 s/op
Iteration  19: 5,249 s/op
Iteration  20: 4,654 s/op

【问题讨论】:

  • 这个问题不是关于如何在 Java 中执行基准测试。这是关于使用 java FJ 的一个奇怪的观察。它最终可能是由糟糕的基准测试引起的,但目前还没有给出。此外,显示的代码是简化版本。我正在测试一个实际的应用程序(不同的数字,非常相似的观察结果),所以没有微基准。
  • 除非我们不知道您是如何执行您的基准测试的,而且有人怀疑您实际上没有正确执行此操作。
  • 我绝对没有做对。尽管如此,这并不意味着观察是错误的。该问题可能缺少信息(我会尽快纠正),但与您上面提到的问题不重复。
  • 事实上,许多关于奇怪基准测试结果的问题已作为该规范问题的重复而被关闭,因为它们没有正确执行。

标签: java parallel-processing fork-join


【解决方案1】:

您的示例中没有任何内容说明您是如何进行此基准测试的。看起来你只是在运行的开始和结束时做了一个毫秒。这是不准确的。我建议你看看这个SO answer 并重新发布你的时间安排。顺便说一句,jmh 基准测试将成为 Java9 中的标准,因此您应该使用它。

编辑:

您承认可扩展性结果符合您的预期。但是你说你对结果仍然不满意。现在是时候查看代码了。

这个框架存在严重的问题。自 2010 年以来,我一直在写一篇关于它的评论。正如我指出的 here ,join 不起作用。作者尝试了各种方法来解决这个问题,但问题仍然存在。

将运行时间增加到大约一分钟,(n=100000000) 或在 compute() 中进行一些繁重的计算。现在在 VisualVM 或其他分析器中分析应用程序。这将向您显示停滞的线程、过多的线程等。

如果这无助于回答您的问题,那么您应该使用调试器查看代码流。剖析/代码分析是您获得问题满意答案的唯一方法。

【讨论】:

  • 上面提到的时间是相当幼稚的挂钟时间(以时间差来衡量,就在执行之前和之后)。也没有进行预热。我意识到这些都是糟糕的基准测试实践。尽管如此,我的问题中说明的时间差异相当大+可以通过增加 n 和 N (工作)来任意大。我会尽快调查 jmh 并更新问题。
  • 这没有提供问题的答案。要批评或要求作者澄清,请在其帖子下方发表评论。
  • @Gimby 如果没有良好的基准,不值得努力逐行检查代码(F/J 池、F/J 任务等)寻找原因。请参阅我提供的链接和原始问题上的 cmets。
  • @edharned 我看不出有什么理由反对您回复的标准审查投票评论。这不是答案,充其量只是对问题的评论。
  • @Gimby 好的,这是你的意见。 OP 说时间差异很大,我说如果不进行热身(编译代码等)就无法判断小/大。如果他对 A 和 B 进行简单比较,那么挂钟时间是合理的。但在这里,他在同一个结构中执行多个线程(它们本身会增加开销)。恕我直言,我需要一个适当的基准来查看内部代码。