【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 CompilerJDK 1.4引入Server CompilerJDK 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(ListStringlist){for(Strings:list){// C2可能基于profiling假设list是ArrayList直接内联ArrayList.get()}}如果profiling数据显示这个list参数99%是ArrayListC2会生成假设是ArrayList的快速路径并在入口插入类型检查一旦传进来的是LinkedList就触发uncommon trap回退。这种speculative optimization是C2峰值性能的来源。C2的劣势编译慢、CPU占用高在启动阶段大量编译会拖慢应用激进优化有时会因假设频繁失败导致性能抖动逆优化风暴分层编译5个层级**分层编译Tiered Compilation**是让C1和C2协作的机制。HotSpot定义了5个执行层级0~4层级 0解释器执行收集基础profiling │ ▼ 方法调用/回边计数超阈值 层级 3C1编译带完整profiling收集方法调用、类型、分支等 │ ▼ 代码进一步变热C2队列繁忙时先降到层级2过渡 层级 2C1编译带轻量级profiling仅方法调用与回边 │ ▼ 代码极热 层级 4C2编译深度优化不带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出手时已经有了足够的运行时数据支撑激进优化。回退机制层级4C2编译的代码如果发生uncommon trap乐观假设失败会逆优化回层级3或层级0重新解释执行随后可能再次被编译到层级4。分层编译的参数# JDK 8默认在某些场景开启可手动开启-XX:TieredCompilation# JDK 10默认开启无法关闭关掉也无意义控制各层级阈值的参数JDK 8/11-XX:Tier0BackedgeNotifyFreqLog0-XX:Tier3InvocationThreshold200-XX:Tier3MinInvocationThreshold100-XX:Tier3CompileThreshold2000-XX:Tier4InvocationThreshold5000-XX:Tier4BackEdgeThreshold40000实际生产中很少手动调这些参数默认值已是多年调优的结果。Profile-Guided OptimizationPGOProfile-Guided OptimizationPGO是指基于运行时profiling数据指导优化的技术。它不是JVM独有概念C/C编译器如GCC、LLVM也有PGO模式需要先跑一次收集profile再重新编译。但JVM的PGO是在线的、自适应的——无需重新编译运行时持续收集并反馈。HotSpot收集的profile数据层级3的C1编译代码会收集以下数据方法调用计数被调用次数回边计数循环执行次数参数类型调用点实际接收的对象类型用于虚方法去虚化分支频率if/else分支走向用于分支预测优化异常抛出是否抛出异常异常路径通常被C2排除在快速路径外这些数据存储在方法的**MethodDataMDO**中。-XX:PrintInlining和-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly能间接反映profiling结果。PGO如何指导优化一个典型例子是虚方法内联。假设有interfaceShape{doublearea();}classCircleimplementsShape{publicdoublearea(){...}}classSquareimplementsShape{publicdoublearea(){...}}voiddraw(Shapes){doubleas.area();// 虚方法调用}如果profiling显示1000次调用中998次是CircleC2会基于**内联缓存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 8JDK 8起分层编译在Server模式64位JVM默认下默认开启TieredCompilation默认为true。-client和-server参数仍可指定但64位JVM实际上忽略-client64位只有Server Compiler。JDK 10分层编译默认开启-client/-server参数基本失去意义——分层编译会自动在C1和C2之间切换。-XX:-TieredCompilation可以关闭分层编译此时直接用C2启动慢峰值略高但极少这么做。# 查看当前编译器配置java-XX:PrintFlagsFinal-version|grep-itieredGraalJava编写的C3Graal是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:UseJVMCICompilerJDK 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){intsum0;for(inti0;in;i){sumi*i;}returnsum;}publicstaticvoidmain(String[]args){// 预热让compute变热触发分层编译for(inti0;i100_000;i){compute(100);}// 正式测量longstartSystem.nanoTime();longtotal0;for(inti0;i1_000_000;i){totalcompute(100);}longelapsedSystem.nanoTime()-start;System.out.println(Total: total, elapsed: elapsed/1_000_000ms);}}运行时加上编译日志参数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解读第一行方法首次被编译到层级3C1 profiling第二行方法变热被重新编译到层级4C2深度优化第三行made not entrant表示旧版本失效后续调用使用新的C2版本如果在-XX:-TieredCompilation下运行你会看到直接从层级0跳到层级4没有中间层级3的过渡——启动期性能会更差。实践要点保持分层编译默认开启JDK 10默认开启分层编译是有道理的绝大多数场景手动调参只会更糟。仅在极少数启动慢到不可接受或短生命周期工具场景考虑关闭。理解C2的预热成本服务上线后前几十秒到几分钟性能偏低是正常的C2需要时间收集profile并完成深度优化。压测时务必充分预热JMH默认5轮预热并非浪费。-client参数已过时64位JDK 8实际忽略-clientJDK 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:CompileCommandprint,类名.方法名打印汇编。如果方法一直停留在层级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 OptimizationPGO**是C2激进优化的数据基础HotSpot的PGO是在线自适应的-client/-server参数在JDK 10已基本失效分层编译默认开启Graal是用Java编写的现代JIT编译器可作为C2替代也是GraalVM Native Image的底座下一篇我们将聚焦JIT最重要的单项优化——方法内联深入剖析内联条件、虚方法内联与内联缓存机制。更多内容JVM调优实战