前言

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

相同的 Java 程序在不同 JVM、不同 CPU 架构上可能具有不同的执行路径和性能的原因分析

学习方向:JVMS、HotSpot、JIT 编译、CPU 指令集与系统性能分析

一、核心认识:Java 跨平台不等于执行过程完全一致

从系统工程师角度理解 Java 跨平台,首先需要明确一个重要概念:

Java 跨平台保证的是程序具有相对统一的运行规范,而不是保证不同 JVM、不同操作系统和不同 CPU 架构采用完全相同的执行路径,更不保证具有相同的执行性能。

同一份 Java 源代码经过 javac 编译后,可以生成相同或等价的 Class 文件。但是,当 Class 文件进入不同 JVM 后,其实际执行过程可能发生变化。

例如:

  • 不同 JVM 可以采用不同的解释器实现。
  • 不同 JVM 可以采用不同的 JIT 编译策略。
  • 不同 JVM 可以采用不同的垃圾收集器。
  • 不同 CPU 架构具有不同的指令集和内存排序约束。
  • 不同操作系统具有不同的线程调度、内存管理和系统调用实现。
  • JIT 编译器会根据运行时数据、CPU 特性和代码执行情况生成不同的机器码。

因此,需要区分三个层次:

  1. 规范层: JVMS 和 Java API 定义程序应当遵守的语义。
  2. 虚拟机实现层: JVM 决定如何加载、解释、编译和管理 Java 程序。
  3. 硬件执行层: CPU 根据目标指令集执行本地机器码。

从系统工程角度,研究性能差异,实际上就是研究这三个层次之间的实现差异。

二、Java 程序在不同 JVM 和 CPU 上的执行过程

在这里插入图片描述

整体执行链路可以理解为:

             Java 源代码
                  |
                  v
              javac 编译
                  |
                  v
             Class 文件
              字节码
                  |
          +-------+-------+
          |               |
          v               v
       JVM A            JVM B
          |               |
       类加载            类加载
          |               |
       字节码解释        字节码解释
          |               |
       JIT 编译          JIT 编译
          |               |
       优化策略 A        优化策略 B
          |               |
          v               v
      x86-64 机器码    AArch64 机器码
          |               |
          v               v
       x86 CPU          ARM CPU

需要注意:图中是概念性执行链路。实际 JVM 可以同时使用解释执行和编译执行,也可能通过分层编译、OSR(On-Stack Replacement)等机制改变正在运行的方法的执行方式。

学习重点:Class 文件相同,不代表最终生成的机器码相同;机器码不同,也不代表 Java 程序的外部可观察语义可以随意改变。

三、不同 JVM 为什么会产生不同的执行路径

JVM 规范定义了虚拟机应当遵守的行为,但没有要求所有 JVM 必须采用同一种内部实现。

JVMS 的核心思想是:

实现可以不同,但必须保持规定的字节码语义。

例如,同一个 Class 文件,可以由 HotSpot、OpenJ9 或 GraalVM 运行。它们可能采用不同的解释器、编译器、内存管理机制和运行时优化策略。只要符合相应的 Java 平台规范,就不要求其内部执行过程完全一致。

3.1 JVM 执行引擎不同

以 OpenJDK HotSpot 为例,其执行引擎主要包含:

执行组件主要职责性能特点
Interpreter解释执行字节码启动成本低,执行效率通常较低
C1 Compiler快速 JIT 编译编译速度快,适合快速优化
C2 Compiler高度优化 JIT 编译编译成本较高,适合热点代码
Code Cache保存编译后的机器码支持后续直接执行编译代码

HotSpot 可以通过分层编译,在不同执行阶段选择不同的执行方式。

典型的分层编译等级如下:

编译等级执行方式作用
Level 0Interpreter解释执行并收集运行信息
Level 1C1快速编译,不进行完整性能分析
Level 2C1收集部分运行信息
Level 3C1收集较完整的运行信息
Level 4C2基于运行信息进行深度优化

其基本执行过程:

          Java 方法首次执行
                  |
                  v
             Interpreter
                  |
             收集运行信息
                  |
                  v
              C1 编译
                  |
             继续收集信息
                  |
                  v
              C2 编译
                  |
                  v
             优化机器码
                  |
                  v
              CPU 执行

注意:这不是每个方法都必须经过的固定流程。方法可能停留在解释执行或 C1,也可能根据运行状态直接进入其他编译等级。

HotSpot 还会根据编译队列长度、方法调用次数、循环回边计数器等因素动态调整编译决策。

3.2 不同 JVM 的优化策略不同

即使运行相同的 Java 代码,不同 JVM 也可能采用不同的优化策略。

例如:

(1)方法内联(Inlining)

假设存在以下代码:

public class Calculator {

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

    public int calculate(int x) {
        // JIT 有可能将 add() 方法内联到这里
        return add(x, 10);
    }
}

解释执行时,可能需要通过方法调用机制执行 add()。

JIT 编译后,可能将其优化为类似:

public int calculate(int x) {
    return x + 10;
}

这属于概念性优化结果,不代表所有 JVM 都一定生成完全相同的机器码。

不同 JVM 的内联阈值、调用点分析、编译成本模型可能不同,因此可能产生不同的优化结果。

(2)逃逸分析(Escape Analysis)

public class PointTest {

    public int calculate() {

        // Point 对象只在当前方法中使用
        Point point = new Point(10, 20);

        return point.x + point.y;
    }

    static class Point {
        int x;
        int y;

        Point(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }
}

如果 JIT 能够证明对象不会逃逸到当前方法之外,就可能通过标量替换等优化消除对象分配。

需要注意:

  • 标量替换不等于将 Java 对象真正分配到栈上。
  • 逃逸分析不一定能够消除所有对象分配。
  • 不同 JVM 的逃逸分析能力和优化条件可能不同。

(3)循环优化

public class LoopTest {

    public long sum(int[] array) {

        long result = 0;

        for (int i = 0; i < array.length; i++) {
            result += array[i];
        }

        return result;
    }
}

不同 JIT 编译器可能采用:

  • 循环展开(Loop Unrolling)。
  • 循环不变量外提(Loop Invariant Code Motion)。
  • 范围检查消除(Range Check Elimination)。
  • 自动向量化(Auto Vectorization)。

这些优化会改变最终机器指令的数量和执行顺序,但必须保证 Java 语义要求的结果。

3.3 不同 JVM 的垃圾收集器也会影响性能

垃圾收集器虽然不直接决定每条 Java 字节码如何执行,但会影响整个程序的运行表现。

例如:

GC 类型主要特点关注指标
Serial GC单线程收集小型堆、简单场景
Parallel GC关注吞吐量总体处理能力
G1 GC分区管理,兼顾吞吐量和停顿延迟与吞吐量平衡
ZGC面向低停顿目标尾延迟、停顿时间

同一个 Java 程序,在不同 GC 配置下可能出现不同的:

  • Young GC 频率。
  • Full GC 频率。
  • 对象分配成本。
  • GC CPU 占用。
  • STW 停顿时间。
  • 内存占用情况。

例如,两个 JVM 的业务代码执行时间可能非常接近,但由于 GC 策略不同,最终请求的 P99 延迟可能出现明显差异。

系统工程师需要注意:应用性能不仅取决于 JIT 生成的机器码,也取决于 JVM 的内存管理和垃圾收集行为。

四、不同 CPU 架构为什么会产生不同的机器码

这是理解 Java 跨平台性能差异的核心。

JVM 最终需要把字节码转换为目标 CPU 能够执行的本地机器指令。

不同 CPU 架构具有不同的指令集、寄存器组织、内存模型和硬件执行机制,因此同一段 Java 字节码可能生成完全不同的机器码。

4.1 x86-64 与 AArch64 指令集不同

X86-64
在这里插入图片描述

AArch64
在这里插入图片描述

两种架构的主要差异:

对比维度x86-64AArch64
指令编码变长指令固定长度的基础指令编码
通用寄存器16 个主要 64 位通用寄存器31 个 64 位通用寄存器(X0~X30)
内存访问支持多种内存操作数形式典型采用 Load/Store 模型
SIMDSSE、AVX、AVX2、AVX-512 等NEON、SVE、SVE2 等
原子操作LOCK 前缀等机制LSE 等原子指令扩展
内存排序相对较强相对较弱,需要更明确的排序指令

例如,Java 代码:

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

在 x86-64 上,JIT 可能生成类似:

; x86-64 示例
mov eax, edi
add eax, esi
ret

在 AArch64 上,可能生成类似:

// AArch64 示例
add w0, w0, w1
ret

这里展示的是简化的示意指令,实际寄存器分配、参数传递和指令选择由 JVM 编译器与平台 ABI 决定。

可以发现:

  • Java 源代码相同。
  • Java 字节码语义相同。
  • 目标 CPU 指令集不同。
  • JIT 生成的机器指令不同。

因此,最终执行路径和硬件执行成本也可能不同。

4.2 CPU 指令集扩展影响性能

现代 CPU 通常具有多种指令集扩展。

例如:

指令集主要用途
SSESIMD 数据并行计算
AVX / AVX2更宽的向量计算
AVX-512更宽的 SIMD 运算
NEONARM SIMD 运算
SVE / SVE2ARM 可扩展向量计算

假设 Java 程序执行大量数组计算:

public void addArrays(
        float[] a,
        float[] b,
        float[] result) {

    for (int i = 0; i < a.length; i++) {
        result[i] = a[i] + b[i];
    }
}

普通执行方式可能逐个处理数组元素。

如果 JVM 能够进行有效的循环向量化,就可能一次处理多个元素。

例如:

普通执行:

a[0] + b[0]
a[1] + b[1]
a[2] + b[2]
a[3] + b[3]

向量化执行:

[ a0, a1, a2, a3 ]
          +
[ b0, b1, b2, b3 ]
          |
          v
[ r0, r1, r2, r3 ]

但需要注意,向量化并不是一定发生的。

它取决于:

  1. JIT 编译器是否支持相应优化。
  2. CPU 是否支持对应的向量指令。
  3. 循环结构是否满足向量化条件。
  4. 数据对齐、别名分析和边界处理是否允许优化。
  5. 向量化成本是否低于普通标量执行成本。

因此,即使运行相同的 Java 数组计算程序,在不同 CPU 上也可能表现出不同的吞吐量。

4.3 CPU 内存模型不同,影响并发执行

这一部分与之前学习的 JMM、Happens-Before、Volatile、OrderAccess 有直接关系。

Java 内存模型定义的是 Java 程序的内存语义,而 CPU 内存模型定义的是硬件层面的内存访问和排序约束。

两者不是同一个概念。

以 x86-64 和 AArch64 为例:

  • x86-64 通常采用较强的 TSO 内存排序模型。
  • AArch64 允许更多种类的内存访问重排序。
  • AArch64 在需要特定顺序保证时,可能使用 DMB、LDAR、STLR 等指令。
  • x86-64 则可能根据操作类型使用普通 Load/Store、MFENCE、带锁原子指令等机制。

例如:

public class VisibilityTest {

    private int data = 0;

    private volatile boolean ready = false;

    // 线程 A
    public void producer() {

        data = 100;

        // volatile 写:发布之前的数据
        ready = true;
    }

    // 线程 B
    public void consumer() {

        // volatile 读:获取发布状态
        if (ready) {
            System.out.println(data);
        }
    }
}

这里的关键是:

volatile 写与后续观察到该写入的 volatile 读之间建立同步关系,保证 data = 100 对消费者线程可见。

从底层实现来看:

Java volatile 写
       |
       v
JIT 内存排序约束
       |
       v
目标架构对应的屏障或有序访存指令
       |
       v
CPU 执行

在 x86-64 和 AArch64 上,底层实现可能不同,但必须满足 Java Memory Model 的要求。

重要结论:不能因为 x86-64 的内存排序相对较强,就认为 Java 程序可以不使用 volatile、锁或其他正确同步机制。

硬件实现不能替代 Java 程序的同步语义。

五、结合 OpenJDK HotSpot 源码分析执行路径差异

从系统工程师角度,不能只停留在理论层面,还需要知道 JVM 源码中哪些位置决定了执行路径。

以下以 OpenJDK HotSpot 为例。

5.1 分层编译策略源码

源码位置:

src/hotspot/share/compiler/compilationPolicy.hpp

可以重点关注编译策略及编译等级的定义。

OpenJDK 中的编译策略会根据方法运行时收集的数据决定后续编译行为。

核心逻辑可以概括为:

/*
 * HotSpot 分层编译的典型执行等级:
 *
 * Level 0:解释器
 * Level 1:C1,不进行完整 Profiling
 * Level 2:C1,收集部分运行数据
 * Level 3:C1,收集完整 Profiling 数据
 * Level 4:C2,执行深度优化
 *
 * 注意:
 * 具体等级和策略以对应 JDK 版本的源码为准。
 */

void compilation_policy_example() {

    // 1. 获取方法执行次数、循环回边次数等运行信息
    // 2. 根据方法热度判断是否需要编译
    // 3. 考虑编译线程队列及系统负载
    // 4. 决定是否提交 C1 或 C2 编译任务
    // 5. 编译完成后,将执行入口切换到编译后的代码
}

这里的代码是用于说明决策过程的伪代码,不是 OpenJDK 的原始实现。

建议重点阅读以下文件:

源码文件学习重点
src/hotspot/share/compiler/compilationPolicy.hpp编译策略定义
src/hotspot/share/compiler/compilationPolicy.cpp编译决策实现
src/hotspot/share/compiler/compileBroker.cpp编译任务提交和管理
src/hotspot/share/opto/compile.cppC2 编译过程
src/hotspot/share/c1/c1_Compiler.cppC1 编译入口

其中,CompileBroker 值得重点研究,因为它连接了运行时编译请求与实际编译线程。

可以通过以下命令观察方法的编译情况:

java \
  -XX:+UnlockDiagnosticVMOptions \
  -XX:+PrintCompilation \
  -XX:+PrintInlining \
  MyApplication

通过观察输出,可以分析:

  • 哪些方法被编译。
  • 方法进入了哪个编译等级。
  • 是否发生方法内联。
  • 是否存在重新编译。
  • 是否出现反优化。

5.2 CPU 架构相关源码

HotSpot 的机器码生成与 CPU 架构密切相关。

建议重点研究:

源码目录主要职责
src/hotspot/cpu/x86/x86-64 平台相关实现
src/hotspot/cpu/aarch64/AArch64 平台相关实现
src/hotspot/share/opto/C2 编译器中间表示与优化
src/hotspot/share/runtime/JVM 运行时机制

例如,C2 编译器并不是简单地把 Java 字节码逐条翻译成机器指令,而是经过中间表示、控制流分析、数据流分析和指令选择等阶段。

可以把过程理解为:

Java 字节码
     |
     v
字节码解析
     |
     v
C2 中间表示(IR)
     |
     v
类型分析与数据流分析
     |
     v
优化与指令选择
     |
     v
寄存器分配
     |
     v
目标 CPU 汇编指令
     |
     v
本地机器码

其中:

  • opto/ 重点关注平台无关的编译优化。
  • cpu/x86/ 重点关注 x86 平台的指令生成。
  • cpu/aarch64/ 重点关注 ARM 64 位平台的指令生成。

学习总结:JIT 编译器的优化思想可以具有平台通用性,但最终机器指令必须适配目标 CPU。

5.3 OrderAccess 与 CPU 内存屏障源码

结合之前学习的 JMM、OrderAccess 和 CPU 内存排序,可以进一步理解不同架构如何实现相同的 Java 内存语义。

建议关注:

src/hotspot/share/runtime/orderAccess.hpp

src/hotspot/cpu/x86/orderAccess_x86.hpp

src/hotspot/cpu/aarch64/orderAccess_aarch64.hpp

这些文件体现了 HotSpot 如何抽象和实现不同平台的内存排序操作。

示意代码:

// 以下为概念性伪代码,不是 OpenJDK 原始源码。

void publish_data() {

    data = 100;

    // 保证之前的数据写入不会被重排到发布操作之后
    OrderAccess::release();

    ready = true;
}

void consume_data() {

    if (ready) {

        // 获取发布状态,建立必要的内存排序约束
        OrderAccess::acquire();

        // 读取已经发布的数据
        use(data);
    }
}

实际实现中,屏障可能被编译器屏障、CPU 屏障或带有内存语义的原子指令实现,也可能根据平台特性省略不必要的硬件指令。

特别需要注意:上述伪代码用于说明 Acquire/Release 思想,不能直接视为 Java volatile 的完整实现。

建议从以下三个层次进行分析:

  1. Java 层:volatile、synchronized、VarHandle。
  2. JVM 层:编译器内存屏障、OrderAccess、原子操作。
  3. CPU 层:x86-64 与 AArch64 的实际内存排序指令。

这三个层次必须结合起来研究,才能真正理解 Java 并发程序在不同 CPU 架构上的执行差异。

六、除了 JVM 和 CPU,还有哪些因素影响执行性能

实际生产环境中,性能差异往往不是单一因素造成的。

6.1 操作系统线程调度

Java 线程最终需要映射到操作系统线程执行。

不同操作系统的线程调度策略、上下文切换成本、线程亲和性和调度优先级可能不同。

例如:

Java Thread
     |
     v
JVM 线程实现
     |
     v
操作系统线程
     |
     v
OS 调度器
     |
     v
CPU Core

当系统负载较高时,可能出现:

  • 线程上下文切换增加。
  • CPU 运行队列变长。
  • 线程频繁迁移到不同 CPU 核心。
  • CPU Cache 命中率下降。
  • 锁竞争和线程等待时间增加。

这些因素都会影响 Java 程序的实际性能。

6.2 CPU Cache 和内存访问延迟

CPU 并不是每次访问变量都直接访问主内存。

现代处理器通常具有多级缓存:

             CPU Core
                |
          +-----+-----+
          |           |
         L1          L1
          |           |
          +-----+-----+
                |
               L2
                |
               L3
                |
            主内存

不同 CPU 的缓存容量、缓存一致性协议、缓存行大小、内存带宽以及 NUMA 架构可能不同。

例如,Java 程序中存在大量对象访问时:

  • 对象布局可能影响缓存局部性。
  • 对象分配方式可能影响内存访问。
  • 指针追踪可能导致 Cache Miss。
  • 多线程共享数据可能引发 Cache Line 竞争。
  • NUMA 跨节点访问可能增加内存访问延迟。

因此,不能只通过比较机器的 CPU 主频来判断 Java 程序的执行性能。

6.3 JVM 参数和运行配置

即使是同一款 JVM、同一颗 CPU,如果启动参数不同,也可能产生明显的性能差异。

例如:

JVM 参数影响
-Xms初始堆大小
-Xmx最大堆大小
-XX:+UseG1GC选择 G1 垃圾收集器
-XX:+UseZGC选择 ZGC
-XX:-TieredCompilation关闭分层编译
-Xint仅使用解释器
-XX:ActiveProcessorCount指定 JVM 感知的可用处理器数量

这些配置会影响:

  • JIT 编译行为。
  • GC 执行频率。
  • 内存占用。
  • 线程数量。
  • 启动时间。
  • 吞吐量与延迟。

因此,性能测试时必须确保 JVM 配置具有可比性。

七、系统工程师如何验证不同 JVM、不同 CPU 的性能差异

理解原理之后,需要通过实验验证。

建议构建一个统一的 Java Benchmark,在不同环境下进行测试。

7.1 测试环境设计

测试环境JVMCPU
环境 AOpenJDK HotSpotx86-64
环境 BOpenJDK HotSpotAArch64
环境 COpenJ9x86-64
环境 DGraalVMx86-64 或 AArch64

尽可能保持 Java 版本、业务代码、数据集和 JVM 参数一致。

同时记录操作系统、CPU 型号、核心数量、内存容量和 GC 配置。

7.2 编写统一的测试程序

public class CpuBenchmark {

    private static final int SIZE = 10_000_000;

    public static long calculate() {

        long result = 0;

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

            // 测试整数运算与循环执行性能
            result += (long) i * 3;
        }

        return result;
    }

    public static void main(String[] args) {

        // 预热:让 JVM 收集运行信息并触发可能的 JIT 编译
        for (int i = 0; i < 20; i++) {
            calculate();
        }

        long start = System.nanoTime();

        long result = calculate();

        long end = System.nanoTime();

        System.out.println("result = " + result);

        System.out.println(
            "耗时(ns) = " + (end - start)
        );
    }
}

这只是用于理解执行路径的简单示例,并不是严谨的性能测试程序。正式测试建议使用 JMH,避免死代码消除、预热不足、测试次数不合理等问题。

7.3 建议重点采集的性能指标

指标关注内容
Throughput单位时间处理能力
Average Latency平均响应时间
P95 / P99尾延迟
CPU UsageCPU 使用率
IPC每周期执行指令数
Cache Miss缓存未命中情况
Context Switch上下文切换次数
GC Pause垃圾收集停顿
Allocation Rate对象分配速率
Compilation TimeJIT 编译时间

7.4 推荐使用的诊断工具

JVM 编译诊断

  • -XX:+PrintCompilation

  • -XX:+PrintInlining

  • JFR(Java Flight Recorder)

    操作系统性能分析

  • Linux perf

  • perf stat

  • perf record

  • perf report

    Java 微基准测试

  • JMH(Java Microbenchmark Harness)

  • async-profiler

  • JITWatch

7.5 性能测试时必须注意的几个问题

  1. 预热时间必须充分,不能把解释执行阶段和稳定运行阶段混为一谈。
  2. 不要只测试一次,应当进行多轮测试并分析统计分布。
  3. 应当区分冷启动性能和稳定运行性能。
  4. 必须关注 GC、JIT 编译和操作系统调度造成的额外开销。
  5. 比较不同 CPU 时,应尽量控制 CPU 频率、功耗模式和后台负载。
  6. 对于并发程序,还需要考虑线程数、CPU 核数和 NUMA 影响。

特别提醒:不能因为某个平台一次运行更快,就直接认定该 JVM 或 CPU 架构更优秀。必须通过重复实验、性能剖析和硬件计数器找到真正的性能瓶颈。

八、总结:相同 Java 程序为什么会有不同执行路径和性能

从系统工程师角度,可以将原因归纳为五个方面。

影响 Java 执行路径和性能的五个核心因素

在这里插入图片描述

8.1 必须掌握的六个知识点

知识点需要理解的问题
JVMS为什么不同 JVM 可以采用不同实现?
HotSpot解释器、C1、C2 如何决定执行路径?
JIT为什么相同字节码会生成不同机器码?
CPU ISA为什么 x86-64 和 AArch64 指令不同?
JMM不同 CPU 如何保证 Java 内存语义?
OS为什么线程调度和内存管理会影响性能?

九、最终学习心得

作为系统工程师,我认为学习 Java 跨平台执行差异,不能仅仅停留在 Java 语言和 JVM 参数层面,而应该建立完整的软硬件协同分析思维。

首先,JVMS 规定了 Java 程序的执行语义,但没有限制 JVM 必须采用统一的内部实现。其次,JIT 编译器会根据运行时 Profiling 数据、编译策略以及目标 CPU 的硬件能力,对字节码进行不同程度的优化。最后,CPU 根据自身指令集、缓存结构和内存排序约束执行生成的本地机器码。

因此,相同 Java 程序在不同 JVM、不同 CPU 架构上可能具有不同的执行路径和性能表现,这是 Java 虚拟机实现独立性与硬件架构差异共同作用的结果。

最重要的学习结论:

  • Java 字节码提供跨平台的执行语义。
  • JVM 决定字节码采用什么方式执行。
  • JIT 决定如何将热点代码优化为机器码。
  • CPU 决定如何执行目标机器指令。
  • 操作系统和硬件资源共同影响最终运行性能。

对于系统工程师而言,真正需要掌握的是从 Java 字节码到 JVM 执行引擎,再到 CPU 本地机器码的完整执行链路,并且能够通过 OpenJDK 源码、JIT 编译日志、汇编代码和硬件性能计数器定位不同平台之间的性能差异。

这也是从 Java 应用开发视角转向 JVM 与操作系统底层系统工程视角的重要一步。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐