HotSpot C2 JIT 编译器深度工程实战:从分层编译、Sea-of-Nodes 理想图、IGVN 到逃逸分析与反优化 OopMap 的全链路解析
执行摘要
JVM 圈有个流传很广的误解:Java 是「解释执行所以慢」。实际生产系统上,跑在 CPU 上的几乎全是本地机器码——HotSpot 的两套 JIT 编译器(C1 / C2)把字节码翻译成宿主机指令,而真正决定一个 Java 服务吞吐上限的,是 C2(Server Compiler,内部代号 Opto)。
| 维度 | 结论 |
|---|---|
| C2 的 IR | Sea-of-Nodes 理想图,控制流与数据流统一在一张图上 |
| 核心优化引擎 | IGVN(迭代全局值编号)+ CCP(条件常量传播)交替收敛 |
| 决定性优化 | 内联 → 逃逸分析 → 标量替换 / 锁消除 / 循环优化 |
| 与 GC 的耦合点 | OopMap、Safepoint Poll、Card Mark 屏障 |
| 最大工程陷阱 | Deoptimization 风暴、Code Cache 爆满、多态内联失效 |
本文沿一条真实链路拆解:字节码 → 解释器/C1 → C2 IR → 优化 → 机器码 → 反优化。
一、分层编译:C2 什么时候介入
HotSpot 默认开启分层编译(Tiered Compilation),共 5 个层级:
| 层级 | 执行方式 | 触发条件 |
|---|---|---|
| 0 | 解释器 | 起始状态,同时收集调用计数、回边计数、类型剖面 |
| 1 | C1,无剖面 | 简单方法快速编译 |
| 2 | C1,带调用/回边计数 | 有限 profiling |
| 3 | C1,全量剖面 | 收集方法调用、分支、类型接收者 |
| 4 | C2,全优化 | 计数达阈值,使用 3 层收集的剖面数据 |
关键参数:
-XX:Tier3InvocationThreshold=200 # 进入 C1 全剖面层
-XX:Tier4InvocationThreshold=5000 # 进入 C2 的调用阈值
-XX:Tier4BackEdgeThreshold=40000 # 循环回边阈值(OSR 编译)
-XX:TieredStopAtLevel=1 # 只跑 C1(启动极快,长跑吃亏)
OSR(On-Stack Replacement) 是最容易被忽略的一环:一个方法被调用次数不够,但里面有个跑几百万次的热循环。此时解释器在回边处触发 OSR 编译,把解释器栈帧「整体替换」成编译帧——这需要解释器栈帧布局与编译帧布局之间存在映射(就是 OSRAdapter 的活儿)。OSR 编译的代码质量通常低于常规编译,因为剖面不完整、且入口的支配关系不确定。生产上看到 osr 标记的方法大量出现,往往是「预热不足」的信号。
# 观察编译事件
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
# 输出示例
# 4173 842 % 4 com.example.Order::calc @ 12 (312 bytes) osr
# 4175 843 3 com.example.Order::apply (48 bytes)
# 4190 844 4 com.example.Order::apply (48 bytes) made not entrant
字段含义:时间戳 / 编译 ID / 属性(% = OSR,s = synchronized,! = 有异常处理器)/ 层级 / 方法 / 字节码大小。
二、Sea-of-Nodes:为什么 C2 不用传统 CFG
教科书编译器用「控制流图 + 基本块 + 指令」三层结构。C2 用的是 Cliff Click 提出的 Sea-of-Nodes:一张图里同时表达数据依赖(实边)和控制依赖(虚边),基本块这个概念被彻底取消。
它的结构要点:
- Node 即值:每个节点代表一次计算(AddNode、LoadNode、CallJavaNode),而不是一条指令。
- 控制节点:
IfNode、RegionNode、PhiNode、ProjNode构成控制骨架。IfNode有两个投影(True/False),RegionNode合并多条控制边,PhiNode合并来自不同控制边的值。 - 浮动节点:纯数据计算的节点不绑定在任何基本块上,只要其控制依赖的输入被支配即可。这让节点能「自由浮动」——值编号优化可以把
a + b提到循环外,无需重写基本块结构。 - Memory 边(内存状态):这是最反直觉的地方。C2 把内存建模成一条状态链——
LoadNode除了依赖地址,还依赖一个 memory state 输入;StoreNode产生新的 memory state。这样加载/存储的别名关系被显式编码进图里,重排序自然受到约束。
// 示例代码
int sum(int[] a, int n) {
int s = 0;
for (int i = 0; i < n; i++) {
s += a[i]; // 边界检查 + 加载
}
return s;
}
在理想图里,a[i] 展开为:NullCheck(a) → RangeCheck(i, a.length) → AddP(a, i)(地址计算)→ LoadI。循环体的控制骨架是 Region(If(i<n)),循环变量由 Phi 合并。
为什么值得这么复杂? 因为浮动节点 + 内存状态链让「局部性无关的优化」变得极其廉价:全局值编号(GVN)可以在全图范围内做公共子表达式消除,而不用管它在哪个块里;循环不变代码外提(LICM)只需检查节点的控制输入是否被循环头支配。传统 CFG 上这些优化要遍历支配树、重写块边界,C2 里变成一次图遍历。
代价是:图可能爆炸。一个大方法(字节码 > 8000 字节)或深层内联会让节点数上万,编译时间呈非线性增长。所以 C2 有 NodeCountInliningCutoff、MaxNodeLimit 等硬闸门,超了直接放弃编译,回退到解释执行。
三、IGVN 与 CCP:C2 的优化主循环
C2 不是「跑一遍规则列表」,而是迭代到不动点:
Parse(字节码 → 理想图)
→ Optimize 循环 {
IGVN: 迭代全局值编号(常量折叠、CSE、恒等式化简、类型窄化)
CCP: 条件常量传播(基于 IfNode 分支条件裁剪不可达路径)
GCM: 全局代码调度(把浮动节点放到最佳位置,尽量低、尽量出循环)
} 直到稳定
→ 循环优化(IdealLoopTree)
→ 向量化(SuperWord)
→ 代码生成
IGVN 的本质是维护一个 Hash 表:节点的「值」由 (opcode, 输入节点 ID, 类型) 决定,两个节点哈希相同即为同值,后者直接被前者替代。配合节点自身的 Ideal() 方法(每个节点类实现自己的代数化简规则)和 Value() 方法(常量折叠),迭代跑下去图会持续收缩。
一个工程上很实用的观察:CCP 让「配置开关」零成本。
static final boolean DEBUG = false; // 编译期常量
void run() {
if (DEBUG) { heavyLogging(); } // CCP 裁剪,字节码里完全消失
}
因为 DEBUG 是 static final 的编译期常量,IfNode 的条件被折叠为常量,死分支连同它的控制边一起被删除,后续 GVN 扫掉无人引用的节点。这就是 Java 里「用常量开关代替运行时判断」能真正做到零开销的原因——前提是它必须是 static final 且初始化为常量表达式。
四、内联:C2 一切优化之母
C2 绝大多数优化都只在「同一个编译单元内」生效,所以内联决定了优化的上限。
C2 的内联决策是一棵树(InlineTree),受多个闸门限制:
-XX:MaxInlineSize=35 # 小方法:字节码 ≤ 35 字节无条件内联
-XX:FreqInlineSize=325 # 热方法:≤ 325 字节,若调用点够热
-XX:MaxInlineLevel=9 # 内联深度上限
-XX:MinInliningThreshold=250 # 调用点热度门槛
多态调用的处理是精髓。Java 的虚方法调用在字节码里是 invokevirtual / invokeinterface,C2 靠类型剖面(Type Profile)做投机优化:
- 单态(monomorphic):剖面只记录到一个接收者类型 → 直接内联,前置一个类型断言(Predicated 内联)。
- 双态(bimorphic):两个接收者 → 生成
if (klass == A) {...} else if (klass == B) {...}分支,各自内联。 - 超多态(megamorphic):超过
TypeProfileWidth(默认 2,最多 8 个 slot)→ 放弃内联,走虚表分派。
interface Codec { byte[] encode(byte[] in); }
// 若 hot path 上只流过 ProtobufCodec,C2 生成:
// if (recv.klass != ProtobufCodec.klass) → uncommon_trap(反优化)
// <ProtobufCodec.encode 的内联体>
// 一旦某天 JsonCodec 也进来了,反优化触发,重新走剖面收集
这就是为什么「接口 + 多实现」的抽象在热路径上是有真实成本的,以及为什么 GraalVM Native Image 之类的 AOT 方案要用封闭世界假设换取全量内联。
调用 -XX:+PrintInlining 能看到决策原因:
@ 12 com.example.Codec::encode (21 bytes) inline (hot)
@ 28 com.example.Util::hash (60 bytes) too big
@ 35 com.example.Val::toString (12 bytes) no static binding
五、逃逸分析:标量替换与锁消除
C2 的逃逸分析(Escape Analysis)是一种连接图(Connection Graph)上的不动点迭代,判定对象引用是否逃逸出当前编译单元:
- NoEscape:对象只在方法内使用 → 可做标量替换(Scalar Replacement),把对象拆成若干局部变量,彻底不分配。
- ArgEscape:只作为参数传递但被调用者持有 → 可消除同步(锁消除),但不拆对象。
- GlobalEscape:存入静态字段 / 堆对象 → 无优化。
// 优化前:每次循环分配一个 Point,堆压力巨大
long total = 0;
for (int i = 0; i < 1_000_000; i++) {
Point p = new Point(i, i * 2);
total += p.x + p.y;
}
// C2 后:Point 被标量替换成两个寄存器变量,零分配
同步消除同理:
// StringBuffer 的 append 是 synchronized 的
// 但 sb 未逃逸 → C2 直接删掉锁 (-XX:+EliminateLocks,默认开)
public String concat(String a, String b) {
StringBuffer sb = new StringBuffer();
sb.append(a).append(b);
return sb.toString(); // 这里 sb 逃逸了!需要 -XX:+EliminateNestedLocks 配合
}
注意上例的坑:sb.toString() 会让 sb 逃逸(传入另一个方法的参数),标量替换失效,但锁消除仍可生效(ArgEscape 级别)。这类细节决定了你改一行代码性能差 3 倍。
验证手段:
-XX:+PrintEscapeAnalysis # 打印逃逸判定
-XX:+DoEscapeAnalysis -XX:+EliminateAllocations
六、循环优化与 SuperWord 向量化
C2 把循环组织成 IdealLoopTree,做一系列变换:
| 优化 | 作用 |
|---|---|
| Loop Peeling | 剥离第一次迭代,让循环内剖面稳定 |
| Loop Unrolling | 展开若干次,减少分支开销、暴露并行性 |
| Loop Unswitching | 把循环内不变的 if 提到循环外,生成两个循环副本 |
| Range Check Elimination | 用归纳变量分析证明 i 在 [0, a.length) 内,删掉边界检查 |
| SuperWord | 把连续的标量运算打包成 SIMD 向量指令(AVX2 / AVX-512) |
SuperWord 是 Java 能跑出接近 C 的数值性能的关键。它要求:连续内存访问、同构运算、无跨迭代依赖、循环边界可对齐。
// 可被 SuperWord 向量化
for (int i = 0; i < n; i++) c[i] = a[i] + b[i];
// 不可向量化:跨迭代依赖(前缀和)
for (int i = 1; i < n; i++) c[i] = c[i-1] + a[i];
检查是否真的生成了 AVX 指令:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly \
-XX:PrintAssemblyOptions=intel
# 找 vaddps / vpaddd / vmulpd 等
七、反优化(Deoptimization):投机优化的兜底
C2 大量使用投机:假定某类型、假定某分支不走、假定数组不越界。每个投机点都配一个 Uncommon Trap——万一假设被打破,VM 必须能「从优化后的机器码状态,回退到解释器状态继续执行」。这就是反优化。
回退需要一份映射表:PC → (字节码位置, 栈帧布局, 寄存器中的值如何还原为局部变量/操作数栈)。这份信息由 OopMap + ScopeDesc + DebugInfo 组成:
- OopMap:记录在某个 PC 上,哪些寄存器/栈槽里放的是对象引用(Oop)。GC 扫描栈时必须精确知道这些位置,否则会漏活对象或把整数误当成指针。C2 在生成机器码时同步产出 OopMap,并在 Safepoint Poll(方法返回点、循环回边、非计数循环的特殊处理)位置登记。
- DebugInfo:记录 JVM 状态(局部变量槽、操作数栈深度、monitor 状态)到本地状态(寄存器、栈槽)的映射。
- ScopeDesc:内联展开后,一个物理栈帧对应多个逻辑方法帧。ScopeDesc 是一条链,描述「当前 PC 处于哪条内联路径的第几层」。
// 触发反优化的典型场景
void f(Object o) {
if (o instanceof String) { // 类型剖面:一直是 String
((String) o).length(); // C2 内联 String.length,加类型断言
}
}
// 某天传进来一个 StringBuilder:
// → 断言失败 → uncommon_trap(reason=class_check)
// → 反优化 → 回解释器 → 重新收集剖面 → 可能重新编译为 bimorphic
Deopt 风暴是生产上最隐蔽的性能杀手。表现为 QPS 突然掉一半、CPU 却很高。诊断:
-XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation
# 找 <uncommon_trap> 事件,按 reason 聚合
# 常见 reason:
# class_check / null_check / range_check / unstable_if / not_compiled_exception_handler
unstable_if 特别值得注意:一个 if 分支在剖面里从未走过,C2 会为它插 uncommon trap。如果之后它真的走了,反优化;C2 重新编译时若该分支仍「看起来」不常走,会再次投机——形成反优化—重编译循环。解法是让该分支在预热期被真实触达,或用 -XX:PerBytecodeTrapLimit 限制单字节码位置的陷阱次数。
八、代码生成:从理想图到机器码
优化收敛后,C2 做三步落地:
- 指令选择(Matching):用树模式匹配(BURS / 底部重写)把理想图节点映射到目标机器指令。x86 上大量使用
AddP→lea、寻址模式折叠。 - 全局代码调度(GCM):把浮动节点放到最优位置——尽量晚(减少寄存器压力)、尽量出循环(减少执行次数)。
- 寄存器分配:C2 用 Chaitin-Briggs 图着色,配合
LiveRange分裂与 spill 代价模型。超过物理寄存器(x86-64 只有 16 个通用寄存器)就 spill 到栈。
最终产出是一段 nmethod,登记在 Code Cache 里,并挂上 OopMap、Safepoint Poll、异常处理器表、deopt 入口。
Code Cache 是有限资源:
-XX:ReservedCodeCacheSize=256m # 默认通常 240m/256m
小服务塞满时会看到日志 CodeCache is full. Compiler has been disabled,之后再也不会编译新方法,性能断崖式下跌且不可逆(直到重启)。微服务里大量动态生成类(CGLib、Lambda、Groovy 脚本)时尤其要监控 java.nio:type=BufferPool 旁边的 CodeCache MBean。
jcmd <pid> Compiler.codecache
九、生产调优清单
| 症状 | 根因 | 动作 |
|---|---|---|
| 启动后前几分钟延迟高 | 预热不足,大量解释执行 / C1 | 加预热流量;或配 AOTCache(JDK 24+)/ AppCDS + -XX:Tier4* 下调 |
| QPS 抖动、CPU 飙高 | Deopt 风暴 | -XX:+LogCompilation 查 uncommon_trap reason |
| 吞吐上不去 | megamorphic 调用点 | 拆分类层次,减少热路径上的接口实现数 |
| 大方法从不编译 | 超过 HugeMethodLimit(8000) | 手拆方法;或 -XX:-DontCompileHugeMethods(慎用) |
| 长跑后性能断崖 | Code Cache 满 / 编译线程饥饿 | 调大 ReservedCodeCacheSize;检查 CICompilerCount |
| 数值计算慢 | SuperWord 未生效 | 检查对齐、别名(@ForceInline、避免 a[i] 与 b[j] 别名不确定) |
| 容器里编译慢 | CPU 配额被 cgroup 限死 | -XX:CICompilerCount 不要超过 cgroup 配额;给足 CPU burst |
推荐的一组生产基线参数:
-XX:+TieredCompilation
-XX:ReservedCodeCacheSize=384m
-XX:CICompilerCount=4 # 不超过分配给容器的核数
-XX:+UseCompressedOops # 默认开,别关
-XX:+PrintCompilation # 排障期开,常态关
十、结论:C2 真正的设计遗产
C2 值得研究,不是因为它「让 Java 变快」,而是它示范了「如何在运行期、在信息不完整的前提下,做出可撤销的激进决策」这一整套工程范式:
- 剖面驱动 + 投机 + 可撤销。先用廉价的执行收集事实(解释器 / C1 带剖面),再基于事实下注(C2 投机内联、裁剪分支),并预留完整的撤销通道(Uncommon Trap + OopMap + DebugInfo)。这套「speculate-and-deoptimize」范式后来被 V8 TurboFan、JavaScriptCore 的 DFG/FTL、乃至 ART 的 Profile-Guided Compilation 原样继承。
- 把物理属性编码为图约束。内存别名靠 memory state 链表达,GC 安全点靠 OopMap 精确登记——让「正确性」成为图结构的一部分,优化器无需额外的小心翼翼。
- 成本模型决定闸门,而非感觉。内联阈值、节点上限、trap 次数限制,本质是在「编译时间 + Code Cache 占用」与「运行期收益」之间做有界取舍。理解这些闸门,比背诵一百条
-XX参数更有价值。
当你下次看到 made not entrant 或 uncommon_trap 时,别把它当噪音——那是编译器在告诉你:它对世界的假设刚刚被现实修正了一次。

发表评论 取消回复