字段访问优化
虽然在部分情况下,逃逸分析以及基于逃逸分析的优化已经十分高效了,能够将代码优化到极其简单的地步,但是逃逸分析毕竟不是 Java 虚拟机的银弹。在现实中,Java 程序中的对象或许本身便是逃逸的,或许因为方法内联不够彻底而被即时编译器当成是逃逸的。这两种情况都将导致即时编译器无法进行标量替换。此时,针对对象字段访问的优化也变得格外重要起来。
1. 字段读取优化
即时编译器会优化实例字段以及静态字段访问,以减少总的内存访问数目。具体来说,它将沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。
当即时编译器遇到对同一字段的读取节点时,如果缓存值还没有失效,那么它会将读取节点替换为该缓存值。当即时编译器遇到对同一字段的存储节点时,它会更新所缓存的值。当即时编译器遇到可能更新字段的节点时,如方法调用节点(在即时编译器看来,方法调用会执行未知代码),或者内存屏障节点(其他线程可能异步更新了字段),那么它会采取保守的策略,舍弃所有缓存值。
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;
}
我们知道可以通过 volatile 关键字标记实例字段 a,以此强制对它的读取。实际上,即时编译器将在 volatile 字段访问前后插入内存屏障节点。这些内存屏障节点会阻止即时编译器将屏障之前所缓存的值用于屏障之后的读取节点之上。同理,加锁、解锁操作也同样会阻止即时编译器的字段读取优化。
2. 字段存储优化
除了字段读取优化外,即时编译器还将消除冗余的存储节点。如果一个字段先后被存储了两次,而且这两次存储之间没有对第一次存储内容的读取,那么即时编译器可以将第一个字段存储给消除掉。
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,那么即时编译器也不能将冗余的存储操作消除掉。
这种情况看似很蠢,但实际上并不少见,比如说两个存储之间隔着许多其他代码,或者因为方法内联的缘故,将两个存储操作(如构造器中字段的初始化以及随后的更新)纳入同一个编译单元里。
循环优化
1. 循环无关代码外提
循环无关代码(Loop-invariant Code)指的是循环中值不变的表达式。如果能够在不改变程序语义的前提下将这些循环无关代码提出循环之外,那程序便可以避免重复执行这些表达式,从而达到性能提升的效果。
int foo(int x, int y, int[] a) {
int sum = 0;
for (int i = 0; i < a.length; i++) {
sum += x * y + a[i];
}
return sum;
}
举个例子,在上面这段代码中,循环体中的表达式 x*y 以及循环判断条件中的 a.length 均属于循环不变代码。前者是一个整数乘法运算,而后者则是内存访问操作,读取数组对象a的长度。(数组的长度存放于数组对象的对象头中,可通过 arraylength 指令来访问。)
理想情况下,上面这段代码经过循环无关代码外提之后,等同于下面这一手工优化版本。
int fooManualOpt(int x, int y, int[] a) {
int sum = 0;
int t0 = x * y;
int t1 = a.length;
for (int i = 0; i < t1; i++) {
sum += t0 + a[i];
}
return sum;
}
2. 循环展开
循环展开(Loop Unrolling)指的是在循环体中重复多次循环迭代,并减少循环次数的编译优化。
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i++) {
sum += (i % 2 == 0) ? a[i] : -a[i];
}
return sum;
}
举个例子,上面的代码经过一次循环展开之后将形成下面的代码:
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i += 2) { // 注意这里的步数是2
sum += (i % 2 == 0) ? a[i] : -a[i];
sum += ((i + 1) % 2 == 0) ? a[i + 1] : -a[i + 1];
}
return sum;
}
在 C2 中,只有计数循环(Counted Loop)才能被展开。循环展开的缺点也是显而易见的:它可能会增加代码的冗余度,导致所生成机器码的长度大幅上涨。
不过,随着循环体的增大,优化机会也会不断增加。一旦循环展开能够触发进一步的优化,总体的代码复杂度也将降低。比如示例代码经过循环展开后便可以进一步优化为如下所示的代码:
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 64; i += 2) {
sum += a[i];
sum += -a[i + 1];
}
return sum;
}
循环展开有一种特殊情况,那便是 完全展开(Full Unroll)。当循环的数目是固定值且非常小时,即时编译器会将循环全部展开。此时,原本循环中的循环判断语句将不复存在,取而代之的是若干个顺序执行的循环体。
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < 3; i++) {
sum += a[i];
}
return sum;
}
举个例子,上述代码将被完全展开为下述代码:
int foo(int[] a) {
int sum = 0;
sum += a[0];
sum += a[1];
sum += a[2];
return sum;
}
3. 循环判断外提
循环判断外提(Loop Unswitching)指的是将循环中的 if 语句外提至循环之前,并且在该 if 语句的两个分支中分别放置一份循环代码。
int foo(int[] a) {
int sum = 0;
for (int i = 0; i < a.length; i++) {
if (a.length > 4) {
sum += a[i];
}
}
return sum;
}
举个例子,上面这段代码经过循环判断外提之后,将变成下面这段代码:
int foo(int[] a) {
int sum = 0;
if (a.length > 4) {
for (int i = 0; i < a.length; i++) {
sum += a[i];
}
} else {
for (int i = 0; i < a.length; i++) {
}
}
return sum;
}
// 进一步优化为:
int foo(int[] a) {
int sum = 0;
if (a.length > 4) {
for (int i = 0; i < a.length; i++) {
sum += a[i];
}
}
return sum;
}
公共子表达式消除
公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就称为公共子表达式。对于这种表达式不用花时间再对它重新计算,只需直接用前面计算过的表达式结果代替E。
示例代码:
int d = (c * b) * 12 + a + (a + b * c);
当这段代码进入虚拟机即时编译器后,它将进行如下优化:编译器检测到 cb 与 bc 是一样的表达式,而且在计算期间 b 与 c 的值是不变的,因此这条表达式就可能被视为:
int d = E * 12 + a + (a + E);
数组边界检查消除
我们知道 Java 语言是一门动态安全的语言,对数组的读写访问不像 C、C++ 那样是裸指针操作。在 Java 语言中访问数组元素时系统会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < array.length ,否则抛出 ArraylndexOutOfBoundsException 异常。这种检查对于虚拟机的执行子系统来说,每次数组元素的读写都要带一次隐含的条件判定操作,对拥有大量数组访问的程序代码必定是一种性能负担。
为了安全,数组边界检查肯定是要做的,但并非必须要在运行期间一次不漏地进行检查。比如 foo[3],只要在编译期根据数据流分析来确定 foo.length 的值,并判断下标 3 没有越界,执行时就无须判断了。
如果数组访问发生在循环之中且使用循环变量来进行数组访问。编译器只要通过数据流分析判定循环变量的取值范围永远在区间 [0,foo.length) 之内,那么在循环中就可以把整个数组的上下界检查消除掉。
开发者优化手段
1)调整热点代码门限值
前面介绍过 JIT 的默认门限,server 模式默认 10000 次,client 是 1500 次。门限大小也存在着调优的可能,可以使用 -XX:CompileThreshold=N 参数进行调整;同时该参数还可以变相起到降低预热时间的作用。
那既然是热点,不是早晚会达到门限次数吗?这个还真未必,因为 JVM 会周期性的对计数的数值进行衰减操作,导致调用计数器永远不能达到门限值,此外还可以通过 -XX:-UseCounterDecay 关闭计数器衰减。
2)调整 Code Cache 大小
我们知道 JIT 编译的机器码是存储在 Code Cache 中的,需要注意的是 Code Cache 是存在大小限制的,而且不会动态调整。如果 Code Cache 太小,可能只有一小部分代码可以被 JIT 编译,其他的代码则没有选择,只能解释执行。所以可以通过 -XX:ReservedCodeCacheSize 调整其大小限制。
3)调整编译器线程数或选择适当的编译器模式
JVM 的编译器线程数目与我们选择的模式有关,选择 client 模式默认只有一个编译线程,而 server 模式则默认是两个,如果是分层编译模式则会根据 CPU 核数计算 C1 和 C2 的数值,我们可以通过 -XX:CICompilerCount 参数来指定的编译线程数。
在强劲的多处理器环境中,增大编译线程数,能更加充分的利用 CPU 资源,让预热等过程更快;但是也可能导致编译线程争抢过多资源,尤其是当系统非常繁忙时。生产实践中,也有人推荐在服务器上关闭分层编译,直接使用 server 编译器,虽然会导致稍慢的预热速度,但在特定工作负载上会有微小的吞吐量提高。
4)其他一些相对边界比较混淆的所谓“优化”
比如,减少进入安全点。严格说,它远远不只是发生在动态编译的时候,GC 阶段发生的更加频繁,你可以利用下面选项诊断安全点的影响。
-XX:+PrintSafepointStatistics
‑XX:+PrintGCApplicationStoppedTime
注意,在 JDK 9 之后,PrintGCApplicationStoppedTime 已经被移除了,你需要使用 -Xlog:safepoint 之类方式来指定。很多优化阶段都可能和安全点相关,例如:在 JIT 过程中,逆优化等场景会需要插入安全点。
常规的锁优化阶段也可能发生,比如,偏斜锁的设计目的是为了避免无竞争时的同步开销,但是当真的发生竞争时,撤销偏斜锁会触发安全点,是很重的操作。所以,在并发场景中偏斜锁的价值其实是被质疑的,经常会明确建议关闭偏斜锁,通过 -XX:-UseBiasedLocking 参数。