JVM执行引擎学习总结:热点字节码编译成本地机器码

热点字节码编译成本地机器码

  • 前言
  • JIT编译器如何将热点字节码编译为本地机器码
    • [一、学习目标:理解 JIT 编译的本质](#一、学习目标:理解 JIT 编译的本质)
    • [二、JIT 编译器在 JVM 执行体系中的位置](#二、JIT 编译器在 JVM 执行体系中的位置)
    • [三、JVM 如何识别热点方法](#三、JVM 如何识别热点方法)
      • [3.1 方法调用计数](#3.1 方法调用计数)
      • [3.2 循环回边计数与 OSR](#3.2 循环回边计数与 OSR)
      • [3.3 对应 HotSpot 源码](#3.3 对应 HotSpot 源码)
    • [四、JIT 如何启动编译任务](#四、JIT 如何启动编译任务)
      • [4.1 CompileBroker 的职责](#4.1 CompileBroker 的职责)
      • [4.2 源码分析:CompileTask](#4.2 源码分析:CompileTask)
      • [4.3 编译线程如何执行任务](#4.3 编译线程如何执行任务)
    • [五、JIT 如何将 JVM 字节码转换为中间表示 IR](#五、JIT 如何将 JVM 字节码转换为中间表示 IR)
      • [5.1 为什么需要中间表示 IR](#5.1 为什么需要中间表示 IR)
      • [5.2 C1 的 IR 处理过程](#5.2 C1 的 IR 处理过程)
      • [5.3 C2 的 IR 处理过程](#5.3 C2 的 IR 处理过程)
    • [六、JIT 编译器如何进行热点代码优化](#六、JIT 编译器如何进行热点代码优化)
      • [6.1 方法内联(Method Inlining)](#6.1 方法内联(Method Inlining))
      • [6.2 类型推测与去虚拟化(Devirtualization)](#6.2 类型推测与去虚拟化(Devirtualization))
      • [6.3 逃逸分析(Escape Analysis)](#6.3 逃逸分析(Escape Analysis))
      • [6.4 循环优化(Loop Optimization)](#6.4 循环优化(Loop Optimization))
      • [6.5 常量传播和死代码消除](#6.5 常量传播和死代码消除)
      • [6.6 C2 编译优化需要重点掌握的源码](#6.6 C2 编译优化需要重点掌握的源码)
    • [七、JIT 如何将 IR 转换为 CPU 本地机器码](#七、JIT 如何将 IR 转换为 CPU 本地机器码)
      • [7.1 指令选择(Instruction Selection)](#7.1 指令选择(Instruction Selection))
      • [7.2 寄存器分配(Register Allocation)](#7.2 寄存器分配(Register Allocation))
      • [7.3 指令调度(Instruction Scheduling)](#7.3 指令调度(Instruction Scheduling))
      • [7.4 C1 后端源码分析](#7.4 C1 后端源码分析)
      • [7.5 C2 后端源码分析](#7.5 C2 后端源码分析)
    • [八、机器码如何安装到 Code Cache](#八、机器码如何安装到 Code Cache)
      • [8.1 什么是 Code Cache](#8.1 什么是 Code Cache)
      • [8.2 nmethod 是什么](#8.2 nmethod 是什么)
      • [8.3 源码学习重点](#8.3 源码学习重点)
    • [九、JIT 编译后的机器码如何被 CPU 执行](#九、JIT 编译后的机器码如何被 CPU 执行)
      • [9.1 内联缓存(Inline Cache)](#9.1 内联缓存(Inline Cache))
    • [十、为什么 JIT 需要反优化 Deoptimization](#十、为什么 JIT 需要反优化 Deoptimization)
      • [10.1 反优化的基本过程](#10.1 反优化的基本过程)
      • [10.2 源码分析](#10.2 源码分析)
    • [十一、通过实验观察 JIT 编译过程](#十一、通过实验观察 JIT 编译过程)
      • [11.1 编写测试程序](#11.1 编写测试程序)
      • [11.2 查看 JIT 编译日志](#11.2 查看 JIT 编译日志)
      • [11.3 查看 JIT 编译详细日志](#11.3 查看 JIT 编译详细日志)
      • [11.4 查看机器码](#11.4 查看机器码)
      • [11.5 进一步观察 Code Cache](#11.5 进一步观察 Code Cache)
    • 十二、从系统工程师角度建立完整知识体系
      • [12.1 JIT 与 JVM 内存管理](#12.1 JIT 与 JVM 内存管理)
      • [12.2 JIT 与 CPU 架构](#12.2 JIT 与 CPU 架构)
      • [12.3 JIT 与操作系统](#12.3 JIT 与操作系统)
    • [十三、OpenJDK 源码学习路线](#十三、OpenJDK 源码学习路线)
    • 十四、学习总结与核心知识点
    • [JIT 编译完整流程](#JIT 编译完整流程)

前言

本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。

JIT编译器如何将热点字节码编译为本地机器码

一、学习目标:理解 JIT 编译的本质

从系统工程师角度学习 JVM 的 JIT(Just-In-Time)编译器,不能仅仅停留在"JIT 将 Java 字节码转换为机器码"这一概念上,还需要理解整个编译过程背后的运行机制。

重点需要回答以下问题:

  1. JVM 如何判断一个 Java 方法是热点方法?
  2. 热点方法如何从解释器执行切换到 JIT 编译?
  3. JIT 如何读取 Class 文件中的字节码?
  4. JIT 如何将字节码转换为中间表示(IR)?
  5. JIT 如何进行方法内联、逃逸分析、循环优化等优化?
  6. JIT 如何根据 x86-64 或 AArch64 架构生成本地机器码?
  7. 编译后的机器码如何安装到 Code Cache 并被 CPU 执行?
  8. 当运行时条件发生变化时,JVM 如何进行反优化(Deoptimization)?

核心认识:JIT 并不是简单地将每条 JVM 字节码翻译成一条 CPU 指令,而是通过运行时性能分析、编译优化、中间表示转换、机器码生成和代码安装,将频繁执行的 Java 代码转换为适合当前 JVM 和 CPU 架构执行的本地机器码。

从系统工程角度,可以将 JIT 理解为一个运行时动态优化编译系统。

二、JIT 编译器在 JVM 执行体系中的位置

Java 程序经过 javac 编译后生成 Class 文件,其中包含 JVM 字节码。

JVM 加载 Class 文件后,执行引擎可以通过两种主要方式执行字节码:

  • 解释执行: 解释器逐条读取字节码,并执行相应操作。
  • 编译执行: JIT 将热点方法编译为本地机器码,由 CPU 直接执行。

在 HotSpot JVM 中,典型的分层编译体系包括:

编译层级 编译器 主要特点
Level 0 解释器 直接解释执行字节码
Level 1 C1 简单编译,主要关注执行效率
Level 2 C1 带有限制的性能分析
Level 3 C1 带完整性能分析
Level 4 C2 深度优化,生成高性能机器码

需要注意:

  • C1 是 Client Compiler,强调较快的编译速度。
  • C2 是 Server Compiler,强调更充分的运行时优化。
  • 不同 JVM 构建、启动参数和编译策略可能导致实际编译层级不同。
  • Tiered Compilation(分层编译)是 HotSpot 常见的编译模式。

学习总结 1: 解释器负责快速启动和收集运行时信息,C1 负责较快地生成优化代码,C2 则利用积累的运行时信息进行更深入的优化。

三、JVM 如何识别热点方法

这是 JIT 编译的第一个关键环节。

JVM 不会在程序启动时就把所有 Java 方法编译为本地机器码,而是先通过解释器执行,并持续收集方法运行情况。

HotSpot 主要通过以下计数信息识别热点:

计数器 作用 关注场景
Invocation Counter 方法调用计数 方法被调用的频率
Backedge Counter 循环回边计数 循环执行的频率
MethodData(MDO) 方法运行时分析数据 类型分布、分支概率、调用目标等

3.1 方法调用计数

例如:

复制代码
public class JitDemo {

    public static int calculate(int value) {
        return value * 2 + 1;
    }

    public static void main(String[] args) {

        int result = 0;

        for (int i = 0; i < 1000000; i++) {

            // calculate() 被反复调用
            result += calculate(i);
        }

        System.out.println(result);
    }
}

在程序执行过程中:

  1. JVM 首先通过解释器执行 calculate()。
  2. 每次调用方法,相关调用计数信息逐渐累积。
  3. 当计数达到编译策略设定的条件时,JVM 判断该方法具有较高的编译价值。
  4. JVM 创建编译任务。
  5. 编译线程获取任务并执行 JIT 编译。
  6. 编译完成后,方法可以通过编译后的入口执行。

需要注意:方法调用次数并不是唯一判断条件,编译策略还会考虑编译层级、历史运行信息、编译成本以及其他运行时状态。

3.2 循环回边计数与 OSR

除了热点方法,JVM 还需要识别热点循环。

例如:

java 复制代码
public static int loopCalculate() {

    int result = 0;

    for (int i = 0; i < 100000000; i++) {

        result += i;
    }

    return result;
}

即使一个方法只被调用一次,其内部循环也可能执行数千万次。

因此,HotSpot 会统计循环回边(Backedge)的执行情况。

当循环达到编译条件时,JVM 可以使用 OSR(On-Stack Replacement,栈上替换)。

OSR 的核心思想:

  • 不必等待整个方法执行结束。
  • 在循环运行期间触发编译。
  • 编译器针对循环入口(OSR BCI)生成机器码。
  • JVM 在适当的执行状态转换点,将解释器栈帧中的局部变量、操作数栈等状态映射到编译代码需要的执行状态。
  • 后续循环迭代可以直接执行编译后的代码。

学习总结 2: 热点识别有两个重要维度:方法调用频率和循环执行频率。OSR 使 JVM 能够优化已经运行很久的方法中的热点循环,而不必等到下一次方法调用。

3.3 对应 HotSpot 源码

重点阅读 OpenJDK 源码:

  • src/hotspot/share/compiler/compilationPolicy.hpp
  • src/hotspot/share/compiler/compilationPolicy.cpp
  • src/hotspot/share/compiler/compileBroker.cpp

其中,compilationPolicy.hpp 对分层编译级别有明确说明。

OpenJDK 中的相关定义可以概括为:

复制代码
// HotSpot 分层编译级别说明(对应 compilationPolicy.hpp)

// Level 0:解释器执行

// Level 1:C1 简单编译

// Level 2:C1 编译,带调用和循环回边计数信息

// Level 3:C1 编译,带完整运行时性能分析信息

// Level 4:C2 编译,使用完整 Profile 信息进行优化

这里需要特别理解 Level 3 和 Level 4 的关系:

  • Level 3 的代码可以持续收集运行时 Profile。
  • Level 4 的 C2 编译器可以利用这些 Profile 进行优化。
  • 编译器并不需要在每次方法调用时重新分析全部信息,而是利用已经积累的 MethodData。

源码依据:OpenJDK 的 compilationPolicy.hpp 对编译层级、MethodData 和计数器通知机制有明确说明。

image

GitHub+1

四、JIT 如何启动编译任务

当 JVM 判断一个方法需要编译后,并不会直接在解释器执行线程中完成全部编译工作。

HotSpot 通过 CompileBroker 统一管理编译任务。

典型流程如下:

4.1 CompileBroker 的职责

CompileBroker 是 HotSpot 编译系统的核心调度组件,主要负责:

  1. 接收编译请求。
  2. 创建 CompileTask。
  3. 根据编译级别选择 C1 或 C2 编译器。
  4. 将任务放入相应编译队列。
  5. 唤醒 CompilerThread。
  6. 管理编译任务的完成状态和编译结果。

4.2 源码分析:CompileTask

OpenJDK 源码位置:

src/hotspot/share/compiler/compileBroker.hpp

核心数据结构如下(省略非关键字段):

cpp 复制代码
// CompileTask:代表一个待处理或正在处理的编译任务

class CompileTask : public CHeapObj<mtCompiler> {

private:

    uint _compile_id;

    Method* _method;

    int _osr_bci;

    int _comp_level;

    bool _is_complete;

    bool _is_success;

public:

    Method* method() const {
        return _method;
    }

    int osr_bci() const {
        return _osr_bci;
    }

    int comp_level() {
        return _comp_level;
    }

    bool is_complete() const {
        return _is_complete;
    }
};

重点理解:

  • _method:需要编译的 Java 方法。
  • _osr_bci:OSR 编译对应的字节码位置,普通编译使用特殊入口标识。
  • _comp_level:编译层级。
  • _is_complete:编译任务是否完成。
  • _is_success:编译是否成功。

其中 OSR BCI 非常重要:普通编译通常针对方法入口,而 OSR 编译针对方法内部特定的循环位置。

4.3 编译线程如何执行任务

源码位置:

src/hotspot/share/compiler/compileBroker.cpp

其关键执行逻辑可以概括为:

cpp 复制代码
// CompileBroker::invoke_compiler_on_method()
// 以下为执行流程的简化说明,不是完整原始函数。

void invoke_compiler_on_method(CompileTask* task) {

    // 1. 取得当前编译任务对应的方法
    Method* method = task->method();

    // 2. 创建编译器接口环境
    // ciEnv 为编译器提供 JVM 元数据访问能力

    // 3. 根据编译层级获取 C1 或 C2 编译器
    AbstractCompiler* compiler =
        CompileBroker::compiler(task->comp_level());

    // 4. 调用具体编译器
    // 将 Java 方法及 OSR 入口信息交给编译器
    compiler->compile_method(
        /* ciEnv */,
        /* ciMethod */,
        task->osr_bci()
    );

    // 5. 编译结果由编译任务及编译环境管理
}

这里需要注意:实际 HotSpot 实现还涉及线程状态切换、异常处理、编译结果检查、任务完成通知等机制,上述代码是为了理解调用关系而简化的。

学习总结 3: CompileBroker 将 Java 方法执行与 JIT 编译解耦。Java 线程负责运行程序和触发编译请求,CompilerThread 在后台执行编译工作,从而减少编译过程对业务线程的直接影响。

源码参考:compileBroker.hpp、compileBroker.cpp。

image

GitHub+2

五、JIT 如何将 JVM 字节码转换为中间表示 IR

这是整个 JIT 编译过程最核心的技术环节。

JIT 并不是简单地读取一条字节码,就生成一条本地机器指令。

它通常需要经历:

  1. 读取字节码。
  2. 建立控制流图(CFG)。
  3. 建立中间表示(IR)。
  4. 进行数据流分析。
  5. 执行编译优化。

5.1 为什么需要中间表示 IR

假设 Java 程序如下:

java 复制代码
public static int add(int a, int b) {

    int result = a + b;

    return result;
}

对应的 JVM 字节码可以使用以下形式表示:

复制代码
iload_0       // 将第一个参数压入操作数栈
iload_1       // 将第二个参数压入操作数栈
iadd          // 执行整数加法
ireturn       // 返回计算结果

解释器执行时,主要按照 JVM 操作数栈模型处理这些指令。

但 JIT 编译器希望生成的机器码尽量减少不必要的内存访问。

例如,可以直接使用 CPU 寄存器:

复制代码
; 伪汇编,仅用于说明寄存器运算

add eax, edx

ret

这里的关键变化是:

  • JVM 字节码采用操作数栈模型。
  • JIT 中间表示可以采用显式数据依赖关系。
  • 后端根据目标 CPU 选择寄存器和机器指令。

所以,IR 是连接 JVM 字节码和 CPU 指令的重要桥梁。

5.2 C1 的 IR 处理过程

C1 编译器主要使用高级 IR 和低级 IR。

典型过程:

复制代码
JVM Bytecode
     |
     v
Bytecode Parser
     |
     v
HIR(High-level IR)
     |
     v
HIR Optimization
     |
     v
LIR(Low-level IR)
     |
     v
LIR Assembler
     |
     v
Native Machine Code

HIR 主要保留较高层次的程序语义,便于执行控制流和数据流优化。

LIR 则更加接近机器指令,方便寄存器分配和指令生成。

5.3 C2 的 IR 处理过程

C2 使用 Sea-of-Nodes 风格的 Ideal Graph 作为核心中间表示。

C2 的重要特点是将程序表示为节点之间的控制依赖和数据依赖关系。

例如:

复制代码
public static int calculate(int a, int b) {

    int x = a + b;

    int y = x * 2;

    return y;
}

可以抽象成:

复制代码
       Start
         |
         v
       Load a
         |
         v
       Load b
         |
         v
      AddNode
         |
         v
      MulNode(2)
         |
         v
       Return

这里是用于理解的简化数据依赖图,并非 C2 实际 Ideal Graph 的完整节点结构。

C2 可以利用图结构分析:

  • 哪些计算可以合并?
  • 哪些变量可以消除?
  • 哪些表达式可以常量折叠?
  • 哪些对象不需要真正分配?
  • 哪些方法调用可以内联?
  • 哪些控制流分支可以简化?

学习总结 4: IR 解决了 JVM 字节码与 CPU 指令之间的抽象差异。编译器先理解程序的语义和依赖关系,再决定如何生成机器指令,而不是进行简单的逐条翻译。

六、JIT 编译器如何进行热点代码优化

JIT 相比普通静态编译器的一个重要优势是:它能够利用程序运行期间收集到的信息进行优化。

这也是 HotSpot JIT 能够生成高性能机器码的重要原因。

6.1 方法内联(Method Inlining)

方法内联是 JIT 最重要的优化技术之一。

例如:

复制代码
public class InlineDemo {

    public static int add(int a, int b) {
        return a + b;
    }

    public static int calculate(int x) {

        // 频繁调用 add()
        return add(x, 10);
    }
}

如果 add() 方法足够简单,并且满足编译器的内联条件,JIT 可以将其直接展开。

原始代码:

复制代码
calculate(x)
    |
    +---- 调用 add(x, 10)
                 |
                 +---- return x + 10

内联之后:

复制代码
public static int calculate(int x) {

    // 编译器将 add() 的计算逻辑展开
    return x + 10;
}

内联带来的收益:

  1. 减少方法调用和返回的运行时开销。
  2. 让编译器看到更大的优化范围。
  3. 为常量传播、死代码消除等优化创造条件。
  4. 有机会消除部分参数传递和返回值处理开销。

需要注意,方法内联并不是无限制展开。编译器会考虑方法大小、调用频率、内联深度、代码膨胀等因素。

6.2 类型推测与去虚拟化(Devirtualization)

Java 的多态调用可能需要根据对象的实际类型确定调用目标。

例如:

复制代码
interface Calculator {

    int calculate(int value);
}

class AddCalculator implements Calculator {

    public int calculate(int value) {

        return value + 10;
    }
}

public class DispatchDemo {

    public static int execute(
            Calculator calculator,
            int value) {

        return calculator.calculate(value);
    }
}

正常情况下,calculator.calculate() 属于接口调用。

JVM 可以通过 MethodData 收集调用点的类型分布。

假设运行期间观察到:

复制代码
AddCalculator:99.9%
其他类型:0.1%

C2 可能生成类似下面的优化逻辑:

复制代码
检查 calculator 的实际类型
          |
          v
是否为 AddCalculator?
     /          \
   是            否
   |             |
内联执行       慢路径
   |             |
   +------> 继续执行

这称为基于类型推测的优化。

其优势是将原本动态的虚方法调用转换为带类型检查的快速路径,甚至直接内联调用目标。

但这不是无条件成立的。JIT 必须保留正确的慢路径或依赖失效机制,保证其他类型出现时程序仍符合 Java 语义。

6.3 逃逸分析(Escape Analysis)

逃逸分析用于判断对象是否会逃逸当前方法或线程。

例如:

复制代码
public static int calculate() {

    Point point = new Point(10, 20);

    return point.x + point.y;
}

如果 JIT 确认 point 不会逃逸,并且能够安全地消除其对象分配,那么可能进行标量替换。

原始代码:

复制代码
Point point = new Point(10, 20);

return point.x + point.y;

优化后的逻辑可以近似理解为:

复制代码
int x = 10;

int y = 20;

return x + y;

这样可能消除:

  • 堆对象分配。
  • 对象字段访问。
  • 与该对象相关的部分 GC 压力。

注意:逃逸分析本身并不等于对象一定会被分配到栈上。HotSpot 常见的优化是标量替换,即消除对象分配并将字段拆解为独立值。

6.4 循环优化(Loop Optimization)

循环通常是 CPU 时间消耗较大的地方。

例如:

复制代码
public static int sum(int[] values) {

    int result = 0;

    for (int i = 0; i < values.length; i++) {

        result += values[i];
    }

    return result;
}

JIT 可能执行以下优化:

优化技术 主要目的
循环不变式外提 将循环中不变的计算移到循环外
范围检查消除 在能够证明安全时减少数组越界检查
循环展开 减少循环控制和分支开销
强度削弱 使用更低成本的运算代替昂贵运算
向量化 在满足条件时利用 SIMD 指令

例如,数组循环中可能涉及每次访问的边界检查。

如果编译器能够证明索引始终在合法范围内,就可能消除部分冗余检查。

但要注意,具体优化能否发生取决于循环结构、数据依赖、别名分析结果以及目标架构。

6.5 常量传播和死代码消除

例如:

复制代码
public static int calculate() {

    int a = 10;

    int b = 20;

    int c = a + b;

    int d = c * 2;

    return d;
}

编译器可以进行:

复制代码
a = 10
b = 20
c = 30
d = 60

进一步生成等价于:

复制代码
public static int calculate() {

    return 60;
}

这些优化包括:

  • 常量折叠(Constant Folding)。
  • 常量传播(Constant Propagation)。
  • 死代码消除(Dead Code Elimination)。
  • 公共子表达式消除(Common Subexpression Elimination)。

6.6 C2 编译优化需要重点掌握的源码

建议重点阅读以下 OpenJDK 文件:

源码文件 学习重点
src/hotspot/share/opto/compile.cpp C2 编译主流程
src/hotspot/share/opto/parse1.cpp 字节码解析及初始图构建
src/hotspot/share/opto/parse2.cpp 控制流和字节码解析
src/hotspot/share/opto/loopnode.cpp 循环优化
src/hotspot/share/opto/escape.cpp 逃逸分析
src/hotspot/share/opto/phaseX.cpp 图优化相关阶段
src/hotspot/share/opto/callGenerator.cpp 调用生成及内联相关逻辑

建议重点建立这样的认识:

复制代码
Java 字节码
    |
    v
C2 Parser
    |
    v
Ideal Graph
    |
    v
类型分析与数据流分析
    |
    v
方法内联
    |
    v
循环优化、逃逸分析等
    |
    v
Ideal Graph 优化

学习总结 5: JIT 的性能优势不只是来自机器码执行速度,更来自运行时 Profile 驱动的推测性优化。方法内联、去虚拟化、逃逸分析和循环优化共同决定了热点代码最终的执行效率。

七、JIT 如何将 IR 转换为 CPU 本地机器码

当 IR 优化完成后,就进入编译后端。

这一阶段的任务是:将平台无关或相对平台无关的中间表示,转换为目标 CPU 能够执行的机器指令。

7.1 指令选择(Instruction Selection)

编译器需要根据 IR 节点选择适合目标 CPU 的指令。

例如:

复制代码
int result = a + b;

对于 x86-64,可能生成:

复制代码
add eax, edx

对于 AArch64,可能生成:

复制代码
add w0, w1, w2

这里展示的是示意性汇编,实际寄存器分配及指令形式由参数传递约定、数据类型、优化结果和上下文决定。

7.2 寄存器分配(Register Allocation)

CPU 寄存器的访问速度通常比内存快。

因此,JIT 需要决定:

  • 哪些变量放入 CPU 寄存器?
  • 哪些中间结果可以复用寄存器?
  • 哪些变量需要暂时保存到栈中?
  • 哪些寄存器必须在方法调用时保存?

如果寄存器不足,就可能发生 Spill(溢出),即将部分变量暂时保存到栈内存。

寄存器分配质量会直接影响生成机器码的性能。

7.3 指令调度(Instruction Scheduling)

编译器还需要考虑指令之间的依赖关系。

例如:

复制代码
指令 A:读取数据
指令 B:执行加法
指令 C:执行乘法

如果 B 和 C 之间没有数据依赖,编译器可能调整其顺序,以便更好地利用 CPU 执行资源。

但指令调度不能违反 Java 程序语义、内存模型以及目标处理器的约束。

特别是涉及 volatile、锁和内存屏障时,必须保证对应的内存排序语义。

7.4 C1 后端源码分析

OpenJDK 源码:

src/hotspot/share/c1/c1_LIRGenerator.cpp

以及:

  • src/hotspot/share/c1/c1_LIRAssembler.cpp
  • src/hotspot/cpu/x86/c1_LIRAssembler_x86.cpp
  • src/hotspot/cpu/aarch64/c1_LIRAssembler_aarch64.cpp

C1 的典型编译后端流程:

复制代码
HIR
 |
 v
LIRGenerator
 |
 v
LIR
 |
 v
LIRAssembler
 |
 v
目标 CPU 汇编指令
 |
 v
机器码

例如,LIRGenerator 将高级 IR 转换为 LIR 指令,而 LIRAssembler 根据目标架构将 LIR 转换为机器码。

OpenJDK 的 c1_LIRGenerator.cpp 可以作为理解这一流程的重要源码入口。

image

GitHub

7.5 C2 后端源码分析

C2 后端重点阅读:

复制代码
src/hotspot/share/opto/
    compile.cpp
    matcher.cpp
    output.cpp

src/hotspot/cpu/x86/
    x86.ad

src/hotspot/cpu/aarch64/
    aarch64.ad

需要理解几个核心概念:

概念 作用
Ideal Graph 优化后的中间表示
Matcher 将 Ideal 节点匹配为机器指令模式
AD 文件 描述目标架构的指令模式和生成规则
Register Allocator 分配 CPU 寄存器
Code Buffer 保存最终生成的机器码及相关代码数据

C2 使用目标架构相关的指令描述规则完成指令匹配和生成。

例如:

复制代码
Ideal Graph
     |
     v
Matcher
     |
     v
x86-64 / AArch64 指令匹配
     |
     v
寄存器分配与机器码生成
     |
     v
机器码及相关元数据

学习总结 6: JIT 编译后端是一个与硬件架构紧密相关的模块。同一份 Java 字节码,在 x86-64 和 AArch64 上可能生成完全不同的机器指令,但必须保持相同的 Java 语义。

八、机器码如何安装到 Code Cache

JIT 生成机器码之后,还不能简单地认为编译过程已经结束。

编译结果需要经过管理、分配、安装和发布,才能成为 JVM 可执行的编译代码。

8.1 什么是 Code Cache

Code Cache 是 HotSpot JVM 用来存放本地代码的专用内存区域。

其中包含:

  • JIT 编译产生的 nmethod。
  • JVM 内部使用的 Stub。
  • 其他运行时本地代码。

在启用分层编译的典型 HotSpot 配置中,Code Cache 可以划分为不同区域:

区域 主要用途
Non-profiled Code Heap 非性能分析型编译代码
Profiled Code Heap 带性能分析信息的编译代码
Non-nmethod Code Heap JVM 内部 Stub 等非 nmethod 代码

具体布局取决于 JVM 版本和配置。

8.2 nmethod 是什么

nmethod 是 HotSpot 中表示已编译 Java 方法的核心数据结构。

它不只是机器码,还包含执行和运行时管理所需的信息。

例如:

  • 机器码入口地址。
  • 机器码范围。
  • 异常处理信息。
  • GC 所需的 OopMap。
  • 调试信息。
  • 内联方法对应的作用域信息。
  • 反优化所需的相关元数据。

这意味着,JIT 编译结果不仅需要让 CPU 能执行,还需要让 JVM 能够继续管理 Java 程序的运行状态。

8.3 源码学习重点

建议阅读:

复制代码
src/hotspot/share/code/nmethod.hpp
src/hotspot/share/code/nmethod.cpp
src/hotspot/share/code/codeCache.hpp
src/hotspot/share/code/codeCache.cpp
src/hotspot/share/compiler/compileBroker.cpp

理解以下关系:

复制代码
C1 / C2 编译器
       |
       v
生成机器码和编译元数据
       |
       v
构建 nmethod
       |
       v
Code Cache 分配空间
       |
       v
完成安装和发布
       |
       v
更新方法入口及相关调用信息
       |
       v
后续执行进入编译代码

这里需要注意:方法入口切换不是简单地将 Java 方法对象替换成一段机器码。HotSpot 还需要处理入口状态、调用链接、编译层级、代码有效性以及并发执行等问题。

学习总结 7: Code Cache 是 JIT 生成代码的运行时存储基础设施,nmethod 则是 JVM 对编译后方法的管理载体。机器码、GC 信息、调试信息和反优化信息必须协同工作。

九、JIT 编译后的机器码如何被 CPU 执行

机器码安装完成后,JVM 需要保证后续方法调用能够进入编译后的代码。

其基本过程如下:

复制代码
Java 方法调用
     |
     v
JVM 方法入口
     |
     v
检查当前方法的执行状态
     |
     v
选择解释器入口或编译代码入口
     |
     v
进入 nmethod
     |
     v
CPU 执行本地机器指令
     |
     v
返回执行结果

这里需要区分两个概念:

第一,解释器入口和编译代码入口。

方法可能存在解释执行入口、编译执行入口等不同入口形式。JVM 通过方法入口及相关调用机制,决定如何进入对应执行路径。

第二,Java 方法调用不一定直接跳转到 nmethod。

在 HotSpot 中,方法调用还可能经过调用 Stub、方法分派、内联缓存(Inline Cache)等机制。

如果一个方法已经被内联到调用者的机器码中,那么执行时甚至不需要发生独立的方法调用。

9.1 内联缓存(Inline Cache)

假设程序存在:

复制代码
interface Service {

    void execute();
}

如果某个调用点长期只调用一个实现类,JIT 可以针对这个调用点进行类型推测和内联优化。

后续执行时:

  • 如果类型符合预测,走快速路径。
  • 如果类型不符合预测,走其他调用路径。
  • 如果之前的优化假设失效,可能触发反优化。

这也是为什么 JVM 的机器码并不是完全静态不变的:它会随着运行时信息变化而发生更新、失效和重新编译。

十、为什么 JIT 需要反优化 Deoptimization

这是理解 JIT 的另一个重要知识点。

JIT 为了提高性能,会利用运行时信息做出一些推测。

例如:

复制代码
public static int calculate(Service service) {

    service.execute();

    return 100;
}

假设 JVM 观察到:

复制代码
ServiceA:99.9%
ServiceB:0.1%

C2 可能假设当前调用点主要执行 ServiceA,并将 ServiceA 的方法内联。

但如果后续突然出现 ServiceB,之前的优化假设可能不再成立。

JVM 必须保证程序仍然按照 Java 语义正确执行。

这时可能触发 Deoptimization。

10.1 反优化的基本过程

复制代码
执行 JIT 编译后的机器码
          |
          v
运行时检查优化假设
          |
          v
发现假设不成立
          |
          v
触发 Uncommon Trap
          |
          v
进入 Deoptimization
          |
          v
恢复 Java 执行状态
          |
          v
重建解释器栈帧
          |
          v
继续解释执行
          |
          v
必要时重新编译

这里最重要的是恢复执行状态。

JIT 编译时可能已经:

  • 内联多个 Java 方法。
  • 消除部分对象分配。
  • 将局部变量放入 CPU 寄存器。
  • 消除部分锁操作。
  • 改变原始字节码对应的执行结构。

因此,反优化需要根据编译过程中记录的调试信息,恢复 Java 虚拟机栈帧、局部变量和操作数栈等执行状态。

10.2 源码分析

重点阅读:

src/hotspot/share/runtime/deoptimization.cpp

相关核心逻辑:

复制代码
// Deoptimization::fetch_unroll_info_helper()
// 以下是根据源码逻辑整理的学习说明,不是完整原始函数。

// 1. 获取发生反优化的编译栈帧
frame deoptee = stub_frame.sender(&map);

// 2. 获取对应的 nmethod
nmethod* nm = deoptee.cb()->as_nmethod_or_null();

// 3. 根据编译时保存的 ScopeDesc 等信息
//    识别内联调用对应的 Java 虚拟栈帧

// 4. 创建 compiledVFrame 等结构
//    描述原始 Java 方法的执行状态

// 5. 创建 vframeArray
//    为恢复解释器栈帧准备数据

// 6. unpack_frames()
//    将恢复后的解释器栈帧重新放回线程栈

特别需要关注源码中的:

  • ScopeDesc
  • compiledVFrame
  • vframeArray
  • UnrollBlock
  • unpack_frames()

这些结构和过程共同完成从编译执行状态到解释执行状态的转换。

例如,HotSpot 在反优化过程中可以根据编译时保存的作用域信息,重新构建被内联的 Java 方法对应的逻辑栈帧。

源码参考:deoptimization.cpp 和 deoptimization.hpp。

image

GitHub+1

学习总结 8: 反优化是 JIT 推测性优化的重要保障。JIT 可以大胆利用运行时信息优化机器码,但必须保留能够恢复 Java 程序语义的机制。

十一、通过实验观察 JIT 编译过程

对于系统工程师而言,学习 JIT 不能只阅读源码,还应该通过 JVM 参数、编译日志和汇编输出观察实际执行过程。

11.1 编写测试程序

复制代码
public class JitTest {

    public static int calculate(int a, int b) {

        return a * 2 + b;
    }

    public static void main(String[] args) {

        long result = 0;

        for (int i = 0; i < 10000000; i++) {

            result += calculate(i, 10);
        }

        System.out.println(result);
    }
}

编译:

复制代码
javac JitTest.java

执行:

复制代码
java JitTest

11.2 查看 JIT 编译日志

复制代码
java -XX:+PrintCompilation JitTest

可以观察到类似信息:

复制代码
     45    1       3       JitTest::calculate
     46    2       3       JitTest::main
     52    3       4       JitTest::calculate

这只是示意输出,实际编译时间、编译 ID、层级和方法可能不同。

重点理解:

输出信息 含义
时间 JVM 启动后的编译时间信息
编译 ID 编译任务编号
编译级别 例如 3 表示 C1 完整 Profile 层级,4 表示 C2
方法名 被编译的 Java 方法

如果同一个方法先出现 Level 3,之后出现 Level 4,通常说明它经历了分层编译。

11.3 查看 JIT 编译详细日志

JDK 9 及之后版本可以使用统一日志:

复制代码
java -Xlog:jit+compilation=debug JitTest

观察:

  • 哪些方法触发编译。
  • 哪些方法被重新编译。
  • 编译级别发生了什么变化。
  • 编译任务何时完成。

11.4 查看机器码

在具备 hsdis 等反汇编支持的 HotSpot 环境中,可以尝试:

复制代码
java \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintAssembly \
  JitTest

也可以使用 JMH 配合 -prof perfasm 分析热点代码的汇编执行情况。

重点观察:

  1. Java 方法是否已经被 JIT 编译。
  2. 是否发生方法内联。
  3. 是否存在数组边界检查。
  4. 是否出现条件跳转。
  5. 是否存在寄存器溢出。
  6. 是否使用 SIMD 指令。

注意:PrintAssembly 需要相应的反汇编支持,而且不同 JDK 构建可能存在差异。

11.5 进一步观察 Code Cache

可以使用:

复制代码
jcmd <pid> Compiler.codecache

查看 JVM Code Cache 的使用情况。

也可以结合:

复制代码
jcmd <pid> Compiler.queue

观察编译队列。

通过这些工具,可以从运行时角度理解编译任务、编译结果以及代码缓存之间的关系。

十二、从系统工程师角度建立完整知识体系

前面分析的是 JIT 的内部执行过程。进一步学习时,应该将其与 JVM、操作系统和 CPU 结合起来。

12.1 JIT 与 JVM 内存管理

JIT 不仅生成机器码,还需要维护与 GC 相关的元数据。

例如:

  • OopMap:描述特定机器码位置上的对象引用。
  • Safepoint:支持线程在安全位置暂停。
  • nmethod:维护编译方法及其运行时信息。
  • Code Cache:管理编译代码的内存生命周期。

特别是 OopMap,它对于 GC 扫描编译代码执行时的线程栈和寄存器非常重要。

因此,JIT 生成的机器码必须能够与 JVM 的垃圾回收机制协同工作。

12.2 JIT 与 CPU 架构

同样的 Java 字节码,在不同 CPU 架构上生成的机器码可能完全不同。

比较维度 x86-64 AArch64
指令集 CISC 风格 RISC 风格
通用寄存器 16 个主要通用寄存器 31 个通用寄存器
内存排序 相对较强 相对较弱
内存屏障 根据语义选择相应指令 根据语义选择相应指令
SIMD SSE、AVX 等 NEON、SVE 等

对于系统工程师,尤其需要理解:

  • CPU 指令选择如何影响性能。
  • 寄存器分配如何影响内存访问。
  • 内存屏障如何实现 JMM 语义。
  • CPU 缓存和内存访问延迟如何影响热点代码。
  • 不同架构的指令吞吐量和执行延迟如何影响优化效果。

12.3 JIT 与操作系统

JIT 运行还涉及操作系统底层能力:

  1. 可执行内存的申请与管理。
  2. 内存页权限控制。
  3. 代码缓存映射。
  4. 线程调度和编译线程并发。
  5. CPU 缓存及指令缓存同步。
  6. JIT 代码生成期间的内存管理。

尤其值得关注的是 W^X(Write XOR Execute)安全机制:代码生成阶段需要写入机器码,执行阶段则需要具备执行权限。具体内存权限切换和代码发布策略与操作系统、CPU 架构及 HotSpot 实现有关。

十三、OpenJDK 源码学习路线

建议基于同一个 OpenJDK 版本阅读源码,避免将 JDK 8u、JDK 17、JDK 21 和当前主线的实现细节混在一起。

以下以当前 OpenJDK 主线的 src/hotspot 目录组织为学习参考。

学习阶段 源码路径 核心学习内容
1. 热点识别 compiler/compilationPolicy.cpp 热点判断和编译策略
2. 编译调度 compiler/compileBroker.cpp 编译请求及任务调度
3. 编译接口 ci/ciEnv.cpp、ci/ciMethod.cpp JVM 元数据访问
4. C1 编译 c1/c1_Compilation.cpp C1 编译流程
5. C1 后端 c1/c1_LIRGenerator.cpp HIR 到 LIR
6. C2 编译 opto/compile.cpp C2 编译主流程
7. C2 解析 opto/parse1.cpp、opto/parse2.cpp 字节码与图构建
8. 优化 opto/escape.cpp、opto/loopnode.cpp 逃逸分析、循环优化
9. 指令生成 opto/matcher.cpp、opto/output.cpp 指令匹配及机器码生成
10. 代码安装 code/nmethod.cpp、code/codeCache.cpp nmethod 和 Code Cache
11. 反优化 runtime/deoptimization.cpp 恢复解释执行状态

源码可以从 OpenJDK 官方 GitHub 仓库 获取。

建议按以下顺序阅读:

复制代码
compilationPolicy
       |
       v
compileBroker
       |
       v
c1_Compilation / C2Compiler
       |
       v
ciEnv / ciMethod
       |
       v
C1 HIR / C2 Ideal Graph
       |
       v
优化阶段
       |
       v
LIR / Matcher
       |
       v
机器码生成
       |
       v
nmethod / CodeCache
       |
       v
Deoptimization

十四、学习总结与核心知识点

JIT 编译完整流程

  1. 热点识别

    Invocation Counter、Backedge Counter、MethodData。

  2. 编译请求

    Compilation Policy 判断编译条件,CompileBroker 调度任务。

  3. 字节码解析

    编译器读取字节码、方法元数据及运行时 Profile。

  4. 构建中间表示

    C1 构建 HIR/LIR,C2 构建 Ideal Graph。

  5. 编译优化

    方法内联、常量传播、逃逸分析、循环优化、去虚拟化。

  6. 机器码生成

    指令选择、寄存器分配、指令调度、目标架构代码生成。

  7. 代码安装

    生成 nmethod,安装到 Code Cache。

  8. 本地执行与反优化

    CPU 执行机器码;优化假设失效时恢复 Java 执行状态。

必须掌握的 10 个知识点

序号 知识点 掌握要求
1 热点方法识别 理解方法计数和循环回边计数
2 分层编译 理解 C1、C2 和 Level 0~4
3 OSR 理解运行中循环如何切换到编译代码
4 CompileBroker 理解编译任务调度
5 IR 理解 HIR、LIR 和 Ideal Graph
6 方法内联 理解调用展开及其优化价值
7 逃逸分析 理解标量替换和锁消除
8 机器码生成 理解指令选择和寄存器分配
9 Code Cache 理解 nmethod 的安装与生命周期
10 Deoptimization 理解反优化及解释器栈帧恢复

最终学习心得

从系统工程师角度看,JIT 编译器实际上是 JVM 中连接 Java 字节码、运行时性能分析、编译优化和 CPU 硬件执行的重要桥梁。

它通过热点识别发现高频执行代码,利用运行时 Profile 获取类型和调用信息,再通过 C1、C2 编译器构建中间表示并实施优化,最终生成适合目标 CPU 架构的本地机器码。

更重要的是,JIT 并不是一次性完成代码转换的静态过程,而是一个持续运行、动态优化和能够反优化的系统。

真正需要掌握的是三个层次:

  1. JVM 层: 热点识别、分层编译、编译调度、Code Cache 和反优化。
  2. 编译器层: 字节码解析、IR 构建、数据流分析、方法内联和机器码生成。
  3. 硬件层: CPU 指令集、寄存器分配、缓存、内存屏障和指令执行。

只有将这三个层次结合起来,才能真正理解 Java 程序为什么能够通过 JIT 编译获得接近本地代码的执行效率,以及为什么同一个 Java 程序在不同 JVM 和 CPU 架构上可能产生不同的性能表现。

建议下一阶段学习:JIT 编译优化与 CPU 执行机制的结合,重点研究 C2 Ideal Graph、Matcher、寄存器分配、CPU 指令生成,以及 JMM 内存语义如何落实到最终机器指令。

相关推荐
真上帝的左手1 小时前
2. 编程语言-Java-Spring Boot-JAR
java·spring boot·jar
Jack_hsfs1 小时前
第6章Spring 管理的是对象,还是对象的说明书?
java·后端·spring
kiros_wang1 小时前
通用工具模块封装:防抖节流、参数校验公共工具类
linux·运维·ubuntu
Tairitsu_H1 小时前
[C++高阶数据结构·图论上] 最小生成树:为什么“选边”和“选点”是两条路?
数据结构·c++·图论·最小生成树
佳得动1 小时前
编译和链接的认识(C语言)
c语言·开发语言
honsor1 小时前
以太网温湿度传感器|动环监控核心采集设备,协议、组网、Zabbix接入实战
linux·服务器·ubuntu
Android系统攻城狮1 小时前
Linux FFmpeg深度解析之avcodec_default_get_buffer2调用流程与实战(二十)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
深圳恒讯1 小时前
香港云主机硬盘满了怎么办?Linux和Windows在线扩容操作教程
linux·运维·windows
十二同学啊1 小时前
Python 工程化详解:项目结构、依赖管理、代码质量、测试与部署
java·开发语言·python