想象你经营一家生意火爆的餐厅。新来的实习生照菜谱逐字逐句做菜,慢但保证不会搞砸;快餐主厨出手快,招牌菜几分钟就能上桌;米其林主厨则会花一下午研究火候与摆盘,只为把最受欢迎的菜做到极致。聪明的老板不会让米其林主厨做每一道菜——那样客人会等到饿晕;也不会让实习生一直应付招牌菜——那样老顾客会流失。老板的策略是:先用实习生顶住,观察哪道菜点单率高,再逐步升级给快餐主厨,最后才轮到米其林主厨。 Java 的 JVM 内部,就藏着这样一套“厨房调度系统”,叫 JIT 分层编译(Tiered Compilation)。实习生对应解释器(Interpreter),快餐主厨对应 C1 编译器,米其林主厨对应 C2 编译器。普通代码先用解释器跑起来,保证程序快速启动;某个方法被反复调用、成了“招牌菜”,JVM 就请 C1 快速编译;热度继续飙升,再请 C2 做深度优化,榨出极致性能。 这篇文章带你走进这套厨房系统:认识解释器、C1、C2 的分工,看懂五个编译层级、热度计数与 OSR 栈上替换,并学会用参数调优加快“上菜速度”。学完你就能理解 Java 应用为什么“越跑越快”,也能在生产环境对症下药。

岗位关联

理解 JIT 分层编译,等于掌握了 Java 性能调优的“总开关”——面试聊 JVM、线上排查启动慢或吞吐低,都能直击要害。

目录
  1. 可视化演示
  2. JIT基本认知——先从实习生说起
  3. C1与C2分工——快餐主厨与米其林主厨
  4. 分层五层级——厨房里的五级晋升通道
  5. 热度计数机制——点单本上的正字与颠勺次数
  6. 预热与启动——开业酬宾的学问
  7. 参数调优实战——后厨排班表怎么改
  8. 现代编译器展望——新派主厨 Graal
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示JIT五层编译体系代码热度晋升、C1快速编译与C2峰值优化性能对比,以及OSR栈上替换在循环执行中切换编译版本过程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

五层编译:代码热度逐级晋升 L0解释执行 \u2192 L1/L2 C1轻量编译 L3 C1完整编译 \u2192 L4 C2峰值优化 热度达阈值自动晋升,兼顾启动与峰值 Tier(L0\u2192L4): INT \u2192 C1n \u2192 C1l \u2192 C1f \u2192 C2

JIT基本认知——先从实习生说起

走进 HotSpot 餐厅的后厨,你会看到一名实习生正捧着菜谱,一个字一个字地念:“放油……热锅……下葱花……”。他每做一道菜都要现查菜谱,速度虽然慢,但胜在什么菜都能做、从不撂挑子。这个实习生,就是 JVM 里的解释器(Interpreter):任何字节码它都能逐条翻译执行,灵活但缓慢。

可餐厅生意火爆,若让实习生包办一切,客人早就饿跑了。老板细心观察发现,翻台率高的其实就那么十来道招牌菜。于是他请来一位快餐主厨,把热门菜的做法提前背熟、提炼成快捷卡,点单时直接照着卡片出菜。这个“把菜谱预先变成标准动作”的过程,就是 JIT 编译(Just-In-Time Compilation,即时编译)。

具体到 JVM:方法以字节码形式存在,解释器负责“照本宣科”地执行;当某个方法被反复调用、成为热点方法(Hot Method)时,JIT 就把它的字节码整体编译成机器码并缓存。编译完成后,每次调用直接跑机器码,免去逐条翻译的开销。于是程序“先跑起来,再越跑越快”,整体耗时被拆成三块:

聪明的老板绝不会给每道菜都配快捷卡——墙上的卡片太多反而占地方;JVM 也一样,它只挑“点单率高”的方法做深度优化。解释器保证下限,JIT 拉高上限,这正是 JVM“先跑起来、再变快”的设计哲学,也是后面一切故事的起点。

T总=T解释+T编译+T机器码执行\displaystyle T_{总} = T_{解释} + T_{编译} + T_{机器码执行}
这个公式告诉你:JIT 并不是免费的午餐,编译本身也要花时间。只有热点方法足够“热”,编译的投入才划算。
  1. 总耗时程序从开始到结束的完整时间,等于下面三段时间之和。
    T总T_{总}
  2. 解释耗时解释器逐条翻译字节码执行所花的时间,对应实习生逐字念菜谱的漫长过程。
    T解释T_{解释}
  3. 编译耗时JIT 把热点字节码编译成机器码的时间,对应主厨研制快捷卡的一次性投入。
    T编译T_{编译}
  4. 机器码执行耗时编译后直接执行机器码的时间,对应后厨按快捷卡飞速出菜的稳定动作。
    T机器码执行T_{机器码执行}

C1与C2分工——快餐主厨与米其林主厨

如果只有一位快餐主厨,生意还能做得更大吗?老板的答案是:还不够。快餐主厨的快手做法虽然快,但为了速度牺牲了火候与摆盘;真正让老饕们排队的,是那位米其林主厨——他肯花一个下午研究食材的化学反应,把一道红烧肉做到入口即化。于是后厨有了两位大师傅:一位求快,一位求极致。

在 JVM 中,这两位对应两个编译器的角色。C1 编译器(Client Compiler)追求编译速度:它用简单保守的优化,几十毫秒就能把方法编译完,让程序尽快脱离解释器的慢速执行;C2 编译器(Server Compiler)追求执行极致:它做大量激进优化(如方法内联、循环展开、逃逸分析),单个方法可能编译几百毫秒甚至更久,但产出的机器码执行效率极高。

两者是典型的“用编译时间换运行时间”的权衡:

回到餐厅:如果每次点单都请米其林主厨亲自做,客人等得花儿都谢了;如果只让快餐主厨应付,又留不住老饕。所以老板立下规矩——普通菜让实习生快手应付,热门菜升级给快餐主厨,只有最火爆的招牌菜,才值得米其林主厨闭关修炼。这套“分级上菜”的制度,就是 JVM 的分层编译(Tiered Compilation)。

TC1编译≪TC2编译,PC2执行>PC1执行\displaystyle T_{C1编译} \ll T_{C2编译},\qquad P_{C2执行} > P_{C1执行}
意思是:C1 出手快但成品“家常”,C2 出手慢但成品“惊艳”。JVM 的智慧,就是让“值得”的方法才去找米其林主厨。
  1. C1 编译耗时C1 编译一个方法要花的时间,通常在几十毫秒量级。
    TC1编译T_{C1编译}
  2. 远小于符号“远小于”,表示两者不是一个数量级。
    ≪\ll
  3. C2 编译耗时C2 编译一个方法要花的时间,可能达到数百毫秒。
    TC2编译T_{C2编译}
  4. C2 执行性能C2 产出的机器码在运行时的吞吐表现。
    PC2执行P_{C2执行}
  5. 性能比较说明 C2 的运行时性能优于 C1,但编译开销也更大。
    PC2执行>PC1执行P_{C2执行} > P_{C1执行}

分层五层级——厨房里的五级晋升通道

餐厅后厨可以简单分成“实习生、快餐主厨、米其林主厨”三级,但真正的 JVM 分得更细——它把协作拆成了五个层级(Tier 0 到 Tier 4),每一层代表一种“编译深度”。层数越高,优化越激进,但代价也越高。

第 0 层是纯粹的解释执行,也就是实习生模式,任何方法都能跑。第 1 层是 C1 的简单编译(Simple C1),不带性能分析信息,适合“有点热但要求不高”的方法。第 2 层是受限的 C1 编译(Limited C1),启用部分性能分析,编译又快又能为后续升级留数据。第 3 层是完整的 C1 编译(Full C1),带完整的性能分析(Profiling),相当于快餐主厨边做菜边记录客人的口味偏好。第 4 层是 C2 编译,米其林主厨闭关,利用前几层收集的分析数据做最激进的优化。

这套五层模型是 HotSpot 分层编译的精华:C1 的三种模式让“快餐主厨”既能快速出菜,又能为“米其林主厨”收集食客口味数据;而带上完整画像的第 3 层,让 C2 一开始就知道该在哪些地方发力,不必盲目试探。可以说,C1 不只是 C2 的廉价替代品,更是 C2 的情报员。

用一句话收束:餐厅的晋升通道里,实习生管全面、快餐主厨管速度、米其林主厨管极致;而五层阶梯,就是这套协作的精细化制度设计。理解了层级,队列、OSR、参数调优这些都成了制度的配套设施。

tier∈{0, 1, 2, 3, 4},0 层解释执行,4 层优化最深\displaystyle \text{tier} \in \{0,\,1,\,2,\,3,\,4\},\qquad 0 \text{ 层解释执行,} 4 \text{ 层优化最深}
五个层级不是“越高越好”,而是“越热越升级”。方法会沿着 0→1→2→3→4 的路径逐级晋升,也可能跳过中间层直接升级。
  1. 层级变量当前方法所处的编译层级,取值只能是 0 到 4 的整数。
    tier\text{tier}
  2. 属于集合符号“属于”,表示层级取值被限定在后面的集合中。
    ∈\in
  3. 层级集合五个档位的全集,0 是解释器,1 到 3 是三种 C1 模式,4 是 C2。
    {0, 1, 2, 3, 4}\{0,\,1,\,2,\,3,\,4\}
  4. 区间的意义强调两端点的角色——0 层光解释不编译,4 层优化最深。
    0 层解释执行,4 层优化最深0 \text{ 层解释执行,} 4 \text{ 层优化最深}

热度计数机制——点单本上的正字与颠勺次数

后厨凭什么决定一道菜该由谁来做?答案藏在两本账里。第一本是前台点单本:每被客人点一次,就在菜名后面画一笔正字——这是“方法调用计数”(Invocation Counter)。第二本是后厨的工序记录:厨师炒菜时来回颠锅的次数——这是“回边计数”(Back Edge Counter),对应方法内部循环体执行完一圈又跳回循环头的次数。

JVM 为每个方法维护这两个计数器。每当方法被调用,调用计数加一;每执行完一次循环回边,回边计数加一。二者的总和就是该方法的“热度”(Hotness)。当热度超过某个阈值,JVM 就触发对应层级的编译。

这里有一个易被忽略的细节:如果热点藏在循环里——方法本身只被调用一次,但循环跑了百万遍——调用计数根本不够看。为此 JVM 引入了回边计数的补偿机制:当回边计数达到阈值时,会在方法执行到一半时启动“OSR 编译”(On-Stack Replacement,栈上替换),即把执行状态迁移到编译好的新版本上继续跑,无须等整个方法结束。

类比后厨:一道炖菜正炖到一半,实习生接到指令临时换米其林主厨接手。麻烦的是菜不能倒掉重做——OSR 的妙处恰恰是“锅不换,人换”:编译器在后台把菜谱升级好,运行中的方法在下一次循环回边时无缝切到新版本。这样,长循环方法也能享受到 C2 的极致优化。

hotness=I+O,I+O≥Tthreshold⇒触发编译\displaystyle \text{hotness} = I + O,\qquad I + O \ge T_{threshold} \Rightarrow \text{触发编译}
调用计数衡量“方法被调了多少次”,回边计数衡量“方法内部绕了多少圈”。两个计数器一起算,长循环的方法也能被发现。
  1. 热度反映方法被使用频繁程度的综合指标。
    hotness\text{hotness}
  2. 调用计数方法被显式调用的次数,对应点单本上的正字。
    II
  3. 回边计数方法内循环回边的总次数,对应颠勺绕圈次数。
    OO
  4. 求和运算热度由两种计数相加得到,任何一边的热都能被捕捉。
    hotness=I+O\text{hotness} = I + O
  5. 阈值判断当热度达到阈值(如 10000)时,触发本次层级升级。
    I+O≥TthresholdI + O \ge T_{threshold}
  6. 触发编译条件满足后,JVM 把方法交给对应编译器。
    ⇒触发编译\Rightarrow \text{触发编译}

预热与启动——开业酬宾的学问

新餐厅开业前,老板总要办几场“试营业”:请亲友团来点菜,让后厨系统运转起来,等大家熟悉流程后再正式迎客。Java 程序也有同样的“试营业”——预热(Warmup)。程序刚启动时,热点方法还没来得及被编译,吞吐低得出奇;跑一段时间后,JIT 逐渐把热点方法升到高层级,性能才进入稳态。

预热的快慢取决于三件事:热点方法的数量、编译阈值的高低、编译线程的多少。如果把每个方法从解释执行到完成编译看作一次“升级流程”,预热总耗时大致可以估算:

对启动敏感的场景(如无服务器函数、容器弹性扩缩容),通常的策略是“牺牲一点峰值,换启动速度”——比如把分层编译停在 C1 层,或用 AOT 提前把常用方法编译好。对长期运行的在线服务,则反过来:耐心等待预热,甚至主动在发布流量进来前用压测流量“预热”一遍,让 C2 提前进入状态。

回到餐厅:开业酬宾那天,老板不会让米其林主厨把整本菜单都研发一遍——那得等到打烊。他只会挑估计会爆火的菜提前备好快捷卡,其他的等真正被点爆了再说。预热的本质,就是用最合理的编译资源,让“上菜速度”尽快追上客流。

T预热≈N方法×T阈值N编译线程\displaystyle T_{预热} \approx \frac{N_{方法} \times T_{阈值}}{N_{编译线程}}
方法越多、阈值越高,预热越慢;编译线程越多,预热越快。启动优化说白了,就是在“压启动时间”和“保峰值性能”之间找平衡。
  1. 预热时间从程序启动到大部分热点方法完成编译、性能趋稳所花的时间。
    T预热T_{预热}
  2. 热点方法数量需要升级为编译版本的方法个数,对应需要研制快捷卡的菜色数。
    N方法N_{方法}
  3. 单方法触发耗时单个方法从解释执行到达到编译阈值的累计执行时间。
    T阈值T_{阈值}
  4. 编译线程数并行执行编译任务的线程数量,C1 和 C2 各有专属线程池。
    N编译线程N_{编译线程}
  5. 近似关系分子是总工作量,分母是并发能力,两者相除得到预估预热时长。
    ≈N方法×T阈值N编译线程\approx \frac{N_{方法} \times T_{阈值}}{N_{编译线程}}

参数调优实战——后厨排班表怎么改

老板掌管后厨,手里有几张可以随时改的“排班表”——JVM 也提供了一批和分层编译相关的参数。最核心的是编译阈值:-XX:CompileThreshold 决定方法热度达到多少才触发编译(默认约 10000 次);而回边数触发的 OSR 编译阈值,则由它乘以一个百分比得出:

其他常用旋钮:-XX:TieredStopAtLevel=1 表示把编译停在 1 层,即只用轻量 C1、不做 C2 优化,适合格外在意启动速度的场景;-XX:CICompilerCount 设置 C1 与 C2 的编译线程总数,默认约为 CPU 核数的一半;-XX:+PrintCompilation 打开后,JVM 会打印每一条编译记录,是诊断 JIT 行为最直接的“监控探头”。

调优最忌讳“盲改”。正确姿势是:先用 PrintCompilation 或 JFR(Java Flight Recorder)观察热点方法的编译层级与时间线,确认瓶颈是“热得不够快”还是“热了没编译到位”,再针对性调整阈值与线程数。改完一定要在真实流量下复测启动时长和稳态吞吐,因为这两个指标常常此消彼长。

后厨排班同理:客人少时多排实习生省钱,客流高峰前临时加派主厨。离开了对客流(热点)的判断,任何排班表都是纸上谈兵。调优的本质,是让编译资源跟着真实热度走,而不是拍脑袋调参数。

TOSR=TCompileThreshold×OnStackReplacePercentage100\displaystyle T_{OSR} = T_{CompileThreshold} \times \frac{\text{OnStackReplacePercentage}}{100}
CompileThreshold 控制“方法调用多少回开始编译”,OnStackReplacePercentage 控制“循环绕多少圈提前 OSR”。两个旋钮一起拧,就能调节预热的快慢。
  1. OSR 触发阈值回边计数达到该值后,在循环中途触发栈上替换编译。
    TOSRT_{OSR}
  2. 方法编译阈值-XX:CompileThreshold 对应的值,默认约 10000 次调用。
    TCompileThresholdT_{CompileThreshold}
  3. OSR 百分比-XX:OnStackReplacePercentage 对应的百分比,默认约 140,意为回边阈值是调用阈值的 1.4 倍。
    OnStackReplacePercentage\text{OnStackReplacePercentage}
  4. 缩放计算调用阈值乘以百分比再除以 100,得到回边的编译阈值。
    ×OnStackReplacePercentage100\times \frac{\text{OnStackReplacePercentage}}{100}

现代编译器展望——新派主厨 Graal

米其林主厨再厉害,也有厨艺上限。在 JVM 的世界里,C2 编译器已经服役二十多年,代码庞大而难以维护。于是 Oracle 与社区联手培养了一位“新派主厨”——Graal 编译器。它用 Java 语言编写、以 JIT 形式运行,通过标准接口 JVMCI(JVM Compiler Interface)与 HotSpot 对接,可以整体替换 C2。

Graal 的亮点在于更现代的优化管线:更好的逃逸分析、更智能的内联决策,以及更活跃的实验特性。不过它并不是默认开启——需要显式加上 -XX:+UseJVMCICompiler。对大多数线上系统,默认的 C2 依然是最稳的选择;而对追求极限性能、愿意冒险换“新主厨”的团队,Graal 提供了另一条路。

此外,Java 也在尝试“备菜前置”的 AOT 编译(如 GraalVM Native Image 与 Leyden 项目):与其开业后慢慢备菜,不如在开业前就把招牌菜做成半成品,运行时直接加热。这会大幅缩短启动与预热时间,代价是失去部分基于运行时反馈的动态优化能力。

从实习生到快餐主厨,从米其林主厨到新派主厨——JVM 的性能故事,始终是“用最合适的人,做最合适的活”。理解分层编译,你就拿到了理解这套体系的总钥匙:无论未来编译器怎么换,热度采样、层级递进、编译分工的思想都不会过时。

HotSpot JVM⟷JVMCI⟷Graal Compiler\displaystyle \text{HotSpot JVM} \longleftrightarrow \text{JVMCI} \longleftrightarrow \text{Graal Compiler}
这条公式展示了可插拔的编译器架构:HotSpot 不再“绑定”C2,任何实现了 JVMCI 的编译器都能上岗。Graal 不仅在 JIT 领域发力,还支撑了 AOT(提前编译)的演进。
  1. 主 JVMHotSpot 负责运行时与字节码管理,只通过接口与编译器通信。
    HotSpot JVM\text{HotSpot JVM}
  2. 双向交互运行时向编译器反馈分析数据,编译器向运行时返回编译产物。
    ⟷\longleftrightarrow
  3. 标准接口JVMCI 是连接 JVM 与外部编译器的桥梁,类似后厨的“传菜口”。
    JVMCI\text{JVMCI}
  4. 新派编译器Graal 通过 JVMCI 接入 HotSpot,替代 C2 执行深度优化。
    Graal Compiler\text{Graal Compiler}

AI 应用场景

微服务容器冷启动优化

阶段:服务部署与性能调优

解决的问题:容器频繁扩缩容时,Java 应用启动后前几十秒吞吐很低,常被负载均衡误判为不健康,导致流量损失。传统做法是盲目加机器、调堆内存,费钱却治标不治本。利用分层编译的知识,可以判断出瓶颈是“预热未完成”而非“资源不足”,对症下药。

具体案例:某订单系统的网关服务在 K8s 扩容时,从启动到扛住流量需要 45 秒。团队用 PrintCompilation 观察到大量方法停留在第 0 层(解释器),于是加上 -XX:TieredStopAtLevel=1 并配合启动预热请求,冷启动时间缩短到 18 秒,扩容时段的请求成功率显著提升。

高并发核心链路的峰值性能榨取

阶段:在线推理与核心服务性能优化

解决的问题:长期运行的核心交易方法若停留在 C1 层,峰值吞吐会被“家常菜”品质拖累。靠人工猜热点效率低,借助 JIT 监控手段(PrintCompilation、JFR)可以精确定位“哪些方法该由米其林主厨 C2 接管”,再通过调低 CompileThreshold 或强制预热,让 C2 尽早介入。

具体案例:某支付路由方法在压测 2000 QPS 时 CPU 打满,用 -XX:+PrintCompilation 发现它停留在第 3 层(完整 C1)长期未升到第 4 层。团队针对性调优并加入上线前压测预热流程后,该方法成功升到 C2,同样资源下 QPS 提升约 35%。

AI 推理服务中的热循环优化

阶段:推理服务瓶颈分析与加速

解决的问题:AI 服务里的特征计算、向量检索等方法包含密集循环,一次调用内部的回边次数极高。如果只靠调用计数,这类方法很难触发编译;利用 OSR 栈上替换机制,让长循环方法在运行中途无缝切到 C2 编译版本,能显著降低单次推理延迟。

具体案例:一个推荐系统的向量检索方法,单个请求内循环数十万次。开启分层编译默认参数后,回边计数触发 OSR,方法在第二轮请求时就切换到 C2 版本,P99 延迟下降 42%,且没有额外改动任何代码。

Python 演示

python57 行
import random  class HotMethod:    """模拟一个被反复调用的热点方法(餐厅的招牌菜)"""     def __init__(self, name, c1_threshold=30, c2_threshold=80):        self.name = name        self.c1_threshold = c1_threshold        self.c2_threshold = c2_threshold        self.invoke_count = 0        self.loop_edges = 0        self.tier = 0        self.samples = []     def call(self, loop_iterations=1000):        """调用一次:同时增加调用计数和回边计数"""        self.invoke_count += 1        self.loop_edges += loop_iterations        self._update_tier()        self.samples.append(self._measure())     def _update_tier(self):        """根据热度(调用 + 回边)决定当前编译层级"""        hotness = self.invoke_count + self.loop_edges // 1000        if hotness >= self.c2_threshold:            self.tier = 4          # 米其林主厨 C2 接管        elif hotness >= self.c1_threshold:            self.tier = 3          # 快餐主厨 C1 完整版接管     def _measure(self):        """不同层级下每次调用的耗时(毫秒),层级越高越快"""        base = [5.0, 3.0, 2.0, 1.2, 0.3]        return base[self.tier] + random.uniform(0, 0.1)     def simulate(self, n_calls):        for _ in range(n_calls):            self.call()        return self.samples  def main():    dish = HotMethod("getUserById", c1_threshold=30, c2_threshold=80)    samples = dish.simulate(200)     first_avg = sum(samples[:20]) / 20    last_avg = sum(samples[-20:]) / 20    print(f"方法: {dish.name}")    print(f"调用次数: {dish.invoke_count}, 回边计数: {dish.loop_edges}")    print(f"最终编译层级: 第 {dish.tier} 层 (4 表示 C2)")    print(f"前 20 次平均耗时: {first_avg:.2f} ms")    print(f"后 20 次平均耗时: {last_avg:.2f} ms")    print(f"峰值加速比: {first_avg / last_avg:.1f} 倍")  if __name__ == "__main__":    main()
目的:用两个计数器模拟分层编译的预热过程,直观展示同一方法从解释器到 C2 的升级如何带来数十倍加速。
  1. hotness = self.invoke_count + self.loop_edges // 1000复现“热度 = 调用计数 + 回边计数”的核心公式
  2. if hotness >= self.c2_threshold: self.tier = 4热度达标后 C2 接管,相当于米其林主厨出手
  3. elif hotness >= self.c1_threshold: self.tier = 3次高热度先由完整版 C1 顶住
  4. base = [5.0, 3.0, 2.0, 1.2, 0.3]五个层级对应不同单位耗时,层级越高越快
  5. random.uniform(0, 0.1)模拟真实世界中每次执行的微小抖动
  6. first_avg / last_avg用前 20 次与后 20 次耗时之比,量化预热带来的峰值加速比

常见误区

误区JIT 和解释器差不多,都是边运行边把每一行字节码翻译成机器码去执行,翻译开销大,所以 Java 启动慢。

正解逐条翻译的是解释器,JIT 恰恰相反——它只在方法热度达标后,把整个方法一次性编译成机器码并缓存,之后调用直接执行缓存结果,翻译一次、执行无数次。启动慢的真正原因是热点方法尚未被编译,程序大部分时间在解释器中运行。好比实习生逐字念菜谱才慢,主厨把快捷卡背熟后一点都不慢。

为何常见:很多人把“解释执行”和“即时编译”混为一谈,因为它们都发生在运行时,不像 javac 那样有明显的“编译产物”文件。

误区既然 C2 优化最猛,那就不分层了,所有方法都让 C2 编译,峰值性能肯定最高。

正解C2 编译一个方法可能要几百毫秒,如果所有方法都编译,编译队列会堆积如山,程序大部分时间都在等编译完成,吞吐反而暴跌。分层编译的精髓是“用最合适的人做最合适的活”:普通方法交给解释器或 C1,只有真正热的方法才配得上 C2 的闭关修炼。

为何常见:只看到 C2 的“峰值效果好”,忽略了它高昂的编译成本。实际上编译本身会抢占 CPU 和内存,过度编译是负优化。

误区把 `-XX:CompileThreshold` 调大,方法要多跑很久才编译,这样编译出来的代码一定更优,所以越大越好。

正解CompileThreshold 只是触发编译的“时机”旋钮,不影响优化深度。调得过大,热点方法长时间停留在解释器或 C1 层,稳态吞吐迟迟上不来;调得过小,不够热的方法也被编译,浪费编译线程。正确做法是结合启动时长与稳态压测结果,找到应用特有的平衡点。

为何常见:把“阈值”误解为“优化力度”,忽略了阈值只决定“什么时候编译”而非“编译得多好”。

误区程序启动后随便发一些请求,或者跑一段冒烟测试,就算是预热了,之后压测数据就是“热”的。

正解预热的效果取决于请求是否覆盖真实的热点路径。如果预热流量只走了一两个接口,其他核心方法根本没被调用,它们依然停留在解释器层,正式压测时前几分钟的数据必然虚低。预热要模拟真实流量分布,让 C2 提前编译真正会被打爆的方法,这正是压测前“洗数据”的意义。

为何常见:以为预热是“让机器跑热”,其实预热是“让热度计数器把正确的热点选出来”,流量不对,计数器再高也没用。

面试题

Q1. JIT 编译器的主要职责是什么?

单选题 · easy

  • A.启动时将字节码全量编译
  • B.运行时只编译热点方法为机器码← 正确答案
  • C.逐条解释字节码并立即执行
  • D.仅负责垃圾回收与内存管理

答案:B。JIT 是即时编译,专门挑运行时被反复调用的热点方法做整体编译并缓存。A 是 AOT 的做法,C 是解释器的职责,D 是 GC 的职责,三者都不是 JIT。

Q2. 分层编译中第 0 层表示什么?

单选题 · easy

  • A.C1 编译器轻量优化模式
  • B.解释器逐条翻译字节码← 正确答案
  • C.C2 编译器深度优化模式
  • D.AOT 提前编译的本地代码

答案:B。第 0 层是纯解释执行,对应后厨里照菜谱做菜的实习生,任何方法都能跑但速度慢。第 1 到 3 层是 C1 的三种模式,第 4 层才是 C2 深度优化。

Q3. C1 与 C2 编译器的核心区别是?

单选题 · mid

  • A.编译速度与优化深度不同← 正确答案
  • B.一个编译字节码一个编译源码
  • C.C2 比 C1 更早参与编译
  • D.C2 只处理循环回边计数

答案:A。C1 主打编译快、优化保守,C2 主打编译慢、优化激进,二者是“速度换深度”的关系。两者都编译字节码;C2 在热点足够热后才介入;回边计数只是触发机制,不是 C2 的专属职责。

Q4. 方法调用计数和回边计数的作用是?

单选题 · mid

  • A.决定代码格式化风格
  • B.统计热点并触发层级编译← 正确答案
  • C.控制线程池的最大容量
  • D.计算堆内存使用情况

答案:B。两者相加得到方法热度,热度超过阈值就触发对应层级的编译。它们记录的是“方法被谁调、循环绕几圈”,与代码风格、线程池、堆内存都无直接关系。

Q5. OSR 栈上替换主要解决什么问题?

单选题 · mid

  • A.方法已执行中途也切换编译版本← 正确答案
  • B.将编译后的代码卸载出内存
  • C.减少热点方法的调用次数
  • D.加速 JVM 启动时的类加载

答案:A。当热点藏在长循环里,方法还没结束回边计数就达标了。OSR 允许方法在执行中途、于下一次循环回边时切到编译版本,不必等整个方法跑完。B、C、D 都不是 OSR 的用途。

Q6. 以下哪个参数用于限制分层编译只用到 C1?

单选题 · hard

  • A.-XX:TieredStopAtLevel=1← 正确答案
  • B.-XX:CompileThreshold=3000
  • C.-XX:CICompilerCount=2
  • D.-XX:+PrintCompilation

答案:A。TieredStopAtLevel=1 表示把编译最高层级限制在第 1 层,只启用轻量 C1,适合极致的启动速度优先场景。B 调的是触发阈值,C 调的是编译线程数,D 只是打印编译日志的开关。

Q7. 程序启动阶段主要靠什么保证快速运行?

单选题 · easy

  • A.解释器即刻执行字节码← 正确答案
  • B.C2 预先编译全部方法
  • C.等待热点采样完成后启动
  • D.直接加载已编译的本地镜像

答案:A。启动阶段热点尚未形成,解释器保证了任何方法都能立即执行,程序才能“先跑起来”。C2 不会预先编译全部方法,程序也不会等采样完成才启动。

Q8. 哪些因素会使方法成为编译热点?

多选题 · mid

  • A.被高频调用且循环体较大← 正确答案
  • B.包含大量回边循环逻辑← 正确答案
  • C.被声明为 private 私有方法
  • D.方法体为空或抛出异常

答案:AB。热点由调用计数加回边计数决定,高频调用和大量循环都会拉高热度。方法是否 private 与热度无关;空方法体或不抛异常的方法几乎不产生执行开销,也不会成为热点。

Q9. 关于分层编译,下列说法正确的是?

多选题 · hard

  • A.层级越高优化效果越激进← 正确答案
  • B.所有方法最终都会升到 C2
  • C.C1 编译结果可带性能分析信息← 正确答案
  • D.解释器与编译代码可动态切换← 正确答案

答案:ACD。0 到 4 层层级越高优化越深;第 2、3 层 C1 会携带性能分析信息供 C2 使用;解释器与编译版本可在运行中动态切换(包括 OSR)。但只有真正热的方法才值得升到 C2,绝非所有方法。

Q10. 以下哪些场景适合用 JIT 分层编译的知识来调优?

多选题 · mid

  • A.缩短微服务冷启动时间← 正确答案
  • B.追求长期运行的高吞吐← 正确答案
  • C.排查 GC 异常停顿问题
  • D.压测开始阶段吞吐偏低← 正确答案

答案:ABD。启动优化、稳态吞吐、预热期表现都是 JIT 分层编译的直接影响面。GC 停顿属于垃圾回收器范畴,与 JIT 编译无关,不应混为一谈。