深入学习JVM底层(六):程序编译与代码优化

程序编译与代码优化

编译期优化

概述

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

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

Javac编译器

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

  1. 解析与填充符号表:解析步骤包含了经典程序编译原理中的词法分析和语法分析两个过程;完成词法分析和语法分析之后,下一步就是填充符号表的过程。符号表是由一组符号地址和符号信息构成的表格。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。
  2. 插入式注解处理器的注解处理:注解(Annotation)是在 JDK 1.5 中新增的,有了编译器注解处理的标准 API 后,我们的代码就可以干涉编译器的行为,比如在编译期生成 class 文件
  3. 分析与字节码生成:
    1. 语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,比如进行类型审查。
    2. 字节码生成是 Javac 编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。如前面提到的 () 方法就是在这一阶段添加到语法树中的。
    3. 在字节码生成阶段,除了生成构造器以外,还有一些其它的代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为 StringBiulder 或 StringBuffer。
    4. 完成了对语法树的遍历和调整之后,就会把填充了所需信息的符号表交给 com.sun.tools.javac.jvm.ClassWriter 类,由这个类的 writeClass() 方法输出字节码,最终生成字节码文件,到此为止整个编译过程就结束了。

在这里插入图片描述

Java 语法糖的味道

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

其中包括:

  1. 泛型与类型擦除
  2. 自动装箱、拆箱
  3. 遍历循环
  4. 条件编译

运行期优化

概述

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

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

  • 对于被多次调用的方法,方法体内的代码自然会被执行多次,理所当然的就是热点代码。
  • 而对于多次执行的循环体则是为了解决一个方法只被调用一次或者少量几次,但是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也被重复执行多次,因此这些代码也是热点代码。
HotSpot 虚拟机内的即时编译器

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

解释器与编译器

尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。解释器与编译器两者各有优势:

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

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

编译对象与触发条件

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

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

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

方法调用计数器

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

在这里插入图片描述

回边计数器

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

在这里插入图片描述

编译优化技术

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

实例概览

这段代码看起来简单,但是有许多可以优化的地方:

在这里插入图片描述

第一步是进行方法内联(Method Inlining),方法内联的重要性要高于其它优化措施。因此,各种编译器一般都会把内联优化放在优化序列的最前面。内联优化后的代码如下:

在这里插入图片描述

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

在这里插入图片描述

第三步进行复写传播,因为这段代码里没有必要使用一个额外的变量 z,它与变量 y 是完全等价的,因此可以使用 y 来代替 z。复写传播后的代码如下:

在这里插入图片描述

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

在这里插入图片描述

经过这四次优化后,最新优化后的代码和优化前的代码所达到的效果是一致的,但是优化后的代码执行效率会更高。

技术运用

编译器的这些优化技术实现起来是很复杂的,但是想要理解它们还是很容易的。接下来我们再讲讲如下几项最有代表性的优化技术是如何运作的,它们分别是:

  • 公共子表达式消除

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

  • 数组边界检查消除

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

  • 方法内联

    1. 去除方法调用的成本(比如建立栈帧)
    2. 为其它优化建立良好的基础,方法内联膨胀之后可以便于更大范围上采取后续的优化手段,从而获得更好的优化效果
  • 逃逸分析

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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值