0%

第11章: 晚期(运行期)优化

概述

Java 程序最初是通过解释器(Interpreter)进行解释执行的,虚拟机发现某个方法或代码块运行特别频繁时,会将它们认定为“热点代码”(Hot Spot Code)。为了提高运行效率,虚拟机会把这些代码编译成本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler, JIT编译器)。

JIT 不是虚拟机必需部分,Java虚拟机规范也没有规定实现,但它却是衡量一款商用虚拟机优秀与否的关键指标,也是最能体现虚拟机技术水平的部分。本章所讲的内容都是基于HotSpot虚拟机。

HotSpot 虚拟机的JIT

首先看几个问题:

  • 为何要使用解释器与编译器并存架构
  • 为何要实现两个不同的JIT
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?如何编译为本地代码?
  • 如何从外部观察JIT的编译过程和编译结果?

解释器与编译器

第一个问题,解释器与编译器两者各有优势:当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行;程序运行后,随着时间推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大时,也可以使用解释执行节约内存。同时,解释器可以作为编译器激进优化时的一个“逃生门”。因此,在整个虚拟机架构中,解释器与编译器经常配合工作。

第二个问题,两个不同的JIT一般称为 Client Compiler(也称为 C1 编译) 和 Server Compiler(也称为 C2 编译) 。虚拟机一般会启用分层编译策略,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次:

  1. 第0层:解释执行,解释器不开启性能监控(Profiling),可触发第1层编译。
  2. 第1层,也称C1编译,将字节码编译成本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
  3. 第2层,也称C2编译,将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,C1和C2将会同时工作,许多代码会被多次编译,用C1获取更高的编译速度,用C2获取更好地编译质量,并且解释执行时也无需承担收集性能监控信息的任务。

编译对象与触发条件

第三个问题,上文提到,运行过程中会被即时编译器编译的“热点代码”有两类,即:

  • 被多次调用的方法。
  • 被多次执行的循环体。

解释一下第二点,这是为了解决一个方法只被调用过一次或少量几次,但是方法体内部存在循环次数较多的循环体问题。这样,循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代码”。

编译过程

默认设置下,无论是方法调用产生的即时编译请求还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍将按照解释方式执行。

编译优化技术

以如下代码清单来说明编译优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static class B {
int value;
final int get() {
return value;
}
}


public void foo() {
y = b.get();

//do something

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

首先说明的是,这些代码优化变换是建立在代码的某种中间表示或机器码之上,绝不是建立在Java源码之上,这里是为了展示方便,使用了Java语言的语法来表示这些优化技术所发挥的作用。

以上的代码已经非常简单了,但仍有许多优化余地,第一步就是方法内联(Method Inlining),内联后的foo函数代码如下:

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

//do something

z = b.value;
sum = y + z;
}

方法内联的重要性高于其他优化措施,它主要有两个目的:去除方法调用的成本(如建立栈帧等) 以及为其他优化方法建立良好的基础(方法内联膨胀后可以便于在更大范围上采取后续优化手段)。

第二步进行 冗余访问消除(Redundant Loads Elimination),假设上述代码中 do something 刽改变 b.value的值,那就可以将 z = b.value 替换为 z = y ,因为上一句 y = b.value 已经保证 y 与b.value 是一致的,这样就可以不用再去访问对象b的局部变量了。优化后的代码:

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

//do something

z = y;
sum = y + z;
}

第三步我们进行复写传播(Copy Propagation),因为在这段程序的逻辑中没有必要使用 z 这个变量,它与 y 是完全相等的,因此可以用 y 来替代 z ,复写传播后的代码如下:

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

//do something

y = y;
sum = y + y;
}

第四步我们进行无用代码消除(Dead Code Elimination)。在上述代码清单中, y = y 是没有意义的,把它擦除后的代码如下:

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

//do something

sum = y + y;
}

经过4次优化后,达到的效果一致,但是比原始代码省略了许多语句,执行效率也更高。接下来继续看几项有代表性的优化技术。

公共子表达式消除

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

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

这段代码交给JIT编译后,它将进行如下优化:因为 c * b 与 b * c 是一样的表达式,并且在计算期间 b 与 c 的值是不可变的,因此表达式可能会被视为:

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

这时候,编译器还可能进行 代数化简(Algebraic Simplification),把表达式变为:

int d = E * 13 + a * 2;

数组边界检查消除

如果有一个数组 foo[] ,在Java语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,即i必须满足 i>= 0 && i < foo.length ,否则抛出异常。 为了安全,数组边界检查是必须的,但是运行期间一次不漏地检查则优点浪费,是可以“商量”的。假如程序中访问一个对象 foo 的某个属性 value,那以Java伪代码表示虚拟机访问的过程如下:

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 极少为空的时候,隐式异常优化是值得的。

方法内联

方法内联是编译器最重要的优化手段之一,除了消除方法调用的成本之外,它更重要的意义在于为其他优化手段建立良好的基础,如下代码就解释了内联对其他优化手段的意义:

1
2
3
4
5
6
7
8
9
10
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 虚拟机中的内联过程远没有那么简单,如果不是即时编译器做了一些努力,按照经典编译原理的优化理论,大多数的方法都无法进行内联。

逃逸分析

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸;甚至还可能被外部线程访问到,譬如赋值给类变量或者可以在其他线程中访问的实例变量,称为线程逃逸。

如果能证明一个方法不会逃逸到方法或线程外,则可能为这个变量进行一些高效优化:

  • 栈上分配(Stack Allocation):Java 虚拟机中,在Java 堆上分配创建对象的内存空间几乎是Java程序员都清楚的常识了,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作(无论是筛选可回收对象还是回收和整理内存)都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁,减小垃圾收集系统的压力。

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

  • 标量替换(Scalar Replacement):标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及 refrence 类型等)都不能再进一步分解,它们就可以称为标量。相对地,如果一个数据可以继续分解,那它就称作聚合量,Java中的对象就是最典型的聚合量。如果把一个Java 对象拆散,根据程序访问情况将其使用到的成员变量恢复原始类型来访问就叫做标量替换。

    如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为后续进一步优化手段创建条件。

Java 与 C/C++的编译器对比

有兴趣的时候再来补上,略。

谢谢你的鼓励