ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JIT即时编译原理与实战:从热点检测到性能优化

JIT即时编译原理与实战:从热点检测到性能优化 1. 先搞清楚JIT到底在解决什么问题这几年不管是在技术社区刷帖还是跟同事讨论线上问题总会碰到JIT这个词。Java开发者对它不陌生因为JVM里跑得好好的却总听人说JIT编译后性能才上来做前端的人也在聊V8的JIT做脚本语言的也在说LuaJIT。JIT是即时编译的缩写英文全称Just-In-Time compilation。说白了它就是在程序运行的过程中把代码动态地编译成机器码再执行而不是老老实实一句一句解释着跑。那为什么需要这个东西这得从两种最朴素的执行方式说起。第一种是解释执行程序每执行一行代码就现场翻译一行然后立刻执行。好处是启动快、跨平台容易缺点是翻译本身有开销而且翻译出的机器码质量往往不高跑起来慢。第二种是提前编译也就是AOTAhead-Of-Time在程序还没跑之前就把代码彻底编译成机器码比如C/C干的事。好处是运行性能直通硬件缺点是你编译出来的二进制没法跨平台换个CPU架构就得重新编译。JIT站在这两者中间它搞了个非常巧妙的折中程序跑起来后运行时Runtime一边解释执行一边暗中观测哪些代码被反复执行一旦发现某些代码块是热点就把它们编译成机器码之后这些代码就直接以机器码的形式运行。这样一来启动时可以很快因为不需要等整个程序全部编译完才能跑运行一段时间后性能又能逼近甚至超过AOT因为热点代码已经被精准地编译成了高质量的机器码。这个设计思路放在今天可以类比成什么呢想象一家餐厅菜单上有几百道菜。如果每桌客人来都从头做每道菜后厨会累死但如果提前把所有菜都做好备餐很多菜会因为没人点而浪费。JIT的做法是先正常接待记下哪些菜每天被点得最多然后请一位技术最好的主厨专门盯着这几道菜做精做专其他菜保持普通水平应付着来。餐厅的整体翻台率上去了浪费也少了。所以JIT的核心价值就是一句话它试图在同一段程序的生命周期里同时获得解释执行的低延迟启动和编译执行的长期高性能。需要理解的是JIT不是一个单一的编译动作它是一整套机制包括运行时监控、编译决策、代码优化、缓存管理。这篇文章我会从原理到实践把JIT拆开讲透顺便聊聊我们在实际工程里怎么利用它、遇到问题怎么排查。如果你是一个Java后端开发、Go/C转过来想搞懂虚拟机的同学或者你正在写解释型脚本语言想优化性能又或者你只是在面试时被问到JIT是什么想有个完整答案这篇文章都适合你。2. JIT的核心机制到底是怎么运转的2.1 热点代码是怎么被找出来的JIT的第一步是找到值得编译的代码。这依赖于运行时的一段监控逻辑。以JVM为例HotSpot虚拟机里有一个内置的解释器它每执行一个方法或者每执行一段循环体都会做一次计数器累加。计数器分成两类一类是方法调用计数器统计这个方法被谁调了多少次另一类是回边计数器统计方法里循环体执行了多少次循环的回边就是循环体执行完毕跳回去继续执行的这条边。当一个方法的调用计数突破阈值或者循环回边计数突破阈值这个代码块就会被标记为热点然后被扔给编译器去做即时编译。这个阈值不是固定不变的它跟运行模式有关。JVM默认有三种运行模式解释执行模式-Xint、编译执行模式-Xcomp和混合模式-Xmixed。混合模式是默认状态解释器和JIT编译器共存。在混合模式下方法调用计数器阈值默认大概是10000次回边计数器阈值会按一定规则折算这些都可以通过参数微调。需要特别指出一个容易误解的点计数器不是永远累加的。它有一个半衰期的机制。假如一个方法在短时间内被疯狂调用计数迅速飙升那它很快会被编译但如果这个方法调用频率一般计数会随着时间衰减归零那它可能永远不会被编译。这个设计是有道理的因为JIT编译本身有代价如果编译出来的代码只是偶尔用一次那纯粹是浪费编译时间。2.2 编译出的机器码是怎么执行的一旦代码被标记为热点JIT编译器就开始工作了。它先把这个方法或循环的字节码Bytecode中间表示拿过来做一趟静态分析然后生成优化后的机器码。注意优化这个词不是空话我们后面会专门展开讲它优化了什么。编译完成后JVM需要做一个代码替换的操作把原来解释器执行的那个方法的入口改成指向新编译机器码的入口。这个操作在JVM里叫栈上替换On-Stack ReplacementOSR。OSR是一个非常精妙的设计。如果方法正在被解释执行而且执行到一半还没退出此时热点编译完成了怎么办如果不做OSR那这个方法下一次调用才会用机器码而当前这一次调用还得继续解释执行完这个循环体如果是长跑循环性能提升就大打折扣了。OSR允许当前正在栈上执行的解释执行帧直接切换到编译后的机器码帧无缝衔接。所以你会发现JIT不是下次生效的缓存机制它是当下就生效的运行时优化。这里就引入了JIT的另一个关键概念编译队列。由于编译是耗时的JVM通常会维护一个编译线程池把需要编译的方法放进队列后台线程去处理。编译完成的机器码会被放在CodeCache代码缓存区域里这是一块专门存放编译产物机器码的内存。如果CodeCache满了编译线程会被暂停已经编译的机器码也无法继续堆积这会导致JVM退回到解释执行的模式线上性能断崖式下滑。这个坑我们后面在问题排查里会重点讲。2.3 最核心的编译器优化手段方法内联说到JIT的优化最经典也最影响效果的是方法内联Method Inlining。它的思想看起来平平无奇如果一个方法体非常小比如一个getter/setterJIT会直接把被调用方法的代码搬到调用方里省去一次真正的方法调用。为什么这很重要因为现代处理器的流水线非常讨厌跳转指令方法调用在现代CPU里不只是压栈跳转的开销还会破坏指令预取内联之后这些开销全没了。但这只是浅层理解。深层的内联逻辑是JIT不是盲目内联所有小方法它要评估方法的大小、调用频率、被谁调用以及内联后会带来哪些后续优化的可能性。如果一个方法有多个实现版本比如接口方法JIT会先看当前实际调用的实现类是哪个然后做单态内联也就是假定下一次还是这个实现类把方法内联进来如果发现假设被打破、加载了一个新类它再通过去优化Deoptimization退回解释器重新收集信息。我举个实际场景。业务代码里经常写一些类似这样的结构一个service方法里调用了若干个私有辅助方法每个辅助方法计算一段小逻辑。在解释执行阶段每次调用都有完整的方法分派开销但是当service方法成为热点JIT可能把整个调用链全部内联成一个巨大的机器码方法。这就是为什么压测时经常出现一种现象JVM刚启动时QPS平平跑几分钟后QPS突然冲上去一截。那不是什么神秘缓存而是JIT把业务热点方法全都内联成了一条顺畅的机器码执行路径。2.4 逃逸分析与锁消除除了方法内联JIT还有几个高级优化值得一提。逃逸分析Escape Analysis是另一大杀器。它分析一个对象的作用域这个对象会不会逃逸出当前方法如果不会那这个对象就没必要真的在堆上分配内存可以直接拆散成栈上的局部变量甚至直接放进CPU寄存器。这带来的好处是堆内存分配绕过了GC压力对象生命周期结束连带释放也一并省了。配合逃逸分析的还有一个优化叫锁消除Lock Elimination / Lock Coarsening。当JIT发现一个对象只在单线程内使用根本不会有竞争那它就会把代码里对这个对象的synchronized加锁直接去掉。在解释执行阶段你可能还在傻傻地抢锁、释放锁编译成机器码后锁操作已经被删得干干净净。这也是为什么你写重复加锁的代码性能不一定差热点代码编译后JIT帮你把这些无效开销清走了。还有标量替换Scalar Replacement算是逃逸分析的延伸。如果对象的字段可以被拆成独立的标量JIT会直接用寄存器保存这些字段值彻底不做分配。这类优化对性能的影响非常大因为Java在运行期创建对象极其频繁如果每次都走堆分配GC回收那系统早就被拖垮了。理解这一点之后你就明白为什么很多JIT调优文档都告诉你不要写无意义的方法内临时对象因为哪怕JIT能优化也仍然存在优化失败的情况。2.5 分层编译C1与C2的分工前面说的编译优化后台其实是两套编译器在配合。HotSpot VM里C1编译器Client Compiler编译速度快但优化程度有限C2编译器Server Compiler编译速度慢但优化极其激进能把代码压榨出非常高的性能。JVM默认开启分层编译Tiered Compilation它的逻辑是先用C1快速地把方法编译成还算不错的机器码让性能先上来如果方法的热度还在继续上升再升级用C2做一次彻底优化替换掉C1的机器码。分层编译的版本很多从0到4分了好几层。简单理解就是第0层是纯解释执行第1、2、3层是不同程度的C1编译带不带profiling信息第4层是C2编译。层数越高优化越激进但编译代价也越高。JVM会根据方法的调用热度自动升级层级也会在发现C2编译的假设不成立时降级回去这个动态过程完全由运行时决策。有人会问那直接用C2不是更好吗不行。因为C2编译一个方法可能要几百毫秒甚至几秒对于一个启动时需要快速把关键路径跑起来的程序等C2编译完业务早就等不及了。C1虽然压缩了性能潜力但编译一个方法可能只要几十毫秒能在短时间内把性能拉起来。所以分层编译就是先快后优、渐进增强的调度策略。实际上Java 8之后官方就默认启用分层编译老掉牙的Client模式基本退出历史舞台了。3. 各个主流语言运行时里的JIT长什么样3.1 JVM最成熟的JIT生态说到JIT绕不开JVM。除了前面讲的HotSpot C1/C2还有Oracle的GraalJIT编译器以及OpenJ9等实现。Graal是用Java自身写的编译器设计更模块化在做AOT方面也有建树比如GraalVM Native Image但在动态JIT领域Graal目前更多还是作为实验性/替代性选择。主流线上环境绝大多数还是跑在C2这条路上。JVM的JIT特点是什么第一它有20多年积累的优化黑科技前面说到的内联、逃逸分析、锁消除只是冰山一角还有循环展开、数组边界检查消除、自动向量化Auto-Vectorization等等。第二它的优化是自适应的会根据程序实际运行情况调整编译策略这是AOT永远做不到的。第三Java这种强类型静态语言字节码本身就带有类型信息热点分析做起来相对容易所以JIT效果往往非常可观。3.2 V8JavaScript的JIT之路前端同学熟悉的V8早期只有全量JIT编译后面演进出了Crankshaft编译器再到现在主流的TurboFan和Ignition解释器。V8的架构很有意思JavaScript代码先被解析成AST然后Ignition解释器用字节码执行同时TurboFan编译器基于运行时收集的类型反馈信息做JIT编译。因为JavaScript是动态类型TurboFan需要做很多类型特化的优化把那些大多数时候是同一类型的调用优化成快速路径一旦类型发生变化就得做去优化回退到解释器。V8的JIT给我们的教训是动态语言的JIT高度依赖预测的准确性。你在JavaScript里写代码时保持一个变量始终传同一种类型比如始终是整数或者始终是同一个对象结构V8就能生成极其高效的机器码如果你一会儿传字符串、一会儿传对象、一会儿传数字那JIT的特化优化就会反复失效性能表现跟纯解释执行差不多。实际工程里很多人会强行保证API传参类型稳定其中一条原因就在这。3.3 LuaJIT、PyPy和.NET的JITLuaJIT算是小而美的典范。LuaJIT的核心是一个Trace Compiler跟踪编译器它不按方法做热点分析而是按循环执行路径做追踪解释器发现一段循环跑得飞起就记录这次执行走过了哪些字节码指令形成一个trace然后把整个trace编译成机器码。遇到新的分支就做桥接。这种思路在动态语言里非常有效LuaJIT一度被视作脚本语言性能天花板。PyPy则是用RPython写了一个带JIT的解释器它采用的是一种叫Meta-Tracing的技术意思是先写一个解释器然后用框架自动生成一个对应的JIT编译器。PyPy的JIT效果在纯CPU密集的场景下经常能比CPython快几倍到几十倍。不过因为CPython的C扩展生态太强势PyPy始终没法完全替代CPython。.NET这边的RyuJIT是主力它支持分层编译跟HotSpot思路类似底层采用类似LLVM的中间表示IR。.NET Core时代之后RyuJIT的优化水平提升很大加上ServerGC的配合在高并发服务端表现跟Java已经非常接近。如果你平时主要写C#把JIT参数摸清楚一样能榨出不少性能。运行时代表应用热点识别方式优化特征主要应用场景JVM HotSpotJava/Scala/Kotlin方法调用计数回边计数分层编译、内联、逃逸分析服务端、大数据、中间件V8JavaScript/Node.js类型反馈调用频率类型特化、隐藏类、去优化浏览器、Node服务端LuaJITLua循环跟踪trace跟踪编译、桥接游戏脚本、嵌入式PyPyPython循环识别Meta-Tracing科学计算、批处理RyuJITC#/F#方法调用计数分层编译、SSA优化服务端、桌面、游戏4. 实操如何观察和验证JIT的工作效果4.1 让JVM告诉你它在干什么理论说了一大堆落到工程实践上第一步永远是“看到”JIT。JVM提供了一堆诊断参数最经典的是-XX:PrintCompilation。加上这个参数后JVM会把每一次JIT编译的日志打到标准输出。日志里每一行包含编译的ID、方法名、字节码大小、编译层级、时间戳等信息。我看到很多同学加这个参数后被刷屏量吓到。其实线上一个服务启动后编译几百上千个方法是非常正常的。更细的观测工具是-XX:UnlockDiagnosticVMOptions -XX:PrintInlining。它会把内联决策的细节日志打出来比如哪个方法被内联到哪个方法理由是什么为什么拒绝了某个内联比如方法体太大、调用点太热但目标太分散等。如果你怀疑某个核心方法没被优化到位用这两个参数一查基本能定位到原因。还有-XX:LogCompilation它会输出一个XML文件记录编译的全过程配合JITWatch这个开源工具可以做成可视化界面。JITWatch能展示编译事件、热点方法、内联失败原因、CodeCache占用等等是我个人做JIT分析时最喜欢用的工具。建议你在测试环境里挑一个核心接口压测时顺手把这个日志开起来把JITWatch生成的视图截图下来你会第一次直观感受到原来我的代码是这么被编译的。4.2 关键JIT参数和典型场景调法JVM的JIT参数非常多但真正经常需要手工调的就那么几个。一个是-XX:CompileThreshold它控制方法成为热点需要调用多少次。默认在分层编译下这个值不是直接用方法调用计数但理解成越小的值越快触发编译是没问题的。如果你有一个批处理任务程序生命周期短希望尽快编译到最优状态可以把阈值调低如果程序是长跑服务默认值通常够用。另一个是-XX:ReservedCodeCacheSize这是CodeCache的内存上限。默认值在Java 8里是240MB老版本可能更低Java 11之后默认值有所上调。如果日志里出现CodeCache is full或者CompileBroker相关警告就得考虑调大。极端情况下还要配合-XX:PrintCodeCache观察使用率。-XX:MaxInlineSize控制方法能被内联的最大字节码大小默认是35字节。如果一个方法在35字节以上但你又非常希望它被内联可以提高这个值。但注意盲目提高会带来机器码膨胀、编译时间增加、CodeCache压力上升等问题。我见过有人把内联上限调到几千字节的结果CodeCache爆掉性能不减反降。调优JIT参数一定要小步验证别拍脑袋。参数作用建议场景-XX:PrintCompilation打印编译日志快速观察编译是否发生-XX:PrintInlining打印内联决策分析核心方法为何没被内联-XX:LogCompilation输出XML编译日志配合JITWatch做深度分析-XX:CompileThreshold调整热点触发阈值短生命周期程序可降低-XX:ReservedCodeCacheSize调CodeCache容量出现CodeCache full时调大-XX:MaxInlineSize调整内联大小上限针对特定小方法启用内联-XX:TieredStopAtLevel限制编译最高层级debug/实验时使用所有这些参数在线上修改时要谨慎尤其是影响编译策略的最好先在压测环境里对照GC日志、CPU、TP99等指标跑一轮。4.3 业务代码层面怎么配合JIT很多时候你用不着动JVM参数只需要在写业务代码时给JIT创造更好的优化条件。这听起来玄学实际是有原则的。核心原则有三个方法不要写得又大又杂、热点路径上避免频繁出现多态调用、不要在热点路径上创建短生命周期的对象期望JIT每次都拯救你。第一点方法太大超出内联大小上限JIT就没办法把逻辑揉成一条流畅的执行路径。你说我方法写500行内联上限35字节JIT根本没法内联它只能做方法级别的其他优化优化力度受限。当然也不是说把方法全部拆成一行行小方法就好方法拆得太碎调用点变多内联压力也大反而可能出现方法爆炸。工程上写适中的、职责单一的方法是对JIT最友好的。第二点多态调用。如果热点方法里有个接口调用实际实现类有多个JIT每次都要做虚方法分派内联失败的概率大增。从代码设计上讲热点路径上尽量缩小接口的实现分支或者用模板方法模式把分支收敛到固定结构。你不需要牺牲设计但至少要知道接口多实现从JIT层面是要付出代价的。第三点热点路径上的小对象。逃逸分析不是银弹一个对象是否逃逸取决于引用是否暴露到方法外。如果对象被放进一个容器返回或者作为返回值传递给了外部代码逃逸分析就失败了。所以我的习惯是在性能敏感的循环体里坚决不new对象用基本类型或者对象池。这不是死板的教条而是考虑JIT优化失败时留一手保底。5. 实战中常见的JIT问题和排查记录5.1 启动慢和初期性能抖动正常吗很多团队在微服务压测时会发现刚启动那几分钟接口的延迟很高QPS起不来跑了一会儿突然通畅了。此时如果你不懂JIT会误以为有bug甚至重启应用。其实这就是JIT在预热解释执行阶段性能本来就一般热点编译完成后才进入状态。解决这个问题通常有两个思路一是干脆接受预热线上发布时多做小流量灰度让新实例跑几分钟后再放量二是做主动预热在服务启动后人为地调用一遍核心接口先把热点趟热。我自己的团队做Spring Boot服务发布时就专门有个预热脚本在服务注册到注册中心后、承接真实流量之前先把核心的单品查询、订单提交几个接口各打几千次让JIT把这些方法编译完。实测下来接入预热后的新实例在发布窗口期的超时率肉眼可见地下降。这个实践成本很低但收益非常直接。5.2 CodeCache满了之后性能断崖式下跌前面说过CodeCache是存放编译后机器码的区域。Java 8的默认CodeCache是240MB听起来不小但如果你的应用使用了大量动态代理、反射生成类或者依赖了很多第三方库编译热点数量会非常大。CodeCache满了之后JIT编译会停止已经编译的代码还可以正常使用但新的热点不会再被编译更严重的是JIT编译器为了腾出空间可能会抛弃之前编译的机器码退回解释执行也就是所谓的CodeCache flushing。这时候CPU使用率不一定升高但接口延迟会明显抖动。怎么判断加-XX:PrintCodeCache跑一段时间看输出。如果看到Non-profiled nmethods或者profiled nmethods区域使用率接近100%就得采取行动。常规解法是调大ReservedCodeCacheSize比如调到512MB或更高。如果已经稳定复现需要配合-XX:CompileCommand排除某些不需要编译的方法减少编译压力。还有一种思路是升级JDK版本新版JVM的CodeCache管理和分层编译策略做了很多优化。5.3 反射和动态代理是JIT优化最大的天敌吗反射和动态代理确实会干扰JIT但说天敌有点夸张。反射调用主要走的是native方法解析和类型检查JIT也能把一些反射调用转换成直接调用但前提是它识别到目标方法稳定且访问权限允许。JDK内部有个ReflectionFactory缓存机制很多反射调用到了后期并不比直接调用慢多少但大规模反射仍然会造成方法区的类加载和CodeCache压力。真正让JIT头疼的是无休止生成新类。CGLIB、JDK动态代理这种在启动阶段生成一堆类每个类里的方法都需要被分析编译会导致启动期编译任务堆积。很多人用一些AOP框架后感觉启动慢、预热慢本质原因不只是类加载还包括JIT把这些动态生成类全部趟了一遍。建议做法是启动期能少用代理就少用非用不可的给它足够的预热时间并且把CodeCache预留大一些。5.4 什么时候可以考虑关闭JIT关闭JIT看起来是反着来的操作但有些场景下确实需要。比如一个非常短小的CLI工具进程总共就运行几百毫秒JIT编译还没开始进程就退出了开JIT纯属浪费CPU又比如一些需要极致可预测性的实时系统宁可性能低一点也不要出现编译风暴引发的延迟尖刺。此时可以用-Xint强制解释执行或者-Xcomp强制所有代码都编译执行。但这里有个大坑很多人以为-Xcomp性能最好因为都不解释了全编译了还不好吗。实际恰恰相反全量编译启动极慢、CodeCache压力极大而且在启动阶段所有方法一起编译CPU瞬间打满线上出现严重卡顿。我自己测过一次一个中等规模的Spring应用用-Xcomp启动比默认模式慢了几倍而稳态性能没有本质提升因为默认的分层编译已经把热点处理得很好了。所以非极其特殊的场景别关JIT更别全量编译。JIT这套自适应机制是经过几十年实践验证的默认配置在绝大多数场景下都能正常工作。6. 把JIT落到工作里的几个实用心得6.1 压测一定要过预热期再取数据这是所有做性能验证的人都应该遵守的基本纪律。做JMeter或wrk压测时刚启动就采集的数据反映的是解释执行少量C1编译的性能不是稳态性能。正确的姿势是先以小流量或者直连的方式打几分钟预热观察QPS和TP99逐步稳定之后再开始记录压测数据。否则你可能会得出系统性能很差的错误结论然后去调一堆没用的参数。6.2 JITWatch是个好东西建议学会用如果你在JVM生态里工作我强烈建议花半天时间学会用JITWatch。它能把-XX:LogCompilation生成的日志解析成可视化界面告诉你哪些方法是热点、哪些方法被内联了、哪些方法拒绝了优化。当你面对一个线上性能问题无从下手的时候JITWatch往往能帮你从代码逻辑不对和JIT没优化好中做个区分。我自己遇到过一个接口性能波动的问题查了很久的SQL和缓存最后发现是一个工具类方法因为超出了内联大小导致热点路径上多了一次无法消除的分派开销调整代码结构后性能立刻稳定了。6.3 不同语言的JIT优化思路是相通的前端同学研究V8的优化建议后端同学研究JVM的调优参数看起来领域不同但核心逻辑完全一致都是运行时监测热点、编译热路径、优化失效回退。所以无论你主力语言是什么掌握JIT的原理相当于拿到了一把理解各种高性能运行时的通用钥匙。你以后看Flutter的AOT方案、看Go的编译模型、看Rust的编译时优化都能联想到当前这个方案为什么选择这条路它放弃了什么换来了什么。JIT本身是一个工程上极其漂亮的折中设计。它的存在让程序语言既要跨平台、又要高性能这个看似矛盾的需求变成了现实。没有JITJava不会成为企业级应用的主流之选没有JITJavaScript在浏览器里也撑不起今天复杂的前端应用。理解它不是为了背几个名词而是为了在真正遇到性能问题时你脑子里有那一张完整的动态编译地图知道去哪看、怎么调、什么时候该放手。希望这篇文章能帮你把这层窗户纸捅破。
返回列表