【JVM原理详解】36-JIT编译器概述-C1与C2与分层编译
36-JIT编译器概述-C1与C2与分层编译
引言
前一篇我们剖析了字节码执行引擎的整体架构,知道了HotSpot采用"解释器+JIT编译器"的混合模式。但我们对JIT编译器本身还只停留在"它做了优化"这个粗浅层面。究竟有几个编译器?它们各自擅长什么?分层编译的5个层级分别做什么?为什么需要Profile-Guided Optimization?本篇将系统回答这些问题,为后续几篇深入具体优化手段打下基础。
理解JIT编译器的分工与协作,是看懂-XX:+PrintCompilation日志、诊断"为什么我的方法没被优化"的前提,也是从"会用JVM参数"迈向"会调JVM"的关键一步。
为什么需要多个编译器
JIT编译面临一个根本矛盾:编译速度与生成代码质量的权衡。
- 想要代码跑得快,就要做更多更深的优化(内联、逃逸分析、循环展开),但这些都耗时耗CPU
- 想要尽快进入编译态、减少启动延迟,就要少做优化、快速产出机器码
单一编译器很难同时满足两种诉求。C1编译器在启动早期快速介入,让代码尽快脱离"纯解释"的慢速区间;C2编译器在代码充分"热"起来后接手,用激进优化榨干性能。这种分工是HotSpot几十年工程经验的结晶。
从历史脉络看,JDK 1.3时代只有Client Compiler,JDK 1.4引入Server Compiler,JDK 6开始实验分层编译,JDK 8起在Server模式下默认开启分层编译,JDK 10+进一步强化为推荐默认。JDK 10还引入了用Java编写的Graal编译器作为C2的替代选项。
C1编译器:快速轻量
C1编译器(Client Compiler)又称Client Compiler,它的设计目标是快速编译、低开销优化。
C1的优化手段
C1只做"性价比高"的优化,主要包括:
- 方法内联(仅小方法)
- 去虚化(基于CHA的乐观去虚化)
- 简单逃逸分析(用于同步消除)
- 常量折叠与传播
- 部分空检查消除
C1不做循环展开、不做标量替换、不做复杂的锁优化。这些"重活"留给C2。
C1的编译速度
C1使用线性扫描寄存器分配算法,时间复杂度近似线性,远快于C2的图着色算法。一个几十KB的方法,C1通常几毫秒就能编译完成,而C2可能需要几十到几百毫秒。
C1的三种运行模式
在分层编译中,C1有三种运行模式,对应层级1~3:
- 层级1:不带profiling的C1——纯编译,不收集运行时数据,编译最快,用于极小方法或已知无需profiling的场景
- 层级2:带轻量级profiling的C1——仅收集方法调用次数和回边计数,profiling开销较小
- 层级3:带完整profiling的C1——收集方法调用、参数类型、分支走向、异常抛出等全套数据,为C2的激进优化提供profile支撑
profiling本身有开销(层级3 > 层级2 > 层级1),所以并非所有C1编译都收集完整数据——代码具体落在哪一层,取决于它的热度与C2编译队列的繁忙程度。
C2编译器:激进深度优化
C2编译器(Server Compiler)又称Server Compiler,在64位HotSpot中默认就是C2。它的设计目标是峰值性能最大化,不惜以编译时间和CPU为代价。
C2的核心优化
C2的优化清单很长,主要包括:
- 方法内联(包括大方法和虚方法,基于profiling)
- 逃逸分析与标量替换(彻底消除短期对象的堆分配)
- 锁消除与锁粗化
- 循环展开与循环剥离
- 公共子表达式消除(CSE)
- 死代码消除(DCE)
- 常量传播与折叠
- 空检查消除、范围检查消除
- 激进预测优化(基于profiling的分支预测)
C2基于Sea-of-Nodes IR(中间表示),这是一种数据流与控制流混合的图结构。它的优势是能做全局优化,且便于进行"乐观假设+逆优化"——当运行时假设被打破时,通过uncommon trap回退到解释器。
C2的激进假设
C2敢于做"不一定对"的优化。例如:
voidprocess(List<String>list){for(Strings:list){// C2可能基于profiling假设list是ArrayList,直接内联ArrayList.get()}}如果profiling数据显示这个list参数99%是ArrayList,C2会生成"假设是ArrayList"的快速路径,并在入口插入类型检查;一旦传进来的是LinkedList,就触发uncommon trap回退。这种speculative optimization是C2峰值性能的来源。
C2的劣势
- 编译慢、CPU占用高
- 在启动阶段大量编译会拖慢应用
- 激进优化有时会因"假设频繁失败"导致性能抖动(逆优化风暴)
分层编译:5个层级
**分层编译(Tiered Compilation)**是让C1和C2协作的机制。HotSpot定义了5个执行层级(0~4):
层级 0:解释器执行,收集基础profiling │ ▼ 方法调用/回边计数超阈值 层级 3:C1编译,带完整profiling(收集方法调用、类型、分支等) │ ▼ 代码进一步变热,C2队列繁忙时先降到层级2过渡 层级 2:C1编译,带轻量级profiling(仅方法调用与回边) │ ▼ 代码极热 层级 4:C2编译,深度优化,不带profiling 层级 1(旁路):C1编译,不带profiling,用于极小方法,可从层级0直接跳入层级1是一条"旁路"——某些极小方法(如简单的getter)无需profiling数据,可从层级0直接编译到层级1,跳过profiling开销。最常见的热点方法晋升路径是0 → 3 → 4;当C2编译队列繁忙时,会经过0 → 3 → 2 → 4的过渡路径,在层级2"暂存"等待C2接手。
为什么不直接从0跳到4
直接让C2编译"冷代码"代价巨大:C2编译慢、且没有profiling数据时无法做乐观优化。分层编译让C1先快速介入,同时悄悄收集profiling;等代码真正"热"到值得C2出手时,已经有了足够的运行时数据支撑激进优化。
回退机制
层级4(C2)编译的代码如果发生uncommon trap(乐观假设失败),会逆优化回层级3或层级0重新解释执行,随后可能再次被编译到层级4。
分层编译的参数
# JDK 8:默认在某些场景开启,可手动开启-XX:+TieredCompilation# JDK 10+:默认开启,无法关闭(关掉也无意义)控制各层级阈值的参数(JDK 8/11):
-XX:Tier0BackedgeNotifyFreqLog=0-XX:Tier3InvocationThreshold=200-XX:Tier3MinInvocationThreshold=100-XX:Tier3CompileThreshold=2000-XX:Tier4InvocationThreshold=5000-XX:Tier4BackEdgeThreshold=40000实际生产中很少手动调这些参数,默认值已是多年调优的结果。
Profile-Guided Optimization(PGO)
Profile-Guided Optimization(PGO)是指基于运行时profiling数据指导优化的技术。它不是JVM独有概念,C/C++编译器(如GCC、LLVM)也有PGO模式,需要"先跑一次收集profile,再重新编译"。但JVM的PGO是在线的、自适应的——无需重新编译,运行时持续收集并反馈。
HotSpot收集的profile数据
层级3的C1编译代码会收集以下数据:
- 方法调用计数:被调用次数
- 回边计数:循环执行次数
- 参数类型:调用点实际接收的对象类型(用于虚方法去虚化)
- 分支频率:if/else分支走向(用于分支预测优化)
- 异常抛出:是否抛出异常(异常路径通常被C2排除在快速路径外)
这些数据存储在方法的**MethodData(MDO)**中。-XX:+PrintInlining和-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly能间接反映profiling结果。
PGO如何指导优化
一个典型例子是虚方法内联。假设有:
interfaceShape{doublearea();}classCircleimplementsShape{publicdoublearea(){...}}classSquareimplementsShape{publicdoublearea(){...}}voiddraw(Shapes){doublea=s.area();// 虚方法调用}如果profiling显示1000次调用中998次是Circle,C2会基于**内联缓存(Inline Cache)**生成"假设是Circle"的快速路径:
if (s.getClass() == Circle.class) { // 直接内联 Circle.area() 的代码 } else { // 慢速路径:查虚方法表,或触发uncommon trap }这就是PGO的精髓——用历史数据预测未来,生成针对当前负载特征定制的机器码。
-client / -server / -XX:+TieredCompilation 演进
这几个参数的历史变迁反映了JVM编译策略的演化:
JDK 8之前
java-clientMyApp# 使用C1java-serverMyApp# 使用C2-client和-server选择不同的JVM"模式"。Client模式启动快但峰值低,适合桌面应用;Server模式启动慢但峰值高,适合服务端长跑应用。
JDK 8
JDK 8起分层编译在Server模式(64位JVM默认)下默认开启(TieredCompilation默认为true)。-client和-server参数仍可指定,但64位JVM实际上忽略-client(64位只有Server Compiler)。
JDK 10+
分层编译默认开启,-client/-server参数基本失去意义——分层编译会自动在C1和C2之间切换。-XX:-TieredCompilation可以关闭分层编译,此时直接用C2(启动慢,峰值略高,但极少这么做)。
# 查看当前编译器配置java-XX:+PrintFlagsFinal-version|grep-itieredGraal:Java编写的C3
Graal是JDK 10引入的实验性JIT编译器,用纯Java编写,可作为C2的替代(有时被称为"C3")。
Graal的特点
- 用Java写Java编译器:Graal本身是Java应用,运行在JVM上。这打破了"C2只能用C++写"的传统,可维护性大幅提升
- 基于Sea-of-Nodes IR:与C2类似的中间表示,但实现更现代
- 与C2共存:通过JVMCI接口启用,JDK 10~20使用
-XX:+UseJVMCICompiler,JDK 21+新增更直观的-XX:+UseGraalJIT(均需-XX:+UnlockExperimentalVMOptions解锁实验特性)
启用Graal
# JDK 10~20:通过JVMCI接口启用Graaljava-XX:+UnlockExperimentalVMOptions-XX:+UseJVMCICompilerMyApp# JDK 21+:新增更直观的参数java-XX:+UnlockExperimentalVMOptions-XX:+UseGraalJITMyAppGraal的定位
Graal不只是"另一个C2",它还是GraalVM生态的核心。GraalVM Native Image基于Graal做AOT编译(下一篇详述)。在标准HotSpot中,Graal作为JIT的性能在某些场景超越C2,但在通用场景未必明显胜出,且自身编译开销不小,目前仍是可选实验特性。
代码示例:观察分层编译
下面通过一个简单示例观察分层编译的实际行为。
// 适用 JDK 11/17publicclassTieredDemo{staticintcompute(intn){intsum=0;for(inti=0;i<n;i++){sum+=i*i;}returnsum;}publicstaticvoidmain(String[]args){// 预热:让compute变热,触发分层编译for(inti=0;i<100_000;i++){compute(100);}// 正式测量longstart=System.nanoTime();longtotal=0;for(inti=0;i<1_000_000;i++){total+=compute(100);}longelapsed=System.nanoTime()-start;System.out.println("Total: "+total+", elapsed: "+elapsed/1_000_000+"ms");}}运行时加上编译日志参数:
java-XX:+PrintCompilation-XX:+UnlockDiagnosticVMOptions-XX:+PrintInliningTieredDemo输出片段(截取关键行):
142 21 b 3 TieredDemo::compute (26 bytes) 156 21 b 4 TieredDemo::compute (26 bytes) 158 21 4 TieredDemo::compute (26 bytes) made not entrant解读:
- 第一行:方法首次被编译到层级3(C1 + profiling)
- 第二行:方法变热,被重新编译到层级4(C2深度优化)
- 第三行:
made not entrant表示旧版本失效,后续调用使用新的C2版本
如果在-XX:-TieredCompilation下运行,你会看到直接从层级0跳到层级4,没有中间层级3的过渡——启动期性能会更差。
实践要点
保持分层编译默认开启:JDK 10+默认开启分层编译是有道理的,绝大多数场景手动调参只会更糟。仅在极少数"启动慢到不可接受"或"短生命周期工具"场景考虑关闭。
理解C2的预热成本:服务上线后前几十秒到几分钟性能偏低是正常的,C2需要时间收集profile并完成深度优化。压测时务必充分预热,JMH默认5轮预热并非浪费。
-client参数已过时:64位JDK 8+实际忽略-client,JDK 11+彻底废弃。不要在新项目里用它。CodeCache监控不可少:分层编译比纯C2产生更多编译产物(C1版本+C2版本同时存在),CodeCache占用更高。监控
jcmd <pid> Compiler.CodeCache,必要时调大-XX:ReservedCodeCacheSize(默认240MB,大型应用可调到512MB)。Graal仍是实验特性:生产环境慎用
-XX:+UseGraalJIT。它更适合作为GraalVM Native Image的底座,而非标准HotSpot的JIT替代。若要试,务必做完整压测对比。诊断"没被优化"的方法:用
-XX:+PrintCompilation查看方法是否进入编译队列,用-XX:CompileCommand=print,类名.方法名打印汇编。如果方法一直停留在层级3不上层级4,通常是profiling数据不足或方法本身太小不值得C2介入。注意逆优化风暴:当C2的乐观假设频繁失败(如多态剧烈的虚方法),会反复触发
uncommon trap,性能剧烈抖动。-XX:+PrintCompilation日志中频繁出现made not entrant和uncommon trap是信号,需重新设计代码降低多态性。
小结
- HotSpot的JIT并非单一编译器,而是C1(快速轻量)+C2(深度激进)的分工体系
- 分层编译定义了5个层级(0~4),让代码沿着"解释→C1带profile→C1→C2"的路径渐进优化
- **Profile-Guided Optimization(PGO)**是C2激进优化的数据基础,HotSpot的PGO是在线自适应的
-client/-server参数在JDK 10+已基本失效,分层编译默认开启- Graal是用Java编写的现代JIT编译器,可作为C2替代,也是GraalVM Native Image的底座
下一篇我们将聚焦JIT最重要的单项优化——方法内联,深入剖析内联条件、虚方法内联与内联缓存机制。
更多内容:JVM调优实战