您的位置:首页 > 其它

JVM 解剖公园:初始化开销

2021-01-13 20:39 190 查看

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/


内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: