JVM | Java执行引擎结构及工作原理

2023年 10月 8日 51.2k 0

引言

1.1Java虚拟机(JVM)和其复杂性

在我们先前探讨的文章中,我们已经深入到了Java虚拟机(JVM)的内部,透视了其如何通过元空间存储类的元数据和字节码。JVM的设计初衷是为了实现跨平台兼容性,但随着时间的推移,为了去满足性能和优化的需求,它的结构变得越来越复杂。

1.2执行引擎的角色:为什么保留字节码

JVM中的元空间确实包含了大量的元数据,这些元数据为运行时提供了关于类、方法和字段的重要信息。但为什么在有了这么丰富的元数据之后,JVM还需要保留字节码呢?

答案就在执行引擎。执行引擎是JVM的核心部分,它负责将字节码翻译为可以在特定硬件上运行的机器代码。但是,这并不是一次性的过程。为了提高性能,JVM会使用Just-In-Time (JIT) 编译技术,将“热点”代码段编译成机器代码,从而大大加速程序的执行速度。
因此,尽管元数据为JVM提供了关于类和方法的大量信息,但字节码的存在是为了允许执行引擎进行即时编译优化。这个细节不仅揭示了JVM的优雅设计,也为我们展现了Java为什么能在保持跨平台特性的同时,还能提供出色的性能。

在本篇文章中,我们将更深入地探讨执行引擎,了解其如何与其他JVM组件协同工作,以及如何利用现代硬件的特性来提高Java应用的性能。

基本结构与组件

在深入探索Java虚拟机(JVM)的执行引擎之前,我们首先需要了解其主要组成部分和功能。执行引擎是JVM中的一个核心组件,负责管理和执行Java字节码。它主要由以下部分组成:
在这里插入图片描述

2.1 字节码解释器

在Java程序初次运行时,大多数的字节码是由解释器执行的。解释器逐行读取字节码并翻译成本地机器指令执行。这种方法虽然跨平台,但效率相对较低,因为每次执行相同的字节码都需要重新解释。
字节码解释器的核心是一个巨大的switch-case结构,用于处理每一个Java字节码指令。源码位于:bytecodeInterpreter.cpp。部分源码如下,是不是很熟悉?

//...
CASE(_bipush):
    SET_STACK_INT((jbyte)(pc[1]), 0);
    UPDATE_PC_AND_TOS_AND_CONTINUE(2, 1);

    /* Push a 2-byte signed integer constant onto the stack. */
CASE(_sipush):
    SET_STACK_INT((int16_t)Bytes::get_Java_u2(pc + 1), 0);
    UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);

    /* load from local variable */

CASE(_aload):
    VERIFY_OOP(LOCALS_OBJECT(pc[1]));
    SET_STACK_OBJECT(LOCALS_OBJECT(pc[1]), 0);
    UPDATE_PC_AND_TOS_AND_CONTINUE(2, 1);
//...

上述代码展示了解释器如何处理不同的字节码。对于每一种字节码,解释器都有相应的操作来翻译并执行。

逐行解析字节码也太慢了,有办法优化吗?

2.2 JIT编译器(Just-In-Time Compiler)

为了提高执行效率,JVM引入了JIT编译器。当某部分代码被频繁执行(称为“热点”代码)时,JIT编译器会将这部分字节码编译成本地机器代码。这样,下次执行时,JVM可以直接运行这段已编译的机器代码,从而大大提高执行速度。
JIT编译器的工作是将热点代码编译为本地机器代码。这就是为什么我上篇的元空间中既有类元信息又要有字节码。

对于一个经常被调用的方法,JIT可能会这样处理,我写了一个伪代码,你可以看下:

if (method->is_hot()) {
    // 通过C2编译成机器码
    compile_method_with_c2_compiler(method);
} else {
	// 解释 
    interpret_method(method);
}

C2是啥?我们接着往下看

JVM采用分层编译策略,其中C1和C2是两个主要的编译器。C1,也称为客户端编译器,提供快速编译,但优化程度较低。而C2,也称为服务器编译器,提供高级优化,但编译速度较慢。
在OpenJDK中,有两个主要的JIT编译器:C1和C2。C1编译器快速,但优化较少,而C2编译器则进行深度优化。

if (UseC1Compiler) {
    // 使用C1编译器
} else if (UseC2Compiler) {
    // 使用C2编译器
}

2.3 垃圾收集器

尽管垃圾收集主要与内存管理相关,但它与执行引擎紧密相连。执行引擎需要与垃圾收集器协同工作,以确保在执行Java代码时,不会因为内存问题而中断或崩溃。

字节码解释器

3.1 和解释器相关的其它类

当Java源代码被编译后,它转化为一个或多个字节码文件(.class文件)。这些文件包含了JVM可解释和执行的指令集。Java字节码是源代码和机器代码之间的中间表示形式。
前面我简单介绍了一下字节码执行核心BytecodeInterpreter,我们来介绍一下其它角色:

3.1.1 templateInterpreterGenerator

templateInterpreterGenerator是templateTable的生成器,它为每种字节码指令生成对应的机器代码模板。这些模板在JVM启动时只生成一次,并存储在templateTable中,以供BytecodeInterpreter在后续执行时使用。

3.1.2 templateTable

templateTable为字节码指令提供了相应的机器代码模板。这意味着,对于每种字节码,templateTable都有一个预定义的、优化过的机器代码序列,该序列可以直接在物理硬件上执行。非常重要!

总而言之,BytecodeInterpreter 是处理 JVM 字节码逻辑的核心,而 templateTable 提供了对应的机器代码模板,这些模板是由 templateInterpreterGenerator 生成的。

3.2 解释器的工作原理

3.2.1 初始化过程:

  • 启动阶段:当JVM启动并初始化其核心组件时,templateInterpreterGenerator也被触发。
  • 模板生成:templateInterpreterGenerator的主要任务是为每个Java字节码生成一个对应的机器代码模板。这些模板定义了如何在特定的硬件和操作系统上执行这些字节码。
  • 存储模板:生成的模板随后被存储在templateTable中,为解释器在运行时使用。
  • 3.2.2 运行时:

  • 读取字节码:当Java应用程序运行时,BytecodeInterpreter开始逐条读取并解释字节码。
  • 查找模板:对于每条字节码,BytecodeInterpreter会查询templateTable以找到对应的机器代码模板。
  • 执行机器代码:使用找到的机器代码模板,BytecodeInterpreter将执行对应的操作,从而实现字节码的功能。
  • 这样,通过在启动阶段预先生成模板,JVM避免了在运行时为每个字节码重新生成机器代码,从而提高了效率。

    我画了一张图,你可以看下:
    在这里插入图片描述

    3.2.3 字节码解释器会在哪些情况下会解释字节码?

    字节码解释器在Java虚拟机(JVM)中主要是用来执行Java字节码的。当JVM启动时,它默认使用解释器来逐条解释并执行字节码。但在实际的JVM实现中,尤其是像HotSpot这样的高性能JVM,除了解释执行,还会使用JIT编译器来将某些字节码编译成本地机器代码以提高性能。因此,字节码解释器主要在以下几种情况下解释执行字节码:

  • 启动初期:当Java程序刚启动时,大多数字节码都会被解释器解释执行。运行一段时间之后,JVM尚未收集到足够的运行时信息来决定哪些代码应当被JIT编译。
  • 启动之后还是没成为"热点"的代码:JVM使用分析器来监控程序的执行并识别所谓的“热点”代码,即经常被执行的代码片段。那些不被视为“热点”的代码通常会继续由解释器解释执行。
  • "热点"代码的回退:在某些情况下,已经被JIT编译过的代码可能需要回退到解释执行。我给你举一个例子:有一个方法m在类A中,当前它在JIT编译后被直接内联到其他方法中。随后,一个新的类B被加载,它继承自A并覆盖了方法m。现在,每次调用A的子类的m方法可能需要调用B中的版本,而不是A中的版本。由于之前的内联优化是基于错误的假设,JVM需要撤销这一优化,使得方法调用可以正确地进行。
  • 3.2.4 如何将字节码转换为本地机器代码

    解释器并不会将字节码持久性地转换为机器代码。相反,它在运行时逐条读取字节码,并根据每条指令执行相应的操作。因此,每次程序运行时,字节码都需要被重新解释。
    这与JIT编译器形成了对比,JIT编译器会在运行时将热点代码编译为机器代码,从而提高性能。
    解释器知道如何将其转换为一系列的机器指令,我们在上文介绍的 templateTable 里面记录了机器代码模板,这非常重要。

    换而言之,执行引擎只有解释器它也可以工作,只是编译器提供了一种优化手段,仅此而已。我们来看下这两种编译上的区别吧~

    3.2.5 解释执行与编译执行的区别

  • 解释执行:字节码在每次执行时都被解释器逐条解释并执行。这种方式的主要优点是移植性,但代价是性能较低。

  • 编译执行:JIT编译器将频繁执行的字节码片段(热点代码)编译为机器代码,并存储起来。当这些代码片段再次被调用时,JVM可以直接执行已编译的机器代码,而无需再次解释字节码,从而大大提高了执行效率。

  • 总结来说,虽然字节码解释器为Java提供了出色的移植性,但从性能的角度看,JIT编译器是更优的选择。然而,在这种情况下:JVM首次启动或当应用只运行一小段时间时,解释执行就派上用场了。

    JIT编译器

    4.1 JIT编译的基本概念

    JIT,即“Just-In-Time”编译器,是Java虚拟机的一部分,其主要任务是将热点代码(即频繁执行的代码)从字节码转化为本地机器代码。这种转化过程称为“即时编译”。与传统的“Ahead-Of-Time”编译相反,JIT编译只在运行时进行。

    4.2 为何需要JIT?

  • 性能提升:机器代码通常执行速度比字节码快。当JVM检测到某段字节码被频繁执行(例如,循环中的代码或被频繁调用的方法),JIT编译器将这些字节码编译为机器代码,从而提高执行速度。

  • 平台独立性:Java字节码是跨平台的,可以在任何支持Java的平台上执行。JIT编译器允许这些字节码在特定的硬件和操作系统上转化为高效的机器代码。

  • 动态优化:由于JIT编译在运行时发生,编译器可以利用运行时的信息进行更有效的优化,如内联缓存、逃逸分析等。

  • 4.3 如何确定代码的“热点”

    OpenJDK中的JIT编译器使用内部的分析器(Profiler)来跟踪代码的执行频率。“热点”代码的晋升主要通过两个指标:方法调用次数和循环的回边次数。接下来,我们通过源码角度来分析以下。

    4.3.1 源码分析

    要找到方法调用次数和循环的回边次数,我们定位到methodData.hpp头文件,代码如下:

      // How many invocations has this MDO seen?
      // These counters are used to determine the exact age of MDO.
      // We need those because in tiered a method can be concurrently executed at different levels.
      // MDO就是MethodDataObject
      InvocationCounter _invocation_counter;
      // Same for backedges.
      InvocationCounter _backedge_counter;
    

    templateInterpreterGenerator.hpp可以看到计数器增加的方法:

      void generate_counter_incr(Label* overflow);
    

    在这两个值溢出的情况下,设置状态位:

    // 处理回边计数器的溢出情况
    void CompilationPolicy::handle_counter_overflow(const methodHandle& method) {
      MethodCounters *mcs = method->method_counters();
      if (mcs != nullptr) {
        mcs->invocation_counter()->set_carry_on_overflow();
        mcs->backedge_counter()->set_carry_on_overflow();
      }
      MethodData* mdo = method->method_data();
      if (mdo != nullptr) {
        mdo->invocation_counter()->set_carry_on_overflow();
        mdo->backedge_counter()->set_carry_on_overflow();
      }
    }
    
    

    根据状态位决定是否使用JIT编译器。

    4.4 基于性能的优化策略

    JIT编译器使用多种优化策略来提高生成的机器代码的性能:

  • 方法内联:这是将一个方法的内容直接插入到另一个方法中,从而避免方法调用的开销。例如,小方法和getter/setter通常会被内联。
  • 循环展开:为了减少循环控制的开销,编译器可能会选择展开循环。
  • 死代码消除:删除不会被执行的代码,从而减少代码量和提高执行效率。
  • 常量传播:编译器试图将程序中的常量值传播到尽可能多的位置,从而使其它优化如死代码消除成为可能。
  • 4.5 实战:识别与优化热点代码

    实例背景:我们将实现一个简单的数字排序程序,使用插入排序算法。首先,我们将提供一个未优化的版本,然后观察其性能。接着,我们会对其进行优化,以体现如何使代码变成热点并提高效率。

    1. 初始版本:

    public class InsertionSort {
        public static void sort(int[] arr) {
            for (int i = 1; i = 0 && arr[j] > key) {
                    arr[j + 1] = arr[j];
                    j--;
                }
                arr[j + 1] = key;
            }
        }
    
        public static void main(String[] args) {
            int[] data = new int[10000];
            for (int i = 0; i < data.length; i++) {
                data[i] = (int)(Math.random() * 10000);
            }
            
            long startTime = System.currentTimeMillis();
            for (int i = 0; i < 1000; i++) {
                sort(data.clone());
            }
            long endTime = System.currentTimeMillis();
            System.out.println("Duration: " + (endTime - startTime) + "ms");
        }
    }
    

    当我们多次运行上述代码,sort方法会被频繁调用,并且内部的while循环也会被大量执行,这使得它们很可能被JIT识别为热点代码。

    2. 优化:
    为了提高效率,我们可以考虑以下几点:

    • 使用JVM参数查看JIT编译的方法:-XX:+PrintCompilation。这样,我们可以知道哪些方法被JIT编译。
    • 使用其他排序算法,如快速排序或归并排序,来优化我们的排序方法。
    • 预热JVM:在实际排序前,我们可以先进行几次预排序来使JIT编译器尽快识别并优化热点代码。

    结论:
    JVM会根据代码的运行情况动态地决定何时进行JIT编译。频繁执行的代码块更有可能被识别为热点代码,并被JIT编译成本地机器代码,从而提高程序的运行速度。通过观察和调整代码的执行方式,我们可以有效地促使JVM进行更多的即时编译,进一步提高代码的执行效率。

    4.6 实战:对比被JIT编译的热点代码与非热点代码的性能

    实例背景:我们将实现一个简单的程序,该程序计算从1到一个大数字(如10,000,000)的累加和。首先,我们将编写两个版本的累加函数:一个是循环版本,另一个是递归版本。循环版本很可能被JIT识别为热点代码并进行优化,而递归版本则可能不会。

    1. 循环版本:

    public static long sumUsingLoop(long n) {
        long sum = 0;
        for (long i = 1; i print_cr("notifying compiler thread pool to block");
        #endif
        _should_block = true;
    }
    

    此方法会设置一个标志_should_block = true;,指示编译线程池或者线程应当被阻塞,不允许继续它的活动。一般是因为系统已经处于或正要进入一个安全点,并且希望确保在此期间不会有新的编译活动。

  • 编译代码: 当使用JIT编译时,生成的机器代码也会在某些位置插入检查点,以便在必要时暂停执行并达到安全点。
  • 线程状态: 在OpenJDK中,每个线程都有一个状态,该状态决定了线程是否可以立即到达安全点。例如,处于BLOCKEDWAITING状态的线程已经被视为在安全点上,而正在执行Java代码的线程需要到达下一个字节码边界才能达到安全点。
  • 总结

    经过前面的深入探讨,我们已经对Java执行引擎有了全面的了解。从其基本结构到其工作原理,再到其在实际应用中的表现,Java执行引擎是Java虚拟机中的核心组件之一。
    回到我们一开始的讨论,Java虚拟机的元空间确实既存放了元数据也保存了字节码,这些都是为了更好地服务于执行引擎和JIT编译优化。知道这一点,我们更能理解JVM设计背后的深层次原因。

    参考文献

  • 《深入解析java虚拟机hotspot》
  • 《揭秘Java虚拟机-JVM设计原理与实现》
  • 《深入理解Java虚拟机:JVM高级特性与最佳实践》
  • 相关文章

    JavaScript2024新功能:Object.groupBy、正则表达式v标志
    PHP trim 函数对多字节字符的使用和限制
    新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
    使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
    为React 19做准备:WordPress 6.6用户指南
    如何删除WordPress中的所有评论

    发布评论