虚拟机优化
早期(编译期)优化
概述
Java语言的“编译期”其实是一段“不确定”的操作过程,因为可能出现如下的几种情况
- 可能是指一个前端编译器(其实叫“编译器的前端”更准确)把
*.java
文件转变成.class
文件的过程。代表编译器:Javac,ECJ。 - 可能是指虚拟机的后端运行期编译期(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程。代表编译器:HotSpot VM的C1、C2编译器。
- 可能是使用静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把
*.java
文本编译成本地机器代码的过程。代表编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。
Javac编译器
-
Javac的源码与调试
Java虚拟机规范(第二版)并没有对如何把Java源码文件转变为Class文件的编译过程进行十分严格的定义,这导致Class文件编译在某种程度上与具体JDK实现相关。从Javac的代码来看,编译过程大概科宇分为三个过程:
- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程
- 分析与字节码生成的过程
编译过程如图:
-
解析与符号填充符号表
解析步骤包括了经典程序编译原理中的词法分析和语法分析。- 词法、语法分析
- 词法分析是将源代码的字符流转变为标记集合,单个字符是程序编写的最小元素,而标记则是编译过程的最小元素。
- 语法分析是根据Token序列构造抽象语法树的过程。抽象语法树(Abstract Syntax Tree,ASL)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构。
- 填充符号表
- 符号表是由一组符号地址和符号信息构成的表格。
- 词法、语法分析
-
注解处理器
- 在JDK1.6中实现了JSR-269规范,提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,可以把它看做是一组编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。
- 如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及符号填充表的过程重新处理,知道所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round。
-
语义分析与字节码生成
- 抽象语法树能够表示一个结构正确的源程序的抽象,但无法保证源程序是抽象的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的检查。
- 语义分析过程分为标注检查和数据及控制流分析两部分
- 标注检查
标注检查步骤的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。 - 数据及控制流分析
数据及控制流分析是对程序上下文逻辑的更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理。 - 解语法糖
- 语法糖也称糖衣语法,指在计算机语言中添加某种语法,这种语法对语言的功能并没有影响,但是更方便程序员的使用。
- Java中最常见的语法糖主要有泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的语法结构,这个过程称为解语法糖。
- 字节码生成
- 字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化为字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。
Java语法糖的味道
- 泛型与类型擦除
- 自动装箱/拆箱与循环遍历
- 条件编译
晚期(运行期)优化
概述
- 在部分商用虚拟机中,Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。
- 为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler)
HotSpot虚拟机内的即时编译器
- 为何HotSpot虚拟机要是用解释器与编译器并存的架构?
- 为何HotSpot虚拟机要实现两个不同的即时编译器?
- 程序何时使用解释器执行?何时使用编译器执行?
- 那些程序代码会被编译为本地代码?如何编译为本地代码?
- 如何从外部观察即时编译器的编译过程和编译原理?
解释器与编译器
- HotSpot同时包含解释器于编译器
- 解释器于编译器两者各有优势:
- 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。
- 在程序运行之后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。
- 编译器和解释器经常配合工作
- HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,简称为C1和C2。
- 主流的HotSpot虚拟机默认采用解释器与其中一个编译器直接配合的方式工作,程序采用哪一个编译器,取决于虚拟机的运行模式。
- 为了在启动程序响应速度与与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐采用分层编译的策略。
- 第0层,程序解释执行,解释器不开启性能监控功能,可触发第一层编译。
- 第1层,也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
- 第2层,也称C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
编译对象与触发条件
- 再运行过程中会被即时编译器编译的“热点代码”有两类:
- 被多次条用的方法
- 被多次执行的循环体
- 对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然的会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式。
- 对于第二种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法作为编译对象。这种编译方式因为编译发生在方法执行的过程中,因此也被称为栈上替换(OSR)。
- 判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测。
- 目前主要的热点探测的方式有两种:
- 基于采样的热点探测
- 采用这种方法的虚拟机会周期性的检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那这个歌方法就是“热点方法”。
- 优点: 实现简单、高效,还可以简单的获取方法调用关系。
- 缺点: 很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而打乱热点探测。
- 基于计数器的热点探测
- 采用这种方法的虚拟机会为每个方法(甚至是代码块)简历计数器,统计方法执行的次数,如果方法执行的次数超过一定的阈值就认为它是“热点方法”。
- 优点: 统计结果相对精准
- 缺点: 实现复杂,不能直接获取到方法的调用关系。
- 基于采样的热点探测
- HotSpot使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器和回边计数器。
- 方法调用计数器
用于统计方法被条用的次数,下面给出调用规则
- 如果不做任何设置,执行引擎并不会等待编译请求完成,而是继续进入解释器按照解释的方式执行字节码。
- 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对频率,即一段时间之内方法被调用的次数。
- 会变计数器
统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。
给出调用规则
- 方法调用计数器
编译优化技术
优化技术概览
- 具有代表性的几项优化技术:
- 语言无关的经典优化技术之一:公共子表达式消除
- 语言相关的经典优化技术之一:数组范围检查消除
- 最重要的优化技术之一:方法内联
- 最前沿的优化技术之一:逃逸分析
公共子表达式消除
- 公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。
- 对于公共子表达式没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
- 如果这种优化仅限于程序的基本快内,便称为局部公共子表达式。
- 如果这种优化的返回涵盖了多个基本块,那就称为全局公共子表达式。
数组边界检查消除
- 一种情况是数组下标是一个常量,如
foo[3]
,只要在编译期间根据数据流分析来确定foo.length
的值,并判断下标“3”没有越界,执行的时候就无须判断了。 - 一种情况是数组访问发生在循环中,并且使用循环变量来访问数组,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那在整个循环中就可以把数组的上下界检查消除。
方法内联
- CHA
- 类型继承关系分析(Class Hierarchy Analysis)
- 这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多与一种的实现,某个类是否存在子类、子类是否为抽象类等信息。
- 目的:为了解决虚方法内联的问题。
- 类型继承关系分析(Class Hierarchy Analysis)
- 编译器在进行内联的选择:
- 如果是非虚方法,则直接进行内联。
- 如果是虚方法,则会想CHA查询此方法在当前程序下是否有多个目标版本可供选择,如果只有一个版本,则也可以进行内联。不过这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联。
- 如果是虚方法,且向CHA查询出来的结果是由多个版本的目标方法可供选择,则编译器还会进行最后一次努力,使用内联缓存来完成内联。
逃逸分析
- 逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义之后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。
- 如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问这个对象,则可能为这个变量进行一些高效的优化。
- 栈上分配
如果确定一个对象不会逃逸到方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁,可以减小垃圾收集器的压力。 - 同步消除
如果逃逸分析可以确定一个变量不会逃逸出线程,无法被其他线程访问,那么对这个变量实施的同步措施就可以消除掉。 - 标量替换
- 标量是指一个数据已经无法再分解为更小的数据来表示了。如果一个数据可以继续分解,那它就称为聚合量。
- 如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。
- 栈上分配
- 逃逸分析这项优化并不成熟,原因是:不能保证逃逸分析的性能收益必定高于它的消耗。
总结
- 首先介绍了早期(编译期)的优化,介绍了Java源代码编译为字节码的过程。
- 了解到编译的大致过程:
- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程
- 分析与字节码生成过程。
- 然后介绍了晚期(运行期)的优化
- 介绍了解释器和编译器
- 介绍了HotSpot的及时编译器C1和C2
- 了解了及时编译的触发条件
- 对于方法体的标准JIT
- 对于方法内循环体的栈上替换
- 热点探测
- 了解了几种编译期的优化技术