0%

字段访问优化

逃逸分析优化方式中的标量替换,可以看成将对象本身拆散为一个个字段,并把原本对象字段的访问,替换为对一个个局部变量的访问。

由于Sea-of-Nodes IR的特性,局部变量不复存在,取而代之的是一个个值。

在现实中,Java程序中的对象或许本身便是逃逸的,或许因为方法内联不够彻底而被即使编译器当成是逃逸的。这两种情况都将导致即时编译器无法进行标量替换。这时候,针对对象字段访问的优化也变得格外重要。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//对象o是传入参数,不属于逃逸分析的范围(JVM中的逃逸分析针对的是新建对象)
//该方法会将所传入的int型参数x的值存储至实例字段Foo.a中,然后再读取并返回同一字段的值。
static int bar(Foo o, int x) {
o.a = x;
return o.a;
}
//这段代码将涉及两次内存访问操作:存储以及读取实例字段Foo.a。
//可以将其手工优化为直接读取并返回传入参数x的值。
static int bar(Foo o, int x) {
o.a = x;
return x;
}
//简单的代码,可以被编译为寄存器之间的移动指令。
//将输入参数x的值移至寄存器eax中,这比原本的内存访问指令高效

字段读取优化

即时编译器能够作出类似上述的优化。
即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。

当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。

当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值。当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//缓存字段读取节点  
static int bar(Foo o, int x) {
int y = o.a + x;
return o.a + y;
}
//实例字段Foo.a将被读取两次。
//即时编译器会将第一次读取的值缓存起来,并且替换第二次字段读取操作,以节省一次内存访问。
static int bar(Foo o, int x) {
int t = o.a;
int y = t + x;
return t + y;
}
//如果字段读取节点被替换成一个常量,那么它将进一步触发更多优化
static int bar(Foo o, int x) {
o.a = 1;
if(o.a >= o) {
return x;
} else {
return -x;
}
}
//实例字段Foo.a会被赋值为1。接下来的if语句将判断同一实例字段是否不小于0。经过字段读取优化之后,>=节点的两个输入参数分别为常数1和0,因此可以直接替换为具体结果true。如此一来,else分支将变成不可达代码,可以直接删除。
static int bar(Foo o, int x) {
o.a = 1;
return x;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//bar方法中,实例字段a会被赋值为true,后面紧跟着一个以a为条件的while循环  
class Foo {
boolean a;
void bar() {
a = true;
while(a) {}
}
void whatever() {
a = false;
}
}
//即时编译器会将while循环中读取实例字段a的操作直接替换为常量true
void bar() {
a = true;
while(true) {}
}
//生成的机器码将陷入这一死循环中
0x066b: mov rll,QWORD PTR [r15+0x70] //安全点测试
0x066f: test DWORD PTR [r11],eax //安全点测试
0x0672: jmp 0x066b //while(true)

通过volatile关键字标记实例字段a,以此强制对它的读取。

实际上,即时编译器将在volatile字段访问前后插入内存屏障节点。这些内存屏障节点会阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上。

在X86_64平台上,volatile字段读取操作前后的内存屏障是no-op,在即时编译过程中的屏障节点,还是会阻止即时编译器的字段读取优化,强制在循环中使用内存读取指令访问实例字段Foo.a的最新值。

1
2
3
4
5
0x00e0: movzx  r11d,BYTE PTR [rbx+0xc]   // 读取a
0x00e5: mov r10,QWORD PTR [r15+0x70] // 安全点测试
0x00e9: test DWORD PTR [r10],eax // 安全点测试
0x00ec: test r11d,r11d // while (a)
0x00ef: jne 0x00e0 // while (a)

同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。

字段存储优化

除了字段读取优化之外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么及时编译器可以将第一个字段存储给消除掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Foo {
int a = 0;
void bar() {
a = 1;
a = 2;
}
}
//代码中的bar方法先后存储了两次Foo.a实例字段。由于第一次存储之后没有读取Foo.a的值,因此,即时编译器会将其看成冗余存储,并将之消除掉。
void bar() {
a = 2;
}
//实际上,即便是在这两个字段存储操作之间读取该字段,即时编译器还是有可能在字段读取优化的帮助下,将第一个存储操作当成冗余存储给消除掉。
class Foo {
int a = 0;
void bar() {
a = 1;
int t = a;
a = t + 2;
}
}
//优化为
class Foo {
int a = 0;
void bar() {
a = 1;
int t = 1;
a = t + 2;
}
}
//进一步优化为
class Foo {
int a = 0;
void bar() {
a = 3;
}
}

如果所存储的字段被标记为volatile,那么即时编译器也不能将冗余的存储操作消除掉。如两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。

死代码消除

除了字段存储优化之外,局部变量的死存储(dead store)同样也涉及了冗余存储。这是死代码消除(dead code eliminiation)的一种。不过,由于Sea-of-Nodes IR的特性,死代码的优化无须额外代价。

1
2
3
4
5
6
7
8
9
int bar(int x, int y) {
int t = x*y;
t = x+y;
return t;
}
//代码涉及两个存储局部变量操作。当即时编译器将其转换为Sea-of-Nodes IR之后,没有节点依赖于t的第一个值x*y。该乘法运算将被消除
int bar(int x, int y) {
return x+y;
}

死存储还有一种变体,即在部分程序路径上有冗余存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int bar(boolean f, int x, int y) {
int t = x*y;
if(f)
t = x+y;
return t;
}
//代码中,如果所传入的boolean类型的参数f是true,那么在程序执行路径上将先后进行两次对局部变量t的存储。
//经过Sea-of-Nodes IR转换之后,返回节点所依赖的值是一个phi节点,将根据程序路径选择x+y或者x*y。也就是说,当f为true的程序路径上的乘法运算会被消除
int bar(boolean f, int x, int y) {
int t;
if(f)
t = x+y;
else
t = x*y;
return t;
}

另一种死代码消除则是不可达分支消除。不可达分支就是任何程序路径都不可到达的分支。

在即时编译过程中,经常因为方法内联、常量传播以及基于profile的优化等,生成许多不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。

1
2
3
4
5
6
7
8
9
10
int bar(int x) {
if(false)
return x;
else
return -x;
}
//代码中,if语句将一直跳转至else分支之中。因此,另一不可达分支可以直接消除掉
int bar(int x) {
return -x;
}

总结

即时编译器将沿着控制流缓存字段存储、读取的值,并在接下来的字段读取操作时直接使用该缓存值。

这要求生成缓存值的访问以及使用缓存值的读取之间没有方法调用、内存屏障,或者其他可能存储该字段的节点。

即时编译器还会优化冗余的字段存储操作。如果一个字段的两次存储之间没有对该字段的读取操作、方法调用以及内存屏障,那么即时编译器可以将第一个冗余的存储操作给消除掉。

死代码消除的两种形式:
第一种是局部变量的死存储消除以及部分死存储消除。它们可以通过转换为Sea-of-Nodes IR来完成。
第二种则是不可达分支。通过消除不可达分支,即时编译器可以精简数据流,并且减少编译时间以及最终生成机器码的大小。