JVM 解剖公园(18): 标量替换

ImportNew ImportNew 2019-08-27

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

编译:ImportNew/唐尤华

shipilev.net/jvm/anatomy-quarks/18-scalar-replacement/


1. 写在前面


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


Aleksey Shipilёv,JVM 性能极客

推特 @shipilev

问题、评论、建议发送到 aleksey@shipilev.net"">aleksey@shipilev.net


2. 问题


据说 Hotspot 可以进行堆栈分配,称作逃逸分析(Escape Analysis 简称 EA)。很神奇,对吧?


3. 理论


这种说法令人费解。似乎在“堆栈分配”中,“分配”操作假定整个对象是在堆栈上分配,而不在堆上分配。但实际情况是,编译器会先进行逃逸分析,识别没有逃逸到堆中的新建对象,然后开始做一些有趣的优化。注意:逃逸分析本身不做优化,而是为优化器提供重要的分析数据。


(1) 有人声称逃逸分析实际进行了优化,这让我有些恼火。逃逸分析不做优化,接下来才真正开始优化!


优化器对没有逃逸的对象会重新映射,把对象字段访问映射为访问合成后的局部操作数:进行标量替换(Scalar Replacement)。接下来这些操作数会交给寄存器分配器(register allocator)处理,由于其中一些可能会在当前活跃方法中申请 stack slot(所谓“溢出”),对象字段内存块看起来好像是在堆栈上分配。但是这种映射可能出错且无法对等:操作数可能根本没有出现,也可能驻留在寄存器中,甚至没有创建对象头。与堆栈分配不同,对象字段访问映射的操作数在堆栈上可能不连续。


(2) 类似于编译器中局部变量与其它临时操作数的中间形式。


如果堆栈实际完成分配,将在堆栈上存储包括对象头和字段在内的整个对象,并在生成的代码中引用。在这个方案中需要注意:由于不能确保当前线程一直执行指定方法且持有的对象保持活跃,一旦对象发生逃逸,需要将整个对象块从堆栈复制到堆中。这意味着必须拦截堆存储操作,即以在存储堆栈对象时启动 GC 写屏障。


Hotspot 本身不进行堆栈分配,但使用标量替换完成近似操作。


在实验中能观察到上述结论吗?


4. 实验


看下面这个 JMH 基准测试:创建只有一个字段的对象,初始化并作为输入。接着立刻读取对象字段,然后抛弃对象:


import org.openjdk.jmh.annotations.*;
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class ScalarReplacement {
int x;
@Benchmark
public int single()
{
MyObject o = new MyObject(x);
return o.x;
}
static class MyObject {
final int x;
public MyObject(int x) {
this.x = x;
}
}
}


-prof gc 运行测试,会注意到实际上并没有执行分配:


Benchmark                                      Mode  Cnt     Score    Error   Units
ScalarReplacement.single avgt 15 1.919 ± 0.002 ns/op
ScalarReplacement.single:·gc.alloc.rate avgt 1510⁻⁴ MB/sec
ScalarReplacement.single:·gc.alloc.rate.norm avgt 1510⁻⁶ B/op
ScalarReplacement.single:·gc.count avgt 150 counts


-prof perfasm 的结果表明,字段 x 只访问1次。


....[Hottest Region 1].............................................................
C2, level 4, org.openjdk.ScalarReplacement::single, version 459 (26 bytes)
[Verified Entry Point]
6.05% 2.82% 0x00007f79e1202900: sub $0x18,%rsp ; prolog
0.95% 0.78% 0x00007f79e1202907: mov %rbp,0x10(%rsp)
0.04% 0.21% 0x00007f79e120290c: mov 0xc(%rsi),%eax ; 获取字段 $x
5.80% 7.43% 0x00007f79e120290f: add $0x10,%rsp ; epilog
0x00007f79e1202913: pop %rbp
23.91% 33.34% 0x00007f79e1202914: test %eax,0x17f0b6e6(%rip)
0.21% 0.02% 0x00007f79e120291a: retq
...................................................................................


这里的神奇之处在于,编译器能够检测出 MyObject 实例没有逃逸,因此把对象的字段重新映射为本地操作数。接着,操作数的后续操作都基于映射的地址加载和存储,与本地变量一样没有 store-load。然后分配取消,不再出现任何与对象相关的信息。


当然,识别不会发生逃逸的对象需要进行复杂的逃逸分析。逃逸分析中断时,标量替换也会中断。打断 Hotspot 逃逸分析最简单的办法是在访问字段前合并控制流。例如,有两个不同对象(但内容相同),选择其中任何一个分支都会中断逃逸分析。尽管对人类而言这两个对象显然不会发生逃逸:


public class ScalarReplacement {
int x;
boolean flag;
@Setup(Level.Iteration)
public void shake() {
flag = ThreadLocalRandom.current().nextBoolean();
}
@Benchmark
public int split()
{
MyObject o;
if (flag) {
o = new MyObject(x);
} else {
o = new MyObject(x);
}
return o.x;
}
// ...
}


执行代码后分配内存的结果:

Benchmark                                      Mode  Cnt     Score    Error   Units
ScalarReplacement.single avgt 15 1.919 ± 0.002 ns/op
ScalarReplacement.single:·gc.alloc.rate avgt 1510⁻⁴ MB/sec
ScalarReplacement.single:·gc.alloc.rate.norm avgt 1510⁻⁶ B/op
ScalarReplacement.single:·gc.count avgt 150 counts
ScalarReplacement.split avgt 15 3.781 ± 0.116 ns/op
ScalarReplacement.split:·gc.alloc.rate avgt 15 2691.543 ± 81.183 MB/sec
ScalarReplacement.split:·gc.alloc.rate.norm avgt 15 16.000 ± 0.001 B/op
ScalarReplacement.split:·gc.count avgt 15 1460.000 counts
ScalarReplacement.split:·gc.time avgt 15 929.000 ms


如果这是一次“真正的”堆栈分配,处理这种情况很容易:每次分配首先增加堆栈空间,接着执行访问,最后在离开方法前清空堆栈内容并收回堆栈分配内存。但是防止对象逃逸带来的写屏障问题仍然存在。


5. 观察


逃逸分析是一种有趣的编译器优化技术,可用来优化。标量替换是其中一种。但逃逸分析并没有把对象存储到堆栈上,相反,它会拆分对象并把代码重写为本地访问,实现进一步优化。当寄存器访问压力过高时,会访问堆栈。在许多关键的 hotpath 上,逃逸分析能够成功地完成性能优化。


逃逸分析并不完美:如果不能通过静态分析确认对象不会逃逸,则必须假设会发生逃逸。复杂的控制流程可能会提前退出。调用非内联实例方法也会结束逃逸分析,因为对于当前分析不透明。尽管一些琐碎的情况能够得到有效处理,比如与非逃逸对象比较引,但是与对象身份相关的事情还是会导致逃逸分析结束。


虽然并不完美,但当它起作用时表现非常出色。编译器技术的进步可能会增加逃逸分析成功案例。


(3) 例如,Graal 就以局部逃逸分析特性著称,在复杂的数据流情况下具有更好的适应性。


推荐阅读

(点击标题可跳转阅读)

JVM 解剖公园(17): 信任非 static final 字段

JVM 解剖公园(16): 超多态虚拟调用

JVM 解剖公园(15): 即时常量


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

关注「ImportNew」,提升Java技能

好文章,我在看❤️

    已同步到看一看

    发送中

    本站仅按申请收录文章,版权归原作者所有
    如若侵权,请联系本站删除
    觉得不错,分享给更多人看到