JVM常用概念之标量替换
问题
什么是逃逸分析?
基础知识
在“堆栈分配”中,“分配”似乎假设整个对象都分配在堆栈上而不是堆上。但实际情况是,编译器执行所谓的逃逸分析 (EA) ,它可以识别哪些新创建的对象没有逃逸到堆中,然后它可以进行一些有趣的优化。请注意,逃逸分析 (EA) 本身不是优化,而是在分析阶段为优化器进行有效的优化提供了重要的数据。
优化器可以针对非逃逸对象执行的操作之一是将对对象字段的访问重新映射到对合成本地操作数的访问:执行标量替换。由于这些操作数随后由寄存器分配器处理,因此其中一些操作数可能会在当前方法激活中占用堆栈槽(“溢出”),并且可能看起来像对象字段块已在堆栈上分配。但这是一种错误的对称性:操作数甚至可能根本不实现,或者可能驻留在寄存器中,对象头根本没有创建,等等。从对象字段访问映射的操作数甚至可能在堆栈上不连续!这与堆栈分配不同。
如果堆栈分配确实完成,它将在堆栈上分配整个对象存储,包括标头和字段,并在生成的代码中引用它。此方案的问题是,一旦对象逃逸,我们就需要将整个对象块从堆栈复制到堆,因为我们无法确定当前线程是否停留在方法中并保持堆栈的这一部分保持活动状态。这意味着我们必须拦截对堆的存储,以防我们存储堆栈分配的对象——即执行 GC 写屏障。
Hotspot本身并不进行堆栈分配,但是它使用标量替换来近似地完成该操作。
实验
测试用例
我们创建包含一个字段的对象,该字段由我们的输入进行初始化,然后该初始化后的实例立即读取该字段,丢弃该对象。
源码
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 15 ≈ 10⁻⁴ MB/sec
ScalarReplacement.single:·gc.alloc.rate.norm avgt 15 ≈ 10⁻⁶ B/op
ScalarReplacement.single:·gc.count avgt 15 ≈ 0 counts
通过-prof perfasm执行后,其执行结果表明只剩下一个对字段x访问,如下所示:
....[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 ; get field $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实例没有逃逸,将其字段重新映射到本地操作数,然后识别出该操作数的连续存储跟随加载,并完全消除该存储加载,然后,修剪分配,因为它不再需要了,并且任何对该对象的记录都消失了。
那这是为什么呢?这需要复杂的 逃逸分析 (EA) 实现来识别非逃逸候选对象。当 逃逸分析 (EA) 中断时,标量替换也会中断。当前 Hotspot 逃逸分析 (EA) 中最微不足道的中断是控制流在访问之前合并。例如,如果我们有两个不同的对象(但内容相同),在选择其中任何一个的分支下,逃逸分析 (EA) 就会中断,即使这两个对象显然是非逃逸的,以下述测试实例来分析:
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 15 ≈ 10⁻⁴ MB/sec
ScalarReplacement.single:·gc.alloc.rate.norm avgt 15 ≈ 10⁻⁶ B/op
ScalarReplacement.single:·gc.count avgt 15 ≈ 0 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
由上述执行结果可知,如果这是“真正的”堆栈分配,它可以轻松处理这种情况:它会在运行时为任一分配扩展堆栈,进行访问,然后在离开方法之前删除堆栈内容,堆栈分配将被收回。保护对象逃逸的写屏障的复杂性仍然存在。
总结
逃逸分析是一种有趣的编译器技术,可以实现有趣的优化。标量替换就是其中之一,它不是将对象存储放在堆栈上。相反,它是关于分解对象并将代码重写为本地访问,并进一步优化它们,有时在寄存器压力很高时将这些访问溢出到堆栈上。在许多情况下,在关键热路径上,它可以成功且有利地完成。
但是,逃逸分析 (EA)并不理想:如果我们无法静态地确定对象没有逃逸,那么我们必须假设它确实逃逸了。复杂的控制流可能会更早地被释放。调用非内联(因此对当前分析而言不透明)实例方法会被释放。执行一些依赖于对象身份的操作会被释放,尽管与非逃逸对象进行比较等琐碎的事情可以有效地折叠起来。