0%

jvm10_编译与优化

概述

在Java技术下谈“编译期”而没有具体上下文语境的话,其实是一句很含糊的表述,因为它可能是指一个前端编译器(叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;

也可能是指Java虚拟机的即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;

还可能是指使用静态的提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程 序编译成与目标机器指令集相关的二进制代码的过程。

  • 前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)
  • 即时编译器:HotSpot虚拟机的C1、C2编译器,Graal编译器。
  • 提前编译器:JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET

相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者Java虚拟机的底层改进来支持。

Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;

前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高。

前端编译和优化

Javac编译器

Javac编译器不像HotSpot虚拟机那样使用 C++语言(包含少量C语言)实现,它本身就是一个由Java语言编写的程序。

在JDK 6发布时通过了JSR 199编译器API的提案,使得Javac编译器的实现代码晋升成为标准Java类库之一,它的源码就改为放在 JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac中。

到了JDK 9时,整个JDK所有的Java类库都采用模块化进行重构划分,Javac编译器就被挪到了jdk.compiler模块(路径为: JDK_SRC_HOME/src/jdk.compiler/share/classes/com/sun/tools/javac)里面。

编译过程大致可以分为1个准备过程和3个处理过程:

  1. 准备过程:初始化插入式注解处理器。
  2. 解析与填充符号表过程,包括:
    • 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
    • 填充符号表。产生符号地址和符号信息。
  3. 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段,来影响Javac的编译行为。(Lombok通过此方式实现)
  4. 分析与字节码生成过程,包括:
    • 标注检查。对语法的静态信息进行检查。
    • 数据流及控制流分析。对程序动态运行过程进行检查。
    • 解语法糖。将简化代码编写的语法糖还原为原有的形式。
    • 字节码生成。将前面各个步骤所生成的信息转化成字节码。

解析与填充符号表

词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。关键字、变量名、字面量、运算符都可以作为标记,如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个独立的标记,不可以再拆分。

语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都建立在抽象语法树之上。

注解处理器

JDK 5之后,Java语言提供了对注解(Annotations)的支持,注解在设计上原本是与普通的Java代码一样,都只会在程序运行期间发挥作用的。但在JDK 6中又提出并通过了JSR-269提案,该提案设计了一组被称为“插入式注解处理器”的标准API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。

ps maven项目进行打包的时候,会使用maven自己的打包插件,这个过程中可以识别出lombok的注解处理器,并使他生效,进行编译。

99%的程序员都在用Lombok,原理竟然这么简单? 

分析与字节码生成

标注检查

标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配,等等。

数据以及流程控制分析

数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。

解析语法糖

语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家Peter J.Landin发明的一种编程术语,指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是却能更方便程序员使用该语言。

Java中最常见的语法糖包括了泛型、变长参数、自动装箱拆箱,等等。

Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。

字节码生成

字节码生成是Javac编译过程的最后一个阶段,在Javac源码里面由com.sun.tools.javac.jvm.Gen类来完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。

语法糖详解

泛型

Java选择的泛型实现方式叫作“类型擦除式泛型”(Type Erasure Generics)。

擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上。

1
2
3
4
List<Intger> list = new ArrayList();

//在内存中的表示其实是,Intger 被擦除成了Object,只有在调用get方法的时候,才会强制类型转换成对应的类型。

泛型擦除前的例子

1
2
3
4
5
6
7
8
public static void main(String[] args) {  
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}

把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了Java泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从Object到String的强制转型代码。

1
2
3
4
5
6
7
public static void main(String[] args) {  
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}

自动拆箱、装箱与遍历循环

原始代码

1
2
3
4
5
6
7
8
public static void main(String[] args) {  
List<Integer> list = Arrays.asList(1, 2, 3, 4);
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}

循环编译之后

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public static void main(String[] args) {  

List list = Arrays.asList(new Integer[]{Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4)});

int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {

int i = ((Integer) localIterator.next()).intValue();

sum += i;
}
System.out.println(sum);

}

上述代码含了泛型、自动装箱、自动拆箱、遍历循环与变长参数5种语法糖,展示了它们在编译前后发生的变化。

其他语法糖

  • 内部类
  • 枚举类
  • 断言语句
  • 数值字面量
  • 对枚举和字符串的switch支持
  • try语句中定义和关闭资源(这3个从JDK 7开始支持)
  • Lambda表达式(从JDK 8开始支持, Lambda不能算是单纯的语法糖,但在前端编译器中做了大量的转换工作)

即时编译器

Java程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为即时编译器

解释器与编译器

尽管并不是所有的Java虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用Java虚拟机(HotSpot)内部都同时包含解释器与编译器。

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。

当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

编译对象和触发条件

无论是“多次执行的方法”,还是“多次执行的代码块”,所谓“多次”只定性不定量,并不是一个具体严谨的用语,那到底多少次才算“多次”呢?还有一个问题,就是Java虚拟机是如何统计某个方法或某段代码被执行过多少次的呢?

要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为“热点探测”(Hot Spot Code Detection)。

目前主流的热点探测判定方式有两种分别是:

  • 基于采样的热点探测
  • 基于计数器的热点探测

基于采样的热点探测

采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单高效还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度

基于计数器的热点探测

采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨

在HotSpot虚拟机中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot为每个方法准备了两类计数器:

  • 方法调用计数器(Invocation Counter)
  • 回边计数器(Back Edge Counter,“回边”的意思就是指在循环边界往回跳转)。

方法调用计数器。顾名思义,这个计数器就是用于统计方法被调用的次数,这个阈值可以通过虚拟机参数-XX: CompileThreshold来人为设定。

当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈 值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。

方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。

当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那该方法的调用计数器就会被减少一半,这个过程被称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)

回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,

当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,它将会优先执行已编译的代码,否则就把回边计数器的值加一,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个栈上替换编译请求。

编译过程

在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在编译器还未完成编译之前,都仍然将按照解释方式继续执行代码,而编译动作则在后台的编译线程中进行。

用户可以通过参数-XX:-BackgroundCompilation来禁止后台编译,后台编译被禁止后,当达到触发即时编译的条件时,执行线程向虚拟机提交编译请求以后将会一直阻塞等待,直到编译过程完成再开始执行编译器输出的本地代码。

提前编译

即使现在先进的即时编译器架构有了分层编译的支持,可以先用快速但低质量的即时编译器为高质量的即时编译器争取出更多编译时间,但是,无论如何,即时编译消耗的时间都是原本可用于程序运行的时间,消耗的运算资源都是原本可用于程序运行的资源,这个约束从未减弱,更不会消失,始终是悬在即时编译头顶的达摩克利斯之剑。

提前编译因为没有执行时间和资源限制的压力,能够毫无顾忌地使用重负载的优化手段。

现在提前编译产品和对其的研究有着两条明显的分支

  • 一条分支是做与传统C、C++编译器类似的,在程序运行之前把程序代码编译成机器码的静态翻译工作;
  • 另外一条分支是把原本即时编译器在运行时要做的编译工作提前做好并保存下来,下次运行到这些代码时直接把它加载进来使用。

路径一:完全静态编译,可以把大量的编译优化的操作放在代码运行之前处理,但是会导致编译出来的程序包比较大。

路径二:本质是给即时编译器做缓存加速,去改善Java程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。这种提前编译被称为动态提前编译(Dynamic AOT)或者索性就大大方方地直接叫即时编译缓存(JIT Caching)。

提前编译的缺点:

  • 平台中立性被破坏(必须根据平台编译不同的包)
  • 字节膨胀,前编译的本地二进制码的体积会明显大于字节码的体积。
  • 动态扩展,前编译通常要求程序是封闭的,不能在外部动态加载新的字节码。(代码热更新,字节码动态增强这些不要想了)

编译器优化技术

本章主要介绍 HotSpot 虚拟机的即时编译器在生成代码时采用的代码优化技术。
以下介绍了四种比较有代表性的优化技术

  • 最重要的优化技术之一:方法内联。
  • 最前沿的优化技术之一:逃逸分析。
  • 语言无关的经典优化技术之一:公共子表达式消除。
  • 语言相关的经典优化技术之一:数组边界检查消除。

方法内联

方法内联是编译器最重要的优化手段,内联被业内戏称为优化之母,因为除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。

方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复 制”到发起调用的方法之中,避免发生真实的方法调用而已。

原始代码

1
2
3
4
5
6
7
8
9
10
11
public static void foo(Object obj) {  
if (obj != null) {
System.out.println("do something");

}
}

public static void testInline(String[] args) {
Object obj = null;
foo(obj);
}

内联之后的代码

1
2
3
4
5
6
public static void testInline(String[] args) {  
Object obj = null;
if (obj != null) {
System.out.println("do something");
}
}

基于内联之上,代码又可以进一步被优化为

1
2
3
public static void testInline(String[] args) {  

}

上述流程,我们模拟了编译器发现”Dead Code”的过程。

逃逸分析

逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

针对不同的逃逸程度,可以对代码进行不同的优化。

栈上分配

在Java虚拟机中,Java堆上分配创建对象的内存空间几乎是Java程序员都知道的常识,Java堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。

虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。

如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。

在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。

标量替换(不创建完整java对象)

若一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量

相对的,如果一个数据可以继续分解,那它就被称为聚合量(Aggregate),Java中的对象就是典型的聚合量。

如果把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。

假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

同步消除(锁消除技术)

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉。

公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。

对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E。

局部公共子表达式消除,优化仅限于程序基本块内
全局公共子表达式消除,优化的范围涵盖了多个基本块。

1
int d = (c * b) * 12 + a + (a + b * c);

优化后

1
2
int e = b * c
int d = e * 12 + a + ( a + e)

数组检查边界消除

数组边界检查消除是即时编译器中的一项语言相关的经典优化技术。

参考

反编译工具-jad

ps 选择最原始的jad,一些高级的反编译器可以反编译恢复语法糖