【JVM原理详解】36-JIT编译器概述-C1与C2与分层编译

【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-itiered

Graal:Java编写的C3

Graal是JDK 10引入的实验性JIT编译器,用纯Java编写,可作为C2的替代(有时被称为"C3")。

Graal的特点

  1. 用Java写Java编译器:Graal本身是Java应用,运行在JVM上。这打破了"C2只能用C++写"的传统,可维护性大幅提升
  2. 基于Sea-of-Nodes IR:与C2类似的中间表示,但实现更现代
  3. 与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:+UseGraalJITMyApp

Graal的定位

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的过渡——启动期性能会更差。

实践要点

  1. 保持分层编译默认开启:JDK 10+默认开启分层编译是有道理的,绝大多数场景手动调参只会更糟。仅在极少数"启动慢到不可接受"或"短生命周期工具"场景考虑关闭。

  2. 理解C2的预热成本:服务上线后前几十秒到几分钟性能偏低是正常的,C2需要时间收集profile并完成深度优化。压测时务必充分预热,JMH默认5轮预热并非浪费。

  3. -client参数已过时:64位JDK 8+实际忽略-client,JDK 11+彻底废弃。不要在新项目里用它。

  4. CodeCache监控不可少:分层编译比纯C2产生更多编译产物(C1版本+C2版本同时存在),CodeCache占用更高。监控jcmd <pid> Compiler.CodeCache,必要时调大-XX:ReservedCodeCacheSize(默认240MB,大型应用可调到512MB)。

  5. Graal仍是实验特性:生产环境慎用-XX:+UseGraalJIT。它更适合作为GraalVM Native Image的底座,而非标准HotSpot的JIT替代。若要试,务必做完整压测对比。

  6. 诊断"没被优化"的方法:用-XX:+PrintCompilation查看方法是否进入编译队列,用-XX:CompileCommand=print,类名.方法名打印汇编。如果方法一直停留在层级3不上层级4,通常是profiling数据不足或方法本身太小不值得C2介入。

  7. 注意逆优化风暴:当C2的乐观假设频繁失败(如多态剧烈的虚方法),会反复触发uncommon trap,性能剧烈抖动。-XX:+PrintCompilation日志中频繁出现made not entrantuncommon 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调优实战