为什么更新/更快的Java 8排序方式更糟?

Mar*_*mus 4 java sorting performance java-8 java-stream

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());
Run Code Online (Sandbox Code Playgroud)

使用任何排序方法时,我首先会获得较慢的性能,之后会有所改善.我期待这一点,因为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
Run Code Online (Sandbox Code Playgroud)

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

  • 有没有其他人经历过较新(流)操作的糟糕表现?
  • 有没有办法解决这个缓慢的问题?(不会导致启动延迟)

Tag*_*eev 9

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

你没有提供你Comparator的问题.简单的比较器(比如Comparator.comparingInt(p -> p.x))可以更快地对数据进行排序,因此我假设您有更复杂的比较器:

final Comparator<Point> comparator = Comparator.comparingInt(p -> p.x*p.x + p.y*p.y);
Run Code Online (Sandbox Code Playgroud)

它比较欧几里德距离的点(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;
}
Run Code Online (Sandbox Code Playgroud)

我使用数组而不是直接List测试Arrays.parallelSort.简单的老式就像:

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

基于Parallel Stream API的排序将是

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

我们还添加顺序Stream API版本:

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

parallelSort直接使用:

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

测量代码并不是很困难.是完整的实施.请注意,每个测试都应该独立启动,因此我们在JVM启动期间只测试一种模式.这是我机器上的典型结果(i7-4702MQ 2.20GHz,4核心HT = 8个HW线程,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
Run Code Online (Sandbox Code Playgroud)

结果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
Run Code Online (Sandbox Code Playgroud)

此后不再有完整的GC事件,因为堆现在已经充分扩大了.与Plain发布相比:

[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
Run Code Online (Sandbox Code Playgroud)

只有两个Full GC,花费的时间要少得多,因为垃圾明显少了.

让我们将初始堆大小设置为1Gb,-Xms1G以降低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      
Run Code Online (Sandbox Code Playgroud)

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

总结如下:

  • ParallelStream会产生更多垃圾,并进行额外的复制,从而减慢操作速度.
  • 在JVM启动后,默认堆设置太小,因此垃圾收集器需要花费大量时间,直到它决定充分增加整个堆大小.
  • 如果您想要最大平行速度,请Arrays.parallelSort直接使用,因为它可以就地排序.特别是如果您事先知道数据集的大小.

最后应该注意的是,当您Collections.sort(list, comparator)在Java-7下启动时调用"Java 7集合分类器"时,它的工作速度要慢8-10%,因为Collections.sort实现已更改.