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. 问题
听说分配与初始化不同。Java 有构造函数,它究竟会执行分配还是做初始化呢?
3. 理论
如果打开 [GC Handbook][4],它会告诉你创建一个新对象通常包括三个阶段:
> 译注:GC Handbook 中文版《垃圾回收算法手册》
"分配":从进程空间中分配实例数据。
"系统初始化":按照 Java 语言规范进行初始化。在 C 语言中,分配新对象不需要初始化;在 Java 中,所有新创建的对象都要进行系统初始化赋默认值,设置完整的对象头等等。
"二次初始化(用户初始化)":执行与该对象类型关联的所有初始化语句和构造函数。
在前面 [TLAB 分配][5]中我们对此进行过讨论,现在介绍详细的初始化过程。假如你熟悉 Java 字节码,就会知道 `new` 语句对应了几条字节码指令。例如:
```java public Object t() { return new Object(); } ```
会编译为:
```java public java.lang.Object t(); descriptor: ()Ljava/lang/Object; flags: (0x0001) ACC_PUBLIC Code: stack=2, locals=1, args_size=1 0: new #4 // java/lang/Object 类 3: dup 4: invokespecial #1 // java/lang/Object."<init>":()V 方法 7: areturn ```
[4]:http://gchandbook.org/
[5]:https://shipilev.net/jvm/anatomy-quarks/4-tlab-allocation/
看起来 `new` 会执行分配和系统初始化,同时调用构造函数(`<init>`)执行用户初始化。然而,智能的 Hotspot 虚拟机会不会优化?比如在构造函数执行完成以前查看对象使用情况,优化可以合并的任务。接下来,让我们做个实验。
4. 实验
要解除这个疑问,可以编写下面这样的测试。初始化两个不同的类,每个类只包含一个 `int` 属性:
```java import org.openjdk.jmh.annotations.*; import java.util.concurrent.TimeUnit; @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 UserInit { @Benchmark public Object init() { return new Init(42); } @Benchmark public Object initLeaky() { return new InitLeaky(42); } static class Init { private int x; public Init(int x) { this.x = x; } } static class InitLeaky { private int x; public InitLeaky(int x) { doSomething(); this.x = x; } @CompilerControl(CompilerControl.Mode.DONT_INLINE) void doSomething() { // 此处留白 } } } ```
设计测试时,为防止编译器对 `doSomething()` 空方法进行内联优化加上了限制,迫使优化程序认为接下来可能有代码访问 `x`。换句话说,这样就无法判断 `doSomething()` 是否真的泄露了对象,从而可以有效地把对象暴露给某些外部代码。
建议启用 `-XX:+UseParallelGC -XX:-TieredCompilation -XX:-UseBiasedLocking` 参数运行测试,这样生成的代码更容易理解。JMH `-prof perfasm` 参数可以完美地转储测试生成的代码。
下面是 `Init` 测试结果:
```asm 0x00007efdc466d4cc: mov 0x60(%r15),%rax ; 下面是 TLAB 分配 0x00007efdc466d4d0: mov %rax,%r10 0x00007efdc466d4d3: add $0x10,%r10 0x00007efdc466d4d7: cmp 0x70(%r15),%r10 0x00007efdc466d4db: jae 0x00007efdc466d50a 0x00007efdc466d4dd: mov %r10,0x60(%r15) 0x00007efdc466d4e1: prefetchnta 0xc0(%r10) ; ------- /分配 --------- ; ------- 系统初始化 --------- 0x00007efdc466d4e9: movq $0x1,(%rax) ; header 设置 mark word 0x00007efdc466d4f0: movl $0xf8021bc4,0x8(%rax) ; header 设置 class word ; ...... 系统/用户初始化 ..... 0x00007efdc466d4f7: movl $0x2a,0xc(%rax) ; x = 42. ; -------- /用户初始化 --------- ```
上面生成的代码中可以看到 TLAB 分配、对象元数据初始化,然后对字段执行系统+用户初始化。`InitLeaky` 的测试结果有很大区别:
```asm ; ------- 分配 ---------- 0x00007fc69571bf4c: mov 0x60(%r15),%rax 0x00007fc69571bf50: mov %rax,%r10 0x00007fc69571bf53: add $0x10,%r10 0x00007fc69571bf57: cmp 0x70(%r15),%r10 0x00007fc69571bf5b: jae 0x00007fc69571bf9e 0x00007fc69571bf5d: mov %r10,0x60(%r15) 0x00007fc69571bf61: prefetchnta 0xc0(%r10) ; ------- /分配 --------- ; ------- 系统初始化 --------- 0x00007fc69571bf69: movq $0x1,(%rax) ; header 设置 mark word 0x00007fc69571bf70: movl $0xf8021bc4,0x8(%rax) ; header 设置 class word 0x00007fc69571bf77: mov %r12d,0xc(%rax) ; x = 0 (%r12 的值恰好是 0) ; ------- /系统初始化 -------- ; -------- 用户初始化 ---------- 0x00007fc69571bf7b: mov %rax,%rbp 0x00007fc69571bf7e: mov %rbp,%rsi 0x00007fc69571bf81: xchg %ax,%ax 0x00007fc69571bf83: callq 0x00007fc68e269be0 ; call doSomething() 0x00007fc69571bf88: movl $0x2a,0xc(%rbp) ; x = 42 ; ------ /用户初始化 ------ ```
由于优化程序无法确定是否需要 `x` 值,因此这里必须假定出现最坏的情况,先执行系统初始化,然后再完成用户初始化。
5. 观察
虽然教科书的定义很完美,而且生成的字节码也提供了佐证,但只要不出现奇怪的结果,优化程序还是会做一些不为人知的优化。从编译器的角度看,这只是一种简单优化。但从概念上说,这个结果已经超出了“阶段”的范畴。