我有这么个感受,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 在运行时才生成的,出现在不同平台上长相完全不同。
所以"跨平台"的关键,本质上就是:源码和字节码是平台无关的,但 JVM 是平台相关的。你在每个平台上装的那个 JVM,才是真正的"本地翻译官"。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JIT 凭什么"越跑越快":从翻译到编译的进化
2.1 解释执行的开销在哪
如果 JVM 一直只是老老实实地把字节码一条一条翻译成机器码来执行,那 Java 的性能会非常难看,因为它等于每个方法每次调用都要"现场翻译"一遍。这个过程叫解释执行。
解释执行的性能损失来自哪里?一方面是解析指令本身的 CPU 开销,另一方面是每次都要重新读取字节码、执行对应的处理逻辑,完全没有"复用"——同一段代码如果被调用十万次,就翻译十万次。
类比一下:一个同声传译水平再高,同一句话让他在短时间里反复翻一百遍,效率也远不如你直接把这句话打印出来抄发给所有人。
JIT 这个名字已经把这个机制的思路写在脸上了:Just-In-Time,就是在运行的时候,趁某个机会把字节码编译成当前平台真正的机器码。编译完成后,这段机器码会被缓存起来,下次再执行同样的方法时,就直接运行机器码,不需要再翻译了。
2.2 JIT 出现的背景:Java 早期为什么被人骂"慢"
早期 Java 确实口碑不好,很大程度就是因为它主要靠解释执行。一个叫得响的应用程序要跑得跟 C 差不多快,靠解释执行几乎是不可能的。于是 Sun 在 1998 年左右把 HotSpot VM 作为 JDK 1.2 的默认 JVM 推出来,核心卖点就是 JIT 编译。HotSpot 这个名字本身也暗示了它的策略:只把热点代码(Hot Spot)拿来深度优化。
JIT 的聪明之处不是"把所有代码都编译成机器码"——那样启动时间会爆炸、内存占用也会失控——而是先解释执行,通过运行时的统计信息找到真正的热点,然后只优化这些热点。这个思路甚至比很多人的直觉更实用:大部分程序 80% 的时间都花在 20% 的代码上,把那 20% 优化好,整体收益就已经非常可观。
2.3 用例子看:同一个循环,第一次和第一千次
为了说清楚"解释到编译"的差别,可以看一个最简单的循环:
java复制public class LoopDemo {
public static long sum(int n) {
long total = 0;
for (int i = 0; i < n; i++) {
total += i;
}
return total;
}
public static void main(String[] args) {
long start = System.nanoTime();
long result = sum(100000);
long end = System.nanoTime();
System.out.println(result);
System.out.println("耗时(毫秒): " + (end - start) / 1_000_000.0);
}
}
如果你只跑一次 main 方法,这段循环很小,足够在解释器里跑完,也可能被 JIT 编译;但如果你想真正观察"第一次慢、后面快",应该连续调用 sum 多次。
当 sum 方法第一次被调用时,JVM 大概率走的是解释执行。每跑到 total += i 这一行,解释器都要去解析那条字节码,算一次加法,再把值写回局部变量。随着调用次数增加,JVM 内部的调用计数器在涨,当它超过阈值(默认是 10000 次),JIT 编译器就被触发,把这个方法编译成机器码。
一旦编译完,sum 方法再被调用时就直接执行那一段机器码。你看到的宏观表现就是:同一个方法,第一次调用可能花了 5 毫秒,第一百次调用也许只花 0.01 毫秒。这也是"JVM 越跑越快"的最直观解释。
不过我要强调一下,这个例子里的时间差不一定每次都那么明显,因为方法太小、优化可能瞬间完成,真正肉眼可见差别的大多是那些包含复杂逻辑的热点方法。别拿它去杠"我试了没差别",重点在于理解机制。
3. 热点检测与分层编译:JIT 怎么决定"该不该编译"
讲清楚了"JIT 会编译热点代码"之后,下一个问题顺理成章:什么叫热点?JVM 怎么知道一段代码够不够热?这里就要涉及到 HotSpot 的几个关键核心机制了。
3.1 计数器与阈值:谁算"热"代码
HotSpot 维护了两类主要计数器,来统计一段代码的执行频率。第一类是方法调用计数器,记录一个方法被调用的次数;第二类是回边计数器,记录一个方法内循环回跳的次数。为什么要两个?因为有些方法本身不常调用,但内部有一个超大的循环,比如一个 while(true),如果只看方法调用次数,这个循环体永远不会被认为是热点。回边计数器就是专门处理这种"一个方法内循环体很热"的情况。
相关参数相信不少人在面试题里见过:-XX:CompileThreshold。在普通的 Server 模式下,方法调用计数器的默认阈值是 10000。意思是调用次数累加到一万次左右,就触发对这个方法的 JIT 编译。回边阈值则通常会计算为 OnStackReplacePercentage 乘以 CompileThreshold,默认在 Server 模式下算出来大约是 10700 左右,这就是循环体内触发 OSR 编译的阈值。
OSR(On-Stack Replacement,栈上替换)是个很有意思的机制。想象一下,一个方法已经被调用进来,正在循环的第 5000 次迭代里,这时候回边计数器超过阈值了,JVM 不一定要等这个方法完整执行完才能用编译后的代码。它可以在循环中途,把正在执行的解释器栈帧直接替换成编译后的机器码版本,然后接着往下跑。这就叫栈上替换。正因为有 OSR,一个长期运行的循环体才不用等到整个方法结束才提速。
3.2 C1 和 C2:为什么不是只有一种 JIT
HotSpot 里的 JIT 编译器其实不是单一个体。传统上有两个风格很不一样的编译器:C1,也就是 Client Compiler,编译速度更快,生成的代码优化程度一般;C2,也就是 Server Compiler,编译速度慢,但能做非常激进的优化,生成的代码质量更高。
拿生活类比:C1 像急诊科大夫,速度优先,先把人稳住;C2 像专家会诊团,慢工出细活,给你做全面深度的治疗方案。如果只请急诊大夫,程序启动是快了,但长期运行峰值性能不行;如果一上来只请专家团,冷启动还没优化完,用户早就等着急了。
所以现代 HotSpot 默认开启了分层编译(Tiered Compilation)。分层编译的思路是:先用 C1 快速地把热点方法编译成带基础优化的机器码,让程序能尽快跑起来;运行一段时间之后,JVM 如果发现这个方法仍然是热点,而且值得更深入的优化,就会再用 C2 做第二层编译,替换掉 C1 的版本。整个过程对应用是透明的,你在代码层面完全无感知,只会看到性能曲线在一段时间内逐渐爬升,然后趋于平稳。
3.3 一个方法的完整旅程:从解释到 C2
把一个方法的生命周期完整捋一遍,其实就回答了"JVM 为什么越跑越快"最核心的问题:
- 方法第一次被调用,走解释执行,调用计数器从 0 开始累计。
- 方法调用不断增多,或者内部循环一直在回跳。
- 计数器超过 C1 的编译阈值,JIT 编译队列接管这个方法,C1 在后台线程中生成一份初级的机器码。
- 编译完成标识被翻转,后续调用直接用 C1 生成的机器码执行,不再解释执行。
- 如果调用频率持续走高,计数器在 C1 代码中继续累积,直到触发 C2 的编译。
- C2 在后台做深度优化,生成一份更快的机器码;一旦完成,C1 版本退役,改用 C2 版本。
- 如果环境里装了 GraalVM,还可以用 Graal JIT 编译器替代 C2,那是另一个故事了。
所以"越跑越快"并不是玄学,而是程序从解释执行一路升级到高度优化的机器码的必然结果。反向推导一下,如果你看到一个 Java 服务启动后前几分钟吞吐量不高,很可能是它在预热阶段:热点方法还没积累够调用次数,JIT 还没来得及插手。等预热结束,性能曲线会爬升并稳定下来。
补充一个和内存模型相关的点:JIT 虽然可以激进优化、重排序指令,但它必须严格受 Java 内存模型(JMM)的约束。JMM 规定了什么时候一个线程的修改对另一个线程可见,哪些重排序被禁止。JIT 的优化必须满足这些规则,否则可能导致多线程程序出现诡异的结果。这也是为什么你写并发代码时,不能指望 JIT"帮你想当然",volatile、synchronized 这些关键字该用还得用,它们是告诉 JIT"这里不能乱动"的护栏。
4. JIT 优化的几个经典例子:内联、逃逸分析和死代码消除
光是"编译成机器码"这一点,还不足以解释现代 JVM 的性能表现。真正让 JIT 和普通编译器拉开差距的,是它在运行时做的一系列激进优化。这些优化在静态编译时代很难准确做出来,因为编译器不知道程序在实际运行时的形态;JIT 不同,它能拿到运行时数据,能针对"这个程序实际上怎么跑"做定制优化。
4.1 方法内联:把函数调用"摊开"
最常见的优化是方法内联。看过字节码的人都知道,方法调用本质上是一条 invokevirtual 或者 invokeinterface 指令,机器在执行时还要处理参数传递、栈帧切换这些开销。如果一个方法体特别小,比如一个 getter,这种调用开销甚至可能比方法本身的工作量还大。
JIT 会干什么呢?它会直接把 getter 的方法体"搬"到调用处,取代那一次方法调用。比如:
java复制public class Point {
private int x;
public int getX() { return x; }
}
int total = point.getX() + point.getY();
经过内联之后,JIT 生成的机器码里,可能已经看不到任何调用 getX、getY 的痕迹了,而是直接从对象的字段里拿值来做加法。这不但省掉了调用开销,还给了后续优化更大的视野——它可以继续做常量传播、公共子表达式消除等,把一个看似简单的加法优化到极致。
有个需要注意的地方:内联不是无条件发生的,它受方法大小、调用深度等参数限制。如果你把一个上万行的方法写出来,JIT 很难下手,大概率直接放弃内联,这就是大方法的性能隐患之一。
4.2 逃逸分析:把对象从堆上"省掉"
另一个值得知道的是逃逸分析(Escape Analysis)。它分析一个新建的对象是否会"逃逸"出它所在的方法或线程。如果对象没有逃逸,JIT 可以做一件很暴力的事情:不在堆上分配对象,而是在栈上分配,甚至把对象拆散成标量,直接用寄存器存字段值,根本不在内存里折腾。
举个最简单的例子:
java复制public static int calc() {
Point p = new Point(3, 4);
return p.x + p.y;
}
如果 p 这个对象只在 calc 方法内部使用,没有作为参数传出去,也没有被返回,那 JIT 通过逃逸分析发现它没有逃逸,就可能直接把 new Point(3, 4) 优化成两个局部变量 x=3、y=4,最后算出来的结果栈上直接出,连堆内存分配都免了。别小看这种优化,在高频创建小对象的代码里,它能省下大量 GC 压力。
和逃逸分析相关的还有锁消除。比如在一个方法内部用局部变量作为 synchronized 的锁对象,如果 JIT 分析发现这个锁永远只被同一个线程持有,就会直接把锁消掉,因为这些 synchronized 语句实际上达不到互斥效果,纯属浪费时间。
4.3 死代码消除与激进推测
死代码消除听名字很常规,但 JIT 的版本有意思的地方在于它可以"推测"。比如 JIT 在运行中观察到某个条件的值始终为 true,它就可能把条件永远不会进入的代码分支直接当成"死代码"抹掉。这是在运行时基于实际数据分布做的判断,而不是静态看源码。如果后续真的有反例出现,JIT 还会通过逆优化(deoptimization)把执行栈回退到解释器状态,重新走完整逻辑。这套"先假设、再验证、出错了就撤销"的模式,是 C2 编译器激进优化的底气所在。
这也引出一个很有意思的点:同一个方法在不同调用阶段可能被编译成完全不同的机器码。因为 JIT 掌握的是从程序启动至今看到的"事实",而不是程序员以为的"应该"。这也是为什么 JIT 优化很难用一套固定规则穷尽描述——它是动态的、数据驱动的。
5. 实务:怎么观察 JIT、怎么配合它写出更快的代码
理解了原理,最该做的是把原理落到自己的项目里。很多人在 JVM 调优上喜欢盯着堆内存、GC 日志看,却对 JIT 几乎不看。实际上,对于一个已经稳定运行的服务,JIT 的决策质量对吞吐量和延迟的影响往往是第一梯队的。
5.1 用 JVM 参数看 JIT 干活
想看 JIT 到底编译了哪些方法,最简单的是在 JVM 启动参数里加上:
bash复制-XX:+PrintCompilation
跑起来之后,控制台会刷出类似这样的日志:
text复制 148 1 3 java.lang.String::hashCode (55 bytes)
155 2 3 java.lang.String::equals (47 bytes)
203 23 4 com.demo.HotLoop::handleRequest (120 bytes)
每一行大致是:编号、是否 OSR(OSR 的日志会有特殊标志)、编译层级(3 是 C1 带 profiling,4 是 C2)、方法签名和字节码大小。看到你的核心业务方法出现在第 4 层,说明它确实被深度优化了;如果某个关心的热点方法很长时间没出现在第 4 层,你就要想一想,它是不是太大、太复杂,或者老是因类型不确定导致无法内联。
想进一步看内联情况,可以加:
bash复制-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
不过这个输出量非常大,建议在压测环境里配合 -Xlog 或者重定向到文件里看,别在线上直接用。
在 JDK 9 以上,也可以用新的日志体系:
bash复制-Xlog:jit+compilation=info
输出信息类似,但格式更统一。新版 JDK 里 PrintCompilation 已经 deprecated 了,新日志体系是更长远的选择。
5.2 为什么 JVM 适合"长跑":预热与基准测试
理解了 JIT 之后,各种看似神奇的现象都有了解释。比如微基准测试为什么必须预热?因为如果不预热,前几轮跑的都是解释执行,后面才逐步升级到 C1、C2,测出来的数据方差会非常大。业界普遍用 JMH 做基准测试,它默认会先做 forked VM、预热迭代,就是为了让 JIT 先达到稳定态,再开始测量。
对线上服务来说也一样。一个刚启动的 Java 服务,如果流量还没有真正上来,很多热点方法还没被编译;此时如果突然涌入大量请求,性能可能比不上已经热身了几十分钟的服务。这也是为什么很多高流量服务会做"预热请求"或"推迟摘流量"的原因,不是玄学,而是给 JIT 留出编译时间。
"越跑越快"需要一个前提:程序里有足够稳定、可重复执行的热点。如果代码里全是只跑一次的一次性逻辑,JIT 再强也帮不上什么忙,因为它的优化收益来源于"同一段代码的高频复用"。
5.3 几个会让 JIT 失效的坑
根据我在实际项目里的观察,有几个常见的坑会直接削弱 JIT 的优化效果,列出来供大家排查:
- 巨型方法。一个方法动辄几百上千行,JIT 内联和后续优化都会受限,甚至编译时还会因为方法过大而放弃某些优化。写代码时保持方法短小、职责单一,不只是好习惯,也是给 JIT 铺路。
- 过于极端的反射。每次反射调用可能都会触发类型检查、安全检查,尤其当反射点形态不稳定时,JIT 很难做类型层面的推测和内联。能用 MethodHandle 就优先用,性能模型清晰得多。
- 动态代理滥用。代理类生成在运行时,且类型高度不确定,JIT 面对这种形态很难深挖。这也是为什么像 Spring 这类框架会尽可能缓存方法元信息,虽然不能完全消除开销,但至少给 JIT 留出了更多可识别的形态。
- CodeCache 耗尽。JIT 编译后的机器码装在 CodeCache 里,如果这个区域满了,JVM 只能停止新增编译,性能可能回退。虽然默认配置多数情况下够用,但如果你部署了大量动态生成的类,可以观察一下 CodeCache 的使用率,必要时用
-XX:ReservedCodeCacheSize调大。
用 JIT 的视角反过来指导写代码,其实就一句话:让热路径上的代码形态尽量稳定、尽量小、尽量可识别。剩下的优化,交给 JIT。
我自己在排查线上问题的时候,最常用的顺序是这样的:先看 GC,再看线程,最后才会盯 PrintCompilation。但有一次线上接口延迟抖动,GC 一切正常,结果把 PrintCompilation 打开之后,发现排查的那个热点方法被 C2 编译了,但编译后的代码很快因为某个反优化事件被撤销,然后又重新编译,这样反复了几次,延迟就出现了明显的毛刺。从那之后我才真正意识到,JIT 不是"永远帮你变快"的黑盒,它是一个有自己生命周期的运行时子系统,值得每个后端开发者认真对待。
如果你现在正准备 JVM 面试,这套逻辑也可以直接用:先讲跨平台靠字节码和 JVM 规范,再讲性能靠 JIT 从解释执行走到即时编译,然后讲热点计数、分层编译、内联和逃逸分析,最后能用合适的例子解释预热和反优化。把这几个层级串起来,比零散地背概念要扎实得多。等你自己在项目里跑一次 PrintCompilation,再回来看这篇文章,会比我写一万字还有用。
