2758 字
约 9 分钟
17
S05-X29 · JIT 分层编译:从解释执行到 C2 的五个级别
2024-12-31
2026-11-09

同一段代码,第一次跑 200ms,预热十分钟后跑 2ms。不是缓存起效,是 JVM 在后台把字节码重写成了机器码。它没有开关,但有明确的触发条件、产物形态和失效路径——搞清楚这三点,-XX:+PrintCompilation 那些列才读得懂。

按"执行层级 → 触发条件 → 中间表示 → 去优化 → 代码缓存"拆。


1. 三种执行引擎与它们的分工

HotSpot 里同时存在解释器、C1(Client Compiler)、C2(Server Compiler)。三者不是替代关系,是接力关系。

引擎 编译耗时 生成代码质量 典型角色
解释器 无 极低(逐条派发) 冷代码、启动期、去优化回退落点
C1 快(毫秒级) 中等,带基础优化 尽早接管热点,收集 profile
C2 慢(数十毫秒到数百毫秒) 高(激进内联、循环优化) 长时间运行后的稳定峰值

对应三个运行模式:

$ java -Xint  -jar app.jar     # 纯解释,不编译
$ java -Xcomp  -jar app.jar    # 首次调用就编译(调试用,破坏 profile)
$ java          -jar app.jar   # 默认 -Xmixed,分层编译

-Xcomp 在生产上只会更慢:没有 profile 的 C2 只能靠静态推测内联,猜错就去优化,来回震荡。默认的 -Xmixed 才是推荐形态,JDK 8 起分层编译默认开启。


2. 五个编译级别

分层编译(Tiered Compilation)把"解释 → C1 → C2"细分成 5 级,用 -XX:TieredStopAtLevel=N 可以截断:

level 编译器 带 profile 用途
0 解释器 — 冷启动
1 C1 否 极简 C1,无 profile(仅在特殊场景直接用)
2 C1 是(仅方法调用计数) 有限 profile
3 C1 是(全量 profile) 默认的第一站,为 C2 攒数据
4 C2 是 峰值性能

默认路径是 0 → 3 → 4。level 2 只在小方法、或 C2 队列积压时作为折中出现在日志里。

$ java -XX:+PrintCompilation -jar app.jar 2>&1 | head -12
    127   38       3       com.example.Demo::compute (24 bytes)
    131   39 %     4       com.example.Demo::compute @ 6 (24 bytes)
    158   40       4       java.lang.String::hashCode (55 bytes)

PrintCompilation 的列含义必须记牢,否则整段日志没法读:

列 含义 判读要点
首列 距 JVM 启动的毫秒数 观察"何时开始编译"
第二列 编译任务 ID 同 ID 多次出现 = 重编译
% 发生了 OSR(栈上替换) 说明是循环体热,方法整体还没到阈值
第三列数字 目标 level 3 → 4 是正常晋升
made not entrant 该版本代码被标记淘汰 通常伴随去优化
made zombie 代码已无人引用,等待回收 代码缓存压力信号

3. 触发条件:两个计数器

编译不是按时间触发,是按计数触发。每个方法有两块计数:

# 关键参数(默认值按 JDK 17 核对,不同版本可能有偏移)
-XX:Tier3InvocationThreshold=200      # level 3 的方法调用阈值
-XX:Tier3BackEdgeThreshold=60000      # level 3 的回边阈值
-XX:Tier4InvocationThreshold=5000     # level 4 的方法调用阈值
-XX:Tier4BackEdgeThreshold=40000      # level 4 的回边阈值
-XX:CompileThreshold=10000            # 关闭分层编译时的单一阈值
计数器 何时加一 代表什么
方法调用计数器 每次 invoke* 进入方法 这个方法被反复调用
回边计数器(back edge) 循环体每次跳回循环头 单个方法内部跑得久

计数器衰减排容易被忽略:方法调用计数每过一段时间减半(与 -XX:CounterHalfLifeTime 相关)。被调用一万次但分布很散的方法可能永远到不了阈值——"热点方法没被编译"的常见答案。

循环长的方法走另一条路:不等整体编译,直接在循环体入口做栈上替换(OSR),用编译版本替换正在解释的帧。日志里的 % 就是它。


4. C2 的中间表示:Sea-of-Nodes

C2 不是"字节码到汇编"的直译器。它先把字节码转成一种叫 Sea-of-Nodes 的图 IR:节点是运算(Add、Load、Phi、Region、If),边只表示数据依赖和调度依赖,没有显式的控制流顺序。

好处是优化可以纯图变换:常量折叠把 Add 节点换成常量;公共子表达式消除找结构相同的节点;内联拿被调方法的图替换 Call 节点。坏处是图规模会爆,调试输出极难读。

$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintIdeal -XX:+PrintCompilation \
       -XX:CompileCommand=print,Demo::compute -jar app.jar
想看的产物 参数 输出形态
内联决策 -XX:+PrintInlining 树状缩进,"inline (hot)" / "too big"
高级 IR(Ideal 图) -XX:+PrintIdeal 节点编号 + 依赖连线的文本表示
机器码反汇编 -XX:+PrintOptoAssembly 需 debug/慢速调试构建
最终机器码 -XX:+PrintAssembly + hsdis 真实汇编,需放置 libhsdis
内联失败原因统计 -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation XML 日志,配 JITWatch 读

内联是收益最大的一步:把跨方法调用变成同一张图里的节点,逃逸分析、标量替换才有可能发生。-XX:MaxInlineSize(默认 35 字节码)与 -XX:FreqInlineSize(默认 325)是两个门槛——热点放宽到 325,冷方法只有 35。所以同一方法在单测里"没内联",压测里内联了。


5. 去优化:编译假设被打破之后

C2 会基于 profile 做推测优化,最典型的是"这个接口调用点上永远只出现过 ImplA",于是直接内联 ImplA 的实现。一旦真的来了 ImplB,编译代码没法处理——它必须退回去。

机制是 uncommon trap:编译产物里埋一个检查点,条件不满足就跳到一段"去优化桩",把当前栈帧重建回解释器能理解的状态,从该字节码位置继续解释执行。

$ java -XX:+UnlockDiagnosticVMOptions -XX:+TraceDeoptimization -jar app.jar
DEOPT PACKING thread 0x00007f… pc=0x00007f…
  Unpacking frame 0 (interpreted)  ...
DEOPT UNPACKING thread 0x00007f… pc=0x00007f… mode 0
现象 机制 是否正常
同一个方法反复 3 → 4 → made not entrant 类型 profile 不稳定,反复去优化 异常,通常是多态调用点过杂
压测初期吞吐抖动 热代码在解释 / C1 / C2 间迁移 正常预热成本
uncommon trap 数量随请求量线性增长 每次都撞同一个类型假设 需要看是不是接口实现被频繁切换
去优化后性能不回升 重编译又用同样假设,循环 用 -XX:-TieredCompilation 或调整 profile 收集

判断标准:去优化本身正常,成规模的反复去优化才是问题。-XX:+LogCompilation 的 XML 里每个 <uncommon_trap> 都带原因码,配 JITWatch 能看出哪一行、因为哪个类型假设失败。


6. 代码缓存:编译产物也要地方放

编译出来的机器码存在一段叫 code cache 的本地内存里,默认大小按平台和版本不同,通常是 240MB 量级(-XX:+PrintFlagsFinal | grep ReservedCodeCacheSize 核对本机值)。

段 存放内容 满了会怎样
non-method 编译桩、适配器、解释器入口 直接 CodeCache is full
profiled C1(level 2/3)产物 晋升 C2 失败
non-profiled C2(level 4)产物 热点无法升到峰值
$ java -XX:ReservedCodeCacheSize=256m -XX:+UseCodeCacheFlushing \
       -XX:+PrintCodeCache -jar app.jar

UseCodeCacheFlushing 在 JDK 8 后默认开启,会把 "zombie" 代码清掉换空间。代价是被清掉的方法下次调用要重新编译,于是出现"性能周期性掉坑"。日志里 made zombie 频繁出现,就该加大 ReservedCodeCacheSize,而不是继续依赖 flush。


7. 排错:六种"编译不生效"

现象 原因 处理
PrintCompilation 一个方法都没有 跑的是 -Xint,或在容器里被 JAVA_TOOL_OPTIONS 覆盖 jcmd <pid> VM.flags 看真实生效参数
方法调了几万次仍不编译 计数器按半衰期衰减,调用太分散 看 -XX:CounterHalfLifeTime,或确认是否真热点
日志只有 level 3,永不进 4 C2 编译队列积压 / 代码缓存不足 看 CompileQueue 与 PrintCodeCache
CompileThresholdScaling 被设成小数 容器环境按 CPU 配额自动缩放阈值 直接看 VM.flags 里的实际值,别按默认值推算
内联日志全 too big 方法体超过 FreqInlineSize 拆方法,或谨慎调 -XX:FreqInlineSize
峰值性能比 C1 还差 推测优化猜错,反复去优化 用 TraceDeoptimization 定位,收窄多态调用点

补充第一条:容器 CPU limit 会让 JVM 自动缩放编译阈值(-XX:CompileThresholdScaling 随可用 CPU 变化),"同样的代码在物理机和容器里编译时机不同"是设计行为。任何阈值推断都必须先读 VM.flags。


8. 边界:JIT 管不到的地方

场景 JIT 的实际能力 更该做的事
启动阶段耗时 基本无用(还在解释执行) AppCDS、类数据共享、减少初始加载
一次性脚本 / Serverless 短生命周期 编译成本 > 收益 考虑 AOT(GraalVM native-image)、CRaC
native 方法内部耗时 完全看不到 perf、async-profiler(含 native 模式)
GC 停顿 与 JIT 无关 GC 日志 + GC 参数
纯 IO 等待 编译不改变等待 线程模型、连接池、批量

另一个维度是 JVM 实现差异。HotSpot 用 C1/C2 + Sea-of-Nodes;OpenJ9(内部代号 J9)用共享类缓存 + 自己的 JIT,启动和内存占用通常更省,峰值吞吐视场景而定;GraalVM 的 JIT 用 Java 写的 Graal 编译器,native-image 则走 AOT 完全绕开运行时编译。跨 JVM 对比必须按实现和版本分别核对,"JIT 都差不多"是错的。

一句话收束:JIT 优化的是"已经热了的纯计算代码",它不改变算法复杂度,也不消除 IO 与锁的代价。


9. 自测

  1. 分层编译默认走的是哪条级别路径?为什么不是 0 → 1 → 4?
  2. 日志里的 % 表示什么?它对应哪个计数器?
  3. 一个方法被调用了几万次却没被编译,最可能的原因是什么?
  4. MaxInlineSize 和 FreqInlineSize 分别作用于什么方法?
  5. uncommon trap 触发后,JVM 从哪一层继续执行?
  6. 容器里编译阈值和物理机不同,先查哪个命令确认实际值?
S05-X29 · JIT 分层编译:从解释执行到 C2 的五个级别
https://www.clxhxhhr.top/posts/6022/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。