我有这么个感受,Java 的好处好多人都会背,什么"一次编写,到处运行",什么"JIT 越来越快",但真被问到为什么的时候,很多人就只能说"因为有 JVM"这个层面。前段时间有朋友问我,为什么同一个 Java 服务跑起来之后,过一段时间吞吐量才上去?为什么同一个循环第一次跑那么慢后面就快了?这些问题的答案其实都指向同一个核心机制:JVM 的运行期架构和 JIT 编译器。这篇文章我就用几个具体的例子,把 JVM 跨平台的原理和 JIT 为什么越跑越快这件事拆开讲透。
这篇内容适合这么几类人:准备 JVM 面试题的候选人,想搞明白服务启动后性能波动的后端开发,还有那些只会写 Java 但从来没看过字节码和 JIT 日志的人。我会尽量不堆术语,但该给的参数、该贴的日志都会给到,方便你对照着实操。
1. 先回答最朴素的问题:Java 到底是怎么"跨"过去的
1.1 编译模型的天壤之别
C 和 C++ 的程序,写完源码之后要用各自的编译器编译成机器码。机器码是给 CPU 直接执行的指令,而不同 CPU 的指令集不一样,所以你在 Windows x86 上编译出来的 exe,放到 ARM 的 Linux 上基本跑不了,更不用说 macOS 这种还有自己那一套格式约束的系统了。
Java 走的是另一条路。你写好的 .java 文件,javac 编译完之后得到的不是机器码,而是一份 .class 文件,里面装的是一套 JVM 自己定义的"字节码"。字节码是一种中间表示,它不对应任何真实 CPU,而是对应一个"虚拟 CPU"——也就是 JVM。
打个比方。C/C++ 的思路,是你把一份中文稿子翻译成英文、日文、法文,分别出不同语言的版本发给不同国家的人。Java 的思路,是你保留中文原稿,但给每个国家配一个同声传译,听众听到的是经过传译的母语,但原稿始终是那份原稿。
这就回答了一半:Java 的源码只需要编译一次,得到字节码,然后由每个平台上的 JVM 来"翻译"字节码,翻译成当前平台能执行的机器码。只要那个平台上有 JVM,这份 .class 就能跑。
1.2 JRE 和 JVM 到底什么关系
热词里有一条"jre和jvm之间的关系",这里顺手说清楚。JRE 是 Java 运行时环境,它等于 JVM 加上 Java 核心类库(rt.jar、java.util 这些)。你只需要运行 .class 文件或者 jar 包,装 JRE 就够了;要开发源码才需要 JDK,JDK 里包含了 javac 编译器、JRE 和一些开发工具。
JVM 是 JRE 里最核心的"那个引擎"。HotSpot VM 是现在 Oracle JDK 和 OpenJDK 里最主流的一个实现,除了它还有 OpenJ9、GraalVM 等。每个平台的 JVM 实现都遵守同一份 JVM 规范,所以字节码在哪个平台上的行为应该是一致的。
这里有个关键点很容易被忽略:JVM 规范规定的是"行为"层面的一致性,比如字节码指令的含义、内存模型、类加载流程,但并没有规定每一个内部模块怎么实现。不同厂商的 JVM 可能在垃圾回收器、JIT 编译器、线程模型上有完全不同的实现,但只要它能正确执行字节码,就是合格的 JVM。这也是为什么不是"完全一致"——行为大体一致,性能差异可以很大。
1.3 字节码到底长什么样:用 javap 撕开看一眼
很多人写了几年 Java,却从来没有看过字节码。其实看一眼之后,JVM 跨平台这件事就不再神秘了。随便建一个很简单的类:
java复制public class Demo {
public static int add(int a, int b) {
return a + b;
}
}
编译之后,用 JDK 自带的 javap 命令反汇编:
bash复制javap -c Demo.class
会看到类似这样的输出:
text复制public static int add(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
这四行字节码指令,含义非常直白:把第 0 个局部变量压到操作数栈,再把第 1 个局部变量压进去,然后执行整数加法,最后返回结果。这套指令是 JVM 规范定的,跟你的机器是 x86 还是 ARM 无关。真实的机器码——比如 x86 的 add eax, [rbp-4] 这种——是 JVM 在运
