1. 为什么即时编译器才是JVM调优的真正主战场
很多人一提JVM调优,脑子里第一反应就是堆内存怎么设、垃圾回收器选哪个、GC日志怎么看。这些当然重要,但我干了这么多年下来,发现一个挺有意思的现象:大部分线上性能问题,最后真正卡脖子的往往不是内存不够,而是代码跑得不够快。而代码跑得快不快,很大程度上取决于即时编译器在背后做了多少活儿。
即时编译器这个东西,说白了就是JVM在运行过程中把热点代码从字节码翻译成本地机器码的组件。你写的Java代码先编译成字节码,字节码再被解释执行,解释执行慢,JVM就盯着哪些方法被反复调用,一旦超过阈值,就交给即时编译器编译成机器码直接跑。这个机制决定了同一段代码在程序刚启动时和跑了几分钟之后,性能可能差出几倍甚至几十倍。
我见过太多团队在调优时把注意力全放在GC参数上,结果堆调大了、GC频率降了,吞吐量还是上不去。一查火焰图,热点方法全在解释执行状态,根本没被编译。这种情况在微服务架构里特别常见——服务刚启动、流量刚进来那几分钟,即时编译器还没来得及发力,请求延迟飙高,然后触发限流、熔断,雪崩就这么来的。
所以这篇内容我想聊的是:即时编译器到底怎么工作、它的编译策略有哪些可调参数、在不同场景下怎么让它更快更准地干活、以及那些年我踩过的编译相关的坑。适合已经对JVM有基本了解、想往深里挖的性能调优工程师,也适合刚接触JVM调优、想搞清楚“为什么我的代码跑着跑着就变快了”这个现象背后原理的开发者。哪怕你之前只调过堆大小,看完也能明白即时编译器这块还有多少可操作空间。
2. 即时编译器的工作机制与策略拆解
2.1 解释执行与编译执行的切换逻辑
要理解即时编译器的调优策略,得先搞清楚它什么时候出手。JVM启动时所有方法都是解释执行的,解释器逐条读取字节码并执行,这个阶段启动快但执行慢。与此同时,JVM内部有个计数器在默默记录每个方法的调用次数和循环回边次数,当这些计数超过阈值时,方法就被标记为“热点”,提交给即时编译器编译。
这个阈值不是固定的,它跟编译线程的队列长度有关。队列短的时候阈值低,热点方法很快被编译;队列长了说明编译任务积压,阈值会自动调高,避免编译器被压垮。这个自适应机制很多人不知道,但它解释了一个常见现象:系统刚启动时编译很积极,跑一段时间后编译行为反而变少了——因为大部分该编译的都编译完了。
这里有个关键点:编译不是一蹴而就的。JVM通常采用分层编译策略,先做快速但优化程度低的编译,让方法尽快脱离解释执行状态,然后再根据运行时的 profiling 信息做深度优化编译。这个分层设计是理解后续所有调优参数的基石。
2.2 分层编译的五个层级到底在干什么
分层编译把编译过程分成多个层级,每个层级做的优化深度不同。我把它类比成做饭:第一层是速食加热,能吃但不好吃;中间层是家常小炒,味道过得去;最高层是精细料理,色香味俱全但费时间。
具体来说,层级0是解释执行,层级1到3是不同深度的C1编译(也叫客户端编译器),层级4是C2编译(服务端编译器)。C1编译快、生成的代码质量一般,但带完整的 profiling 信息采集;C2编译慢、生成的代码经过大量激进优化,执行效率高。方法通常先经过C1编译快速上线,采集够运行数据后再升级到C2。
这个升级过程有个细节值得注意:C2的优化非常激进,它会基于“到目前为止观察到的类型信息”做假设。比如某个接口调用点一直只收到一种实现类,C2就把这个调用直接内联成具体类的方法调用,省掉虚方法查找开销。但如果后来突然来了第二种实现类,这个假设就破了,JVM必须做“去优化”,把代码退回解释执行状态重新收集信息。去优化是有成本的,频繁去优化会让性能剧烈抖动。
2.3 编译阈值与触发时机的权衡
编译阈值决定了方法被调用多少次后触发编译。这个值可以通过参数调整,但我不建议一上来就动它。默认值是在大量场景下平衡过的结果,调低会让编译线程压力增大、启动阶段CPU飙高,调高会让热点方法在解释执行状态停留更久。
真正需要关注阈值调整的场景通常是短生命周期任务。比如批处理作业跑几分钟就结束,如果阈值太高,热点方法还没来得及编译任务就完了,全程解释执行,性能自然差。这种场景下适当降低阈值能让编译更早发生。反过来,长时间运行的服务如果发现编译线程占用CPU过多,可以适当提高阈值减少编译频率。
我个人的经验是:先别动阈值,用编译日志观察实际编译行为,确认瓶颈确实在编译时机上再调。盲目调参是调优的大忌。
2.4 编译线程数量与CPU资源的分配
即时编译是在独立线程里做的,编译线程数量和CPU核数相关。默认配置下,JVM会根据可用处理器数量分配若干C1和C2编译线程。编译线程太多会跟应用线程抢CPU,太少又会导致编译任务积压、热点方法迟迟编译不出来。
在容器化环境里这个问题特别突出。容器限制了CPU配额,但JVM如果没正确识别容器限制,可能按宿主机核数分配编译线程,结果编译线程把有限的CPU配额吃掉了,应用线程反而饿着。我遇到过好几次容器里服务响应慢的问题,最后查出来是编译线程数量配置不当导致的CPU争抢。
判断编译线程是否成为瓶颈有个简单方法:看编译日志里方法从达到阈值到编译完成的时间间隔。如果这个间隔很长,说明编译队列积压严重,可能需要调整线程数或者检查CPU是否被其他进程占用。
3. 核心调优参数与实操配置要点
3.1 编译日志:调优前必须打开的观测窗口
不看编译日志就调即时编译器,等于闭着眼睛开车。编译日志能告诉你哪些方法被编译了、编译花了多长时间、有没有发生去优化、编译线程队列有多长。这些信息是判断调优方向的唯一依据。
开启编译日志的命令行参数组合大概是这样的:
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:+LogCompilation -XX:LogFile=./jit_compile.logPrintCompilation输出的是简版编译事件流,每行一个编译事件,包含时间戳、编译ID、方法名、编译层级等信息。LogCompilation输出的是详细的XML格式日志,信息量大但需要工具解析。日常排查我一般先用简版日志快速定位,需要深挖内联决策时再上详细日志。
简版日志里有个细节要注意:方法名后面带百分比符号表示发生了OSR编译(栈上替换),也就是循环体在方法执行过程中被单独编译了。带感叹号表示有异常处理器的编译,带星号表示编译被标记为不可逆。这些符号的含义在官方文档里有完整说明,看日志前最好先过一遍。
3.2 代码缓存大小:被严重低估的调优参数
代码缓存是JVM存放编译后机器码的内存区域,默认大小在较新版本里通常是240MB左右,分成非方法代码段、profiled代码段和非profiled代码段三块。这个区域一旦满了,JVM会停止编译新方法,已经编译的代码还可能被清理掉,性能直接断崖式下跌。
代码缓存满的典型症状是:服务运行一段时间后性能突然下降,编译日志里出现“CodeCache is full”的警告,然后看到大量方法反复编译又反复被清理。这种情况在大型单体应用或者用了大量动态生成代码的框架里特别容易发生。
调整代码缓存大小的参数是:
-XX:ReservedCodeCacheSize=512m -XX:InitialCodeCacheSize=64mReservedCodeCacheSize设的是上限,InitialCodeCacheSize设的是初始大小。我一般建议把上限设到512MB甚至1GB,具体看应用规模。有人担心设太大会浪费内存,实际上代码缓存是虚拟内存预留,物理内存是按需提交的,设大一点没有实际内存开销,但能避免缓存满了导致的编译停止。
判断代码缓存是否够用,可以开启-XX:+PrintCodeCache,在JVM退出时打印代码缓存使用情况。更实时的方式是通过JMX或者jcmd查看CodeCache区域的使用率。如果使用率长期在80%以上,就该考虑调大了。
3.3 内联策略:即时编译器最值钱的优化
内联是即时编译器所有优化里对性能影响最大的一项。方法调用有开销——压栈、跳转、返回、可能还有虚方法查找——内联把这些开销全部消除,把被调方法的代码直接嵌到调用点。更关键的是,内联之后编译器能在更大的代码范围内做优化,常量传播、死代码消除、循环优化都能发挥更大作用。
内联的决策逻辑基于方法大小和调用频率。JVM有一套启发式规则判断某个方法值不值得内联,太大会拒绝内联,太小但调用不频繁也可能不内联。相关参数有:
-XX:MaxInlineSize=35 -XX:FreqInlineSize=325 -XX:MaxInlineLevel=15 -XX:MaxRecursiveInlineLevel=1MaxInlineSize是非热点方法的内联大小上限(字节码字节数),FreqInlineSize是热点方法的内联大小上限。默认值35和325是平衡过的,但有些场景下调整能带来明显收益。比如大量使用getter/setter的代码,这些方法通常很小,默认就能内联;但如果框架生成的代码方法体稍大,可能就卡在阈值边缘。
我调内联参数的经验是:先用-XX:+PrintInlining看哪些热点方法因为“too big”被拒绝内联,如果这些方法确实值得内联,再考虑调大FreqInlineSize。但要注意,内联太多会导致编译时间变长、代码缓存占用增大,是个需要权衡的事。
3.4 去优化监控:性能抖动的隐形杀手
去优化是即时编译器调优里最容易被忽视的环节。前面说过,C2编译基于类型假设做激进优化,假设破了就要去优化。去优化的代价不只是退回解释执行,还包括重新收集profiling信息、重新编译,这期间方法执行效率大幅下降。
监控去优化需要开启-XX:+TraceDeoptimization,日志里会记录每次去优化的原因、涉及的方法和字节码位置。常见的去优化原因包括“unstable_if”(分支预测失败)、“class_check”(类型检查失败)、“null_check”(空指针检查失败)等。
如果发现某个方法频繁去优化,通常意味着代码里有类型不稳定的调用点。比如一个接口变量在不同请求里指向不同实现类,C2每次做的单态内联假设都会被打破。解决办法要么是重构代码让调用点类型稳定,要么是接受这个现实、通过参数减少去优化的影响。
有个参数值得了解:-XX:-UseOnStackReplacement可以关闭OSR编译,但我不建议随便关。OSR编译让长时间运行的循环体能提前享受编译优化,关掉它会让大循环在解释执行状态跑很久。只有在排查特定问题时才考虑临时关闭。
4. 不同场景下的即时编译器调优实战
4.1 微服务启动阶段的编译预热策略
微服务架构下,服务启动后头几分钟是最脆弱的。流量进来了,但热点方法还在解释执行,延迟高得吓人。这时候如果上游有超时限制或者熔断机制,很容易触发级联故障。
解决思路是让编译提前发生。常见做法是启动时做预热——用代表性请求打几轮,把主要代码路径都跑热,让即时编译器在正式流量进来前完成编译。预热请求的量要足够触发编译阈值,但又不能大到拖慢启动速度。我一般建议预热请求覆盖核心接口,每个接口跑几百到上千次,具体看方法调用深度。
另一个手段是调整编译阈值让编译更早触发:
-XX:CompileThreshold=1000默认阈值在分层编译下是动态的,但显式设置一个较低的值能让方法更快被编译。代价是启动阶段CPU使用率会高一些,编译线程更忙。这个取舍在启动速度不敏感、但启动后延迟敏感的服务里是划算的。
还有个偏方是用-XX:+AlwaysCompileLoopMethods让循环方法总是被编译,适合那些启动后立刻进入长时间循环处理的场景。但这个参数比较激进,用之前要确认没有副作用。
4.2 大数据量批处理的编译策略调整
批处理作业的特点是生命周期短、计算密集、方法调用模式相对固定。这类场景下调优目标和在线服务完全不同——不需要考虑长期运行的稳定性,只需要在有限时间内把吞吐量拉满。
首先编译阈值要调低,让热点方法尽快编译。批处理作业通常跑几分钟到几十分钟,如果阈值太高,编译还没发生作业就结束了。其次代码缓存可以设小一些,因为作业生命周期短,不需要为长期运行预留大缓存。再者可以关闭一些为长期运行设计的优化,比如分层编译的层级5(如果版本支持),直接上C2编译减少编译层级的开销。
我处理过一个数据清洗类的批处理作业,原始配置下跑完要40分钟,调整编译参数后降到28分钟。主要改动就是降低编译阈值、增大FreqInlineSize让更多数据处理方法被内联、以及确保代码缓存足够大不会中途满掉。这个提升幅度在批处理场景里很常见,因为批处理代码往往方法调用层次深、热点集中,内联和编译时机的收益特别明显。
4.3 低延迟场景下的编译行为控制
低延迟场景对即时编译器的要求最苛刻。一方面希望代码尽快编译到最优状态,另一方面又不能让编译过程本身造成延迟抖动。编译是CPU密集型操作,编译线程跑起来会跟应用线程抢CPU,在延迟敏感的系统里这就是抖动源。
控制编译抖动的手段有几个。一是限制编译线程数量,减少编译对CPU的争抢:
-XX:CICompilerCount=2这个参数设的是编译线程总数,设太小会导致编译积压,设太大会争抢CPU。低延迟场景下我一般设2到4个,具体看CPU核数和延迟要求。
二是用-XX:+UseCompressedOops之类的参数减少编译后代码的内存占用,间接减少代码缓存压力。三是监控去优化频率,去优化造成的性能抖动在低延迟场景里是致命的,必须确保热点路径上没有频繁去优化。
还有个高级手段是AOT编译(提前编译),在应用启动前就把部分代码编译好,运行时直接加载。但这个技术在不同JDK版本里成熟度差异大,生产环境用之前要充分测试。我目前只在少数对启动延迟极度敏感的场景里用过,效果因应用而异。
4.4 容器环境下的编译资源适配
容器化部署给即时编译器调优带来了新变量。容器有CPU配额限制,但JVM默认可能按宿主机核数来分配编译线程和决定编译策略,导致资源分配失配。
首先要确保JVM正确识别容器CPU限制。较新版本的JDK默认支持容器感知,但老版本需要显式开启。确认方式是看JVM启动日志里的“Active Processor Count”,如果这个数跟容器配额不符,就需要调整。
然后根据容器配额重新计算编译线程数。容器只给2核的话,编译线程设太多就是自找麻烦。我一般按“编译线程数不超过CPU配额的1/4”来估算,2核容器设1个编译线程,4核设1到2个,8核以上可以设2到4个。
代码缓存大小也要跟着调整。容器内存有限,代码缓存设太大可能挤占堆内存。但设太小又会导致编译停止。折中方案是设256MB左右,同时开启代码缓存使用率监控,接近上限时告警。
5. 常见问题排查与避坑经验实录
5.1 编译日志里的异常信号速查
编译日志信息量大,新手容易看花眼。我整理了一张速查表,把常见异常信号和对应的问题列出来:
| 日志信号 | 含义 | 可能原因 | 排查方向 |
|---|---|---|---|
| CodeCache is full | 代码缓存满 | 缓存设太小或代码生成过多 | 调大ReservedCodeCacheSize,检查动态代码生成 |
| made not entrant | 方法被标记为不可进入 | 发生去优化或类卸载 | 查去优化原因,检查类型稳定性 |
| made zombie | 方法代码被清理 | 类被卸载 | 检查类加载器泄漏 |
| OSR编译频繁 | 循环体反复栈上替换 | 大循环方法被反复调用 | 考虑重构循环或调整OSR参数 |
| 编译队列积压 | 编译任务排队 | 编译线程不足或CPU争抢 | 调整CICompilerCount,检查CPU负载 |
这张表是我这些年排查问题的经验浓缩,大部分编译相关的异常都能在里面找到线索。看到异常信号先别急着调参,顺着排查方向确认根因再动手。
5.2 去优化频繁导致的性能抖动排查
去优化频繁是即时编译器调优里最难缠的问题之一。症状是性能曲线呈锯齿状——跑一段好一段差一段,好的时候是编译优化生效,差的时候是去优化后退回解释执行。
排查第一步是开-XX:+TraceDeoptimization看去优化原因。如果大量是“class_check”或“unstable_if”,说明代码里有类型不稳定的调用点。常见场景是接口或抽象类的调用点在运行时收到多种实现类型,C2的单态内联假设反复被打破。
解决办法分两种。如果类型不稳定是业务逻辑固有的(比如策略模式在不同请求里用不同策略),那就接受去优化,通过减少调用频率或者把调用点拆开来降低影响。如果类型不稳定是代码写法导致的(比如用Object类型接不同返回值),那就重构代码让类型稳定下来。
我遇到过一个典型案例:某服务里有个工具方法参数是Object类型,内部根据实际类型做不同处理。这个方法被高频调用,每次调用传入的类型都不一样,C2编译后频繁去优化。后来把方法拆成几个重载版本,每个版本参数类型固定,去优化就消失了,吞吐量提升了将近一倍。
5.3 内联失败的热点方法怎么救
用-XX:+PrintInlining能看到每个方法的内联决策,被拒绝内联的方法会标注原因,比如“too big”、“already compiled”、“recursive”等。看到热点方法因为“too big”被拒绝内联时,先别急着调大阈值,想想有没有别的办法。
最直接的办法是重构方法让它变小。把大方法拆成几个小方法,每个小方法都能被内联,整体效果可能比内联一个大方法还好。因为内联小方法后编译器能在调用点做更精细的优化,而内联大方法只是省了调用开销。
如果方法确实没法拆小,再考虑调大FreqInlineSize。但要注意调大之后编译时间和代码缓存占用都会增加,要观察是否引入了新问题。我一般一次调大50到100字节,观察效果后再决定是否继续调。
还有个偏方是用-XX:CompileCommand对特定方法强制内联或者禁止内联。这个参数适合临时验证假设,比如怀疑某个方法没内联导致性能问题,先用CompileCommand强制内联看效果,确认后再改代码或调全局参数。生产环境长期依赖CompileCommand不是好习惯,它会让配置变得难以维护。
5.4 编译线程与应用线程的CPU争抢
编译线程争抢CPU在容器环境和小规格机器上特别常见。症状是应用吞吐量上不去,但CPU使用率很高,看线程栈发现编译线程在疯狂干活。
判断是否争抢有个简单方法:对比开启和关闭分层编译(或者调整编译线程数)前后的吞吐量。如果减少编译线程后吞吐量反而上升,说明之前编译线程确实在抢资源。
解决思路是给编译线程设优先级或者限制数量。Java层面没有直接设编译线程优先级的参数,但可以通过减少线程数量来间接控制。另外确保应用线程池大小和CPU配额匹配,别让应用线程自己也过度争抢。
还有个容易忽视的点:JIT编译是CPU密集型操作,在CPU配额紧张的容器里,编译线程跑满会触发CPU限流(throttling),导致所有线程都被暂停。这种场景下宁可让编译慢一点,也要保证应用线程有稳定的CPU可用。具体做法就是减少编译线程数,接受编译延迟增加。
5.5 代码缓存碎片化与清理策略
代码缓存用久了会出现碎片化——已编译代码被清理后留下空洞,新代码找不到足够大的连续空间。碎片化严重时即使总使用率不高,也可能编译失败。
JVM有代码缓存清理机制,在缓存快满时触发,清理掉不再使用的编译代码。但清理本身有开销,频繁清理会造成性能抖动。相关参数有-XX:+UseCodeCacheFlushing控制是否开启清理,默认是开启的。
如果发现代码缓存频繁清理,先确认是不是缓存设太小。调大缓存通常能缓解碎片化问题,因为空间充足时清理频率降低。如果调大后仍然频繁清理,就要查是不是有大量动态生成的类——某些框架会运行时生成大量类,每个类的方法都编译的话代码缓存消耗很快。这种情况要么限制动态类生成,要么接受较高的代码缓存配置。
我在一个用了大量动态代理的项目里遇到过这个问题,代码缓存设到1GB还是频繁清理。最后发现是某个框架每次请求都生成新的代理类,类越积越多。改成缓存代理类之后问题解决,代码缓存使用率稳定在40%以下。
6. 我个人的调优心得与几个实用技巧
调即时编译器这些年,最大的体会是:参数是死的,场景是活的。网上流传的“最佳配置”放到你的场景里可能就是灾难。我见过有人把别人博客里的编译参数直接抄到生产环境,结果启动时间翻倍、CPU飙高,回滚都来不及。
我的习惯是每次调参前先问三个问题:当前瓶颈到底是什么、这个参数影响的是哪个环节、调了之后怎么验证效果。三个问题答不上来就不动手。验证手段首选编译日志加性能压测,改一个参数跑一轮对比,确认有效再保留,无效就回滚。一次改多个参数看着效率高,实际上出了问题根本不知道是哪个参数导致的。
另一个心得是关于观测的。即时编译器的行为是动态的,今天调好的配置明天流量模式变了可能就不适用了。所以监控要常态化,编译日志定期采集分析,代码缓存使用率、去优化频率、编译队列长度这些指标要进监控大盘。等到出问题再查就晚了。
最后分享一个小技巧:如果你不确定某个编译参数的效果,可以用-XX:+PrintFlagsFinal先看这个参数在当前JVM版本里的实际生效值。有些参数在不同JDK版本里默认值不一样,有些参数在分层编译开启和关闭时行为不同。确认了实际值再调,能避免很多“我明明设了怎么没生效”的困惑。这个习惯帮我省过不少排查时间,尤其是跨版本迁移的时候。