Code前端首页关于Code前端联系我们

Java虚拟机深入研究(程序编译与代码优化)

terry 2年前 (2023-09-25) 阅读数 46 #后端开发

本文将从虚拟机层面来看看虚拟机对于我们编写的代码所采用的优化方法。

1。早期优化(编译时优化)

Java语言的“编译时”实际上是一个“不确定”的操作过程。因为它可以是前端编译器(如Javac)将*.java文件编译成*.class文件的过程;也可以是程序执行过程中即时编译器(JIT编译器,Just In Time Compiler)转换字节码的过程。将文件编译成机器代码的过程;也可以是通过静态提前编译器(AOT编译器)直接将*.java文件编译为本地机器码的过程。

Javac 这类编译器对于代码的执行效率几乎没有任何优化措施。虚拟机设计团队将所有的性能优化都放到后端即时编译器中,让非Javac生成的类文件(Groovy、Kotlin等语言生成的类文件)也能享受到编译器带来的好处优化。不过,Javac针对Java语言编码过程做了很多优化措施,以改善程序员的编码风格,提高编码效率。相当多的Java新语法特性都是由编译器的“语法糖”来实现的,而不是依赖虚拟机的底层增强来支持。

Java中即时编译器的运行时优化过程对于程序的执行更为重要,而前端编译器的编译时优化过程与程序编码的关系更为密切。 ?字节码生成。

这三个步骤的关系如下图所示: Java虚拟机(程序编译与代码优化)深入学习

解析并填充符号表

解析步骤包括经典程序编译原理中的词法分析和语法分析两个过程;完成词法分析和语法分析之后,下一步就是填充符号表的过程。符号表是由一组符号地址和符号信息组成的表。在语义分析中,符号表中注册的内容将用于语义控制和中间代码生成。在目标代码生成阶段,为符号名分配地址时,符号表是地址分配的基础。

注释处理器

注释(注释)是 JDK 1.5 中的新增功能。使用标准编译器注释 API,我们的代码可以干扰编译器的行为,例如在编译期间生成类。文档。

语义分析和字节码生成

经过语法分析,编译器得到程序代码的抽象语法表示。语法树可以表示一个结构正确的源程序的抽象,但它不能保证源程序是逻辑的。的。语义分析的主要任务是对结构正确的源程序进行上下文相关的审查,例如类型审查。

字节码生成是 Javac 编译过程的最后阶段。字节码生成阶段不仅仅是将前面步骤生成的信息(语法树、符号表)转换为字节码并写入磁盘,编译处理器还做了少量的代码添加和转换。正如前面提到的, () 方法在这个阶段被添加到语法树中。

在字节码生成阶段,除了生成构造函数之外,还有一些其他的代码替换任务,用于优化程序的实现逻辑,例如用StringBiulder或StringBuffer替换字符串加法操作。

完成语法树的审核和对齐后,填充了必要信息的符号表将被交给com.sun.tools.javac.jvm.ClassWriter类,该类的writeClass()方法将输出单词段代码,最终生成字节码文件。至此,整个编译过程就结束了。

1.2 Java语法糖

Java提供了很多语法糖来方便程序开发。虽然语法糖不会提供显着的功能改进,但它可以提高开发效率、语法严谨性并减少编码错误。可能性。我们来看看语法糖背后我们看不到的东西。

泛型和类型删除

泛型,顾名思义,就是类型泛化。它的本质是使用参数化类型,也就是说将操作的数据类型指定为参数。此类参数可用于创建类、接口和方法,分别称为泛型类、泛型接口和泛型方法。

当Java语言没有泛型时,类型泛化只能通过Object作为所有类型的父类和强制类型转换相结合来实现。例如,HashMap的get()方法返回一个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");
}
复制代码

2.后期优化(运行时优化)

在一些商业虚拟机中,Java最初是由解释器解释并执行的。当虚拟机检测到某个特定方法或代码块执行得特别频繁时,这些代码就会被识别为“热点代码”。为了提高热代码的执行效率,在运行时,虚拟机会将这些代码编译成与本地平台相关的机器代码,并进行各种级别的优化。完成这个任务的编译器称为即时编译器(JIT)。 )。

即时编译器不是虚拟机的必需部分。 Java虚拟机规范不要求虚拟机内存在即时编译器,也不限制或指导即时编译器应该如何实现。然而,JIT编译性能的好坏和代码优化程度是衡量商用虚拟机优秀程度的最关键指标之一。

2.1 HotSpot虚拟机中的即时编译器

由于Java虚拟机规范并没有限制如何实现即时编译器,因此本节内容完全取决于具体实现它的虚拟机。这里我们使用HotSpot来说明,但是后面的内容涉及到的具体实现细节很少。 JIT在常见虚拟机中的实现有很多相似之处,因此对于了解其他虚拟机的实现也具有很高的参考价值。

解释器和编译器

虽然并不是所有的Java虚拟机都采用解释器和编译器共存的架构,但是很多常见的商业虚拟机,如HotSpot、J9等都包含了解释器和编译器。译者。

解释器和编译器都有各自的优点:

  • 当程序需要快速启动并运行时,解释器可以先生效,节省编译时间并立即运行。程序运行后,随着时间的推移,编译器逐渐生效。将越来越多的代码编译成本地机器码后,可以获得更高的执行效率。
  • 当程序执行环境内存资源限制较大时(如某些嵌入式系统),可以使用解释器执行来节省内存,反之可以使用编译执行来提高效率。

同时,解释器也可以作为编译器激进优化的“逃生门”。当编译器根据概率选择一些大多数时候可以提高运行速度的优化方法时,当激进优化的假设不成立时,例如加载添加新类后,当继承结构的类型发生变化而“罕见”时,出现“trap”时,可以通过去优化回到解释状态继续执行。

编译器对象和触发条件

在程序执行过程中,即时编译器会编译出两类“热代码”:

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

这两类重复执行的代码称为“热代码”。

  • 对于被多次调用的方法,方法体中的代码自然会被执行多次,那当然就是热点代码。
  • 关于多次执行的循环体,是为了解决一个方法只被调用一次或几次,但方法体内有很多循环的问题,所以循环体的代码为也被反复执行。所以这些代码也是热门代码。

对于第一种情况,由于编译是由方法调用触发的,所以编译器自然会使用整个方法作为编译对象。这种编译也是虚拟机中默认的JIT编译方式。在后一种情况下,即使编译动作是由循环体触发的,编译器仍然会使用整个方法(而不是单独的循环体)作为编译对象。因为这种编译方式发生在方法执行过程中,所以称为live On Stack Replacement(简称OSR编译,即方法栈帧还在栈上,方法就被替换了)。

我们已经提过好几次了,但这算多少次呢?虚拟机如何计算一个方法或一段代码被执行了多少次?回答这两个问题也就为即时编译器的触发条件提供了答案。

评估一段代码是否是热代码,是否必须触发即时编译。这种行为称为“热点检测”。事实上,热点检测并不一定需要知道该方法被调用了多少次。目前热点检测和判定方法主要有两种。

  • 基于采样的热点检测:使用该方法的虚拟机会定期检查每个线程堆栈的顶部。如果发现某个特定(或任何)方法频繁出现在堆栈顶部,则该方法就是“热点”方法。基于采样的热点检测的优点是实现简单高效,而且还可以轻松实现方法调用关系(只需扩展调用栈即可)。缺点是很难准确确认某个方法的受欢迎程度,并且容易受到线程阻塞或其他因素的影响。外部因素的影响会干扰热点检测。
  • 基于计数器的热点检测:使用该方法的虚拟机将为每个方法(偶数代码块)建立一个计数器,以统计该方法的执行次数。如果执行次数超过一定阈值,则被认为是“热点方法”。这种统计方法实施起来比较麻烦。它需要为每个方法创建和维护计数器,并且无法直接获取方法的调用率。但统计结果相对更加准确和严谨。

HotSpot虚拟机采用第二种方法:基于计数器的热点检测。因此,它为每个方法准备了两种类型的计数器:方法调用计数器(Inlogging Counter)和后沿计数器(Back Edge Counter)。

一旦虚拟机的运行参数确定,两个计数器都有一定的阈值。当计数器超过阈值时,将触发JIT编译。

方法调用计数器

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

如果不进行设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释模式执行字节码,直到提交的请求被编译器编译完成。编译完成后,该方法的调用地址会被系统自动重写为新的地址,下次调用该方法时将使用编译后的版本。 Java虚拟机(程序编译与代码优化)深入学习

如果不进行设置,方法调用计数器统计的不是方法调用的绝对次数,而是相对执行频率,即一段时间内方法调用的次数。当超过一定时间限制后,如果方法调用次数仍然不足以发送到即时编译器进行编译,则方法调用计数器值将减半。这个过程称为该方法的衰减,称为反流行度,而这个时间段称为该方法的统计半衰期。

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

后沿计数器

后沿计数器的功能是计算方法中循环文本被执行的次数。当字节码中遇到控制流时跳回的指令称为“后沿”。建立边沿计数器统计的目的是触发OSR编译。

当解释器遇到回绕指令时,它会首先检查要执行的代码片段是否已经有编译版本。如果是,它将首先运行编译后的代码,否则它将增加换行计数器值。 1. 接下来,确定方法调用计数器和边缘计数器值的总和是否超过计数器阈值。当超过阈值时,将发送OSR编译请求,并且边缘计数器的值将递减,以继续执行解释器中的循环,并等待编译器输出编译结果。 Java虚拟机(程序编译与代码优化)深入学习

与方法计数器不同,边缘计数器不计算热分解的过程,因此该计数器统计的是方法的循环执行的绝对次数。当计数器溢出时,它也会将方法计数器值调整到溢出条件,这样下次进入该方法时就会执行默认的编译过程。

2.2 编译优化技术

我们都知道,以编译模式执行本地代码比解释它要快。一方面节省了虚拟机解释执行字节码所花费的额外时间;另一方面,它。虚拟机设计团队几乎将所有对代码的优化工作都集中到了即时编译器中。本节我们将介绍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代码是为了更容易演示。

上面的代码看起来很简单,但是有很多地方可以优化。

第一步是执行方法内联。方法内联比其他优化措施更重要。方法内联有两个主要目的。一是消除方法调用的成本(比如建立栈帧),二是为其他优化打下良好的基础。方法内联扩展后,后续的优化方法就可以更大规模地使用。 ,从而达到更好的优化效果。因此,不同的编译器通常将内联优化放在优化序列的顶部。内联优化后的代码如下:

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

第二步,消除冗余。 “z = b.值;”代码中的可以替换为“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;
}
复制代码

第四步,剔除无用代码。死代码可以是永远不会执行的代码,也可以是根本没有意义的代码。因此,它也被称为“死代码”。上面代码中的y = y是没有意义的,所以剔除无用代码后的代码如下:

public void foo() {
    y = b.value;
    sum = y + y;
}
复制代码

经过这四次优化,最后优化后的代码与优化前的代码效果相同,但优化后的代码执行效率会更高。这些编译器优化技术实现起来很复杂,但很容易理解。接下来,我们来谈谈以下最具代表性的优化技术是如何工作的。它们是:

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

公共子表达式的消除

如果一个表达式E已经被求值,并且E中所有变量的值自上次求值以来没有改变,那么E的这个实例就成为一个公共子表达式表达式。对于这种类型的表达式,不需要花时间重新计算它,只需使用之前计算的表达式的结果来代替E。如果这种优化仅限于程序的一个基本块,则称为局部公共子表达式消除。如果这种优化的范围涵盖了几个基本块,则称为全局公共子表达式消除。

消除数组边界检查

如果是数组array[],在Java中访问数组元素array[i]时,系统会自动进行上下限范围检查,即控制i必须满足 i > = 0 && i 对于虚拟机执行子系统来说,每次对数组元素的读写都携带着隐式的条件赋值操作。对于具有大量数组访问的程序代码,这是很大的性能开销。为了安全,必须进行矩阵边界检查,但不一定每次都进行矩阵边界检查。例如,在循环中访问数组时,如果编译器只需通过数据流分析知道循环变量是否在区间[0, array.length]内,则可以省去数组的上下限检查整个循环。

方法内联

方法内联之前已经通过代码分析介绍过,这里不再赘述。

逃逸分析

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

如果能够证明一个对象不会逃逸某个方法或线程,即其他方法和线程无法以任何方式访问这个方法,那么就有可能对这个变量进行一些有效的优化。例如:

  1. 在栈上分配:如果确定某个对象不会逃逸到方法外,就可以在栈上分配内存,出栈帧时可以销毁该对象占用的内存空间。通常,不会逃逸的局部对象的比例很大。如果能分配在栈上,GC的压力就会大大减轻。
  2. 同步消除:如果逃逸分析能够确定某个变量不会逃逸出线程,无法被其他线程访问,那么读写这个变量就不会出现多线程争用的问题,所以同步目标为变量 can 也被消除。 。
  3. 标量替换:标量是指无法分解为更小的数据来表示的数据。 Java虚拟机中的原始数据类型无法进一步共享,因此它们是标量。相反,如果一段数据可以继续分解,则称为聚合,Java中的对象就是聚合。如果将一个Java对象进行反汇编,根据访问条件将所使用的成员变量恢复为原来的类型进行访问,称为标量替换。如果逃逸分析表明某个对象不会被外部访问并且该对象可以被反汇编,则程序运行时可能不会创建该对象,而是创建该方法使用的几个成员变量。 。对象拆分后,除了可以让对象的成员变量在栈上分配、读写之外,还可以为进一步的优化方法创造前提条件。

3。总结

本文用两个部分来介绍Java程序从源代码编译到字节码和从字节码编译到本地机器码的过程。 Javac字节码编译器和虚拟机 JIT编译器的执行过程实际上与传统编译器执行的编译过程类似。在下一篇文章中,我们将讨论虚拟机如何有效地管理并发。

参考:

  • 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 2 版)》

作者:张雷BARON
链接:https://juejin.im/post/5d17809af265da1b8c199fe7来源:掘金归作者所有。商业转载请联系作者获取授权。非商业转载请注明来源。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

发表评论:

◎欢迎参与讨论,请在这里发表您的看法、交流您的观点。

热门