《深入理解Java虚拟机》 编译 & 优化

———— JDK 1.9
words: 6.7k    views:    time: 24min
JIT


Java的编译过程可以分为两个阶段,首先是将.java文件编译为.class文件的过程,称为前端编译,比如javac;然后在运行期,可能即时编译器(JIT)又会将字节码转变为本地机器码,比如HotSopt的C1、C2编译器,或者Graal编译器。

对于优化处理,Java虚拟机团队选择将性能优化的措施全部集中在运行期的即时编译器中,以便让那些不是由 javac 产生的 Class 文件也能享受到性能优化的好处,至于 javac 中则做了一些针对Java语言编码过程的优化,用来降低开发者的编码复杂度、提高编码效率。可以这样认为,即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升,而javac在编译期的优化过程,则支撑了开发者的编码效率和语言使用者的体验。

1. javac 编译器

javac编译器不像HotSpot虚拟机那样使用C++语言(以及少量C语言)实现,它本身就是一个由Java语言编写的程序。但是这里并不打算去研究它的源码实现细节了,下面主要来看一下javac对泛型的处理

.之前对Java 泛型的使用已经做过相关笔记:https://shanhm1991.github.io/2017/06/03/20170603/ ,只是这里的角度不一样,感觉这里更像是站在一个Java语言设计者的角度去体会Java泛型在实现时的取舍。

1.1. 泛型

泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法,这样进一步增强了编程语言的类型系统和抽象能力。

Java泛型的实现方式叫作类型擦除式泛型,而C#选择的方式是具现化式泛型,这些术语都源于C++模板语法中的概念。

在C#中,泛型无论在程序源码中,还是编译后的中间语言,或者运行期的CLR中都是切实存在的,比如List<int>List<string>就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。

而在Java语言中,泛型只存在于程序源码中,在编译后的字节码文件中,全部泛型都被替换位原来的裸类型,并且在相应的地方插入强制转型代码,因此对于运行期的Java语言来说,List<int>List<string>其实是同一个类型。

所以Java泛型更像是一种语法糖,其无论是在使用效果上还是运行效率上,几乎全面落后于C#泛型的方式。唯一的优势则在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在javac编译器上作出改进即可,不需要改的字节码或者Java虚拟机,这样也保证了即使以前没有使用泛型的库也可以直接运行在Java 5.0之上。

也就是说,Java在实现泛型时希望能保证以前直接用ArrayList的代码,在泛型新版本中还能继续使用同一个容器,这就必须让所有泛型化的实例类型,比如ArrayList<T>,能自动成为ArrayList的子类型才可以,否则类型转换就是不安全的。由此引出了裸类型的概念,比如Java泛型希望能支持下面这样的操作

1
2
3
4
5
6
List<String> strList = new ArrayList<>();strList.add("a");
List<Integer> intList = new ArrayList<>(); intList.add(1);

List list;
list = strList;
list = intList;

对于裸类型的实现可以有两种选择,一种是在运行期由Java虚拟机来自动地、真实地构造出ArrayList<Integer>这样的类型,并且自动实现从ArrayList<Integer>派生自ArrayList的继承关系,来满足裸类型的定义;

另一种则是索性简单粗暴地直接在编译时把ArrayList<Integer>还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令,这样看起来也能满足需求,而Java就是这样实现的。

比如下面的代码

1
2
3
4
5
6
7
8
public void test(){
Map<String, String> map = new HashMap<>();
map.put("name", "shanhm");
map.put("email", "163.com");

String name = map.get("name");
String email = map.get("email");
}

在编译之后实际上是这样的

1
2
3
4
5
6
7
8
public void test2(){
Map map = new HashMap();
map.put("name", "shanhm");
map.put("email", "163.com");

String name = (String)map.get("name");
String email = (String)map.get("email");
}

但是,由于使用的类型擦除方式,导致运行期无法获取到泛型类型信息,这样在Java泛型在的际使用中将受到很多限制,比如只要与类型相关的操作基本都无法支持,它更多的作用只是提供一种编译期的类型安全检查

ParameterizedType.java
1
2
3
4
5
6
7
8
9
10
public class ParameterizedType<E> {    
public void doSomething(Object item) {
if(item instanceof E) { // 非法,无法对泛型类型进行实例判断
// ...
}

E newItem = new E(); // 非法,无法使用泛型类型创建对象
E[] itemArray = new E[10]; // 非法,无法使用泛型类型创建数组
}
}

这样如果要写一个从List到数组转换的泛型版本方法,由于不能从List取得参数化类型T,就不得不再额外传入一个数组的元素类型作为参数,比如下面这样

1
2
3
4
5
public <T> T[] list2Array(List<T> list, Class<T> componentType){
T[] array = (T[])Array.newInstance(componentType, list.size());
// ... ...
return array;
}

此外,使用擦除法实现泛型还有一个问题就是无法支持基础类型,因为不支持int、long等与Object之间的强制转型。这里Java给出的解决方案依然是简单粗暴:既然没法转换那索性就别支持原生类型了,大家都用ArrayList<Integer>ArrayList<Long>吧,反正已经做了自动类型转换,以及自动装箱拆箱操作。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,也成为了Java泛型慢的一个重要原因,也是现在Valhalla项目要重点解决的问题之一。

2. 即时编译器

目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)中,Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块运行得特别频繁,就会将这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时。虚拟机会把这些代码编译成本地机器码,并以各种手段尽可能地优化,运行时完成这个任务的编译器就称为即时编译器。

解释器与编译器各有优势,当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译时间,立即运行。当程序启动后,随着时间推移,编译器逐渐发挥作用,将越来越多的代码编译成本地代码,这样可以减少解释器的中间消耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释器节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时后备的逃生门。

通常,java虚拟机都会采用将解释器与编译器搭配使用混合模式

java -version
1
2
3
java version "1.8.0_11"
Java(TM) SE Runtime Environment (build 1.8.0_11-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.11-b03, mixed mode)

使用-Xint可以强制虚拟机运行于解释模式,此时编译器完全不介入工作,完全使用解释方式执行

java -Xint -version
1
2
3
java version "1.8.0_11"
Java(TM) SE Runtime Environment (build 1.8.0_11-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.11-b03, interpreted mode)

使用-Xcomp可以强制虚拟机运行于编译模式,此时优先采用编译方式执行程序,但解释器仍然会在编译器无法进行的情况下介入执行过程

java -Xcomp -version
1
2
3
java version "1.8.0_11"
Java(TM) SE Runtime Environment (build 1.8.0_11-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.11-b03, compiled mode)

2.1. CodeCache

Java虚拟机在运行时会生成本机代码,并将其存储在称为代码缓存(CodeCache)的内存区域中。JVM生成本机代码的原因有多种,包括动态生成的解释器循环、Java本机接口(JNI)存根,以及由即时编译器(JIT)编译为本机代码的Java方法。目前为止,JIT是 CodeCache 的最大用户,下面整理一些使用 CodeCache 的控制参数,具体内容参考自官网

Codecache大小控制选项 默认值 描述
InitialCodeCacheSize 160K 默认的CodeCache区域大小(单位:字节)
ReservedCodeCacheSize 32M/48M CodeCache区域的最大值(单位:字节)
CodeCacheExpansionSize 32K/64K CodeCache每次扩展大小(单位:字节)
Codecache刷新选项
ExitOnFullCodeCache false 当CodeCache区域满了的时候是否退出JVM
UseCodeCacheFlushing false 是否在关闭JIT编译前清除CodeCache
MinCodeCacheFlushingInterval 30 刷新CodeCache的最小时间间隔(单位:秒)
CodeCacheMinimumFreeSpace 500K 当剩余空间小于指定值时停止JIT编译,剩余的空间不会再用来存放方法的本地代码, 可以存放本地方法适配器
编译策略选项
CompileThreshold 10000 方法在(重新)进行JIT编译之前被调用的次数
OnStackReplacePercentage 140至933 该值为用于计算是否触发OSR(OnStackReplace)编译的阈值
编译限制选项
MaxInlineLevel 9 在进行方法内联前,方法的最多嵌套调用数
MaxInlineSize 35 被内联方法的字节码最大值
MinInliningThreshold 250 被内联方法的最小调用次数
InlineSynchronizedMethods true 是否对同步方法进行内联
JIT诊断选项
PrintFlagsFinal false 是否打印所有的JVM参数
PrintCodeCache false 是否在JVM退出前打印CodeCache的使用情况
PrintCodeCacheOnCompilation false 是否在每个方法被JIT编译后打印CodeCache区域的使用情况

如果添加参数–XX:+PrintCodeCache,那么在VM退出时,将看到类似于下面的输出:

1
2
3
4
CodeCache: size=245760Kb used=1585Kb max_used=1597Kb free=244174Kb
bounds [0x00007f2b25000000, 0x00007f2b25270000, 0x00007f2b34000000]
total_blobs=490 nmethods=229 adapters=177
compilation: enabled

主要信息在第一行中:

  • size: CodeCache的最大值,由参数–XX:ReservedCodeCacheSize指定,注意这里并不是CodeCache使用的实际物理内存 RAM 量,而是为其预留的虚拟地址空间量

  • used:CodeCache实际使用量,通常是指占用的 RAM 量,但由于碎片化以及CodeCache中空闲和已分配内存块的混合,实际占用的 RAM 可能偏大,因为已使用然后释放的块可能仍在 RAM 中。

  • max_used:CodeCache使用的高水位线,通常被认为是CodeCache占用的 RAM 量。因此,在确定应用程序使用了多少CodeCache时,一般使用这个值。

  • free: CodeCache空闲量

2.2. 编译器优化技术

编译器的任务虽然是将字节码编译为本地机器码,但其实难点并不在于能不能成功翻译出机器码,能输出高质量的优化代码才是决定编译器优秀与否的关键。下面介绍几种HotSpot虚拟机中的即时编译器在生成代码时常用的优化手段,希望能以小见大,见微知著。

2.2.1. 方法内联

方法内联是编译器最重要的优化手段,因为除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。如果没有内联,多数其他优化手段可能都无法有效进行,比如下面示例中,如果不做内联,就无法发现testInline()中的代码全部是 Dead Code,也就无法进行代码消除

1
2
3
4
5
6
7
8
9
10
public static void testInline() {
Object obj = null;
foo(obj);
}

public static void foo(Object obj) {
if(obj != null){
// do sometyhing
}
}

方法内联理解起来并不困难,无非就是将目标方法的代码合并到发起调用的方法之中,避免发生真实地方法调用而已。但在Java中,由于提倡使用面向对象的方式进行编程,而Java对象的方法默认是虚方法。因此,在实际实现时会发现大部分的方法都是虚方法,而虚方法的调用必须要在运行时进行方法接收者的多态选择,它们有可能存在多于一个版本的方法接收者。

于是,在内联与虚方法之间就存在一个矛盾,在C/C++中会默认给每个方法使用 final 进行修饰,如果需要用到多态,再用 virtual 关键字来修饰,但Java选择了在虚拟机中解决这个问题。

Java虚拟机引入了一种名为类型继承关系分析的技术(CHA),这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。这样,编译器在进行内联时就可以根据不同的情况采取不同的处理:如果是非虚方法,那么直接进行内联就可以了;如果是虚方法,则会向 CHA 查询此方法在当前程序状态下是否有多个目标版本可选择,如果只有一个版本,那么就可以直接内联,这种内联也称为守护内联

但由于Java是动态连接的,说不准什么时候会加载到新的类型从而改变 CHA 的结论,因此这种内联属于激进预测性优化,必须预留好“逃生门”,即当作假设条件不成立时的“退路”。如果程序在后续执行过程中,一直没有加载到令这个方法的接收者的继承关系发生变化的类,那么这个内联优化的代码就可以一直使用下去。而如果加载了导致继承关系发生变化的新类,那么就必须抛弃已经编译的代码,退回到解释状态进行执行,或者重新进行编译。

如果向 CHA 查询出来的方法确实有多个版本,那么即时编译器还会进行最后一次努力,即使用内联缓存的方式来缩减方法调用的开销。但这种情况下方法调用是实际发生了的,只是比起直接查虚方法表要快一些。虽然内联缓存带有内联二字,但是它并没有内联目标方法。需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

内联缓存是一种加快动态绑定的优化技术,它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,则退化至使用基于方法表的动态绑定。

当内联缓存没有命中的情况下,JVM需要重新使用方法表进行动态绑定。这时对于内联缓存中的内容,有两种选择:

一种是替换单态内联缓存中的记录,这种做法就好比CPU中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。但考虑最坏情况,如果两种不同类型的调用者,轮流进行调用,那么每次方法调用都要替换内联缓存,也就是说缓存没有起到效果反倒成为一种累赘。

另一种选择是劣化为超多态状态,处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法,与替换内联缓存记录的做法相比,它牺牲了优化的机会,但是节省了更新缓存的额外开销。

JVM对于方法内联也提供了对应的控制参数:-XX:CompileCommand

  • 格式:-XX:CompileCommand=command,method[,option]

  • 含义:该参数用于定制编译需求,比如过滤某个方法不做JIT编译,若未指定方法描述符,则对全部同名方法执行命令操作,也可以使用通配符(*)指定类或方法。该参数也可以多次指定,或使用 换行符(\n)分隔参数后的多个命令。

  • 命令:
    exclude: 跳过编译指定的方法
    compileonly: 只编译指定的方法
    inline/dontinline: 设置是否内联指定方法
    print: 打印生成的汇编代码
    break: JVM以debug模式运行时,在方法编译开始处设置断点
    quiet: 不打印在此命令之后、通过-XX:CompileCommand指定的编译选项
    log: 记录指定方法的编译日志,若未指定,则记录所有方法的编译日志
    其他命令: option,help

  • 示例:

设置编译器跳过编译com.jvmpocket.Dummy类test方法的4种写法:

1
2
3
4
-XX:CompileCommand=exclude,com/jvmpocket/Dummy.test
-XX:CompileCommand=exclude,com/jvmpocket/Dummy::test
-XX:CompileCommand=exclude,com.jvmpocket.Dummy::test
-XX:CompileCommand="exclude com/jvmpocket/Dummy test"

设置编译器只跳过编译java.lang.String类int indexOf(String)方法

1
-XX:CompileCommand="exclude,java/lang/String.indexOf,(Ljava/lang/String;)I"

设置编译器跳过编译所有类的indexOf方法

1
-XX:CompileCommand=exclude,*.indexOf
2.2.2. 逃逸分析

逃逸分析并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术,与类型继承关系分析类似。其基本思路是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,比如作为调用参数传递到其他方法中,这种就称为方法逃逸。甚至还可能被外部线程访问,比如赋值给某个其他线程能够访问的实例变量,这种就称为线程逃逸

如果能证明一个对象不会逃逸到方法或线程之外,或者逃逸程度较低,则可以为这个对象采取不同程度的优化:

  • 栈上分配:如果确定一个对象不会逃逸出线程之外,那么就可以在栈上为这个对象进行内存分配,这样对象所占用的内存就可以随栈帧的出栈而销毁。在一般应用中,不会线程逃逸的对象所占的比例还是很大的,如果能使用栈上分配,那么就省去了大量的对象回收操作,从而降低垃圾收集器的压力。考虑到复杂度等原因,HotSpot中暂时还没有做这项优化,但在其他一些虚拟机,比如Excelsior JET中则使用了这项优化。

  • 标量替换:如果一个数据无法再分解成更小的数据来表示,比如Java中的基本数据类型,那么就称这些数据为标量,反之,则称为聚合量。如果将一个Java对象拆散,根据程序访问情况,将其用到的成员变量恢复为原始类型来访问,那么这个过程就称为标量替换

如果根据逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行时就可以不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来替代。对象拆分后,除了可以让对象的成员变量在栈上分配和读写外(栈上存储的数据,大概率会被虚拟机分配至物理机的高速寄存器中缓存),还可以为后续进一步的优化手段创咋条件。

表里替换可以视为栈上分配的一种特例,实现更简单,因为不用考虑整个对象完整结构的分配,但对逃逸程度的要求更高,它不允许对象逃逸出方法之外,更不用提逃逸出线程之外。

  • 同步消除:线程同步本身是一个相对耗时的操作,如果逃逸分析能确定一个变量不会逃逸出线程,即无法被其它线程访问,那么就可以消除掉对这个变量的同步措施。

关于逃逸分析的研究论文早在1999年就已发表,但直到JDK 6,HotSpot才开始支持初步的逃逸分析,而且直到现在这项优化技术也尚未足够成熟,仍有很大的改进余地。主要原因是逃逸分析的计算成本非常高,甚至不能保证逃逸分析带来的收益会高于它的消耗。比如在极端情况下,逃逸分析完毕后发现几乎找不到几个不逃逸的对象,那么运行期耗用的时间就白白浪费了,所以目前虚拟机只能采用不那么准确,但时间压力相对较小的算法来完成分析。

C/C++语言中原生就支持栈上分配(不使用 new 操作符即可),C#也支持值类型,可以很自然地做到标量替换(但并不会对引用类型做这种优化)。在灵活运用栈内存方面,确实是Java的一个弱项,目前仍处于实验阶段的 Valhalla 项目中,设计了新的 inline 关键字来定义Java的内联类型,目的是实现与C#中值类型相对标的功能,有了这个标识与约束,以后逃逸分析做起来就能相对简单一些。

2.2.3. 公共子表达式消除

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

1
int d = (c * b) * 12 + a + (a + b * c);
2.2.4. 检查消除

Java语言是一门动态安全的语言,对数组的访问无法像C/C++那样通过裸指针进行操作。如果一个数组foo[],在Java中访问其元素foo[i]时系统会自动进行边界检查,即i必须满足i >= 0 && i < foo.length,否则将抛出越界异常。这对开发者来说是一件很友好的事情,但对虚拟机执行子系统来说,每次数组元素的访问都带有一次隐含的条件判断操作,这对于用于大量数组访问的程序来说,必然是一种性能负担。

一种优化的思路是尽可能把这种运行期检查操作提前到编译期来完成,数组边界检查并不一定要在运行期一次不漏的进行,如果能在编译期根据数据流分析确定数组的 length 值,并判断访问时的下标没有越界,那么运行时就无须检查了。通常,数组访问会发生在循环之中,并且使用循环变量来进行元素访问,如果编译期通过数据流分析后能判断循环变量的取值永远在 [0, length] 之间,那么在运行时,就可以取消整个循环期间的边界检查。

除了数组越界异常,常见的还有空指针异常和除零异常,至于处理方式则是隐式异常处理。比如下面以伪代码的形式来表示虚拟机访问程序中的某个对象属性的过程

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

在使用隐式异常优化之后,访问过程会变成如下所示

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

虚拟机会注册一个Segment Fault信号的异常处理器,注意这里指的是进程层面的异常处理器,而并非Java中的 try-catch 异常处理。这样当 foo 不为空时,对 value 的访问不会有任何额外的判空开销,但是当 foo 真的为空时,则必须转到异常处理器中恢复中断并抛出 NullPointException 异常。进入异常处理器会涉及到进程从用户态转入内核态的过程,结束后再回到用户态,速度远比一次判空检查慢得多。

因此,当 foo 极少为空时,隐式异常优化是值得的,但假如 foo 经常为空,那这样的优化反而会让程序更慢。所以在HotSpot中,它会根据运行期收集到的性能监控信息来自动选择最合适的方案。


参考:

  1. Copyright ©《深入理解java虚拟机》
  2. https://docs.oracle.com/javase/8/embedded/develop-apps-platforms/codecache.htm
  3. http://thinkhejie.github.io/2016/05/05/JVM%E7%B3%BB%E5%88%97_06/
  4. https://www.cnblogs.com/dwtfukgv/p/14875571.html
  5. https://blog.csdn.net/ning0323/article/details/75451955
  6. https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html