【深入理解JVM 二】Java程序的编译过程

本文深入探讨Java编译过程,从Javac编译器的前端编译阶段,包括解析、填充符号表、注解处理和语义分析与字节码生成,到后端的JIT即时编译和编译对象与触发条件。讲解了Java编译器如何优化代码,如类型擦除、条件编译、自动装箱拆箱,并讨论了JIT编译器在提升性能方面的关键作用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本篇是深入理解JVM的第二篇,上一篇在全面理解Java程序的整体流程之后,这一篇开始详细的按照Java代码执行顺序分模块的深入理解。首先第一个阶段我们知道,就是Java代码要编译为字节码文件,当然因为Java编译有些优化策略,所以具体而言有一些详细划分:

  1. *.java文件转为 *.class的过程称为编译器的前端(前端编译)。例如:JDK的javac编译器。
  2. 把字节码( *.class文件) 转变为 本地机器码 的过程称为Java虚拟机的即时编译运行期(JIT编译器,Just In Time)。例如:HotSpot虚拟机的C1、C2编译器。
  3. 使用静态的提前编译器(AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标及其指令集相关的二进制代码的过程。例如:JDK的Jaotc,这类编译器我们应该比较少遇到

我们通常说到的最主要的就是第一种情况,前端编译,然后剩下的字节码文件转机器码交由JVM处理。

Javac编译

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

编译过程

Javac 编译器的编译过程大致可分为 1个准备过程3个处理过程 ,准备过程是:初始化插入式注解处理器,处理过程分别为解析与填充符号表插入式注解处理器的注解处理分析与字节码生成

在这里插入图片描述

解析与填充符号表

第一个步骤为解析步骤,解析步骤包含了经典程序编译原理中的词法分析语法分析两个过程,第二个步骤则是填充符号表过程:

  1. 词法分析,是将源代码的字符流转变为标记(Token)集合单个字符是程序编写过程中的的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符等都可以成为标记,比如整型标志int由三个字符构成,但是它只是一个标记,不可拆分。
  2. 语法分析,是根据Token序列来构造抽象语法树的过程。抽象语法树是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构,如包、类型、修饰符、运算符等。经过这个步骤后,编译器就基本不会再对源码文件进行操作了,后续的操作都建立在抽象语法树之上。
  3. 填充符号表,完成词法分析和语法分析之后,下一步就是填充符号表的过程。符号表是由一组符号地址和符号信息构成的表格。在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据

经过以上三个步骤后代码的关键信息就被拆解记录完毕了。

注解处理

注解(Annotation)是在 JDK 1.5 中新增的,注解在设计上原本是与普通代码一样,只在运行期间发挥作用。但是在JDK1.6中,插入式注解处理器可以提前至编译期对代码中的特点注解进行处理,从而影响到前端编译器的工作过程。我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round),这也就对应着上图回环过程有了编译器注解处理过程。Lombok就是依赖于插入式注解器实现的。

语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。所以需要接下来的步骤:

语义分析

语义分析,主要任务是对结构上正确的源程序进行上下文有关性质的审查,比如进行类型检查,控制流检查,数据流检查,解语法糖

  1. 标注检查,检查的内容包括诸如变量使用前是否已被声明、变量和赋值之间的数据类型是否匹配等。
  2. 数据及控制流分析,对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。

完成亦或是哪个步骤后即可进行字节码生成

字节码生成

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

  1. 生成构造器,如前面提到的<init> () 方法和<clinit>()方法 就是在这一阶段添加到语法树中的。这里的实例构造器并不是指默认的构造函数,而是指我们自己重载的构造函数,如果用户代码中没有提供任何构造函数,那编译器会自动添加一个没有参数、访问权限与当前类一致的默认构造函数,这个工作在填充符号表阶段就已经完成了
  2. 程序优化逻辑,还有一些其它的代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为 StringBiulder 或 StringBuffer。
  3. 写入磁盘,完成语法树的遍历和调整后,就会把填充了所需信息的符号表交给com.sun.tools.javac.jvm.ClassWriter 类,由这个类的 writeClass() 方法输出字节码,最终生成字节码文件,到此为止整个编译过程就结束了

这样,Javac的编译任务就完成了,生成了一个字节码文件写入到了磁盘。

在这里插入图片描述

编译期优化

Java 中提供了有很多语法糖来方便程序开发,虽然语法糖不会提供实质性的功能改进,但是它能提升开发效率、语法的严谨性、减少编码出错的机会。Java中最常用的语法糖主要有泛型、变长参数、条件编译、自动拆装箱、内部类等。虚拟机并不支持这些语法,它们在编译阶段就被还原回了简单的基础语法结构,这个过程称为解语法糖

泛型与类型擦除

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

泛型是JDK1.5之后引入的一项新特性,Java语言在还没有出现泛型时,只能通过Object是所有类型的父类和类型强制转换这两个特点的配合来实现泛型的功能,这样实现的泛型功能要在程序运行期才能知道Object真正的对象类型,在Javac编译期,编译器无法检查这个Object的强制转型是否成功,这便将ClassCastException 一些风险转接到了程序运行期中

Java语言在JDK1.5之后引入的泛型实际上只在程序源码中存在,在编译后的字节码文件中,就已经被替换为了原来的原生类型,并且在相应的地方插入了强制转型代码,因此对于运行期的Java语言来说,ArrayList<String>和ArrayList<Integer>就是同一个类。所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型被称为伪泛型

泛型擦除示例

下面是一段简单的Java泛型代码示例:

Map<Integer,String> map = new HashMap<Integer,String>();
map.put(1,"No.1");
map.put(2,"No.2");
System.out.println(map.get(1));
System.out.println(map.get(2));

将这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都变回了原生类型,如下面的代码所示:

    Map map = new HashMap();  
    map.put(1,"No.1");  
    map.put(2,"No.2");  
    System.out.println((String)map.get(1));  
    System.out.println((String)map.get(2));  

为了更详细地说明类型擦除,再看如下代码:

    import java.util.List;  
    public class FanxingTest{  
        public void method(List<String> list){  
            System.out.println("List String");  
        }  
        public void method(List<Integer> list){  
            System.out.println("List Int");  
        }  
    }  

产生报错信息:大意是使用了相同的方法。这是因为泛型List和List编译后都被擦除了,变成了一样的原生类型List,擦除动作导致这两个方法的特征签名变得一模一样,在Class类文件结构一文中讲过,Class文件中不能存在特征签名相同的方法。所以重载失败了,其实只要挺过编译器,在泛型擦除后加上强制类型转换重载就成功了(因为不同的参数列表),但这里挺不过去啊!

    import java.util.List;  
    public class FanxingTest{  
        public int method(List<String> list){  
            System.out.println("List String");  
            return 1;  
        }  
        public boolean method(List<Integer> list){  
            System.out.println("List Int");  
            return true;  
        }  
    }  

发现这时编译可以通过了(注意:Java语言中true和1没有关联,二者属于不同的类型,不能相互转换,不存在C语言中整数值非零即真的情况)。两个不同类型的返回值的加入,使得方法的重载成功了。这是为什么呢?

我们知道,Java代码中的方法特征签名只包括了方法名称、参数顺序和参数类型,并不包括方法的返回值,因此方法的返回值并不参与重载方法的选择,这样看来为重载方法加入返回值貌似是多余的。对于重载方法的选择来说,这确实是多余的。但我们现在要解决的问题是让上述代码能通过编译,让两个重载方法能够合理地共存于同一个Class文件之中,这就要看字节码的方法特征签名,它不仅包括了Java代码中方法特征签名中所包含的那些信息,还包括方法返回值及受查异常表。为两个重载方法加入不同的返回值后,因为有了不同的字节码特征签名,它们便可以共存于一个Class文件之中。

条件编译

Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段(com.sun.tools.javac.comp.Lower类中)完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的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");
}

此代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码中只包括System.out.println("block 1");一条语句,并不会包含if语句及另外一个分支中的System.out.println("block 2"),需要注意的是:只能使用条件为常量的if语句才能达到上述效果,如果使用常量与其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译

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

自动装箱、拆箱与遍历循环是 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程序最初是仅仅通过解释器解释执行的,即对字节码逐条解释执行,这种方式的执行速度相对会比较慢,尤其当某个方法或代码块运行的特别频繁时,这种方式的执行效率就显得很低。于是后来在虚拟机中引入了JIT编译器(即时编译器),当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码认定为“Hot Spot Code”(热点代码),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,完成这项任务的正是JIT编译器

JIT即时编译

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

现在主流的商用虚拟机(如Sun HotSpot、IBM J9)中几乎都同时包含解释器和JIT编译器,二者各有优势:

  • 编译器:负责把一种编程语言编写的源码转换成另外一种计算机代码,后者往往是以二进制的形式被称为目标代码(object code)。这个转换的过程通常的目的是生成可执行的程序。编译器,往往是在「执行」之前完成,产出是一种可执行或需要再编译或者解释的「代码」。前端的javac编译就是把java源代码编译为字节码然后交给解释器去执行,而后端的jit编译就是把热点字节码编译为本地机器码并且执行, 显然机器码的执行速度更快。
  • 解释器:它直接执行由编程语言或脚本语言编写的代码,并不会把源代码预编译成机器码。它是把程序源代码一行一行的读懂然后执行,发生在运行时,产物是「运行结果」。

既然JIT处理后的是机器能够快速执行的代码,为啥还要解释执行呢,干嘛不把全部代码编译成机器代码呢?这是由于编译本地代码比较费时间,而且编译后还要进行进一步的优化导致耗时更久;而解释器是能够立即解释字节码文件的,毕竟我们的应用放到服务器上的时候就已经是字节码文件了,解释器可以拿来直接用。而且解释器执行的时候占用的内存更小,在内存受限的场景难以使用编译器(比如手机上)。编译器会概率性地选择多数时候都能提升运行效率的手段进行优化,如果“优化”后发现还不如不优化(甚至执行有问题)就得“逆优化”,回退到解释执行状态。所以解释器与编译器两者各有优势:

  • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。
  • 在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码之后,可以获得更高的执行效率。

例如当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释器执行来节约内存,反之可以使用编译执行来提升效率。解释执行可以节约内存,而编译执行可以提升效率。HotSpot虚拟机中内置了两个JIT编译器:Client Complier和Server Complier,分别用在客户端和服务端,目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。

总而言之就是,前端编译Javac,后端编译在jvm里解释包含:即使编译器JIT和解释器配合工作。

编译对象与触发条件

运行过程中会被即时编译器编译的“热点代码”有两类:被多次调用的方法被多次调用的循环体。 两种情况,编译器都是以整个方法作为编译对象,这种编译也是虚拟机中标准的编译方式。要知道一段代码或方法是不是热点代码,是不是需要触发即时编译,需要进行Hot Spot Detection(热点探测)。目前主要的热点 判定方式有以下两种:

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

HotSpot 虚拟机采用的是第二种:基于计数器的热点探测。因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的情况下,这两个计数器都有一个确定的阈值,当计数器超过阈值就会触发 JIT 编译,触发了JIT编译后,在默认设置下,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成为止(编译工作在后台线程中进行)。当编译工作完成后,下一次调用该方法或代码时,就会使用已编译的版本。

方法调用计数器

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

在这里插入图片描述
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器值就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰期

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

回边计数器

回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为「回边」(Back Edge)。建立回边计数器统计的目的是为了触发 OSR 编译。当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否已经有编译好的版本,如果有,它将优先执行已编译的代码,否则就把回边计数器值加 1,然后判断方法调用计数器和回边计数器值之和是否超过计数器的阈值。当超过阈值时,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
在这里插入图片描述
与方法计数器不同,回边计数器没有计算热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

存在morning

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值