概述
- java的“编译期”是一个“不确定的过程”,他可能指前端编译期(如javac)把.java文件转为.class文件的过程。也可能指虚拟机的后端运行时编译器(JTT编译器,如HotSpot VM的C1、C2编译器)把字节码转变成机器码的过程。还可能是静态提前编译器(AOT编译器,如GNU Complier for the Java)直接把*.java转为本地机器码。
- 虚拟机设计团队把对性能的优化集中到了即时编译器中,在运行时的优化对于程序运行来说很重要。javac在编译期的优化能改善程序员的编码风格和提高编码效率,如编译器的"语法糖"。
一、早期(编译期)优化
-
javac编译过程
javac的编译过程.png
(1)解析和填充符号表
①词法、语法分析。
词法分析是将源代码的字符流转变为标记(Token)集合。
语法分析根据生成的Token序列构造抽象语法树的过程。
②填充符号表
符号表是由一组符号地址和符号信息构成的表格。在编译的不同时期都会用到。在语义分析中,符号登记表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,对符号名进行地址分配时,符号表是地址分配的依据。
(2)注解处理器
①注解和Java代码一样,是在运行期间发挥作用的,JDK提供了一组插入式注解处理器的标准API在编译期间对注解进行处理。在这个过程中可以读取、修改、添加抽象语法树的任意元素。如果在注解处理期间对语法树做了修改,则回到解析和填充符号表阶段,直到没有进行修改。
②应用有Hibernate Validator、编码风格检查、lombok的代码自动生成
(3)语义分析与字节码生成
①标注检查
检查的内容包括变量使用前是否已被声明、变量与赋值之间的数据类型能否匹配等。在这过程中,有一个重要动作,称为常量折叠。
②数据及控制流分析
对程序上下文逻辑进行进一步的验证,可检查诸如程序局部变量在使用前是否有赋值、方法是否有返回值、必检异常是否被处理等问题。
局部变量在常量池没有符号引用,因此,将局部变量声明为final,对运行期是没有影响的,变量的不变性仅有编译器在编译器保障。
③解语法糖
常见的语法糖有:泛型、自动装箱拆箱、循环遍历(还原成迭代器形式)、可变长参数、条件编译(使用条件为常量的if语句)、内部类、对枚举和字符串的switch支持、断言语句、try语句定义和关闭资源。
虚拟机运行时不支持这些语法,它们在编译阶段被还原成基础语法结构,这个过程称为解语法糖。
④字节码生成
把前面各个步骤所生成的信息(语法数、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转化工作。如实例构造器<init>()方法和类构造器<clinit>()就是在这个阶段添加到语法树中的。
ClassWriter.writeClass()输出字节码,生成最终的Class文件,到此整个编译过程完成。 -
使用泛型能增加编译时类型的检查,还能解决重复代码的编写,能够复用代码。
(1)Java的泛型只在程序源码中存在,在编译后的字节码文件中,泛型会被擦除,替换为原生类型,如List<String> -> List,并在相应地方进行强制转换。
(2)List<String>和List<Integer>是同一种类型List,不能通过他们实现重载。返回值不参与重载,但在Class文件格式中,只有返回值不一样的方法仍然可以共存。
(3)擦除法所谓的擦除,仅仅对方法Code属性的字节码进行擦除,实际元数据还是保留了泛型信息,这也是我们能通过反射手段获取参数化类型的根本依据。
二、晚期(运行期)优化
- 当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认为是“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机会将这些代码编译成与平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT编译器),它是虚拟机中最核心,最能体现虚拟机技术水平的部分。
-
解释器和编译器并存
(1)在虚拟机架构中,解释器和编译器经常配合工作。
当需要快速启动和执行时,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行时,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码后,可以获得更高的执行效率。
当运行时内存资源限制较大,可以使用解释执行节约内存,反之使用编译执行提升效率。
解释器还可以作为编译器激进优化的“逃生门”,当激进优化假设不成立时,可通过逆优化退回解释状态继续执行。
解释器和编译器的交互.png
HotSpot虚拟机内置了两个即时编译器,分别称为Client Complier和Server Complier。也称为C1编译器和C2编译器。HotSpot虚拟机默认采用解释器和其中一个编译器直接配合工作。具体使用哪个编译器取决于虚拟机运行的模式。
(2)分层编译
根据不同编译器编译、优化的规模和耗时、划分不同的编译层次,其中包括:
第0层:解释执行,解释器不开启性能监控功能,可触发第1层编译。
第1层:C1编译,将字节码编译为本地码,进行简单、可靠的优化,可加入性能监控的逻辑。
第2层:C2编译,将字节码编译为本地码,会启动一些编译耗时较长的优化,甚至会根据性能监控进行一系列不可靠的激进优化。
分层编译后,C1、C2同时工作,许多代码可能会被多次编译,用C1获取更高的编译速度,C2获取更好的编译质量,在解释执行时也无需承担收据性能监控信息的任务。
(3)编译对象与触发条件
"热点代码"有两类:被多次调用的方法和多次执行的循环体。对于前者,编译器会以整个方法为编译对象。对于后者编译器依然会以整个方法作为编译对象。这种方式由于方式在方法执行过程,称为栈上替换(简称OSR编译,即方法栈帧还在栈上,方法就被替换了)。
判断是不是"热点代码",是否需要即时编译的行为称为热点检测。
主流的检测算法有两种:
①基于采样的热点检测:周期性检查栈顶,若某个方法经常出现在栈顶,则认为该方法为热点代码。好处是简单、高效。缺点是很难精确判断一个方法的热度,因为很容易受到线程阻塞或其他外界因素的影响。
②基于计数器的热点检测:为每个方法建立计数器,统计方法执行次数,超过一定阀值则认为是热点代码。这种方法更精确,但比较麻烦。
HotSpot虚拟机采用的是第二种方法,为每个方法准备了两个计数器:方法调用计数器和回边计数器。
方法调用计数器触发即时编译.png
若不做任何设置,方法调用计数器统计的不是方法被调用的绝对次数,而是一个相对的执行频率。即一段时间内方法被调用的次数。当超过一定时间,若该方法还没被编译,则次数会减半,这个过程称为热度的衰减。
回边计数器触发即时编译.png回边计数器的作用是统计一个方法中循环体代码执行的绝对次数。当计数器溢出时,它还会将方法计数器的值也调整到溢出状态,这样下次进入该方法时就会执行标准编译过程。
在字节码中遇到控制流向后跳转的指令称为“回边”。
(4)编译过程
在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,仍以解释方式继续执行,而编译动作则在后台的编译线程中进行。
①C1编译器是一个简单的三段式编译器,主要关注点在于局部性的优化。
第一阶段:一个平台独立的前端将字节码构造成一种高级中间码表示(HIR),HIR使用静态单分配(SSA)来表示代码值,使得之后的优化动作更容易实现。
第二阶段:一个平台相关的后端从HIR中产生低级中间码表示(LIR)。
第三阶段:平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。
Client Complier架构.png
②C2编译器
C2编译器是专门面向服务端的典型应用。它是一个充分优化过的高级编译器,会执行所有经典的优化动作,如无用代码消除、消除公共子表达式、常量传播等。还会实施一些与Java语言密切相关的技术,如范围检查消除、空值检查消除等。还可能进行一些激进优化,如守护内联、分支频率预测。
C2的寄存器分配器是一个全局图着色分配器,可以充分利用某些处理器架构上的大寄存器集合。
-
优化技术
(1)语言无关的经典优化技术之一:公共子表达式消除。
(2)语言相关的经典优化技术之一:数组范围检查消除。
(3)最重要的优化技术之一:方法内联。
在编译器进行内联时,如果是非虚方法,直接进行内联。如果遇到非虚方法,则会向CHA(类型继承关系分析)查询,如果查询结果只有一个版本,也可以进行内联,这种内联属于激进优化,需要预留一个“逃生门”,称为守护内联。如果虚拟机一直没有加载到会另这个方法的接受者的继承关系变化的类,则这个继承关系可以一直使用。否则需要抛弃已经编译好的代码,退回解释状态执行,或重新编译。
如果查询结果有多个版本,则使用内联缓存来完成方法内联。工作原理为:在未发生方法调用前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接受者的版本信息,以后每次方法调用都比较接收者版本,若一样,则内联可使用。否则,取消内联,查找虚方法表进行方法分派。
(4)最前言的优化技术之一:逃逸分析。
逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,如作为参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问,如赋值给类变量或可在其他线程被访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到其他方法或线程,则可对其进行一些高效优化:
①栈上分配:如果确定一个对象不会发生方法逃逸,则可让这个对象在栈上分配内存,则对象所占用的内存空间可以随栈帧出栈而销毁。
②同步消除:如果能确定一个变量不会发生线程逃逸,则可消除对这个变量的同步措施。
③标量替换:如果确定一个对象不会被外部访问,并且这个对象可以拆散的话,那程序真正执行时将可能不创建这个对象,改为直接创建它若干个被方法使用到的成员变量替换。除了可以让对象的成员变量在栈上(栈上存储的数据,很大可能会在高速寄存器存储)分配和读写之外,还可为后续的优化创建条件。
由于不能保障逃逸分析的性能收益必定大于它的消耗,所以这项优化还不那么成熟。 -
即时编译器和静态编译器对比
劣势:
①即时编译占用的是用户程序的运行时间。
②Java是动态安全的语言,需进行频繁的动态检查。
③使用虚方法、运行时方法接收者进行多态选择的频率高,优化难度大。
④动态扩展,运行时新加载的类可能会改变程序的继承关系,使很多全局优化难以进行。
⑤对象的内存分配是在堆上的,需进行垃圾回收。
优势:
①别名分析难度相比较而言较低。
②可以进行以运行期性能监控为基础的优化措施。
网友评论