【问题标题】:Why is the newer/faster Java 8 way of sorting acting worse?为什么更新/更快的 Java 8 排序方式表现更差?
【发布时间】:2016-05-11 20:06:22
【问题描述】:
List<Point> pixels = new ArrayList<>(width * height); // 1280*960

for (int y = 0; y < height; y++)
    for (int x = 0; x < width; x++)
        pixels.add(new Point(x, y));

// Java 7 sorting
Collections.sort(pixels, comparator);

// Java 8 sorting
pixels = pixels.stream().parallel().sorted(comparator).collect(Collectors.toList());

当使用任何排序方法时,一开始我的性能会很慢,后来会有所改善。我希望如此,因为 JIT 编译器需要时间来优化高使用率的代码。

奇怪的是,旧的分拣机一开始有点慢,而新的分拣机更慢,慢了 60% 以上。一段时间后,新的分拣机变得更快,正如预期的那样。但是前两/三次执行如此缓慢的方式是无法接受的。

Java 7 collection sorter
0.258992413
0.265509443
0.536536068
0.117830618
0.136303916
0.111004611
0.134771877
0.108078261

Java 8 stream sorter
0.631757108
0.868032669
0.076455248
0.087101852
0.070401965
0.056989645
0.072018371
0.078908912
0.074237648

规格:
CPU:Intel I7 3770(8核8M/1M/128K缓存)
cmd: javaw -server -cp bin Myclass

  • 是否有其他人经历过较新(流)操作的性能更差?
  • 有没有办法解决这个缓慢的问题? (不会导致启动延迟)

【问题讨论】:

  • 您如何对此进行基准测试?。如果你没有使用JMH 或类似的,你可以放心地忽略这些“基准”。
  • 如上。您可以放心地忽略这些值。
  • 你为什么要早点在 EDT 上排序??
  • P.S.我不太相信parallel 会在这里为您提供帮助。尤其是因为如您所见,您必须创建一个全新的List
  • 您需要显示这些数字背后的代码。否则它们毫无意义。确实,当涉及 lambda 时,前几次执行会变慢。但是,当您的程序投入生产时,最初的几次执行很快就会完成,并且有一天,这没什么好担心的。

标签: java performance sorting java-8 java-stream


【解决方案1】:

似乎你关心热身阶段的性能(即JVM启动后的第一次和第二次排序)。所以标准的 JMH 基准测试可能不适合你。没关系,让我们手动编写基准测试。正如我们所说的几十毫秒,使用System.nanoTime() 的简单基准测试将提供足够的精度。

您没有在问题中提供您的Comparator。简单的比较器(如Comparator.comparingInt(p -&gt; p.x))可以更快地对数据进行排序,所以我假设您有更复杂的比较器:

final Comparator<Point> comparator = Comparator.comparingInt(p -> p.x*p.x + p.y*p.y);

它通过与(0, 0) 的欧几里得距离来比较点(不需要平方根,因为它是单调函数,所以顺序不会改变)。

另外让我们将数据准备与排序分开来仅衡量排序性能:

private Point[] prepareData() {
    Point[] pixels = new Point[width*height];
    int idx = 0;
    for (int y = 0; y < height; y++)
        for (int x = 0; x < width; x++)
            pixels[idx++] = new Point(x, y);
    return pixels;
}

我使用数组而不是 List 来直接测试 Arrays.parallelSort。普通的旧排序是这样的:

public List<Point> sortPlain(Point[] data) {
    List<Point> list = Arrays.asList(data);
    Collections.sort(list, comparator);
    return list;
}

基于并行流 API 的排序将是

public List<Point> sortParallelStream(Point[] data) {
    return Stream.of(data).parallel().sorted(comparator).collect(Collectors.toList());
}

让我们也添加顺序流 API 版本:

public List<Point> sortStream(Point[] data) {
    return Stream.of(data).sorted(comparator).collect(Collectors.toList());
}

直接使用parallelSort

public List<Point> sortParallel(Point[] data) {
    Arrays.parallelSort(data, comparator);
    return Arrays.asList(data);
}

测量代码不是很困难。 Here 的完整实现。请注意,每个测试都应该独立启动,因此我们在 JVM 启动期间只测试一种模式。这是我机器上的典型结果(i7-4702MQ 2.20GHz,4 Cores HT = 8 HW threads,Win7 64bit,java 1.8.0_71)。

Iter  Plain      Parallel   Stream     ParallelStream
#01:  0.38362s   0.37364s   0.28255s   0.47821s
#02:  0.23021s   0.25754s   0.18533s   0.72231s
#03:  0.18862s   0.08887s   0.21329s   0.18024s
#04:  0.19810s   0.06158s   0.68004s   0.12166s
#05:  0.19671s   0.06461s   0.17066s   0.08380s
#06:  0.14796s   0.05484s   0.18283s   0.12931s
#07:  0.16588s   0.04920s   0.21481s   0.13379s
#08:  0.21988s   0.05932s   0.19111s   0.12903s
#09:  0.14434s   0.05123s   0.14191s   0.11674s
#10:  0.18771s   0.06174s   0.14977s   0.07237s
#11:  0.15674s   0.05105s   0.21275s   0.06975s
#12:  0.17634s   0.06353s   0.14343s   0.07882s
#13:  0.15085s   0.05318s   0.16004s   0.11029s
#14:  0.18555s   0.05278s   0.19105s   0.12123s
#15:  0.14728s   0.05916s   0.14426s   0.07235s
#16:  0.18781s   0.05708s   0.21455s   0.07884s
#17:  0.14493s   0.12377s   0.14415s   0.11170s
#18:  0.14395s   0.05100s   0.18201s   0.07878s
#19:  0.14849s   0.05437s   0.14484s   0.08364s
#20:  0.14143s   0.12073s   0.18542s   0.11257s

PlainParallelStream 测试的结果与您的有些相似:ParallelStream 的前两次迭代要慢得多(尤其是第二次)。您还可以注意到,直接执行Arrays.parallelSort 没有这种效果。最后,非并行流是最慢的。这是因为 Stream API 总是使用中间缓冲区进行排序,所以它需要更多的空间和时间来执行额外的复制到缓冲区,对其进行排序,然后执行复制到结果列表。

为什么ParallelStream 的前两次迭代如此缓慢(尤其是第二次)?仅仅因为您的起始堆非常小,可以方便地放置所有中间缓冲区,所以在前两次迭代期间会发生几个 full-gc 事件,最终导致显着延迟。如果您使用-verbose:gc 运行测试,您将看到ParallelStream

[GC (Allocation Failure)  16384K->14368K(62976K), 0.0172833 secs]
[GC (Allocation Failure)  30752K->30776K(79360K), 0.0800204 secs]
[Full GC (Ergonomics)  30776K->30629K(111104K), 0.4487876 secs]
[GC (Allocation Failure)  63394K->74300K(111104K), 0.0215347 secs]
[Full GC (Ergonomics)  74300K->45460K(167936K), 0.1536388 secs]
[GC (Allocation Failure)  76592K->57710K(179712K), 0.0064693 secs]
#01: 0.41506s
[GC (Allocation Failure)  101713K->103534K(180224K), 0.0567087 secs]
[Full GC (Ergonomics)  103534K->39365K(203776K), 0.5636835 secs]
[GC (Allocation Failure)  84021K->53689K(266752K), 0.0103750 secs]
#02: 0.71832s

在此之后不再有 Full GC 事件,因为堆现在已充分扩大。与Plainlaunch 比较:

[GC (Allocation Failure)  16384K->14400K(62976K), 0.0162299 secs]
[GC (Allocation Failure)  30784K->30784K(79360K), 0.0762906 secs]
[Full GC (Ergonomics)  30784K->30629K(111616K), 0.4548198 secs]
#01: 0.43610s
[GC (Allocation Failure)  63397K->58989K(111616K), 0.0330308 secs]
[Full GC (Ergonomics)  58989K->25278K(133120K), 0.2479148 secs]
#02: 0.20753s

只有两次 Full GC 花费的时间明显减少,因为垃圾明显减少。

让我们使用-Xms1G 将初始堆大小设置为 1Gb,以降低 GC 压力。现在我们得到了完全不同的结果:

Iter  Plain      Parallel   Stream     ParallelStream
#01:  0.38349s   0.33331s   0.23834s   0.24078s      
#02:  0.18514s   0.20530s   0.16650s   0.07802s      
#03:  0.16642s   0.10417s   0.16267s   0.11826s      
#04:  0.16409s   0.05015s   0.19890s   0.06926s      
#05:  0.14475s   0.05241s   0.15041s   0.06932s      
#06:  0.14358s   0.05584s   0.14611s   0.06684s      
#07:  0.17644s   0.04913s   0.14619s   0.06716s      
#08:  0.14252s   0.04642s   0.19333s   0.10813s      
#09:  0.14427s   0.04547s   0.14673s   0.06900s      
#10:  0.14696s   0.04634s   0.14927s   0.06712s      
#11:  0.14254s   0.04682s   0.15107s   0.07874s      
#12:  0.15455s   0.09560s   0.19370s   0.06663s      
#13:  0.15544s   0.05133s   0.15110s   0.13052s      
#14:  0.18636s   0.04788s   0.15928s   0.06688s      
#15:  0.14824s   0.04833s   0.15218s   0.06624s      
#16:  0.15068s   0.04949s   0.19183s   0.13925s      
#17:  0.14605s   0.04695s   0.14770s   0.12714s      
#18:  0.14130s   0.04660s   0.14903s   0.15428s      
#19:  0.14695s   0.05491s   0.14389s   0.07467s      
#20:  0.15050s   0.04700s   0.18919s   0.07662s      

现在,即使Plain(因为我们的 GC 暂停少得多)和ParallelStream 现在的结果也更加稳定,现在总是比Plain 好(尽管它仍然产生更多对象,但分配它们和收集垃圾更容易当你有更大的堆时)。对于所有四个测试,-Xms1G 均未观察到完整的 gc 事件

所以总结一下:

  • ParallelStream 会产生更多垃圾并执行额外的复制,这会减慢操作速度。
  • JVM 启动后默认堆设置太小,因此垃圾收集器需要相当长的时间才能决定充分增加整体堆大小。
  • 如果您想要最大的并行速度,请直接使用Arrays.parallelSort,因为它会就地排序。特别是如果您事先知道数据集的大小。

最后应该注意的是,当您在 Java-7 下启动时将 Collections.sort(list, comparator) 称为“Java 7 集合分类器”,它的运行速度会慢 8-10%,因为 Collections.sort 的实现发生了变化。

【讨论】:

  • 太棒了。您的比较器与我用于测试的比较器相同。当我回到家时,我会看看你的修复是否对我也同样有效。
  • -Xms1G 似乎并没有太大的不同,但parallelsort 的预热时间现在从 0.38 秒降至 0.23 秒。不过,我认为这比您的情况要好。该标志仅对流有很大影响,我一定会牢记这一点
  • @tagir-valeev。谢谢,这太棒了。作为后续我阅读了几篇文章,其中一篇讨论了竞争流可能发生的问题以及 ForkJoin 公共线程池的使用:Common Fork Join Pool and Streams。这是否应该被考虑到答案中,如果是的话,它会改变这些结果吗?
【解决方案2】:

很难用widthheight 的固定值来判断,但一般来说,并行化算法需要额外的时间资源来设置和拆除,但在更大的数据集上运行速度更快,可以重新- 从长远来看,弥补这笔费用。

为了完成你的实验,我会慢慢增加widthheight,看看在测试运行的“预编译”部分,并行算法是否开始超越旧算法。

【讨论】:

  • 使用较小的集合:1/2 大小:4 倍的速度。 1/4 尺寸:5 倍。 1/8 尺寸:8 倍。使用较大的集合:2 倍大小:1.5 倍慢。 4 倍大小:慢 5 倍(以及奇怪的热身行为:第一次半慢,第二次非常快,第三次最慢)
  • 与那些数据集的旧算法相比如何?取决于算法正在做什么,只要 JVM 决定进行垃圾收集,速度就会慢慢蔓延。
  • @Mark Jeronimus:根据你发布的数字,Java7 风格的代码也是第三次运行最慢的。也许这与你的“基准测试”方式有关。
  • @Holger Java 7风格调用第3次变慢可能是JIT编译器运行的原因。
猜你喜欢
  • 2014-07-08
  • 2018-01-07
  • 2011-11-30
  • 2014-02-25
  • 1970-01-01
  • 1970-01-01
  • 2013-01-02
  • 1970-01-01
相关资源
最近更新 更多