Java虚拟机——程序编译与代码优化

1,866 阅读15分钟

从计算机程序出现的第一天起,对效率的追逐就是程序员天生的坚定信仰,这个过程犹如一场没 有终点、永不停歇的F1方程式竞赛,程序员是车手,技术平台则是在赛道上飞驰的赛车。

前端编译与优化

这里指的前端编译是把.java文件转变成.class文件的过程,比如JDK中的Javac编译器

Javac编译器

本身是一个由Java语言编写的程序,代码存放在tools.jar中,从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程

准备过程:初始化插入式注解处理器。

处理过程:

  1. 解析与填充符号表过程。
  2. 插入式注解处理器的注解处理过程
  3. 分析与字节码生成过程

上述3个处理过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转 回到之前的解析、填充符号表的过程中重新处理这些新符号

Javac编译动作的入口是 com.sun.tools.javac.main.JavaCompiler类

1.解析与填充符号表

解析过程包括了经典程序编译原理中的词法分析和语法分析两个步骤

1.词法、语法分析

词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元 素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但 是它只是一个独立的标记,不可以再拆分。在Javac的源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner类来实现。

语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一 种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个 语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都 建立在抽象语法树之上。

大家在IDEA上面搜索**JDT AstView**插件,右键Enable JDT AST View 可以查看当前代码的抽象语法树结构,如下图

2.填充符号表

完成了语法分析和词法分析之后,下一个阶段是对符号表进行填充的过程。

符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,读者可以把它类比想象成哈希表中键值对的存储形式(实际上符号表不一定是哈希表实现,可以是有序符号表、树状符号表、栈结构符号表等各种形式)。符号表中所登记的信息在编译的不同阶段都要被用到。譬如在语义分析的过程中,符号表所登记的内容将用于语义检查 (如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

2.插入式注解处理器的注解处理过程

插入式注解处理器可以看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法 树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有 再对语法树进行修改为止,每一次循环过程称为一个轮次(Round)。

例如Java著名的编码效率工具Lombok,它可以通过注解来实现自动产生 getter/setter方法、进行空置检查、生成受查异常表、产生equals()和hashCode()方法就是依赖插入式注解处理器来实现的。

3.语义分析与字节码生成

经过语法分析之后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正 确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源 程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等。

int a = 1;
boolean b = false;
char c = 2
后续可能出现的赋值运算:
int d = a + c;
int d = b + c;
char d = a + c;

上面的编码IDEA 中看到由红线标注的错误提示,其中大部分都是来源于语义分析阶段的检查结果。

Javac在编译过程中,语义分析过程可分为标注检查和数据及控制流分析两个步骤

1.标注检查

标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否 能够匹配,等等,刚才3个变量定义的例子就属于标注检查的处理范畴。在标注检查中,还会顺便进行一个称为常量折叠(Constant Folding)的代码优化,这是Javac编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)

2.数据及控制流分析

数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问 题。编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上可以看作是一致的, 但校验范围会有所区别,有一些校验项只有在编译期或运行期才能进行。

3.字节码生成

字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令 写到磁盘中,编译器还进行了少量的代码添加和转换工作。

完成了对语法树的遍历和调整之后,输出字节码,生成最终的Class 文件,到此,整个编译过程宣告结束。

Java语法糖

通常来说使用语法糖能够减少代码量、增加程序的可读性,从而减少程序代码出错的机会。

Java中最常见的语法糖包括了泛型、变长参数、自动装箱拆箱,等等,Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。

1.泛型

泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用。

Java泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics)

Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
System.out.println(map.get("hello"));
---编译成Class文件,然后再用字节码反编译工具进行反编译后,发现泛型都不见了-----------
Map map = new HashMap();
map.put("hello", "你好");
//在元素访问时插入了从Object到String的强制转型代码
System.out.println((String) map.get("hello"));

擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

2.自动装箱、拆箱与遍历循环

List<Integer> list = Arrays.asList(1, 2);
int sum = 0;
for (int i : list) {
sum += i;
}
---------------------------------编译之后--------------------------------
List list = Arrays.asList( new Integer[] {
Integer.valueOf(1),
Integer.valueOf(2) });
int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
int i = ((Integer)localIterator.next()).intValue();
sum += i;
}

自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,上面的的Integer.valueOf()与Integer.intValue()方法,而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。

后端编译与优化

字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话, 那编译器无论在何时、在何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。

即时编译器

通过解释器 (Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器。

  • 解释器与编译器

HotSpot虚拟机中内同时包含解释器与编译器,当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升效率。因此在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作。

由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花 费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信 息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡, 

HotSpot虚拟机在编译子系统中加入了分层编译的功能:

  • 第0层。程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。 
  • 第1层。使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启 性能监控功能。 
  • 第2层。仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。 
  • 第3层。仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如 分支跳转、虚方法调用版本等全部的统计信息。 
  • 第4层。使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启 用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多 次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行 的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客 户端编译器可先采用简单优化来为它争取更多的编译时间。

  • 编译对象与触发条件

上面提到的即时编译器编译的目标是“热点代码”,这里所指的热点代码主要有两类: 

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

一个方法被调用得多了,方法体内代码执行的次数自然就多,它成为“热点代 码”是理所当然的。而后者则是为了解决当一个方法只被调用过一次或少量的几次,但是方法体内部存 在循环次数较多的循环体,这样循环体的代码也被重复执行多次,因此这些代码也应该认为是“热点代 码”

第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一 部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执 行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(Hot Spot Code Detection)。

HotSpot 虚拟机中使用的是基于计数器的热点探测,每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为 它是“热点方法”。

HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”的意思 就是指在循环边界往回跳转)。

方法调用计数器统计的是一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器 热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time), 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:- UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。

                                      方法调用计数器触发即时编译

回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数器统计的目的是为了触发栈上的替换编译。

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有 的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回 边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求, 并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果

                            回边计数器触发即时编译

  • 编译过程

在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器 还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。 用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即 时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。

Javac这类将Java代码转变为字节码的编译器称作“前 端编译器”,是因为它只完成了从程序到抽象语法树或中间字节码的生成,Java虚拟机内部的“后端编译器”来完成代码优化以及从字节码生成本地机器码的过程,即即时编译器,这个后端编译器的编译速度及编译结果质量高低,是衡量Java虚拟 机性能最重要的一个指标。

参考

深入理解Java虚拟机:JVM高级特性与最佳实践(第3版