
1.1 Java程序的运行生命周期
Java可执行应用程序的入口是主函数,当我们执行一个Java应用程序时,看似是从主函数开始的,但实际需要在JVM初始化后才会调用Java主函数开始执行应用程序。
整个Java程序的执行生命周期如图1-1所示,可以分为VM初始化、应用初始化、应用预热、应用稳定和关闭这5个阶段。图的横坐标代表应用执行的时间顺序,纵坐标代表CPU利用率,各个区域代表该行为的CPU使用率,VM区域代表JVM,CL区域代表类载入,JIT(Just In Time)区域代表实时编译,GC区域代表垃圾回收(以下简称GC),GC右上方区域代表解释执行应用程序,最右边的区域代表执行经过JIT编译的应用代码。从图中可以看到各个阶段中花费时间最多的行为是什么,但这里的使用情况并不是按实际比例绘制的,而是只反映整体趋势的示意,因为具体的数据会随应用的不同而变化。

图1-1 Java应用程序的运行声明周期示意图[1]
从图1-1可以看到Java程序的总体运行生命周期如下。
1)启动JVM,执行各种VM的初始化动作。
2)调用Java程序的主函数进入应用初始化,此时才会开始通过解释执行方式运行Java代码,随着Java代码运行而同时开始的还有GC,JIT编译会在出现热点函数时才开始;
3)当程序初始化完成后,开始执行应用程序的业务代码,此时才算进入了程序执行的预热阶段,这个阶段会有大量的类载入和JIT编译行为。
4)当程序充分预热后,就进入了运行时性能最好的稳定阶段,此时的理想状态是只有应用本身和GC操作在运行,其他行为都已渐渐退出。
5)关闭应用,各个行为次第结束。
下面我们会分两节详细介绍Java程序执行生命周期的前3个阶段,解释Java程序是如何启动并逐渐达到性能巅峰的。
1.1.1 初始化
我们将图1-1中的前两个阶段——VM初始化和应用初始化合称为初始化,它们负责启动并初始化JVM虚拟机和执行Java主函数所必需的基础JDK类,为主函数的运行做好准备。
VM初始化会首先调用JVM的主函数,也就是我们平时熟悉的JDK里的本地可执行文件$JAVA_HOME/bin/java,以启动JVM。然后依次在VM中执行解析送入的参数、为JVM申请内存、创建Java主线程、寻找并加载系统类和应用类,以及通过JNI调用Java程序的主函数等过程。这些步执行的都是JVM中的本地程序。
应用初始化是指从JVM调用程序主函数的时刻到开始执行应用程序的实际业务代码的时刻之间的各种应用程序内初始化准备工作。比较典型的例子是Spring应用,在执行业务代码之前会先初始化Spring框架。这个阶段已经开始运行Java程序了,会有执行类初始化、GC和JIT等行为。
通过观察一个空程序的执行时间可以直观地感受到VM初始化的耗时。我们创建一个名为Empty的类,其中仅包含一个内容为空的主函数。以笔者在Windows 10内嵌的Ubuntu子系统中运行为例,通过执行命令time java -cp bin Empty,可以看到耗时为63ms,基本可以认为这就是VM初始化的时间。如果需要更进一步了解启动时间的具体分布,就需要使用debug版本的JDK的-XX:+TraceStartupTime选项。
从图1-1中可以看到应用初始化时,类加载最为耗时,因为加载类时需要先从磁盘上读取jar文件和class文件,然后将文件解析为类。而jar文件实际上就是zip压缩文件,解压并读取文件的I/O操作较为耗时。应用程序越是复杂,初始化时载入类的数量就越多,相应的JVM启动时间越长。
JVM的选项-XX:DumpLoadedClassList=可以将Java程序载入的所有类都打印到指定文件,我们打开这个选项,分别执行java -version、Empty应用和greeting-service服务,对比它们的启动时间和加载的类数量可以得到表1-1。
表1-1 Java应用启动时间和加载类数量对比

Java -version输出当前JDK的版本信息,会执行大部分的VM初始化流程;Empty会执行完整的VM初始化流程,但是没有任何应用初始化和业务逻辑,这两项的时间统计使用了Linux的time命令;因为greeting-service项因为需要启动Spring Boot框架,所以有稍微复杂一些的应用初始化过程,这一项的启动时间是从Spring Boot框架输出的日志中获取的,是完成初始化并达到可以接受用户请求的状态的耗时。
这三项的加载类数量是从-XX:DumpLoadedClassList=选项打印出的文件中统计出来的。从表1-1中可以明显看出,启动时间随加载类的数量增加而上升。
1.1.2 程序预热
Java语言最初被认为是一种解释型语言,因为Java源代码并非被先编译为与机器平台相关的汇编代码再执行,而是先编译为与平台无关的字节码(bytecode),然后由JVM解释执行。解释执行是由JVM将字节码逐条翻译为汇编代码,然后再执行。比如,对于一个简单的加法操作:
b + c;
其对应的字节码大致为:
0: iload_0 1: iload_1 2: iadd
JVM按照取数据、执行操作、保存数据三段式结构,为每条字节码指令都提前准备好了汇编代码模板,然后在运行时将具体数据填入模板执行。由于这样的代码缺少编译优化,只是简单地将模板中的指令堆积在一起,因此运行时性能较低。
解释执行具有平台无关和灵活性两大特点。JVM解释器输入的是与平台无关的字节码,其指令行为是由JVM规范(JVM specification)定义的,从而保证了JVM在不同平台上对每个字节码指令的解释总是一致的,因此可以预期解释执行的结果不会随平台变化而产生差异。其灵活性在于可以通过解释执行支持诸如动态类加载这样的动态特性。Java可以在运行时解释执行一段在编译时尚不存在的代码,这种特性对于编译执行类型的语言来说是难以想象的。
为了解决运行时性能低的问题,Java引入了实时编译技术,即在运行时将热点函数编译为汇编代码,当程序再次运行到经过实时编译的函数时,就可以执行经过编译和优化的汇编代码,而不再需要解释执行了。由于编译是在运行时进行的,因此JIT编译器可以获得程序的运行时状态,比如路径、热点和变量值。基于这些信息,JIT编译器可以做出非常激进的编译优化,从而获得执行效率更高的代码。比如程序中有两个分支,仅静态地看代码无法分辨哪个分支被执行的概率更大,但是如果在运行时发现程序总是只执行其中某一个分支,而不执行另一个分支,那么JIT编译器就可以将总是执行的分支放到条件判断的fallthrough下,从而节省一次跳转,甚至可以把另一个不执行的分支删除。万一出错了也没有关系,还可以回退到解释执行。这种有保底的激进优化在一些场景下甚至可以将Java程序运行时的性能提高到超越C++程序的程度。
现在的Java程序基本都是采用解释执行加JIT执行的混合模式,当函数执行次数较少时解释执行,而当函数执行次数超过一定阈值后JIT执行,从而实现了热点函数JIT执行、非热点函数解释执行的效果。不过既然JIT带来了非常显著的性能优势,为什么不全部采用JIT方式呢?因为编译优化本身是需要占用系统资源的资源密集型运算,它会影响应用程序的运行时性能,在实践中甚至可能出现过JIT线程占用过多资源,导致应用程序不能执行的状况。此外,如果代码执行的次数较少,编译优化代码造成的性能损失可能会大于编译执行带来的性能提升。
我们以代码清单1-1中的程序为例进行简单的性能测试。
代码清单1-1 不同执行模式下的性能对比样例程序
package org.book; public class SimpleDemo { private static int limit; public static void main(String[] args) { limit = Integer.valueOf(args[0]); long start = System.nanoTime(); int i = 0; while( i < limit ){ long total = test(); i++; } long end = System.nanoTime(); System.out.println((end - start)); } private static long test(){ final int factor = 5; int i = 0; long total = 0; while( i < limit ) { int ret = add( factor * limit / 100, i); total+=ret; i++; } return total; } private static int add(int a, int b) { return a + b; } }
将加粗的limit变量设置为1,代表测试的主体代码只运行一次。然后在解释执行模式(使用-Xint参数开启)、JIT编译执行模式(使用-XX:CompileThreshold=1参数将JVM启动编译的函数执行次数阈值设为1)和混合执行模式(无须参数,默认模式)下各运行100次main函数,取平均值得到表1-2第二行的数据。然后将limit改为1000,实际测试代码循环100万次时,分别在3种不同模式下各执行100次main函数后取平均值得到表1-2第三行的数据。
表1-2 解释执行模式与JIT编译执行模式性能简单对比

这个测试并不严格,数据只对测试的这段特定代码和测试时的机器配置有效负责,但是从中已足以看出3种执行模式的性能差异。当测试主体循环只执行一次时,编译带来的性能损失要高于获得的性能提升,因此其性能要低于解释执行;而当测试主体循环被执行次数较多时,编译后的运行性能相比解释执行会有一个数量级的提升。可见,解释执行和编译执行各有所长,并不能相互取代。而混合模式的性能总是介于解释执行性能和编译执行性能之间,因此默认使用的混合执行模式具有更加广泛的适用性。
我们可以再通过一个简单的实验进一步了解从解释执行到JIT执行的过程中应用程序的性能变化情况。对代码清单1-1中的代码稍做修改,把main函数中的计时代码放到while循环中,以输出test函数每次执行的时间花销,然后将limit设为1000,即得到1000次test函数的执行时间。为了消除抖动,执行10轮独立的测试并取平均值,得到如图1-2所示的test函数执行时间随调用次数的变化图。图中纵坐标是test函数的执行时间,单位是ns,横坐标是调用次数,因为自500次后执行时间基本稳定不变,所以这里只截取了前500次以便于展示。

图1-2 test函数执行时间和调用次数关系图
从图1-2中可以明显看到,程序的性能经过了4个阶段的变化:第1阶段大约是从开始到第100次,test函数的执行时间在20 000ns上下波动;第2个阶段大约是从101次到第330次,test函数的执行时间在4900ns左右;第3个阶段大约从第330次到第370次,函数的执行时间从4900ns逐渐下降到了1450ns左右;第4个阶段大约从370次到结束,函数的执行时间稳定在1450ns左右。当然这里描述的都是执行时间的大体趋势,可以看到在各个阶段实际上都还存在很多波动毛刺,这些是由收集优化数据、编译以及其他JVM事件引起的。
我们可以认为前3个阶段对应了图1-1中的程序预热,最后一个阶段对应了图1-1中的程序稳定执行。应用程序的性能在预热时并不稳定,甚至会出现短暂的劣化,比如图1-2中第315次执行花费的时间(55 920ns)甚至远超过了解释执行阶段的最大值(47 850ns),因此一个应用程序只有经过“充分”的预热后才能达到其运行时的最佳性能。但是在一般情况下,并非所有的代码都会经过JIT编译执行,只有一部分高频使用的热点函数才会被JIT编译执行,所以“充分”二字实际是很难实现的。我们可以看到在图1-1的稳定阶段中依然会有少量的解释执行,所以Java程序很难在其理论上可能的最佳性能状态下执行。
[1]图片来源https://shipilev.net/talks/j1-Oct2011-21682-benchmarking.pdf。