探秘 JVM:编译与优化
以 java 语言为例,JVM 针对 java 程序的优化可以发生在编译期和运行期,相应的优化操作分别发生在 javac 编译器在将 java 源程序编译成字节码期间,以及运行时即时编译器(JIT: Just In Time Compiler)将字节码编译成本地机器码期间。此外,还有一类编译器可以将源程序直接编译成与目标机器指令集相关的二进制代码,此类编译器称为提前编译器。运行期依赖于 JIT 的编译优化的主要目的在于提升程序的执行效率,而编译期优化的主要目的在于支持 java 语法糖,提升语言的易用性和编码效率。如果以字节码所处的位置作为参考线,那么编译期的编译可以称为前端编译,而即时编译和提前编译合起来则可以称为后端编译。
前端编译与优化
编译期优化主要发生在 javac 命令将 java 源码编译成字节码期间,其主要目的在于支撑 java 语法糖,提升语言的易用性和编码效率。
Javac 编译器
Javac 编译器是 java 语言内置的编译器,采用 java 语言实现,其编译过程大致分为如下 4 个阶段:
- 初始化插入式注解处理器;
- 解析与填充符号表,包括词法解析、语法解析,以及填充符号表;
- 执行插入式注解处理器;
- 分析与字节码生成,包括:标注检查、数据流与控制流分析、解语法糖,以及生成字节码。
执行步骤如下图所示:
- 解析与填充符号表
解析过程主要包含词法分析和语法分析两个过程,由 JavaCompiler#parseFiles
方法实现:
- 词法分析 :将源码的字符流转换成标记集合的过程,类比 NLP 中的分词,标记是编译过程的最小元素。该过程主要由
com.sun.tools.javac.parser.Scanner
类实现。 - 语法分析 :由标记构造抽象语法树的过程,该过程在
com.sun.tools.javac.parser.Parser
类中实现,对应的抽象语法树则有类com.sun.tools.javac.tree.JCTree
表示。
经过这两个步骤之后,编译器基本不会再对源码文件进行操作,后续的操作都建立在抽象语法树上。
完成了词法分析和语法分析之后,下一阶段是对符号(Symbol Table)表进行填充(位于 JavaCompiler#enterTrees
方法)。符号表是由一组符号地址和符号信息构成的数据结构,其中的信息在编译的不同阶段都会用到,填充符号表的过程由 com.sun.tools.javac.comp.Enter
类实现。
- 应用插入式注解
插入式注解可以看作是一组编译器的插件,允许对抽象语法树中的任意元素执行读取、修改和添加操作。如果这些注解对语法树进行了修改,编译器需要回到解析与填充符号表阶段重新执行,直到所有插入式注解不再对语法树进行修改为止,每次循环称为一个轮次(Round),这也是为什么上述图中会有一个循环的原因。有了插入式注解,我们可以通过编写自己的代码来干涉编译器的行为,典型的例子就是 Lombok。
插入式注解的初始化过程在 JavaCompiler#initProcessAnnotations
方法中完成,而执行则是在 JavaCompiler#processAnnotations
方法中完成,该方法会判断是否有新的注解处理器需要执行,若有则通过 JavacProcessingEnvironment#doProcessing
方法生成一个新的 JavaCompiler 对象,对编译的后续步骤进行处理。
- 语义分析与字节码生成
抽象语法树能够描述一个正确的源程序,但无法保证程序语义的正确性,而语义分析的主要目的就在于结合上下文对程序的相关性质进行检查,包括标注检查和数据及控制流分析两个步骤。下面的代码块对应语义分析和字节码生成的执行步骤:
1 | case BY_TODO: |
标注检查 的内容主要包括如变量使用前是否声明,变量与赋值之间的数据类型是否匹配,以及常量折叠(比如将 int a = 1 + 2
折叠为为 int a = 3
)等。 数据与控制流分析 用于对程序的上下文执行更进一步的验证,与类加载时的数据与控制流分析的目的基本一致,但校验范围有所区别。
语法糖(Syntactic Sugar)是这样一类语法,它对语言的编译结果和功能没有实质性的影响,但却能够让开发人员更加方便的使用语言,例如 java 语言中的泛型、变长参数、自动装箱与拆箱机制等。JVM 并不直接支持语法糖对应的语法,所以需要在编译阶段将其还原成基础语法结构,这个过程称为 解语法糖 。
字节码生成 阶段不仅仅是把各个步骤生成的信息(语法树、符号表)转换成字节码,编译器还会进行少量的代码添加和转换工作。这一过程在 com.sun.tools.javac.jvm.Gen
类中完成,并由方法 ClassWriter#writeClass
方法输出字节码。
至此,整个编译过程宣告结束。
Java 语法糖
语法糖是这样一类语法,它对语言的编译结果和功能没有实质性的影响,但却能够让开发人员更加方便的使用语言,提升编码效率和语言的严谨性,降低编码出错的机会。
- 泛型与类型的擦除
Java 中的泛型并不是真正意义上的泛型,而是为了方便使用的一种语法糖实现,这也是 java 泛型被人所诟病的原因。Java 的泛型仅存在于源码层面,在将源码编译成字节码的过程中,泛型就会被擦除成为原生类型,可以理解为 Object 类型。不过,这里的擦除仅仅作用于方法 Code 属性中的字节码,实际上元数据中还是保留了泛型信息,这也是我们能够通过反射手段取得参数化类型的根本依据。
- 自动装箱、拆箱与遍历循环
自动装箱和拆箱在编译之后被转换成了对应的包装(eg. Integer#valueOf
)和还原(eg. Integer#intValue
)方法,而遍历循环则还原成了迭代器的实现。
除了上述介绍的语法糖之外,java 中还有很多语法糖,包括条件编译、内部类、枚举类、断言语句、数值字面量、对枚举和字符串的 switch 语法支持、try-with-resources,以及 java 8 新增的 lambda 表达式等。
后端编译与优化
后端编译主要包含即时编译和提前编译,其中即时编译会在程序运行期间依据环境和程序的运行状态选择性的将字节码编译生成机器码执行,而提前编译则是直接将源程序编译成平台相关的二进制指令。
即时编译器
主流虚拟机(eg. HotSpot, J9)对于 java 程序一开始采用解释器解释执行,当发现某个方法或代码块被频繁调用时会将这些代码标记为热点代码,JIT 在运行时会将热点代码编译成机器相关的机器码,并执行各种层次的优化。运行期优化发生在将字节码编译成本地机器码期间,其主要目的在于提升程序的运行性能。
分层编译
解释器和编译器各有优势,前者因为省去了编译的时间,程序可以具备较快的启动速度,同时占用更少的内存;后者因为将代码编译成本地机器码,所以具备更快的执行速度,但是需要额外的编译时间和内存开销。
以 HotSpot 为例,采用了解释器与编译器共存的架构,如上图所示。在 JDK 10 之前,HotSpot 内置了 1 款解释器和 2 款即时编译器,其中即时编译器分为:Client 编译器(简称 C1)和 Server 编译器(简称 C2)。在 JDK 10 之后增加了 Graal 编译器,其目的在于取代 C2 编译器,不过目前 Graal 还处于实验阶段。
在分层编译(Tiered Compilation)工作模式出现之前,HotSpot 虚拟机通常采用一个解释器搭配一个 JIT 的方式工作。我们可以在程序启动时通过 -client
参数或 -server
参数来强制虚拟机所使用的 JIT 类型,前者使用 C1 编译器,具备较好的编译速度,后者使用 C2 编译器,具备较好的编译质量。
HotSpot 虚拟机默认采用解释器与编译器混合运行的模式,可以通过 java -version
命令查看,如下所示:
1 | $ java -version |
我们也可以通过 -Xint
参数指定虚拟机仅运行于解释模式,这时 JIT 将完全不介入工作;或者通过 -Xcomp
参数指定虚拟机运行于编译模式,这时优先采用编译方式执行,只有当编译无法进行的情况下才会让解释器介入执行。
前面已经介绍了解释器和编译器各有其优点,解释执行模式能够让程序具备较快的启动速度,但是编译模式执行能够让程序具备较快的执行速度,并且不同级别的编译器在编译速度和执行速度上是矛盾的。如何在解释执行和编译执行,以及不同级别编译器的编译速度和执行速度之间进行取舍,从而让程序能够兼顾启动速度和运行速度,HotSpot 的解决方案是引入分层编译机制,并在 JDK 7 的 server 模式中采用了默认开启(分层编译最早在 JDK 6 中引入,但是需要通过 -XX:+TieredCompilation
参数来手动开启)。分层编译机制具体分层细节如下:
- 第 0 层 :程序纯解释执行,解释器不开启性能监控。
- 第 1 层 :使用 C1 编译器将字节码编译成本地机器码执行,并进行简单、可靠的稳定优化,但不开启性能监控。
- 第 2 层 :仍然使用 C1 编译器执行,但开启方法及回边次数统计等有限的性能监控。
- 第 3 层 :仍然使用 C1 编译器执行,但开启全部的性能监控,如分支跳转、虚方法调用版本等。
- 第 4 层 :使用 C2 编译器将字节码编译成本地机器码执行,相对于 C1 而言会启动更多编译耗时更长的优化,甚至是一些不可靠的激进优化。
上述分层策略并不是固定不变的,可以依据运行参数和版本来调整分层数量。
热点探测
JIT 会对热点代码执行即时编译,这里的热点代码主要包含 被多次调用的方法 和 被多次执行的循环体 两类,但不管是哪种类型的热点代码,即使编译的单位都是针对整个方法体,而不是单独的循环体。
针对被多次执行的循环体进行优化的原因主要在于一些方法虽然被调用的次数不多,但是因为方法中存在较多的循环,导致循环部分代码被多次执行,这样的代码也是热点代码。对多次被调用的方法执行即时编译属于 JIT 的标准编译方式,而针对被多次执行的循环体的即时编译则发生在方法执行过程中,形象称之为 栈上替换(OSR: On Stack Replacement) 。
判断一段代码是否属于热点代码称之为 热点探测(Hot Spot Code Detection) ,判断的方式主要分为两种:
- 基于采样的热点探测 :JVM 周期性检查各个线程的调用栈顶,如果发现某个方法经常出现在栈顶则视为热点方法,该策略实现简单且高效,但是存在一定的误判率。
- 基于计数器的热点探测 :JVM 会为每个方法(或代码块)设置一个计数器以统计方法的执行次数,超过一定的阈值则视为热点方法,该策略实现相对复杂,但是统计结果更加精确严谨。
HotSpot 虚拟机采用基于计数器的热点探测策略,而 J9 虚拟机则采用基于采样的热点探测策略。
以 HotSpot 为例,它为每个方法设置了两个计数器:方法调用计数器 和 回边计数器 。
上图分别展示了两种基于两种计数器的热点探测流程。
对于 方法调用计数器 而言,当一个方法被调用时会先去检查这个方法是否有对应已编译的版本,如果没有则将方法调用计数器的值加 1;然后判断方法调用计数器和回边调用计数器之和是否超过阈值,如果超过则向即时编译器提交编译请求。默认执行引擎不会阻塞等待编译完成(可以通过 -XX:BackgroundCompilation
参数调整),而是继续以解释器的方式执行该方法,编译完成后会更新相应方法的入口地址,当下一次调用该方法时就会切换成编译后的版本。
方法计数器触发编译的阈值默认在客户端模式下是 1500 次,而在服务端模式下是 10000 次,可以通过 -XX:CompileThreshold
参数进行调整。计数器在依据计数器值判定是否需要触发编译时,默认调用的并非是计数器计数的绝对值,而是一个相对的值,即一段时间内方法被调用的次数。当超过这个时间段,方法的调用次数仍然没有达到阈值,则方法调用计数会将计数值减半,这一机制称之为 热度衰减(Counter Decay) ,这个时间跨度也被称为 半衰周期(Counter Half Life Time) 。可以通过参数(-XX:-UseCounterDecay
)关闭热度衰减策略,以及设定半衰周期(-XX:CounterHalfLifeTime
),当关闭热度衰减之后,只要时间足够长,绝大部分方法都会被编译成本地代码。
对于 回边计数器 而言,当遇到一条回边指令(即字节码中控制流向后跳转的指令)时,会先检查需要执行的代码片段是否有对应已编译的版本,如果没有则将回边计数器的值加 1;然后判断方法调用计数器和回边调用计数器之和是否超过阈值,如果超过则发起一个 OSR 编译请求,并将回调计数器的值降低一些。默认情况下执行引擎同样不会等待编译完成,而是继续以解释器的方式执行该方法,编译完成后会更新相应方法的入口地址,当下一次调用该方法时就会切换成编译后的版本。
回边计数器虽然提供了 -XX:CompileThreshold
和 -XX:BackEdgeThreshold
两个参数用于对阈值进行控制,但是当前 HotSpot 虚拟机并未实现这两个参数,而是通过 -XX:OnStackReplacePercentage
参数间接控制该计数器的阈值。
不同于方法调用计数器,回边计数器并没有热度衰减机制。
提前编译器
即时编译虽然极大提升了 JVM 对于程序的执行效率,但是不能忽视其所带来的运行时资源消耗,所以就衍生出了一种想法,在程序启动之前先提前编译成平台相关的机器码,对应的编译器就是提前编译器。
提前编译在 java 客户端和服务端应用领域一直不温不火,但却在 Android 领域大放异彩。Android 在 2013 年推出的基于提前编译器 ART(Android Runtime) 虚拟机,一经推出就马上干掉了之前基于即时编译的 Dalvik 虚拟机。
目前,提前编译主要分为两种思路:
- 与传统 C/C++ 编译器类似,在程序运行之前将其编译成平台相关机器码。
- 讲之前即时编译的编译结果保存下来,供下次运行时直接使用,以减少再次即时编译的时间,俗称即时编译缓存(JIT Caching)。
即时编译优化技术
方法内联
方法内联优化机制的本质就是将目标方法的代码“复制”到发起调用的方法之中,避免产生真实的方法调用。方法内联是编译器最重要的优化手段,为其它优化手段建立了良好的基础。
然而,java 语言中默认的实例方法都是虚方法,所以无法简单的进行内联,需要借助 类型继承关系分析(CHA: Class Hierarchy Analysis) 技术。CHA 是一种基于整个应用程序的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否覆盖实现了父类的某个虚方法等信息,从而为编译器执行方法内联提供分析依据。
逃逸分析
逃逸分析(Escape Analysis)与 CHA 一样,同样是为其它优化策略提供分析依据,其基本原理是分析对象的动态作用域,从而判定一个对象是否发生逃逸,是方法逃逸还是线程逃逸。
- 方法逃逸 :当一个对象在方法中被定义后,它可能被外部方法所引用,例如通过参数传递给其它方法。
- 线程逃逸 :当一个对象在线程中被定义后,它可能被外部线程所访问,例如将对象赋值给其它线程中的实例变量。
如果可以确定一个对象不会逃逸到方法或线程外,或者逃逸程度较低(线程逃逸 > 方法逃逸 > 不逃逸
),则可以对这个对象变量进行一些高效的优化,比如:
- 栈上分配(Stack Allocations) :对象一般在堆上进行创建,这些对象可以被各个线程所共享,也是垃圾收集的主要目标。如果能够确定一个对象不会出现线程逃逸,就可以在栈上进行分配,这个对象的生命周期与栈帧相同(无需 GC),并且这部分对象的占比较大,所以栈上分配可以极大优化内存使用率和运行效率。栈上分配支持方法逃逸,但不支持线程逃逸。
- 标量替换(Scalar Replacement) :所谓标量是指一个数据已经无法再被分解(例如原始数据类型),与其对应的可以继续分解的数据称为聚合量,对象是典型的聚合量。标量替换就是将一个聚合量拆散,根据程序访问情况将其使用到的成员变量恢复成原始类型来访问。如果可以确定一个对象不会逃逸,且这个对象是可拆分的,程序执行时就可以不创建该对象,而改为直接创建若干个被这个方法使用到的成员变量进行代替,这样可以在栈上进行内存分配,同时为后续进一步优化创造条件。
- 同步消除(Synchronization Elimination) :多线程访问一个可以被共享的变量,为保证安全性需要同步加锁,如果可以确定一个对象不会逃逸,那么就不存在线程竞争关系,相应的加锁就是多余的,可以被消除。
逃逸分析从 JDK 7 开始默认开启,虽然目前技术成熟度一般,但却是 JIT 编译优化的一个重要方向。
公共子表达式消除
如果一个表达式 E 已经计算过了,并且到目前为止 E 中的所有变量的值均未发生变化,那么 E 的这次出现就成了公共子表达式,没有再次计算的必要,直接用之前的计算结果替换即可。
如果这种优化仅限于程序基本块内,称之为 局部公共子表达式消除 ,如果涉及多个程序基本块,则称之为 全局公共子表达式消除 。
数组边界检查消除
Java 是动态安全的语言,对数组的读写不会像 C/C++ 那样裸指针操作。Java 在执行数组访问时会去检查当前指针是否越界,这样在保证运行安全的同时也造成了一定的负担,但是这种机制是不能去除的。
实际上我们不需要每次去访问元素都检查一下是否越界,一些数组的访问越界在编译期即可确定,对于另外一些无法确定的可以借助 隐式异常机制 ,让原本边界判定操作变为在越界时主动触发异常,这样的优化措施可以在保证边界检查的前提下,又减少了非必要的边界判定。然而,异常处理过程是一个从用户态转到内核态,再转到用户态的过程,所以如果频繁发生异常的话,隐式异常处理机制也是一个比较耗时的过程,还不如边界检查,不过 JVM 会根据实际情况来选择是否采用隐式异常处理机制。