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

时间:2016-02-02 20:34:48

标签: java performance sorting 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());

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

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

2 个答案:

答案 0 :(得分: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);

它将欧几里德距离与(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;
}

基于Parallel Stream API的排序将是

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

我们还要添加顺序Stream 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核心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

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

此后不再有完整的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

只有两个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实现已更改。

答案 1 :(得分:0)

很难用widthheight的固定值来判断,但一般来说,并行化算法需要额外的时间资源来设置和拆除,但是对于较大的数据总体上运行得更快可以长期重新支付这笔费用的套装。

为了完成您的实验,我会慢慢增加widthheight并查看并行算法是否在预编译期间开始超过旧算法&#34;试运行的一部分。