深刻理解Java虚拟机-程序编译与代码优化

本博客主要参考周志明老师的《深刻理解Java虚拟机》第二版前端

读书是一种跟大神的交流。阅读《深刻理解Java虚拟机》受益不浅,对Java虚拟机有初步的认识。这里写博客主要出于如下三个目的:一方面是记录,方便往后阅读;一方面是加深对内容的理解;一方面是分享给你们,但愿对你们有帮助。java

《深刻理解Java虚拟机》全书总结以下:程序员

序号 内容 连接地址
1 深刻理解Java虚拟机-走近Java http://www.javashuo.com/article/p-wmquqpab-n.html
2 深刻理解Java虚拟机-Java内存区域与内存溢出异常 http://www.javashuo.com/article/p-qbxkmyli-a.html
3 深刻理解Java虚拟机-垃圾回收器与内存分配策略 http://www.javashuo.com/article/p-oivmnobw-ds.html
4 深刻理解Java虚拟机-虚拟机执行子系统 http://www.javashuo.com/article/p-qaeyzgca-a.html
5 深刻理解Java虚拟机-程序编译与代码优化 http://www.javashuo.com/article/p-gmrxlpja-o.html
6 深刻理解Java虚拟机-高效并发 http://www.javashuo.com/article/p-yuiduguo-b.html

从计算机程序出现的第一天起,对效率的追求就是程序天生的坚决信仰,这个过程犹如一场没有终点,永不停歇的F1方程式竞赛,程序员试车手,技术平台则是在赛道上飞驰的赛车。web

早期(编译期)优化

概述

Java 语言的「编译期」实际上是一段「不肯定」的操做过程。由于它多是一个前端编译器(如 Javac)把 *.java 文件编译成 *.class 文件的过程;也多是程序运行期的即时编译器(JIT 编译器,Just In Time Compiler)把字节码文件编译成机器码的过程;还多是静态提早编译器(AOT 编译器,Ahead Of Time Compiler)直接把 *.java 文件编译成本地机器码的过程。后端

Javac 这类编译器对代码的运行效率几乎没有任何优化措施,虚拟机设计团队把对性能的优化都放到了后端的即时编译器中,这样可让那些不是由 Javac 产生的 class 文件(如 Groovy、Kotlin 等语言产生的 class 文件)也能享受到编译器优化带来的好处。可是 Javac 作了不少针对 Java 语言编码过程的优化措施来改善程序员的编码风格、提高编码效率。至关多新生的 Java 语法特性,都是靠编译器的「语法糖」来实现的,而不是依赖虚拟机的底层改进来支持。数组

Java 中即时编译器在运行期的优化过程对于程序运行来讲更重要,而前端编译器在编译期的优化过程对于程序编码来讲更加密切。安全

Javac编译器

Javac的源码与调试

Javac 编译器的编译过程大体可分为 3 个步骤:多线程

  1. 解析与填充符号表;
  2. 插入式注解处理器的注解处理;
  3. 分析与字节码生成。

这 3 个步骤之间的关系以下图所示:架构

在这里插入图片描述

解析与填充符号表

解析步骤包含了经典程序编译原理中的词法分析和语法分析两个过程;完成词法分析和语法分析以后,下一步就是填充符号表的过程。符号表是由一组符号地址和符号信息构成的表格。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。并发

注解处理器

注解(Annotation)是在 JDK 1.5 中新增的,有了编译器注解处理的标准 API 后,咱们的代码就能够干涉编译器的行为,好比在编译期生成 class 文件。

语义分析与字节码生成

语法分析以后,编译器得到了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但没法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,好比进行类型审查。

字节码生成是 Javac 编译过程的最后一个阶段,字节码生成阶段不只仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少许的代码添加和转换工做。如前面提到的 () 方法就是在这一阶段添加到语法树中的。

在字节码生成阶段,除了生成构造器之外,还有一些其它的代码替换工做用于优化程序的实现逻辑,如把字符串的加操做替换为 StringBiulder 或 StringBuffer。

完成了对语法树的遍历和调整以后,就会把填充了所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter 类,由这个类的 writeClass() 方法输出字节码,最终生成字节码文件,到此为止整个编译过程就结束了。

Java 语法糖的味道

Java 中提供了有不少语法糖来方便程序开发,虽然语法糖不会提供实质性的功能改进,可是它能提高开发效率、语法的严谨性、减小编码出错的机会。下面咱们来了解下语法糖背后咱们看不见的东西。

泛型与类型擦除

泛型顾名思义就是类型泛化,本质是参数化类型的应用,也就是说操做的数据类型被指定为一个参数。这种参数能够用在类、接口和方法的建立中,分别称为泛型类、泛型接口和泛型方法。

在 Java 语言尚未泛型的时候,只能经过 Object 是全部类型的父类和强制类型转换两个特色的配合来实现类型泛化。例如 HashMap 的 get() 方法返回的就是一个 Object 对象,那么只有程序员和运行期的虚拟机才知道这个 Object 究竟是个什么类型的对象。在编译期间,编译器没法检查这个 Object 的强制类型转换是否成功,若是仅仅依赖程序员去保障这项操做的正确性,许多 ClassCastException 的风险就会转嫁到程序运行期。

Java 语言中泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型,而且在相应的地方插入了强制类型转换的代码。所以对于运行期的 Java 语言来讲, ArrayList 与 ArrayList 是同一个类型,因此泛型其实是 Java 语言的一个语法糖,这种泛型的实现方法称为类型擦除。

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

自动装箱、拆箱与遍历循环是 Java 语言中用得最多的语法糖。这块比较简单,咱们直接看代码:

public class SyntaxSugars {

    public static void main(String[] args){

        List<Integer> list = Arrays.asList(1,2,3,4,5);

        int sum = 0;
        for(int i : list){
            sum += i;
        }
        System.out.println("sum = " + sum);
    }
}

自动装箱、拆箱与遍历循环编译以后:

public class SyntaxSugars {

    public static void main(String[] args) {

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

        int sum = 0;
        for (Iterator iterable = list.iterator(); iterable.hasNext(); ) {
            int i = ((Integer) iterable.next()).intValue();
            sum += i;
        }
        System.out.println("sum = " + sum);
    }
}

第一段代码包含了泛型、自动装箱、自动拆箱、遍历循环和变长参数 5 种语法糖,第二段代码则展现了它们在编译后的变化。

条件编译

Java 语言中条件编译的实现也是一颗语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除。

public static void main(String[] args) {
    if (true) {
        System.out.println("block 1");
    } else {
        System.out.println("block 2");
    }
}

上述代码通过编译后 class 文件的反编译结果:

public static void main(String[] args) {
    System.out.println("block 1");
}

实战:插入式注解处理器

感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》

晚期(运行期)优化

概述

在部分商业虚拟机中,Java 最初是经过解释器解释执行的,当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为「热点代码」(Hot Spot Code)。为了提升热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各类层次的优化,完成这个任务的编译器称为即时编译器(JIT)。

即时编译器不是虚拟机必须的部分,Java 虚拟机规范并无规定虚拟机内部必需要有即时编译器存在,更没有限定或指导即时编译器应该如何实现。可是 JIT 编译性能的好坏、代码优化程度的高低倒是衡量一款商用虚拟机优秀与否的最关键指标之一。

HotSpot 虚拟机内的即时编译器

因为 Java 虚拟机规范中没有限定即时编译器如何实现,因此本节的内容彻底取决于虚拟机的具体实现。咱们这里拿 HotSpot 来讲明,不事后面的内容涉及具体实现细节的内容不多,主流虚拟机中 JIT 的实现又有颇多类似之处,所以对理解其它虚拟机的实现也有很高的参考价值。

解释器与编译器

尽管并非全部的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。

解释器与编译器二者各有优点:

  • 当程序须要迅速启动和执行的时候,解释器能够首先发挥做用,省去编译的时间,当即执行。在程序运行后,随着时间的推移,编译器逐渐发挥做用,把愈来愈多的代码编译成本地机器码以后,能够得到更高的执行效率。
  • 当程序运行环境中内存资源限制较大(如部分嵌入式系统),可使用解释器执行来节约内存,反之可使用编译执行来提高效率。

同时,解释器还能够做为编译器激进优化时的一个「逃生门」,当编译器根据几率选择一些大多数时候都能提高运行速度的优化手段,当激进优化的假设不成立,如加载了新的类后类型继承结构出现变化、出现「罕见陷阱」时能够经过逆优化退回到解释状态继续执行。

编译对象与触发条件

程序在运行过程当中会被即时编译器编译的「热点代码」有两类:

  • 被屡次调用的方法;
  • 被屡次执行的循环体。

这两种被屡次重复执行的代码,称之为「热点代码」。

  • 对于被屡次调用的方法,方法体内的代码天然会被执行屡次,理所固然的就是热点代码。
  • 而对于屡次执行的循环体则是为了解决一个方法只被调用一次或者少许几回,可是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也被重复执行屡次,所以这些代码也是热点代码。

对于第一种状况,因为是方法调用触发的编译,所以编译器理所固然地会以整个方法做为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种状况,尽管编译动做是由循环体所触发的,可是编译器依然会以整个方法(而不是单独的循环体)做为编译对象。这种编译方式由于发生在方法执行过程当中,所以形象地称之为栈上替换(On Stack Replacement,简称 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。

咱们反复提到屡次,但是多少次算屡次呢?虚拟机如何统计一个方法或一段代码被执行过多少次呢?回答了这两个问题,也就回答了即时编译器的触发条件。

判断一段代码是否是热点代码,是否是须要触发即时编译,这样的行为称为「热点探测」。其实进行热点探测并不必定须要知道方法具体被调用了多少次,目前主要的热点探测断定方式有两种。

  • 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程栈顶,若是发现某个(或某些)方法常常出如今栈顶,那这个方法就是「热点方法」。基于采样的热点探测的好处是实现简单、高效,还能够很容易地获取方法调用关系(将调用栈展开便可),缺点是很难精确地确认一个方法的热度,容易由于受到线程阻塞或别的外界因数的影响而扰乱热点探测。
  • 基于计数器的热点探测:采用这种方法的虚拟机会为每一个方法(甚至代码块)创建计数器,统计方法的执行次数,若是执行次数超过必定的阈值就认为它是「热点方法」。这种统计方法实现起来麻烦一些,须要为每一个方法创建并维护计数器,并且不能直接获取到方法的调用关系,可是统计结果相对来讲更加精确和严谨。

HotSpot 虚拟机采用的是第二种:基于计数器的热点探测。所以它为每一个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

在肯定虚拟机运行参数的状况下,这两个计数器都有一个肯定的阈值,当计数器超过阈值就会触发 JIT 编译。

方法调用计数器

顾名思义,这个计数器用于统计方法被调用的次数。当一个方法被调用时,会首先检查该方法是否存在被 JIT 编译过的版本,若是存在,则优先使用编译后的本地代码来执行。若是不存在,则将此方法的调用计数器加 1,而后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。若是超过阈值,将会向即时编译器提交一个该方法的代码编译请求。

若是不作任何设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译完成后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。

在这里插入图片描述

若是不作任何设置,方法调用计数器统计的并非方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法调用的次数。当超过必定的时间限度,若是方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器值就会被减小一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰期。

进行热度衰减的动做是在虚拟机进行 GC 时顺便进行的,能够设置虚拟机参数来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。此外还能够设置虚拟机参数调整半衰期的时间。

回边计数器

回边计数器的做用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为「回边」(Back Edge)。创建回边计数器统计的目的是为了触发 OSR 编译。

当解释器遇到一条回边指令时,会先查找将要执行的代码片断是否已经有编译好的版本,若是有,它将优先执行已编译的代码,不然就把回边计数器值加 1,而后判断方法调用计数器和回边计数器值之和是否超过计数器的阈值。当超过阈值时,将会提交一个 OSR 编译请求,而且把回边计数器的值下降一些,以便继续在解释器中执行循环,等待编译器输出编译结果。

在这里插入图片描述

与方法计数器不一样,回边计数器没有计算热度衰减的过程,所以这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

编译过程

感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》

查看及分析即时编译结果

感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》

编译优化技术

咱们都知道,以编译方式执行本地代码比解释执行方式更快,一方面是由于节约了虚拟机解释执行字节码额外消耗的时间;另外一方面是由于虚拟机设计团队几乎把全部对代码的优化措施都集中到了即时编译器中。这一小节咱们来介绍下 HotSpot 虚拟机的即时编译器在编译代码时采用的优化技术。

优化技术概览

代码优化技术有不少,实现这些优化也颇有难度,可是大部分仍是比较好理解的。为了便于介绍,咱们先从一段简单的代码开始,看看虚拟机会作哪些代码优化。

static class B {
    int value;
    final int get() {
        return value;
    }
}

public void foo() {
    y = b.get();
    z = b.get();
    sum = y + z;
}

首先须要明确的是,这些代码优化是创建在代码的某种中间表示或者机器码上的,毫不是创建在 Java 源码上。这里之所使用 Java 代码来介绍是为了方便演示。

上面这段代码看起来简单,可是有许多能够优化的地方。

第一步是进行方法内联(Method Inlining),方法内联的重要性要高于其它优化措施。方法内联的目的主要有两个,一是去除方法调用的成本(好比创建栈帧),二是为其它优化创建良好的基础,方法内联膨胀以后能够便于更大范围上采起后续的优化手段,从而得到更好的优化效果。所以,各类编译器通常都会把内联优化放在优化序列的最前面。内联优化后的代码以下:

public void foo() {
    y = b.value;
    z = b.value;
    sum = y + z;
}

第二步进行冗余消除,代码中「z = b.value;」能够被替换成「z = y」。这样就不用再去访问对象 b 的局部变量。若是把 b.value 看作是一个表达式,那也能够把这项优化工做当作是公共子表达式消除。优化后的代码以下:

public void foo() {
    y = b.value;
    z = y;
    sum = y + z;
}

第三步进行复写传播,由于这段代码里没有必要使用一个额外的变量 z,它与变量 y 是彻底等价的,所以可使用 y 来代替 z。复写传播后的代码以下:

public void foo() {
    y = b.value;
    y = y;
    sum = y + y;
}

第四步进行无用代码消除。无用代码多是永远不会执行的代码,也多是彻底没有意义的代码。所以,又被形象的成为「Dead Code」。上述代码中 y = y 是没有意义的,所以进行无用代码消除后的代码是这样的:

public void foo() {
    y = b.value;
    sum = y + y;
}

通过这四次优化后,最新优化后的代码和优化前的代码所达到的效果是一致的,可是优化后的代码执行效率会更高。编译器的这些优化技术实现起来是很复杂的,可是想要理解它们仍是很容易的。接下来咱们再讲讲以下几项最有表明性的优化技术是如何运做的,它们分别是:

  • 公共子表达式消除;
  • 数组边界检查消除;
  • 方法内联;
  • 逃逸分析。

公共子表达式消除

若是一个表达式 E 已经计算过了,而且从先前的计算到如今 E 中全部变量的值都没有发生变化,那么 E 的此次出现就成了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只须要直接使用前面计算过的表达式结果代替 E 就行了。若是这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,若是这种优化的范围覆盖了多个基本块,那就称为全局公共子表达式消除。

数组边界检查消除

若是有一个数组 array[],在 Java 中访问数组元素 array[i] 的时候,系统会自动进行上下界的范围检查,即检查 i 必须知足 i >= 0 && i < array.length,不然会抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException,这就是数组边界检查。

对于虚拟机执行子系统来讲,每次数组元素的读写都带有一次隐含的条件断定操做,对于拥有大量数组访问的程序代码,这是一种不小的性能开销。为了安全,数组边界检查是必须作的,可是数组边界检查并不必定每次都要进行。好比在循环的时候访问数组,若是编译器只要经过数据流分析就知道循环变量是否是在区间 [0, array.length] 以内,那在整个循环中就能够把数组的上下界检查消除。

方法内联

方法内联前面已经经过代码分析介绍过,这里就再也不赘述了。

逃逸分析

逃逸分析不是直接优化代码的手段,而是为其它优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象的动态做用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如做为调用参数传递到其它方法中,称为方法逃逸。甚至还有可能被外部线程访问到,例如赋值给类变量或能够在其余线程中访问的实例变量,称为线程逃逸。

若是能证实一个对象不会逃逸到方法或者线程以外,也就是别的方法和线程没法经过任何途径访问到这个方法,则可能为这个变量进行一些高效优化。好比:

  1. 栈上分配:若是肯定一个对象不会逃逸到方法以外,那么就能够在栈上分配内存,对象所占的内存空间就能够随栈帧出栈而销毁。一般,不会逃逸的局部对象所占的比例很大,若是能栈上分配就会大大减轻 GC 的压力。
  2. 同步消除:若是逃逸分析能肯定一个变量不会逃逸出线程,没法被其它线程访问,那这个变量的读写就不会有多线程竞争的问题,于是变量的同步措施也就能够消除了。
  3. 标量替换:标量是指一个数据没法再拆分红更小的数据来表示了,Java 虚拟机中的原始数据类型都不能再进一步拆分,因此它们就是标量。相反,一个数据能够继续分解,那它就称做聚合量,Java 中的对象就是聚合量。若是把一个 Java 对象拆散,根据访问状况将其使用到的成员变量恢复成原始类型来访问,就叫标量替换。若是逃逸分析证实一个对象不会被外部访问,而且这个对象能够被拆散,那程序执行的时候就可能不建立这个对象,而改成直接建立它的若干个被这个方法使用到的成员变量来替代。对象被拆分后,除了可让对象的成员变量在栈上分配和读写,还能够为后续进一步的优化手段创造条件。

Java与C/C++编译器对比

感兴趣的小伙伴能够自行阅读《深刻理解Java虚拟机》

总结

本文介绍了 Java 程序从源代码编译成字节码和从字节码编译成本地机器码的过程,Javac 字节码编译器与虚拟机内的 JIT 编译器的执行过程合并起来其实就等同于一个传统编译器所执行的编译过程。