Java晚期(运行期)优化(二)

本文承接上文——Java晚期(运行期)优化(一),主要讲解了Java代码的编译过程和运行期间的一些常用的编译优化技术,如公共子表达式的消除,数组边界检查的消除,方法内联等,本文参考资料见文末

编译过程

允许后台编译:

在默认设置下,无论是方法调用产生的即时编译请求,还是 OSR 编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。

不允许后台编译:

用户可以通过参数 -XX: -BackgroundCompilation 来禁止后台编译,但是仍然将按照解释方式继续执行,在禁止后台编译后,一旦达到 JIT 的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,知道编译过程完成后再开始执行编译器输出的本地代码。

Server Compiler 和 Client Compiler 两个编译器的编译过程是不一样的。

Client Compiler :

它是一个简单快速的一段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。

第一个阶段

一个平台独立的前端将字节码构造成一种高级中间代码(HIR)表示。

HIR 使用静态单分配(SSA)的形式来代表代码值,这可以使得一些在 HIR 的构造过程之中和之后进行的优化动作更容易实现。

在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成 HIR 之前完成。

第二个阶段

一个平台相关的后端从 HIR 中产生低级中间代码表示(LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等,以便让 HIR 达到更高效的代码表示形式。

最后阶段

在平台相关的后端使用线性扫描算法在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器代码。

1557298340071

Server Compiler

是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器。

​ 它会执行所有经典的优化动作,如无用代码消除、循环展开(Loop Unrolling)、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等。

​ 还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除、空值检查消除等。

​ 另外,还可能根据解释器或 Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等。

编译优化技术

以编译方法执行本地代码比解释方式更快,即时编译器产生的本地代码会比javac产生的字节码更加优秀。原因如下:

  1. 减少了虚拟机解释执行字节码时额外消耗时间
  2. 虚拟机团队几乎把对所有代码的优化都集中在了即时编译器上

举一个普通的例子

下面是优化前原始代码:

1
2
3
4
5
6
7
8
9
10
11
12
static class {
int value;
final int get() {
return value;
}
}
public void foo() {
y = b.get();

z = b.get();
sum = y + z;
}

​ 这些代码优化变换是建立在代码的某种中间表示或机器码之上的,绝不是建立在Java 源码之上的,为了展示方便,笔者使用了 Java 语言的语法来表示这些优化技术所发挥的作用。代码已经非常简单了,但是仍然有许多优化的余地。

第一步进行方法内联

方法内联的重要性要高于其他优化措施,它的主要目的有两个:

  • 一是去除方法调用的成本(如建立栈帧等)。
  • 二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。(因此,各种编译器一般都会把内联优化放在优化序列的最靠前位置)
1
2
3
4
5
6
public void foo() {
y = b.value;

z = b.value;
sum = y + z;
}
第二步进行冗余访问消除

假设代码中间注释掉的 “do stuff…” 所代表的操作不会改变 b.value 的值,那就可以把 “z = b.value” 替换为 “z = y”。

因为上一句 “y = b.value” 已经保证了变量 y 与 b.value 是一致的,这样就可以不再去访问对象 b 的局部变量了。

如果把 b.value 看做是一个表达式,那也可以把这项优化看成是公共子表达式消除(Common Subexpression Elimination)

1
2
3
4
5
6
public void foo() {
y = b.value;

z = y;
sum = y + z;
}
第三步我们进行复写传播

因为在这段程序的逻辑中并没有必要使用一个额外的变量 “z”,它与变量 “y” 是完全相等的,因此可以使用 “y” 来代替 “z”。

1
2
3
4
5
6
public void foo() {
y = b.value;

y = y;
sum = y + y;
}
第四步我们进行无用代码消除

无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码,因此,它又形象地称为 “Dead Code”,“y = y” 是没有意义的,把它消除。

1
2
3
4
5
public void foo() {
y = b.value;

sum = y + y;
}

经过四次优化之后,原始代码与最后版本的代码所达到的效果是一致的,但是前者比后者省略了许多语句(体现在字节码和机器码指令上的差距会更大),执行效率也会更高。

公共子表达式消除——与语言无关的经典优化技术

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。

对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替 E 就可以了。

如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination)

如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)

有如下代码:

1
int d = (c * b) * 12 + a + (a + b * c);

如果这段代码交给 Javac 编译器则不会进行任何优化,是完全遵照 Java 源码的写法直译而成的。编译之后的字节码如下(无优化)

1
2
3
4
5
6
7
8
9
10
11
12
13
iload_2     // b
imul // 计算b * c
大专栏  Java晚期(运行期)优化(二)>bipush 12 // 推入12
imul // 计算(c * b)*12
iload_1 // a
iadd // 计算(c * b)*12+a
iload_1 // a
iload_2 // b
iload_3 // c
imul // 计算b * c
iadd // 计算a+b * c
iadd // 计算(c * b)*12+a+(a+b * c)
istore 4

当这段代码进入到虚拟机即时编译器后,它将进行如下优化:编译器检测到 “cb” 与 “bc” 是一样的表达式,而且在计算期间 b 与 c 的值是不变的。因此,这条表达式就可能被视为:

1
int d = E * 12 + a + (a + E);

这时,编译器还可能(取决于那种虚拟机的编译器以及具体的上下文而定)进行另外一种优化:代数化简(Algebraic Simplification),把表达式变为

1
int d = E * 13 + a * 2;

数组边界检查消除——语言相关的经典优化技术

​ 数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。我们知道 Java 语言是一门动态安全的语言,对数组的读写访问也不像 C、C++ 那样在本质上是裸指针操作。

​ 如果有一个数组 foo[],在 Java 语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < foo.length 这个条件,否则将会抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException。

​ 这对软件开发者来说是一件很好的事情,即使程序员没有专门编写防御代码,也可以避免大部分的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。


​ 无论如何,为了安全,数组边界检查肯定是必须做的,但数组边界是不是必须在运行期间一次不漏地检查则是可以 “商量” 的事情。(也就是说可以有捡漏的机会)

​ 例如下面这个简单的情况:数组下标是一个常量,如 foo[3],只要在编译期根据数组流分析来确定 foo.length 的值,并判断下标 “3” 没有越界,执行的时候就无须判断了。

​ 更加常见的情况是数组访问发生在循环之中,并且使用循环遍历来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0, foo.length)之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作


在更高的角度来看,大量的安全检查令编写 Java 程序比编写 C/C++ 程序容易很多。

​ 如数组越界会得到 ArrayIndexOutOfBoundsException 异常,空指针访问会得到 NullPointException,除数为零会得到 ArithmeticException 等。

​ 在 C/C++ 程序中出现类似的问题,一不小心就会出现 Segment Fault 信号或者 Windows 编程中常见的 “xxx 内存不能为 Read/Wrie” 之类的提示,处理不好程序就和直接崩溃退出了。

但这些安全检查也导致了相同的程序,Java 要比 C/C++ 做更多的事情(各种检查判断),这些事情就成为一种隐式开销,除了如数组边界检查优化这种尽可能把运行期检查提到编译期完成的思路之外,另外还有一种避免思路——隐式异常处理


​ 举个例子,例如程序中访问一个对象(假设对象叫 foo)的某个属性(假设属性叫 value),那以 Java 伪代码来表示虚拟机访问 foo.value 的过程如下。

1
2
3
4
5
if (foo != null) {
return foo.value;
else {
throw new NullPointException();
}

​ 在使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码。

1
2
3
4
5
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}

​ 虚拟机会注册一个 Segment Fault 信号的异常处理器(伪代码中的 uncommon_trap()),这样当 foo 不为空的时候,对 value 的访问是不会额外消耗一次对 foo 判空的开销的。

代价就是当 foo 真的为空时,必须转入到异常处理器中恢复并抛出 NullPointException 异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。

​ 当 foo 极少为空的时候,隐式异常优化是值得的,但假如 foo 经常为空的话,这样的优化反而会让程序更慢,还好 HotSpot 虚拟机足够 “聪明”,它会根据运行期收集到的 Profile 信息自动选择最优方案。

方法内联——最重要的优化技术

​ 除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础

​ 例子就揭示了内联对其他优化手段的意义

1
2
3
4
5
6
7
8
9
public static void foo (Object obj) {
if (obj != null) {
System.out.println("do something");
}
}
public static void testInline(String[]args) {
Object obj = null;
foo (obj);
}

​ 事实上 testInline() 方法的内部全部都是无用的代码,如果不做内联,后续即使进行了无用代码消除的优化,也无法发现任何 “Dead Code”,因为如果分开来看,foo() 和 testInline() 两个方法里面的操作都可能是有意义的。

方法内联的优化行为看起来很简单,不过是把目标方法的代码 “复制” 到发起调用的方法之中,避免发生真实的方法调用而已。但实际上 Java 虚拟机中的内联过程远远没有那么简单,因为如果不是即时编译器做了一些特别的努力,按照经典编译原理的优化理论,大多数的 Java 方法都无法进行内联。

​ 无法内联的原因,请参考Java方法解析和分派调用。

逃逸分析——最前沿的优化技术

​ 它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。

​ 逃逸分析的基本行为就是分析对象动态作用域

​ 当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸

​ 甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下所示。

栈上分配(Stack Allocation)

​ Java 虚拟机中,在 Java 堆上分配创建对象的内存空间几乎是 Java 程序员都清楚的常识了。Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。

虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间

​ 如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。

​ 在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。


同步消除(Synchronized Elimination)

​ 线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。


标量替换(Scalar Replacement)

​ 标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量。

​ 相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java 中的对象就是最典型的聚合量。

​ 如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的告诉寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。

猜你喜欢

转载自www.cnblogs.com/liuzhongrong/p/12371431.html
今日推荐