JVM是如何做热点识别的?

0 阅读5分钟

你学习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 1C1快速编译,不做 profiling
Level 2C1带简单计数(方法调用次数等)
Level 3C1带完整 profiling(分支概率、类型分布等)
Level 4C2重度优化,利用 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。

可以看到 hashCodecharAt 被 C1 编译了,而 equals 已经冲到了 C2——说明 equals 在你的程序里被调得更多、更频繁。

如果用 JITWatch 这个工具打开 LogCompilation 的日志文件,还能看到每个方法在各个层级停留了多久、是什么条件触发了升级——非常直观。

小结

回顾一下 JVM 识别热点代码的全过程:

在这里插入图片描述

搞懂了这个过程,再去调 CompileThreshold、看 PrintCompilation 输出、分析 JIT 行为,就能彻底理解热点识别的本质了。


如果你想进一步了解 C2 的具体优化手段(内联、逃逸分析、标量替换等),推荐阅读 Oracle 官方的 JIT Compiler Overview