Java 技术下的编译期是一段"不确定"的操作过程:可能是一个前端编译器(编译器的前端)把 .java 文件转变成 .class 文件的过程;也可能是指虚拟机的后端运行期编译器( JIT 编译器)把字节码转变为机器码的过程;还可能是使用静态提前编译器( AOT 编译器)直接把 .java 文件编译成本地机器代码的过程。
1、编译器的三个方面:
● 前端编译器:
把 Java 文件转变成 Class 文件
。如 Sun的 javac.、Eclipse JDT 中的增量式编译器(ECJ);
● 即时编译器(Just In Time Compiler):常称 JIT 编译器,在运行期把字节码编译为本地机器码
。比如 HotSpot VM 的 C1、C2 编译器,Graal 编译器。
● 提前编译器(Ahead Of Time) :常称 AOT 编译器,把java 文件接编译成目标机器二进制代码,不常用
。如 GUN GCJ、Excelsior JET;
2、三种编译方式的优缺点比较
(1)前端编译
把Java源码文件(.java)编译成Class文件(.class)的过程,也即把满足Java语言规范的程序转化为满足JVM规范所要求格式的功能;
● 优点:
这阶段的优化是指程序编码方面的;
许多Java语法新特性(“语法糖”:泛型、内部类等等),是靠前端编译器实现的,而不是依赖虚拟机;
编译成的Class文件可以直接给JVM解释器解释执行,省去编译时间,加快启动速度;
● 缺点:
对代码运行效率几乎没有任何优化措施;解释执行效率较低,所以需要结合下面的JIT编译;
(2)即时编译(JVM 调优阶段)
通过Java虚拟机(JVM)内置的即时编译器(Just In Time Compiler,JIT编译器);在运行时把Class文件字节码编译成本地机器码的过程;
● 优点:
通过在运行时收集监控信息,把"热点代码"(Hot Spot Code)编译成与本地平台相关的机器码,并进行各种层次的优化;
可以大大提高执行效率;
● 缺点:
收集监控信息影响程序运行;
编译过程占用程序运行时间(如使得启动速度变慢);
编译机器码占用内存;
● 另外,JIT编译速度及编译结果的优劣,是衡量一个JVM性能的很重要指标;所以对程序运行性能优化集中到这个阶段;也就是说可以对这个阶段进行JVM调优
;
(3)提前编译
程序运行前,直接把Java源码文件(.java)编译成本地机器码的过程;
● 优点:
编译不占用运行时间,可以做一些较耗时的优化,并可加快程序启动;
把编译的本地机器码保存磁盘,不占用内存,并可多次使用;
● 缺点:
因为Java语言的动态性(如反射)带来了额外的复杂性,影响了静态编译代码的质量;
一般静态编译不如JIT编译的质量,这种方式用得比较少;
不像 HotSpot 虚拟机要使用 C++ 语言(包含少量 C 语言)编写,
Javac 编译器是使用 Java 语言编写的程序
。Javac 有许多针对Java语言编码过程的优化措施来降低程序员的编码复杂度、提高编码效率。相当多新生的 Java 语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者 Java 虚拟机的底层改进来支持
。
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示:
(1)
准备过程:初始化插入式注解处理器
。
(2)解析与填充符号表过程
,包括:
● 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
● 填充符号表。产生符号地址和符号信息。
(3)插入式注解处理器的注解处理过程
:插入式注解处理器的执行阶段,本章的实战部分会设计一个插入式注解处理器来影响Javac的编译行为。
(4)分析与字节码生成过程
,包括:
● 标注检查。对语法的静态信息进行检查。
● 数据流及控制流分析。对程序动态运行过程进行检查。
● 解语法糖。将简化代码编写的语法糖还原为原有的形式。
● 字节码生成。将前面各个步骤所生成的信息转化成字节码。
上述的三个处理过程,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回之前的解析、填充过程表的过程,重新处理这些新符号。因此,从总体来看,三者之间的关系与交互顺序如图:
① 词法、语法分析
通俗来讲,
词法分析就是对源码文件做分词,语法分析就是检查源码文件是否符合 Java 语法
。
● 词法分析
将源码中的字符流转变为标记(Token)集合的过程。关键字、变量名、运算符等都可作为标记。该过程有点类似“分词”的过程。虽然这些代码我们一眼就能认出来,但编译器要逐个分析过之后才能知道。
● 语法分析
根据上面的标记序列构造抽象语法树的过程。抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方法,每个节点都代表程序代码中的一个语法结构(Syntax Construct),比如包、类型、修饰符等。
② 填充符号表
符号表(Symbol Table)是一种数据结构,它由一组符号地址和符号信息组成(类似“键-值”对的形式)。符号由抽象类 com.sun.tools.javac.code.Symbol 表示,Symbol 类有多种扩展类型的符号,比如 ClassSymbol 表示类、MethodSymbol 表示方法等。
符号表记录的信息在编译的不同阶段都要用到,如:用于语义检查和产生中间代码;在目标代码生成阶段,符号表是对符号名进行地址分配的依据。
这个阶段主要是根据上一步生成的抽象语法树列表完成符号填充,返回填充了类中所有符号的抽象语法树列表
。
(3)注解处理器(编译期)
JDK 5 提供了注解(Annotations)支持(注:该版本的注解只在运行期发挥作用),
JDK 6 提供了“插入式注解处理器”,可以在编译期对代码中的特定注解进行处理,从而影响前端编译器的工作过程
。注解可以看做是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树的任意元素。比如效率工具 Lombok 就是在这个阶段进行处理的。
如果这些注解在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。每一次循环称为一个轮次(Round),也就是回环过程。语法树的任意元素,甚至包括代码注释都可以在注解之中访问到。
(4)语义分析与字节码生成
语义分析
抽象语法树能表示一个结构正确的源程序,却无法保证语义是否符合逻辑。而
语义分析就对语法正确的源程序结合上下文进行相关性质的检查(类型检查、控制流检查和数据流检查等)
。
① 标注检查
检查内容:变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配等。该过程中,还会进行一个常量折叠(Constant Folding)的代码优化。
② 数据及控制流分析
主要检查内容:局部变量使用前是否赋值;方法的每条路径是否有返回值;受检查异常是否被正确处理等;
③ 解语法糖
语法糖(Syntactic Sugar):也称糖衣语法,指的是在计算机语言中添加某种语法,该语法
对语言的编译结果和功能并没有实际影响
,却能更方便开发者使用该语言。Java 中常见的语法糖有泛型、变长参数、自动装箱拆箱等
。JVM 不支持这些语法,它们在编译阶段要被还原成原始的基础语法结构
。该过程就称为解语法糖(打回原形)。
字节码生成
Javac 编译过程的最后一个阶段,该阶段不仅仅是
把前面各个步骤生成的信息(语法树,符号表)转化为字节码指令写到磁盘中
,编译器还进行了少量的代码添加和转换
工作。比如实例构造器 < init>() 和类构造器 < clinit >() 方法就是在这个阶段被添加到语法树的。还有一些代码替换
工作用于优化程序的实现逻辑,例如将字符串的 “+” 操作替换为 StringBuilder(JDK 5 及以后)或 StringBuffer(JDK 5 之前) 的 append() 操作。
在部分的商用虚拟机(Sun HotSpot)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块运行的特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,
虚拟机将会把这些热点代码编译成本地机器能够执行的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器
(Just In Time Compiler,下文中简称 JIT 编译器)。
即时编译器并不是虚拟机必须的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有即时编译器存在,更没有规定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它是虚拟机中最核心且最能体现虚拟机技术水平的部分。
即时编译存在的意义在于它是提高程序性能的重要手段之一。根据“二八定律”(即:百分之二十的代码占据百分之八十的系统资源),对于大部分不常用的代码,我们无需耗时间将之编译为机器码,而是采用解释执行的方式,用到就去逐条解释运行;对于一些仅占据小部分的热点代码(可认为是反复执行的重要代码),则可将之翻译为符合目标机器的机器码高效执行,提高程序的效率,此为运行时的即时编译
。
即时编译的优势:
● 性能分析制导优化
比如某个程序点抽象类通常是什么实际类型、条件判断通常会走哪条分支。
● 激进预测性优化
做一些有可能出错的优化,出错后退回到低级编译器甚至解释器。
● 链接时优化
可以实现跨链接库的优化
(1)Java 是基于解释执行和编译执行的
准确的来说,像 HotSpot 等虚拟机,Java 是基于解释执行和编译执行的
。下面用一张图来解释该过程:
(2)JVM 中解释器与编译器的并存
既然即时编译器进行了各层次的优化,那么为什么 Java 还使用解释器来 “拖累” 程序的性能呢?这是因为,解释器与编译器两者各有优势。
● 解释器优点:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行
;解释执行占用更小的内存空间
;当编译器进行的激进优化失败的时候,还可以进行逆优化来恢复到解释执行的状态。
● 编译器优点:在程序运行时,随着时间的推移,编译器逐渐发挥作用,把热点代码编译成本地代码之后,可以获得更高的执行效率
。
因此,整个虚拟机执行架构中,解释器与编译器经常配合工作,如下图所示。
① 时间开销对比:我们所说的 JIT 比解释器快,仅限于
对 “热点代码” 编译之后的代码执行起来要比解释器解释执行的快
。通过下图可以看出,如果是只是单次执行的代码,JIT 编译比解释器要多出一步“执行编译”,因此,只执行一次时,JIT 是要比解释器慢的。只执行一次的代码通常包括只被调用一次的代码(比如构造器)、没有循环的代码等,此时使用 JIT 显然得不偿失。
② 空间开销对比:
对一般的 Java 方法而言,编译后代码的大小相对于字节码,膨胀比达到 10 倍是很正常的。只有对执行频繁的代码才值得编译,如果把所有代码都编译则会显著增加代码所占空间,导致 “代码爆炸”。这就是为什么有些 JVM 不会单一使用 JIT 编译,而是选择用解释器 + JIT 编译器的混合执行引擎。
在编译时期,我们通过将源代码编译成 .class 文件 ,配合 JVM 这种跨平台的抽象,屏蔽底层计算机操作系统和硬件的区别,实现了“一次编译,到处运行” 。 而在运行时期,目前主流的JVM 都是混合模式(-Xmixed),即解释运行和编译运行配合使用。
并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机(如 HotSpot),都同时包含解释器和编译器。
Java 虚拟机运行时,解释器和即时编译器能够相互协作,取长补短。无论采用解释器进行解释执行,还是采用即时编译器进行编译执行,最终字节码都需要被转换为对应平台的本地机器码指令
。某些服务并不看重启动时间,而某些服务却非常看重,这就需要采用解释器与即时编译器并存来换取一个平衡点。
(3)HotSpot 虚拟机的两种即时编译器
HotSpot 虚拟机内置了两个即时编译器:Client Complier 和 Server Complier,简称为 C1、C2 编译器,分别用在客户端和服务端。
Client Complier 可获取更高的编译速度,Server Complier 可获取更好的编译质量
。
● C1:即 Client 编译器,面向对启动性能有要求的客户端GUI程序,采用的优化手段比较简单,因此编译的时间较短。
● C2:即 Server 编译器,面向对性能峰值有要求的服务端程序,采用的优化手段复杂,因此编译时间长,但是在运行过程中性能更好。
目前主流的 HotSpot 虚拟机中默认是采用解释器与其中一个编译器配合的方式工作,这种配合称作混合模式(Mixed Mode)。用户可以使用参数 - Xint 强制虚拟机运行于 “解释模式”(Interpreted Mode),这时候编译器完全不介入工作。使用 - Xcomp 强制虚拟机运行于 “编译模式”(Compiled Mode),这时将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。通过虚拟机 java -version 命令可以查看当前默认的运行模式。
默认情况下,使用 C1 还是 C2 编译器,要取决于虚拟机运行的模式。HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用 “-client” 或“-server”参数去强制指定虚拟机运行在 Client 模式或 Server 模式。
(4)分层编译
JDK 1.7 之后默认开启分层编译。由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机还会逐渐启动分层编译的策略,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
● 第0层:程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
● 第1层:使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
● 第2层:仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
● 第3层:仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
● 第4层:使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
实施分层编译后,Client Compiler 和 Server Compiler 将同时工作,许多代码都可能会被多次编译,用 Client Compiler 获取更高的编译速度,用 Server Compiler 获取更好的编译质量,在解释执行的时候也无需再承担收集性能监控信息的任务!
(5)即时编译对象和触发条件
在运行过程中会被即时编译器编译的“热点代码”有两类:
①被多次调用的方法
;
②被多次执行的循环体
;
对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准得JIT编译方法。而对于后一种情况,尽管编译动作由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行的过程中,因此形象的称之为栈上替换(On Stack Replacement,简称OSR编译,即方法栈帧还在栈上,方法就被替换了)。
判断一段代码是否是热点代码,是不是需要出发即时编译,这样的行为称为热点探测(Hot Spot Detection),探测算法有两种:
●
基于采样的热点探测
(Sample Based Hot Spot Detection):虚拟机会周期的对各个线程栈顶进行检查,如果某些方法经常出现在栈顶,这个方法就是“热点方法”。好处是实现简单、高效,很容易获取方法调用关系。缺点是很难确认方法的reduce,容易受到线程阻塞或其他外因扰乱。
●基于计数器的热点探测
(Counter Based Hot Spot Detection):为每个方法(甚至是代码块)建立计数器,执行次数超过阈值就认为是“热点方法”。优点是统计结果精确严谨。缺点是实现麻烦,不能直接获取方法的调用关系。
在程序运行前,直接把Java源码文件(.java)编译成本地机器码。
对代码的优化措施主要集中在即时编译器中,即时编译器产生的本地代码会比Javac产生的字节码更加优秀。
方法内联的主要目的有两个:
一是去除方法调用的成本(如建立栈帧等),
二是为其他优化建立良好的基础。
非虚方法可以直接内联。通过类型继承关系分析,对只有一个版本的虚方法进行内联。称为守护内联。加载新类后,守护内联可能失效,需要回退编译结果。有多个版本的虚方法,使用内联缓存。
方法的调用过程
(1) 首先会有个执行栈,存储目前所有活跃的方法,以及它们的本地变量和参数;
(2) 当一个新的方法被调用了,一个新的栈帧会被加到当前线程的栈顶,分配的本地变量和参数会存储在这个栈帧中;
(3) 跳到目标方法代码执行;
(4) 方法返回的时候,本地方法和参数会被销毁,栈顶被移除;
(5) 返回原来地址执行;
基本原理:在一个方法里创建对象后,该对象可能被其他方法访问,称为方法逃逸;也可能被其他线程访问,称为线程逃逸。对不逃逸的对象可进行一些优化:
(1)栈上分配
对象分配到栈上,支持不逃逸或方法逃逸。
(2)标量替换
不去创建对象,而是直接创建它的被这个方法使用的成员变量来代替。仅支持不逃逸。
(3)同步消除
如果一个变量不会逃逸出线程,这个变量的同步措施就可以消除。
如果一个表达式之前已经被计算过了,并且表达式中所有变量都没有发生变化,则不需要重新进行计算。
比如在循环中进行数组访问,如果分析出下标取值范围不越界,则可以消除上下界检查。