文章目录
java 代码的运行流程如下 :
针对这类流程而言,编译上的优化就有:
- 前端编译器(叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;
- 指 Java 虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器 码的过程;
- 指使用静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程 序编译成与目标机器指令集相关的二进制代码的过程。
下面笔者列举了这3类编译过程里一些比较有代 表性的编译器产品:
- 前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)[1]。
- 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
- 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)[2]、Excelsior JET[3]。
Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的 编码效率和语言使用者的幸福感的提高。
前端编译与优化(即 .java 文件到 .class文件的编译)
Javac编译器
整体工作流程
从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程
,它们分别如下所示。
- 准备过程:初始化插入式注解处理器。
- 解析与填充符号表过程,包括:
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树
- 填充符号表。产生符号地址和符号信息。
- 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,本章的实战部分会设计一 个插入式注解处理器来影响Javac的编译行为。
- 分析与字节码生成过程,包括:
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。
- 字节码生成。将前面各个步骤所生成的信息转化成字节码
Java语法糖的味道
泛型
泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口 和方法的创建中,分别构成泛型类、泛型接口和泛型方法
。泛型让程序员能够针对泛化的数据类型编 写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。
实现泛型的两种方式
类型擦除式泛型
Java语言中的泛型则不同,它只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型(Raw Type,稍后我们会讲解裸类型具体是什么)了,并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList与ArrayList其实是同一个类型,由 此读者可以想象“类型擦除”这个名字的含义和来源。
以ArrayList为例来介绍Java泛型的类型擦除具体是如何实现的。由于Java选择了第二条路,直接把已有的类型泛型化。要让所有需要泛型化的已有类型,譬如ArrayList,原地泛型化后变成 了ArrayList,而且保证以前直接用ArrayList的代码在泛型新版本里必须还能继续用这同一个容器,这就必须让所有泛型化的实例类型,譬如ArrayList、ArrayList这些全部自动成为 ArrayList的子类型才能可以,否则类型转换就是不安全的。由此就引出了“裸类型”(Raw Type)的概 念,裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type),只有这样,像代码清单10- 4中的赋值才是被系统允许的从子类到父类的安全转型。
ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list; // 裸类型
list = ilist;
list = slist;
那么如何实现类型擦除呐?有两种方式:
- 一种是在运行期由Java虚拟机来自动地、真实地构造出ArrayList这样的类型,并且自动实现从ArrayList派生自ArrayList 的继承关系来满足裸类型的定义
- 直接在编译时把ArrayList还原 回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令
那么带来了哪些缺陷呐?
- 原始类型支持困难,因此在写代码时,ArrqyList 这种是不被允许的。
- 运行期无法取到泛型类型信息
- 丧失了一些面向对象思想应有的优雅,带来了一些模棱两可的模糊状况
具现化式泛型
无论在程序源码里面、编译后的中间语言表示(Int ermediateLanguage,这 时 候 泛 型 是 一 个 占 位 符 ) 里 面 , 抑 或 是 运 行 期 的 CLR 里 面 都 是 切 实 存 在 的 List 与 List 就是两个不同的类型,它们由系统在运行期生成,有着自己独立的虚方法表和类型数据。 而Java语言中的泛型则不同。
装箱,拆箱,遍历循环
遍 历 循 环 则 是 把 代 码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现It erable接口的原因
特别注意:
- 包装类 的“==”运算在不遇到算术运算(即加减乘除)的情况下不会自动拆箱
- 包装类的 equals() 方法是不处理数据转型的关系。
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d); //true
System.out.println(e == f); //false
//原因:321没有被缓存,==比较的是内存地址,所以为false,拓展出去:每一个new的对象使用==都是false
System.out.println(c == (a + b)); //true
System.out.println(c.equals(a + b)); //true
System.out.println(g == (a + b)); //true
System.out.println(g.equals(a + b)); //false
//原因:equal 方法不处理数据转型
}
后端编译与优化(字节码转换为机器码或.java直接转换为机器码)
即时编译器(JIT)
根据上面的图来讲,Java 程序都是被解释器解释执行的。所以很容易想到的一个优化就是将热点代码直接转换为本地代码,提高解释执行的效率。
解释器与编译器
为何HotSpot虚拟机要使用解释器与即时编译器并存的架构?
为何HotSpot虚拟机要实现两个(或三个)不同的即时编译器?
程序何时使用解释器执行?何时使用编译器执行?
哪些程序代码会被编译为本地代码?如何编译本地代码? ·
如何从外部观察到即时编译器的编译过程和编译结果?
JVM 基本上都是解释器与即时编译器并存的结构,因为:
- 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率
- 当程序运行环境中内存资源限制较大,可以使用解释执行 节约内存,反之就可以使用编译器
- 如果编译器优化的不对时,还可以通过解释器来作为兜底,进行逆优化。
编译器
HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称 为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和 C2编译器。
分层编译
由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡。HotSpot虚拟机在编译子系统中加入了分层编译
的功能。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 程序纯解释执行,并且解释器不开启性能监控功能(Profiling)
- 使用客户端编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
- 仍然使用客户端编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 仍然使用客户端编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如 分支跳转、虚方法调用版本等全部的统计信息。
- 使用服务端编译器将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启 用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化
实施分层编译后,解释器、客户端编译器和服务端编译器就会同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。
编译对象与触发条件
热点代码主要包含:(1)多次调用的方法 (2)多次执行的循环体
对于这两种情况,编译的目标对象都是整个方法体,而不会是单独的循环体
。第一种情况,由于是依靠方法调用触发的编译,那编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机 中标准的即时编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执 行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”(On Stack Replacement,OSR),即 方法的栈帧还在栈上,方法就被替换了。(不知道如果把整个方法作为替换的话, 是不是会更加好呐?这两者有什么区别呐?
)
- 何为之多次?如何定义多次的?
-
基于采样的热点探测(Sample Based Hot Spot Code Detection)。采用这种方法的虚拟机会周期性 地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方 法”。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展 开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
-
基于计数器的热点探测(Counter Based Hot Spot Code Detection)。采用这种方法的虚拟机会为 每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为 它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。
HotSpot 虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)
和回边计数器(Back Edge Counter,“回边”的意思 就是指在循环边界往回跳转)
。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
- 如何统计方法调用次数?
当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的 版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法 的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈 值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。
在默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让 它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器 热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期
(Counter Half Life Time), 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数-XX:- UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样只要系统运行时间足 够长,程序中绝大部分方法都会被编译成本地代码。另外还可以使用-XX:Count erHalfLifeTime参数设 置半衰周期的时间,单位是秒。
- 如何统计回边次数?
作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数 器统计的目的是为了触发栈上的替换编译。
没有计数热度衰减的过程
编译过程
客户端编译器的执行过程
提前编译器(AOT)
提前编译的研究方向:
- 一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作。
- 另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码(譬如公共库代码在被同一台机器 其他Java进程使用)时直接把它加载进来使用。
本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dy namic AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)
即时编译器相比提前编译器的优势
- 性能分析制导优化
- 激进预测性优化(可以做更过分的事情,因为有解释器来兜底)
- 链接时优化
编译器优化技术
- 最重要的优化技术之一:方法内联。
- 最前沿的优化技术之一:逃逸分析。
- 语言无关的经典优化技术之一:公共子表达式消除。
- 语言相关的经典优化技术之一:数组边界检查消除。
方法内联
把目标方法的代码原封不动地“复 制”到发起调用的方法之中,避免发生真实的方法调用。
逃逸分析
分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:
- 栈上分配:栈上分配可以支持方法逃逸,但不能支持线程逃逸。线程间不共享的变量,就可以存储在栈上。
- 标量替换:不能分解的变量称为标量。逃逸分析能够证明一个对象不会被方法外部 访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创 建它的若干个被这个方法使用的成员变量来代替。不允许方法逃逸
- 同步消除:如果逃逸分析 能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。
示例见:深入理解Java虚拟机书本
公共子表达式消除
如果一 个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E 的这次出现就称为公共子表达式。
数组边界检查消除—隐式异常处理
if (foo != null) {
return foo.value;
}else{
throw new NullPointException();
}
转换为:
try {
return foo.value;
} catch (segment_fault) {
uncommon_trap();
}
直接取,有异常,再处理!