软件开发架构师

JVM 解剖公园:初始化开销

java 159 2019-06-07 02:23

(给ImportNew加星标,提高Java技能)

编译:ImportNew/唐尤华

shipilev.net/jvm/anatomy-quarks/7-initialization-costs/


1. 写在前面


“[JVM 解剖公园][1]”是一个持续更新的系列迷你博客,阅读每篇文章一般需要5到10分钟。限于篇幅,仅对某个主题按照问题、测试、基准程序、观察结果深入讲解。因此,这里的数据和讨论可以当轶事看,不做写作风格、句法和语义错误、重复或一致性检查。如果选择采信文中内容,风险自负。


Aleksey Shipilёv,JVM 性能极客   

推特 [@shipilev][2]   

问题、评论、建议发送到 [aleksey@shipilev.net][3]


[1]:https://shipilev.net/jvm-anatomy-park

[2]:http://twitter.com/shipilev

[3]:aleksey@shipilev.net


2. 问题


为什么创建新对象开销很大?怎样定义对象实例化性能?


3. 理论


如果仔细观察大型对象的实例化过程,就会不可避免地想要探究不同组件究竟如何扩展,以及在现实世界中瓶颈究竟是什么。我们已经知道,[TLAB 分配看起来非常高效][4],[系统初始化可以与用户初始化过程结合][5]。但是最终还是要写入内存,怎样知道开销究竟有多大?


4. 实验


普通的 Java 数组能够告诉我们关于初始化的故事。数组需要初始化而且长度可变,这让我们有机会观察不同大小数组在初始化过程中的区别。考虑到这一点,让我们构建下面基准测试:


```java
import org.openjdk.jmh.annotations.*;

@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class UA {
@Param({"1", "10", "100", "1000", "10000", "100000"})
int size;

@Benchmark
public byte[] java() {
return new byte[size];
}
}
```


用最新的 JDK 9 EA 运行,通过 `-XX:+UseParallelOldGC` 参数最小化 GC 开销,使用 `-Xmx20g -Xms20g -Xmn18g` 保留新分配堆空间。如果没有其他因素造成延迟,运行基准测试能看到以下输出(我的配置 i7-4790K、4.0 GHz、Linux x86_64),所有8个硬件线程都在运行:


```shell
Benchmark (size) Mode Cnt Score Error Units

# Time to allocate
UA.java 1 avgt 15 20.307 ± 4.532 ns/op
UA.java 10 avgt 15 26.657 ± 6.072 ns/op
UA.java 100 avgt 15 106.632 ± 34.742 ns/op
UA.java 1000 avgt 15 681.176 ± 124.980 ns/op
UA.java 10000 avgt 15 4576.433 ± 909.956 ns/op
UA.java 100000 avgt 15 44881.095 ± 13765.440 ns/op

# Allocation rate
UA.java:·gc.alloc.rate 1 avgt 15 6228.153 ± 1059.385 MB/sec
UA.java:·gc.alloc.rate 10 avgt 15 6335.809 ± 986.395 MB/sec
UA.java:·gc.alloc.rate 100 avgt 15 6126.333 ± 1354.964 MB/sec
UA.java:·gc.alloc.rate 1000 avgt 15 7772.263 ± 1263.453 MB/sec
UA.java:·gc.alloc.rate 10000 avgt 15 11518.422 ± 2155.516 MB/sec
UA.java:·gc.alloc.rate 100000 avgt 15 12039.594 ± 2724.242 MB/sec
```


可以看到,分配过程大约需要20纳秒(单线程开销相对较低,但是平均值会被超线程赶超)。分配100K大小的数组开销会逐渐增加到40纳秒。如果查看分配率,会发现它在12GB/秒左右达到饱和。顺便说一下,这些实验构成了其他性能测试的基础:了解在特定机器上可以达到的`内存带宽/分配率`顺序很重要。


我们能够找到究竟是哪些代码占用了大部分执行时间吗?当然可以。我们再次启用 JMH `-prof perfasm`。指定 `-p size=100000` 会找到以下开销最大的代码:


```ASM
0x00007f1f094f650b: movq $0x1,(%rdx) ; 保存 mark word
0.00% 0x00007f1f094f6512: prefetchnta 0xc0(%r9)
0.64% 0x00007f1f094f651a: movl $0xf80000f5,0x8(%rdx) ; 保存 klass word
0.02% 0x00007f1f094f6521: mov %r11d,0xc(%rdx) ; 保存数组长度
0x00007f1f094f6525: prefetchnta 0x100(%r9)
0.05% 0x00007f1f094f652d: prefetchnta 0x140(%r9)
0.07% 0x00007f1f094f6535: prefetchnta 0x180(%r9)
0.09% 0x00007f1f094f653d: shr $0x3,%rcx
0.00% 0x00007f1f094f6541: add $0xfffffffffffffffe,%rcx
0x00007f1f094f6545: xor %rax,%rax
0x00007f1f094f6548: cmp $0x8,%rcx
0x00007f1f094f654c: jg 0x00007f1f094f655e ; 长度足够? jump
0x00007f1f094f654e: dec %rcx
│╭ 0x00007f1f094f6551: js 0x00007f1f094f6565 ; 长度为0? jump
││↗ 0x00007f1f094f6553: mov %rax,(%rdi,%rcx,8) ; 初始化小循环
│││ 0x00007f1f094f6557: dec %rcx
││╰ 0x00007f1f094f655a: jge 0x00007f1f094f6553
││ ╭ 0x00007f1f094f655c: jmp 0x00007f1f094f6565
↘│ │ 0x00007f1f094f655e: shl $0x3,%rcx
89.12% │ │ 0x00007f1f094f6562: rep rex.W stos %al,%es:(%rdi) ; 初始化大循环
0.20% ↘ ↘ 0x00007f1f094f6565: mov %r8,(%rsp)
```


你可能已经发现,这段代码与本系列中的“[TLAB 分配][4]”和“[新建对象过程][5]”中的代码很像。有趣的是,这里必须初始化更大的数组空间。出于这个原因,可以看到x86上的 `rep stos` 内联序列——重复存储给定大小的字节,在最新的x86上似乎非常有效。如果仔细观察可以看到,对小数组(小于等于8个元素)也有循环初始化—— `rep stos` 需要前期启动开销,小循环因此受益。


从例子中可以看到,对于大型`对象或数组`,初始化开销将主导性能。如果`对象或数组`很小,那么(头部、数组长度)元数据写入会占据主要开销。小数组与小对象之间没有明显区别。


如果设法绕过初始化,能猜到性能会是怎样吗?编译器会经常合并系统和用户初始化,那么能不能根本不进行初始化呢?得到未初始化的对象没有任何实际意义,因为稍后仍然需要填充数据ーー但是设计测试很有趣,不是吗?


事实证明,`Unsafe` 方法能用来分配未初始化的数组,我们可以拿来进行实验。`Unsafe` 不是 Java 代码,它不遵守 Java 规则,有时甚至违反 JVM 规则。它不是公开使用的 API,只在 JDK 内部使用,进行 JDK 与 VM 实现互操作。无法确保 `Unsafe` 一直正常工作,随时可能崩溃。


尽管如此,还是可以拿它设计测试,像下面这样:


```JAVA
import jdk.internal.misc.Unsafe;
import org.openjdk.jmh.annotations.*;

@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class UA {
static Unsafe U;

static {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
U = (Unsafe) field.get(null);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}

@Param({"1", "10", "100", "1000", "10000", "100000"})
int size;

@Benchmark
public byte[] unsafe() {
return (byte[]) U.allocateUninitializedArray(byte.class, size);
}
}
```


运行结果:


```SHELL
Benchmark (size) Mode Cnt Score Error Units
UA.unsafe 1 avgt 15 19.766 ± 4.002 ns/op
UA.unsafe 10 avgt 15 27.486 ± 7.005 ns/op
UA.unsafe 100 avgt 15 80.040 ± 15.754 ns/op
UA.unsafe 1000 avgt 15 156.041 ± 0.552 ns/op
UA.unsafe 10000 avgt 15 162.384 ± 1.448 ns/op
UA.unsafe 100000 avgt 15 309.769 ± 2.819 ns/op

UA.unsafe:·gc.alloc.rate 1 avgt 15 6359.987 ± 928.472 MB/sec
UA.unsafe:·gc.alloc.rate 10 avgt 15 6193.103 ± 1160.353 MB/sec
UA.unsafe:·gc.alloc.rate 100 avgt 15 7855.147 ± 1313.314 MB/sec
UA.unsafe:·gc.alloc.rate 1000 avgt 15 33171.384 ± 153.645 MB/sec
UA.unsafe:·gc.alloc.rate 10000 avgt 15 315740.299 ± 3678.459 MB/sec
UA.unsafe:·gc.alloc.rate 100000 avgt 15 1650860.763 ± 14498.920 MB/sec
```


喔!100K大小的数组分配速度达到1.6太(兆兆)字节/s。看看现在哪里花费的时间最大?


```ASM
0x00007f65fd722c74: prefetchnta 0xc0(%r11)
66.06% 0x00007f65fd722c7c: movq $0x1,(%rax) ; 保存 mark word
0.40% 0x00007f65fd722c83: prefetchnta 0x100(%r11)
4.43% 0x00007f65fd722c8b: movl $0xf80000f5,0x8(%rax) ; 保存 class word
0.01% 0x00007f65fd722c92: mov %edx,0xc(%rax) ; 保存 array length
0x00007f65fd722c95: prefetchnta 0x140(%r11)
5.18% 0x00007f65fd722c9d: prefetchnta 0x180(%r11)
4.99% 0x00007f65fd722ca5: mov %r8,0x40(%rsp)
0x00007f65fd722caa: mov %rax,%rdx
```


是的,大部分时间花在了预获取(prefetch),为即将到来的写操作预先访问内存。


有人可能好奇,这对 GC 会有什么影响?答案是没有很大影响。这些对象几乎都是“死的”,GC 可以非常轻松地清扫这些对象。当对象开始以TB/秒速度进入 Survior 区,情况开始变得有趣。一些 GC 开发者称之为“不可能出现的负载”,由于它们在现实中不可能出现,因此也无法处理。可以想象一下“用消防水龙头喝水”。


无论如何,我们可以看到只进行分配时 GC 都运行得很好。在相同的工作负载下,可以使用 JMH -prof pauses profiler 观察时应用运行中出现的暂停。通过运行高优先级线程,记录可察觉的暂停:


```SHELL
Benchmark (size) Mode Cnt Score Error Units
UA.unsafe 100000 avgt 5 315.732 ± 5.133 ns/op
UA.unsafe:·pauses 100000 avgt 84 537.018 ms
UA.unsafe:·pauses.avg 100000 avgt 6.393 ms
UA.unsafe:·pauses.count 100000 avgt 84.000 #
UA.unsafe:·pauses.p0.00 100000 avgt 2.560 ms
UA.unsafe:·pauses.p0.50 100000 avgt 6.148 ms
UA.unsafe:·pauses.p0.90 100000 avgt 9.642 ms
UA.unsafe:·pauses.p0.95 100000 avgt 9.802 ms
UA.unsafe:·pauses.p0.99 100000 avgt 14.418 ms
UA.unsafe:·pauses.p0.999 100000 avgt 14.418 ms
UA.unsafe:·pauses.p0.9999 100000 avgt 14.418 ms
UA.unsafe:·pauses.p1.00 100000 avgt 14.418 ms
```


可以看到,上面检测到大约有84次暂停,最长停顿时间14毫秒,平均停顿时间6毫秒。Profiler 本身并不精确,因为它们依赖操作系统调度,需要与其他工作负载竞争 CPU。


在许多情况下,最好允许 JVM 告知何时停止应用程序线程。可以通过 JMH 的 `-prof safepoints profiler` 记录“safe point”、“stop the world”事件。当所有应用程序停止后,VM 会完成它的工作。GC 暂停是 safepoint 事件的子集。


```SHELL
Benchmark (size) Mode Cnt Score Error Units
UA.unsafe 100000 avgt 5 328.247 ± 34.450 ns/op
UA.unsafe:·safepoints.interval 100000 avgt 5043.000 ms
UA.unsafe:·safepoints.pause 100000 avgt 639 617.796 ms
UA.unsafe:·safepoints.pause.avg 100000 avgt 0.967 ms
UA.unsafe:·safepoints.pause.count 100000 avgt 639.000 #
UA.unsafe:·safepoints.pause.p0.00 100000 avgt 0.433 ms
UA.unsafe:·safepoints.pause.p0.50 100000 avgt 0.627 ms
UA.unsafe:·safepoints.pause.p0.90 100000 avgt 2.150 ms
UA.unsafe:·safepoints.pause.p0.95 100000 avgt 2.241 ms
UA.unsafe:·safepoints.pause.p0.99 100000 avgt 2.979 ms
UA.unsafe:·safepoints.pause.p0.999 100000 avgt 12.599 ms
UA.unsafe:·safepoints.pause.p0.9999 100000 avgt 12.599 ms
UA.unsafe:·safepoints.pause.p1.00 100000 avgt 12.599 ms
```


可以看到,上面的分析器记录了639个 safepoint,平均时间小于1毫秒,最大时间为12毫秒!考虑1.6TB/秒的分配速率,结果不算糟糕。


5. 观察


初始化`对象或数组`是实例化过程中最主要的开销。使用 TLAB 分配,`对象或数组`的创建速度在很大程度上取决于写入元数据开销(较小的内容)或者内容初始化开销(较大的内容)。分配率并不总是一种很好的性能指标,你可以通过各种诡异的方法提高分配率。


[4]:https://shipilev.net/jvm/anatomy-quarks/4-tlab-allocation/

[5]:https://shipilev.net/jvm/anatomy-quarks/6-new-object-stages/


推荐阅读

(点击标题可跳转阅读)

Kotlin 与 Java:哪个更合适

按 CompletableFuture 完成顺序实现 Streaming Future

Java 8 并行流介绍


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

文章评论