JVM规范学习总结-class执行性能差异
- 前言
- [为什么相同的 Java 程序在不同 JVM、不同 CPU 架构上,执行路径与性能可能不同?](#为什么相同的 Java 程序在不同 JVM、不同 CPU 架构上,执行路径与性能可能不同?)
-
- 一、核心结论:跨平台兼容,不代表底层执行过程完全相同
- [二、原因一:JVMS 只规定执行语义,不规定唯一的执行实现](#二、原因一:JVMS 只规定执行语义,不规定唯一的执行实现)
- [三、原因二:不同 JVM 会选择不同的运行与编译路径](#三、原因二:不同 JVM 会选择不同的运行与编译路径)
-
- [1. 解释执行与 JIT 编译的区别](#1. 解释执行与 JIT 编译的区别)
- [2. 为什么同一个方法可能经历多条执行路径?](#2. 为什么同一个方法可能经历多条执行路径?)
- [四、原因三:不同 CPU 架构需要不同的机器指令](#四、原因三:不同 CPU 架构需要不同的机器指令)
-
- [1. 从内存屏障看架构差异](#1. 从内存屏障看架构差异)
- [2. 除内存排序外,还有哪些硬件差异?](#2. 除内存排序外,还有哪些硬件差异?)
- 五、原因四:垃圾回收、对象布局和运行时配置也会改变性能
- 六、必须区分:性能不同,不等于程序语义可以随意不同
- 七、系统工程师应该如何验证这些差异?
-
- 第一步:建立可比较的运行环境
- [第二步:观察解释执行与 JIT 编译](#第二步:观察解释执行与 JIT 编译)
- [第三步:检查生成的机器码与 CPU 行为](#第三步:检查生成的机器码与 CPU 行为)
- 第四步:采用合适的基准测试方法
- 八、最终学习总结
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
为什么相同的 Java 程序在不同 JVM、不同 CPU 架构上,执行路径与性能可能不同?
一、核心结论:跨平台兼容,不代表底层执行过程完全相同
从 Java 虚拟机规范(JVMS)的角度看,Java 的跨平台能力建立在一个关键原则上:不同 JVM 可以采用不同的内部实现,但必须遵守规范所定义的外部语义。
JVMS 定义了 Class 文件格式、字节码指令、运行时数据区及指令执行语义,却不要求所有 JVM 使用相同的对象布局、垃圾回收算法或机器码生成方案。规范还明确允许 JVM 实现者根据不同平台和性能目标进行优化。
因此,系统工程师需要区分三个层面:

最重要的认识:相同的 Java 字节码,并不意味着相同的机器指令;相同的机器指令,也不意味着相同的运行耗时。 只要兼容规范规定的语义,JVM 就可以采用不同的执行路径和优化策略。
二、原因一:JVMS 只规定执行语义,不规定唯一的执行实现
在阅读 JVMS 时,需要重点理解第 2.13 节 Public Design, Private Implementation(公共设计与私有实现)。
规范要求 JVM 正确读取 Class 文件,并实现对应的指令语义,但允许实现者自由选择底层技术。例如:
- 使用解释器逐条执行字节码。
- 在程序运行时将字节码编译为 CPU 本地指令。
- 使用不同的对象内存布局和垃圾回收算法。
- 对方法调用、对象分配、锁操作等采用不同的优化策略。
这些实现上的差异不会自动违反 Java 的跨平台承诺。
可以将其理解为以下执行链路:

这里有一个容易忽略的区别:
同一份 Java 源码不一定生成相同的 Class 文件。 如果使用不同版本的 javac,编译目标版本、字节码版本以及编译器生成策略都可能不同。
因此,进行跨平台性能研究时,首先应该明确比较对象究竟是相同源码,还是相同的 Class 文件。
三、原因二:不同 JVM 会选择不同的运行与编译路径
以 OpenJDK HotSpot 为例,它不是简单地将所有方法一次性编译成机器码,而是可以根据运行情况采用解释执行、分层编译等方式。
1. 解释执行与 JIT 编译的区别
解释执行通常需要通过解释器处理字节码,执行过程中存在额外的分派和运行时管理开销。
JIT 编译则会将热点方法编译成本地机器码,并利用运行时收集的信息进行优化。
HotSpot 的源码提供了 UseInterpreter、UseCompiler 和 TieredCompilation 等相关开关。编译代理 CompileBroker 负责协调编译任务,并可以初始化 C1、C2 编译器。
下面是 OpenJDK src/hotspot/share/runtime/globals.hpp 中相关声明的节选说明:
cpp
// OpenJDK HotSpot:globals.hpp,相关 VM 参数节选
product(bool, UseInterpreter, true,
"Use interpreter for non-compiled methods")
// 学习要点:决定是否使用解释器执行未编译方法。
product(bool, UseCompiler, true,
"Use Just-In-Time compilation")
// 学习要点:控制是否使用 JIT 编译。
product_pd(bool, TieredCompilation,
"Enable tiered compilation")
// 学习要点:分层编译的默认配置可以具有平台相关性。
这些参数只是入口。实际运行过程还受编译策略、方法调用频率、循环执行次数、编译任务队列及编译器是否可用等因素影响。
2. 为什么同一个方法可能经历多条执行路径?
假设有如下程序:
java
public class ExecutionPathDemo {
// 观察同一个计算方法在解释执行、JIT 编译中的表现。
static int calculate(int x) {
return x * 31 + 7;
}
public static void main(String[] args) {
long sum = 0;
// 多次调用方法,为热点识别和编译提供机会。
for (int i = 0; i < 10_000_000; i++) {
sum += calculate(i);
}
System.out.println(sum);
}
}
这段代码的实际执行过程可能是:
| 阶段 | 可能的执行方式 | 主要特征 |
|---|---|---|
| 类加载后 | 解释执行 | 还没有可用的热点编译代码 |
| 运行过程中 | 收集调用与循环信息 | 为编译决策积累运行时数据 |
| 方法成为热点 | JIT 编译 | 生成目标 CPU 的机器码 |
| 优化条件变化 | 反优化或重新编译 | 恢复解释执行或使用新的编译版本 |
这些是 HotSpot 可能采用的执行过程,并不是所有 JVM 都必须采用完全相同的阶段和阈值。
另一个关键因素是运行时类型信息。例如,接口调用在某个应用中可能长期只出现一种实现类,JIT 便可能去虚拟化并内联调用;如果后续加载了其他实现类,原有优化条件可能不再成立,JVM 就可能调整编译代码。
因此,同一段代码在冷启动时和充分预热后,本来就可能走不同的执行路径;即使 JVM 与 CPU 没有变化,也不一定始终执行相同的机器码。
四、原因三:不同 CPU 架构需要不同的机器指令
即使两个 JVM 使用相似的编译器优化策略,x86-64 与 AArch64 生成的机器码也可能不同。
因为它们具有不同的指令集、寄存器分配约束、原子操作指令、向量指令以及内存排序规则。
1. 从内存屏障看架构差异
以 HotSpot 的 OrderAccess 为例,可以看到 JVM 为不同处理器平台提供了不同的内存排序实现。
在 Linux x86 的实现文件 src/hotspot/os_cpu/linux_x86/orderAccess_linux_x86.hpp 中,有如下代码:
cpp
// x86 平台的 HotSpot OrderAccess 实现,节选
inline void OrderAccess::loadload() {
compiler_barrier();
}
inline void OrderAccess::storestore() {
compiler_barrier();
}
inline void OrderAccess::loadstore() {
compiler_barrier();
}
// StoreLoad 排序约束需要更强的屏障实现。
inline void OrderAccess::storeload() {
fence();
}
inline void OrderAccess::fence() {
// 使用带 LOCK 前缀的指令实现完整屏障。
// 该实现还约束编译器的内存访问重排。
__asm__ volatile ("lock; addl $0,0(%%rsp)"
: : : "cc", "memory");
compiler_barrier();
}
而在 Linux AArch64 对应实现 src/hotspot/os_cpu/linux_aarch64/orderAccess_linux_aarch64.hpp 中:
cpp
// AArch64 平台的 HotSpot OrderAccess 实现,节选
inline void OrderAccess::loadload() {
acquire();
}
inline void OrderAccess::storestore() {
release();
}
inline void OrderAccess::loadstore() {
acquire();
}
inline void OrderAccess::storeload() {
fence();
}
// acquire、release 和 fence 的实际实现继续使用
// 编译器提供的原子操作及内存栅栏接口。
#define FULL_MEM_BARRIER \
__sync_synchronize()
#define READ_MEM_BARRIER \
__atomic_thread_fence(__ATOMIC_ACQUIRE);
#define WRITE_MEM_BARRIER \
__atomic_thread_fence(__ATOMIC_RELEASE);
这两段源码可以在 OpenJDK 仓库中核对。
从系统工程师的角度,最值得注意的是:
- JVM 需要满足相同的内存语义,但不必插入完全相同的屏障指令。
- x86-64 的内存排序约束通常比 AArch64 更强,某些排序约束可以通过编译器屏障实现,而不必额外发出硬件屏障指令。
- AArch64 的实现通过 Acquire、Release 等语义表达排序要求,再由底层编译器将其落实为符合目标平台要求的指令。
需要强调,以上代码是特定 OpenJDK 源码版本中 Linux x86 和 Linux AArch64 的实现示例,并不意味着所有版本、编译器或所有屏障都采用这些具体指令。
工程结论:Java 层看似相同的 volatile、锁或原子访问,最终产生的机器指令数量、执行延迟和对流水线的影响可能不同。 这也是跨架构并发性能分析不能只停留在 Java 源码层面的原因。
2. 除内存排序外,还有哪些硬件差异?
| 硬件因素 | 可能造成的性能差异 |
|---|---|
| 指令集与 CPU 特性 | 是否能使用特定原子、向量或位操作指令 |
| 寄存器数量和分配约束 | 溢出到栈上的频率、寄存器复制成本 |
| CPU 流水线与指令吞吐率 | 相同操作的延迟和每周期执行数量不同 |
| L1/L2/L3 缓存及内存延迟 | 数据访问、对象遍历、缓存未命中成本不同 |
| 内存排序约束 | 屏障指令的实现与执行成本不同 |
| 多核互连与缓存一致性 | 共享数据竞争、缓存行迁移及伪共享的成本不同 |
这些因素最终会通过机器码生成、实际指令执行和内存访问过程反映到程序性能中。即使 JIT 生成相同数量的机器指令,不同 CPU 微架构也可能产生明显不同的耗时。
五、原因四:垃圾回收、对象布局和运行时配置也会改变性能
不同 JVM 的差异不只体现在解释器与 JIT 上,还包括运行时系统的实现选择。
JVMS 并不规定唯一的对象内部结构,也不要求使用指定的垃圾回收算法。
例如,同一个创建大量短生命周期对象的 Java 程序,在不同的 JVM 配置中可能出现以下差异:
- 使用不同垃圾回收器,导致暂停时间、并发工作量和 CPU 占用不同。
- 使用不同的堆大小和内存分配策略,导致 GC 触发频率不同。
- 使用不同的对象引用表示、压缩引用配置或对象布局,导致内存占用与缓存行为不同。
- 使用不同的内建函数、编译器优化或本地代码实现,导致某些方法的调用成本不同。
- 受到不同操作系统调度策略、容器 CPU 配额及内存限制的影响,导致吞吐量和尾延迟发生变化。
这些因素相互作用。例如,某次运行速度较慢,原因可能不是某条算术指令效率低,而是缓存未命中增加、垃圾回收更频繁,或线程在 CPU 配额限制下被暂停执行。
所以,系统性能诊断应把执行引擎、内存管理、操作系统和硬件放在同一个分析框架中。
六、必须区分:性能不同,不等于程序语义可以随意不同
这一点对理解 JVMS 尤其重要。
如果程序完全符合 Java 规范,同一份 Class 文件在兼容的 JVM 上执行时,JVM 不能仅仅因为 CPU 架构不同,就任意改变规范要求的程序语义。
但以下情况确实可能造成可观察结果不同:
第一,存在数据竞争。 如果多个线程无同步地访问共享变量,程序可能没有建立所需的 happens-before 关系。不同的 JIT 优化、CPU 内存排序及运行时调度可能使程序表现出不同的结果。
第二,使用具有允许误差的 API。 某些数学函数、时间相关 API 或随机数 API,本来就具有规范定义的误差范围、环境依赖或非确定性。
第三,执行环境不同。 本地 JNI 代码、系统调用、文件系统、网络服务、可用 CPU 特性及环境变量都会影响执行结果或时序。
还有一个重要的历史边界:Java SE 17 及以后要求浮点表达式采用严格求值语义,较早版本曾允许某些扩展中间浮点值的行为。不能把早期 Java 的浮点规则直接套用到现代 JDK。
因此,排查跨平台差异时,应先判断问题属于哪一种:
| 现象 | 优先检查的方向 |
|---|---|
| 结果语义不同 | Java 规范、数据竞争、API 契约、JNI 和环境差异 |
| 冷启动速度不同 | 类加载、解释执行、JIT 编译与预热 |
| 稳态吞吐量不同 | 生成的机器码、CPU 微架构、GC 与内存访问 |
| 尾延迟不同 | GC、调度、锁竞争、缺页、I/O 与资源限制 |
| 同一平台多次测试差异大 | JIT 状态、输入分布、CPU 频率、系统负载与测试方法 |
七、系统工程师应该如何验证这些差异?
建议采用以下步骤,避免只凭 Java 源码或单次运行结果下结论。
第一步:建立可比较的运行环境
记录 JDK 版本、JVM 实现、CPU 架构、操作系统、GC 类型、堆大小、容器限制,以及全部 JVM 参数。
bash
# 确认 Java 版本、供应商和 JVM 实现
java -version
# 查看 VM 相关设置
java -XshowSettings:vm -version
# 输出初始化与最终配置中的 JVM 参数
java -XX:+PrintCommandLineFlags -version
java -XX:+PrintFlagsFinal -version
若比较不同 CPU,尽可能使用相同的 Class 文件、相同的应用输入与一致的堆配置。若比较不同 JVM 实现,则记录各自的默认配置差异,不要把默认参数不一致造成的差异误判为编译器优劣。
第二步:观察解释执行与 JIT 编译
在支持相应 HotSpot 参数的环境中,可以使用:
bash
# 查看热点方法编译事件以及内联决策
java \
-XX:+UnlockDiagnosticVMOptions \
-XX:+PrintCompilation \
-XX:+PrintInlining \
-jar app.jar
这些参数可帮助分析热点方法何时被编译、调用是否被内联,以及不同运行阶段是否采用不同优化策略。具体输出与参数可用性取决于 JDK 版本和 JVM 实现。
还可以使用 -Xint 进行解释执行对照,但应注意:它改变了执行模式,不代表真实生产环境的性能。不能直接将其结果当作正常 JIT 运行的性能基线。
第三步:检查生成的机器码与 CPU 行为
若需要深入分析,可以在具备相应工具支持的 HotSpot 环境中查看汇编输出,再结合 CPU 性能计数器观察:
- 指令数量及执行效率。
- 分支预测失败与缓存未命中。
- 锁竞争及原子操作成本。
- GC 时间与分配速率。
- CPU 利用率、上下文切换和线程运行时间。
HotSpot 的 -XX:+PrintAssembly 通常还需要相应的反汇编支持,例如兼容的 hsdis 组件。仅看到汇编不同,也不能直接断定某份代码更快;还需要结合实际运行数据判断。
第四步:采用合适的基准测试方法
对于 Java 微基准测试,建议使用 JMH,设置合理的预热、测量阶段和独立进程(fork),避免将 JIT 编译时间误认为稳态执行时间,也要防止死代码消除、输入分布和测试顺序带来偏差。
如果是并发程序,还要确保两种平台测试的是相同的正确性语义,而不是通过数据竞争来比较性能。
八、最终学习总结
从系统工程师的角度,理解 JVMS 与硬件的关系,重点不是记住某一种 CPU 的指令,而是建立从规范到实现的完整分析链路。
第一,规范层:理解 JVMS 规定的 Class 文件格式、字节码语义与执行约束,明确哪些行为必须兼容。
第二,JVM 实现层:研究解释器、C1/C2、运行时剖析、内联、反优化、垃圾回收及内存布局,理解为什么同一份字节码会进入不同的内部执行路径。
第三,硬件层:研究 x86-64 与 AArch64 的指令集、寄存器、缓存、内存排序和原子操作,分析 JVM 如何把统一的 Java 语义映射到不同机器指令。
第四,性能验证层:统一测试条件,查看编译日志与机器码,并通过基准测试和系统性能数据验证猜测。
最终应形成这样一条分析思路:
Java 语义要求 → JVM 实现策略 → JIT 优化决策 → 目标 CPU 机器码 → 操作系统和硬件实际执行 → 可观测性能。
只要沿着这条链路逐层定位,就能更准确地区分规范保证、JVM 的实现选择与硬件的执行成本,而不再把所有跨平台差异简单归结为"Java 虚拟机不一样"或"CPU 架构不一样"。