之前做了一次
关于Java程序的执行的分享,里面提到了“逃逸分析”,以及它可以引出的一些优化,包括“标量替换”“栈上分配”“锁削除”之类。这里用一个实际例子来演示Sun的JDK 6中实现的标量替换优化。
简单说明两个概念:
逃逸分析,escape analysis。分析程序中指针的动态作用域,看某个指针是否指向某个固定的对象并且没有“逃逸”出某个函数/方法或者线程的范围。如果没有逃逸则可知该指针只在某个局部范围内可见,外部(别的函数/方法或线程)无法看到它。于是可以为别的一些优化提供保证。
标量替换,scalar replacement。Java中的原始类型无法再分解,可以看作标量(scalar);指向对象的引用也是标量;而对象本身则是聚合量(aggregate),可以包含任意个数的标量。如果把一个Java对象拆散,将其成员变量恢复为分散的变量,这就叫做标量替换。拆散后的变量便可以被单独分析与优化,可以各自分别在活动记录(栈帧或寄存器)上分配空间;原本的对象就无需整体分配空间了。
虽然概念上的JVM总是在Java堆上为对象分配空间,但并不是说完全依照概念的描述去实现;只要最后实现处理的“可见效果”与概念中描述的一直就没问题了。所以说,“you can cheat as long as you don't get caught”。Java对象在实际的JVM实现中可能在GC堆上分配空间,也可能在栈上分配空间,也可能完全就消失了。这种行为从Java源码中看不出来,也无法显式指定,只是聪明的JVM自动做的优化而已。
引用一段注释:
escape.cpp
// in order for an object to be scalar-replaceable, it must be:
// - a direct allocation (not a call returning an object)
// - non-escaping
// - eligible to be a unique type
// - not determined to be ineligible by escape analysis
普通Java对象的大小貌似不影响标量替换的判定,不过数组的大小则会影响。
escape.cpp, ConnectionGraph::process_call_result()
case Op_Allocate:
{
Node *k = call->in(AllocateNode::KlassNode);
const TypeKlassPtr *kt = k->bottom_type()->isa_klassptr();
assert(kt != NULL, "TypeKlassPtr required.");
ciKlass* cik = kt->klass();
PointsToNode::EscapeState es;
uint edge_to;
if (cik->is_subclass_of(_compile->env()->Thread_klass()) ||
!cik->is_instance_klass() || // StressReflectiveCode
cik->as_instance_klass()->has_finalizer()) {
es = PointsToNode::GlobalEscape;
edge_to = _phantom_object; // Could not be worse
} else {
es = PointsToNode::NoEscape;
edge_to = call_idx;
}
set_escape_state(call_idx, es);
add_pointsto_edge(resproj_idx, edge_to);
_processed.set(resproj_idx);
break;
}
case Op_AllocateArray:
{
Node *k = call->in(AllocateNode::KlassNode);
const TypeKlassPtr *kt = k->bottom_type()->isa_klassptr();
assert(kt != NULL, "TypeKlassPtr required.");
ciKlass* cik = kt->klass();
PointsToNode::EscapeState es;
uint edge_to;
if (!cik->is_array_klass()) { // StressReflectiveCode
es = PointsToNode::GlobalEscape;
edge_to = _phantom_object;
} else {
es = PointsToNode::NoEscape;
edge_to = call_idx;
int length = call->in(AllocateNode::ALength)->find_int_con(-1);
if (length < 0 || length > EliminateAllocationArraySizeLimit) {
// Not scalar replaceable if the length is not constant or too big.
ptnode_adr(call_idx)->_scalar_replaceable = false;
}
}
set_escape_state(call_idx, es);
add_pointsto_edge(resproj_idx, edge_to);
_processed.set(resproj_idx);
break;
}
这里可以看到数组的大小(元素个数)与EliminateAllocationArraySizeLimit的关系。EliminateAllocationArraySizeLimit的默认值是64:
c2_globals.hpp
product(intx, EliminateAllocationArraySizeLimit, 64, "Array size (number of elements) limit for scalar replacement")
=================================================================
接下来开始实验。
从官网下载
JDK 6 update 21 build 03的fastdebug版。运行java -server -version应可以看到:
command prompt 写道
java version "1.6.0_20-ea-fastdebug"
Java(TM) SE Runtime Environment (build 1.6.0_20-ea-fastdebug-b02)
Java HotSpot(TM) Server VM (build 17.0-b12-fastdebug, mixed mode)
本帖的例子都是使用上述JDK在32位Windows XP SP3上运行的。
该版本HotSpot的server模式在32位x86上的一些默认参数值如下:
CompileThreshold = 10000
OnStackReplacePercentage = 140
InterpreterProfilePercentage = 33
InterpreterBackwardBranchLimit =
(CompileThreshold * (OnStackReplacePercentage - InterpreterProfilePercentage)) / 100
= 10700
注意到CompileThreshold与InterpreterBackwardBranchLimit这两个值:
CompileThreshold:当某个方法被调用+循环次数累计超过该值时,触发标准的JIT编译;
InterpreterBackwardBranchLimit:当某个方法调用+循环次数累计超过该值时,触发OSR形式的JIT编译。
下面就来造一个能演示逃逸分析/标量替换优化的例子。
我们希望能写一个test()方法,让它被调用足够次数以触发标准JIT编译;由于HotSpot默认采用后台编译,还得让程序运行足够时间让编译完成;最好能避免其它方法的编译,减少输出结果的干扰。
代码如下:
public class TestEAScalarReplacement {
private static int fooValue;
public static void test(int x) {
int xx = x + 2;
Point p = new Point(xx, 42);
fooValue = p.getX(); // "publish" result
}
private static void driver0(int x) {
for (int i = 0; i < 5; i++) {
test(i + x);
}
}
private static void driver1(int x) {
for (int i = 0; i < 5; i++) {
test(i + x);
}
}
private static void driver2(int x) {
for (int i = 0; i < 5; i++) {
test(i + x);
}
}
private static void driver3(int x) {
for (int i = 0; i < 5; i++) {
test(i + x);
}
}
public static void driver(int x) {
for (int i = 0; i < 40; i++) {
driver0(i + x);
driver1(i + x);
driver2(i + x);
driver3(i + x);
}
}
public static void main(String[] args) {
for (int i = 0; i < 40; i++) {
driver(i);
}
}
}
class Point {
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
}
这里,Point是用于演示被替换为标量的类型,TestEAScalarReplacement.test()是目标方法,其它一些方法都是用于预热用的。
这种预热方式可以保证test()被调用足够次数,在收集足够多的profile信息的前提下触发标准JIT编译;而驱动预热的方法各自被调用的次数与循环的次数都不足以触发其OSR形式的JIT编译。
test()方法中,最后一句将一个p.x赋值给一个镜头变量。这是为了保证局部变量xx对应的计算能存活而写的。
由于成员变量与静态变量都“外部可见”,例如说从别的线程可以观察到这些值的变化,所以JVM不能够对成员变量与静态变量的赋值做太多优化。这里的赋值保证可以生效,于是p.x就被“使用”过,间接也就“使用”过局部变量xx的值。可以确定没有被使用过的值/计算则可以被优化掉,在最终生成的代码里就看不到了。
这种向成员变量或静态变量赋值的操作也叫做“发布”(publish)一个值。
OK,那么用下述参数跑一下该实验:
java -server -XX:+DoEscapeAnalysis -XX:+EliminateAllocations TestEAScalarReplacement
什么也没看到?呵呵,那就让HotSpot输出点日志来看看。下面是几个输出日志的参数与对应的日志:
-XX:+PrintCompilation
3 TestEAScalarReplacement::test (23 bytes)
这里说明TestEAScalarReplacement.test()方法确实被编译了,而且是标准JIT编译。
-XX:+PrintInlining
@ 11 Point::<init> inline (hot)
@ 1 java.lang.Object::<init> inline (hot)
@ 16 Point::getX inline (hot)
这里说明test()方法编译时将Point与Object的构造器,以及Point.getX()方法内联了进去。也就是说,test()方法在内联后等价变为:
public static void test(int x) {
int xx = x + 2;
Point p = new_Point(); // 创建一个Point对象但不调用其构造器
p.x = xx; // Point构造器内容被内联到这里
p.y = 42;
fooValue = p.x; // getX()方法也被内联了
}
留意到现在变量p指向的Point对象还是一个整体。
-XX:+PrintEscapeAnalysis
======== Connection graph for TestEAScalarReplacement::test
27 JavaObject NoEscape [[ 94F 88F]] 27 Allocate === 5 6 7 8 1 ( 25 23 24 1 1 22 1 ) [[ 28 29 30 37 38 39 ]] rawptr:NotNull ( int:>=0, java/lang/Object:NotNull *, bool, int ) TestEAScalarReplacement::test @ bci:4 !jvms: TestEAScalarReplacement::test @ bci:4
LocalVar [[ 27P]] 39 Proj === 27 [[ 40 94 88 ]] #5 !jvms: TestEAScalarReplacement::test @ bci:4
这里说明通过逃逸分析,发现变量p没有逃逸出test()方法。
-XX:+PrintEliminateAllocations
Scalar 27 Allocate === 5 6 7 8 1 ( 25 23 24 1 1 22 1 ) [[ 28 29 30 37 38 39 ]] rawptr:NotNull ( int:>=0, java/lang/Object:NotNull *, bool, int ) TestEAScalarReplacement::test @ bci:4 !jvms: TestEAScalarReplacement::test @ bci:4
++++ Eliminated: 27 Allocate
这里说明变量p指向的Point对象被标量替换了。被标量替换后的test()方法等价于:
public static void test(int x) {
int xx = x + 2;
int px = xx; // Point消失了,留下其成员为分散的局部变量
int py = 42;
fooValue = px;
}
-XX:+PrintAssembly
Decoding compiled method 0x00be5248:
Code:
[Disassembling for mach='i386']
[Entry Point]
[Verified Entry Point]
# {method} 'test' '(I)V' in 'TestEAScalarReplacement'
# parm0: ecx = int
# [sp+0x10] (sp of caller)
0x00be5320: push %ebp
0x00be5321: sub $0x8,%esp ;*synchronization entry
; - TestEAScalarReplacement::test@-1 (line 5)
0x00be5327: add $0x2,%ecx
0x00be532a: mov $0x150,%ebp
0x00be532f: mov %ecx,0x1024b4e0(%ebp) ;*putstatic fooValue
; - TestEAScalarReplacement::test@19 (line 7)
; {oop('TestEAScalarReplacement')}
0x00be5335: add $0x8,%esp
0x00be5338: pop %ebp
0x00be5339: test %eax,0x9c0000 ; {poll_return}
[Deopt MH Handler Code]
0x00be533f: ret
[Exception Handler]
[Stub Code]
0x00be5340: jmp 0x00be4d40 ; {no_reloc}
[Deopt Handler Code]
0x00be5345: push $0xbe5345 ; {section_word}
0x00be534a: jmp 0x00bcbb40 ; {runtime_call}
[Constants]
0x00be534f: int3
这里显示了test()方法最终被JIT编译器变成了怎样的x86指令(AT&T记法)。无法使用该参数的同学可以用-XX:+PrintOptoAssembly来代替,看到的是C2在最终生成代码之前的中间代码,已经非常接近最后结果了。
刨去一些结构信息,test()方法对应的编译结果是:
; 方法入口处理
0x00be5320: push %ebp
0x00be5321: sub $0x8,%esp
;; xx = x + 2
; (参数x从ECX寄存器传入,然后局部变量xx被叠加分配在ECX上)
0x00be5327: add $0x2,%ecx
;; TestEAScalarReplacement.fooValue = xx
0x00be532a: mov $0x150,%ebp
0x00be532f: mov %ecx,0x1024b4e0(%ebp)
;; 方法出口处理
0x00be5335: add $0x8,%esp
0x00be5338: pop %ebp
; 下面这句是检查是否要进入safepoint
0x00be5339: test %eax,0x9c0000 ; {poll_return}
; 最后返回
0x00be533f: ret
也就是说,最后编译出来的test()方法等价于:
public static void test(int x) {
fooValue = x + 2;
}
=================================================================
上面的实验演示了Sun的JDK 6中实现的逃逸分析与标量替换。不过话说回来,这个实现确实不够理想,要达到上面实验中这么明显的效果比较困难,特别是分析的代码规模一大就不太能行了……也就是说很RP。所以逃逸分析及相关优化即便在JDK 6的server模式默认也是不开的。
之前在JDK6u18的时候这些优化还被暂时禁用了一段时间。所以用6u18来测逃逸分析的同学们请换新版本来测吧,那个版本上看不到效果的……