你学习JVM的时候肯定听过一句话:"Java 先解释执行,热点代码才编译"。但问题是——JVM 怎么知道哪段代码是热点?
技术背景
在开始之前,我们得知道 JVM 执行代码的两种方式。
解释执行:就像同声传译,拿到一句字节码,翻译一句,执行一句。启动速度快,但是执行速度慢。
编译执行:JVM 内置了两个编译器,一个是 C1(编译快,优化少),一个是 C2(编译慢,优化多)。它们把字节码整体翻译成本地机器码,之后直接用机器码执行要快得多。
但编译本身很耗时。如果每行代码都送 C2,程序光编译就得好几分钟。所以 JVM 的策略是:
对解释过程做热点识别,只让真正的热点编译成字节码缓存在 Code Cache 中,下次执行时就直接执行该字节码
这就是热点探测(Hot Spot Detection)。那JVM是如何进行探测的呢?
JVM 的探测手段:两种计数器
JVM 采用了最直观的思路——计数器。计数器的值越大,就证明这段代码是热点。
热点代码分成两类,JVM 给每类配了一个计数器。
第一类:方法调用计数器
每个方法都有一个计数器,叫 Invocation Counter。这个方法每被调用一次,计数器就加 1。
一旦计数超过阈值,JVM 就认为这个方法是热点方法,把它提交给编译器。
阈值按照模式区分:
- Client 模式(C1):1500 次
- Server 模式(C2):10000 次
可以通过 -XX:CompileThreshold=10000 来调整阈值。
第二类:回边计数器
针对一些频繁执行的循环体代码,JVM 的做法是再加一个计数器:回边计数器(Back Edge Counter)。
所谓的"回边",就是字节码指令里从后往前跳的边——这正好是循环的特征。每次执行循环体末尾跳回入口的那条指令,回边计数器就加 1。
while (i < 100000) { // ← 循环入口
doWork();
i++; // ← 执行完这行,跳回入口——这就是"回边"
}
回边计数器到达阈值后,JVM 会触发一种特殊的编译,叫 OSR(On-Stack Replacement) 。顾名思义——不等方法退出,直接在循环中间就把解释执行替换成编译版本。
OSR 的触发阈值比方法调用阈值低不少,公式大致是:
OSR 阈值 ≈ CompileThreshold × OnStackReplacePercentage / 100
默认 OnStackReplacePercentage=140,但不同 JVM 版本计算方式有差异。你只需要记住"OSR 一般在几千次就触发"就够了。
一个巧妙的机制:计数器衰减
我们设想一个场景:一个不怎么热的方法,调用次数慢慢攒到阈值,不就被误判成热点了吗?
JVM 的处理很聪明——计数器衰减(Counter Decay)。
在 GC 的时候,JVM 趁 Safe Point 把所有方法的调用计数器减半。
这样的话,一些真正的热点代码才会被 JIT 即时编译器编译成机器码
所以衰减做的事情就是:避免一些不是热点的代码进入编译流程
不想用衰减的话,加 -XX:-UseCounterDecay 关闭即可。关了以后计数器只涨不跌,绝对到了阈值才编译。
编译不是一步到位:分层编译
热点代码被探测到之后,不是直接扔给 C2 的。JVM 采用的是分层编译(Tiered Compilation) ,一共 5 个级别:
| 级别 | 谁在干活 | 干什么 |
|---|---|---|
| Level 0 | 解释器 | 纯解释执行 |
| Level 1 | C1 | 快速编译,不做 profiling |
| Level 2 | C1 | 带简单计数(方法调用次数等) |
| Level 3 | C1 | 带完整 profiling(分支概率、类型分布等) |
| Level 4 | C2 | 重度优化,利用 profiling 数据做激进优化 |
为什么不一上来就用 C2? 因为 C2 编译又慢又消耗内存,对不那么热的代码来说性价比太低。
为什么 C1 要收集 profiling? C1 在编译期间记录的信息——比如哪个 if 分支走了 99% 的时间,虚方法实际调的是哪个子类——直接丢给 C2。C2 拿着这些数据做推测性优化,激进到"假设某个类型永远不会出现"的程度。万一推测错了就反优化(deoptimization) 退回到解释执行。
通过编译日志查看这一过程
说这么多,不如自己看一眼。加两个参数跑一个 Java 程序:
java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation YourClass
你会看到类似这样的输出:
66 1 3 java.lang.String::hashCode (55 bytes)
67 2 3 java.lang.String::charAt (29 bytes)
68 3 4 java.lang.String::equals (81 bytes)
第二列是编译 ID(从 1 开始递增),第三列是层级。3 = C1 + 完整 profiling,4 = C2。
可以看到 hashCode 和 charAt 被 C1 编译了,而 equals 已经冲到了 C2——说明 equals 在你的程序里被调得更多、更频繁。
如果用 JITWatch 这个工具打开 LogCompilation 的日志文件,还能看到每个方法在各个层级停留了多久、是什么条件触发了升级——非常直观。
小结
回顾一下 JVM 识别热点代码的全过程:
搞懂了这个过程,再去调 CompileThreshold、看 PrintCompilation 输出、分析 JIT 行为,就能彻底理解热点识别的本质了。
如果你想进一步了解 C2 的具体优化手段(内联、逃逸分析、标量替换等),推荐阅读 Oracle 官方的 JIT Compiler Overview。