JIT 分层编译、去优化与可观察行为:从解释执行到 C2 的证据
本页语义基线为 Java SE 17,HotSpot 源码固定到
jdk-17.0.12+7,实验运行 Oracle JDK 17.0.12(macOS x86_64)。分层结构、阈值与去优化机制属 HotSpot 实现,不是规范承诺;本页所有耗时、分配字节与事件明细都标注为观察值,单次快照不当因果证明。对象布局、TLAB 与逃逸分析的证据链在内存区域单元,本页只承接其 JIT 侧的可观察影响。
为什么要分层:解释器、C1、C2 各自解决什么
解释执行启动即用、逐条翻译,没有编译等待,但同样的循环要重复付翻译成本。C2(服务端编译器)优化深、峰值高,但编译本身消耗 CPU 与时间,冷启动阶段直接等 C2 反而更慢。分层编译(tiered compilation)在两者之间插入带 profile 的快速层:C1 编译快、带方法调用与分支画像;画像攒够后,同一方法再交给 C2 做投机优化(内联、去虚化、逃逸分析、锁消除)。层级大致为 0(解释器)、1/2/3(C1 无画像/带部分画像/带全画像)、4(C2)。
这套结构的直接推论是:同一个方法在不同生命周期阶段由不同执行器运行。刚启动时解释执行,热起来后 C1,最热的路径最终 C2;每个升级点都有阈值判断。由此产生三个必须分开回答的问题:现在这段代码在哪个层执行(-Xlog:compilation / JFR jdk.Compilation 事件);画像与投机假设失效后发生什么(去优化);观察这些行为本身要付多少代价(编译线程 CPU、JFR 缓冲、日志量)。不能笼统说"JIT 让 Java 变快"——短生命周期进程可能全程没等到 C2,本页实验的 -Xint 配置下吞吐差一个数量级以上,而启动反而更早进入工作。
怎样观察与控制:真实开关、默认值与 API
控制开关是产品级参数,含义与作用域如下(默认值在本机 Oracle 17.0.12 用 -XX:+PrintFlagsFinal 核对过,与固定 tag compiler_globals.hpp 一致):
| 开关 | 默认值 | 作用与边界 |
|---|---|---|
-Xint | 关 | 全程解释执行。用于隔离"编译了吗"这个变量;吞吐大幅下降,不是性能建议 |
-XX:TieredStopAtLevel=1 | 4 | 分层只到 C1 无画像层,关闭 C2(含逃逸分析)。诊断"是否 C2 优化引入问题"的对照工具 |
-XX:-DoEscapeAnalysis | 开 | 只关 C2 逃逸分析/标量替换这一类优化,其余 C2 优化保留。与上一个开关的作用面不同 |
Tier3InvocationThreshold / Tier3MinInvocationThreshold / Tier3CompileThreshold | 200 / 100 / 2000 | 进入带全画像 C1 的调用次数阈值(另有回边计数合流) |
Tier4InvocationThreshold / Tier4MinInvocationThreshold / Tier4CompileThreshold | 5000 / 600 / 15000 | 升入 C2 的调用次数阈值 |
ReservedCodeCacheSize | 本机 240 MiB(ergonomic) | 代码缓存预留上限;用满后新编译被拒,已编译代码继续运行 |
观察通道有四类,各自能证明的范围不同:
-Xlog:compilation*=debug(等价旧PrintCompilation)逐条打印编译任务与made not entrant。JDK 17 的版本事实:编译日志挂在jit,compilation标签集上,写-Xlog:compilation选不中它,要compilation*通配;且 JDK 17 没有deopt日志标签(-Xlog:help可核对),网络文章里-Xlog:deopt的写法是其他版本的。- JFR 事件:
jdk.Compilation(谁被编译到哪层)与jdk.Deoptimization(何时、哪个方法、什么 reason/action 去优化),可用jfr metadata核对本 JDK 的事件定义;进程内jdk.jfr.Recording也能订阅。事件是结构化证据,比日志文案稳定。 CompilationMXBean.getTotalCompilationTime():累计编译耗时(毫秒,不支持时 -1),适合看趋势不适合归因单方法。com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes():线程累计分配字节,差量可观测逃逸分析是否真的消除了分配(HotSpot 扩展接口,不可用时降级)。
低层级的 -Xlog:codecache=info、jcmd PID Compiler.codecache、jcmd PID Compiler.queue 属于采集侧工具,入口见诊断工具单元;编译线程吃 CPU 的排障归故障实战单元,此处不重复。
原码:升级到哪一层由阈值与画像决定
原码连续摘录(仅统一公共缩进,截取方法整体):compilationPolicy.cpp,jdk-17.0.12+7,L270–L286。
static bool apply_scaled(const methodHandle& method, CompLevel cur_level, int i, int b, double scale) {
double threshold_scaling;
if (CompilerOracle::has_option_value(method, CompileCommand::CompileThresholdScaling, threshold_scaling)) {
scale *= threshold_scaling;
}
switch(cur_level) {
case CompLevel_none:
case CompLevel_limited_profile:
return (i >= Tier3InvocationThreshold * scale) ||
(i >= Tier3MinInvocationThreshold * scale && i + b >= Tier3CompileThreshold * scale);
case CompLevel_full_profile:
return (i >= Tier4InvocationThreshold * scale) ||
(i >= Tier4MinInvocationThreshold * scale && i + b >= Tier4CompileThreshold * scale);
default:
return true;
}
}i 是调用计数、b 是回边计数:到达 Tier3 的条件是"调用够多"或"调用不那么多但循环回边攒够了"——这就是为什么只调用一两次但循环巨大的方法会先被**OSR(栈上替换)**编译,而调用很多次的方法按调用计数整体编译。scale 来自编译线程负载反馈与 CompileThresholdScaling 编译命令:编译队列积压时阈值放大、升级变慢,所以"同样的代码何时到 C2"在不同机器与负载下并不同步,观察值不能当契约。Tier4 分支的阈值表数值即上表默认值。
去优化不是错误,是安全回退
C2 的很多优化建立在画像与类层次快照上:调用点几乎总是同一个接收者(单形态内联)、某分支从未走过、某接口只有一个实现。这些假设写进机器码时都附带"投机检查",检查失败就走 uncommon trap——保存现场、把解释器需要的栈帧状态恢复出来、回到解释器继续执行,语义上与方法从未被优化过完全一致;随后按新画像决定是否重新编译(maybe_recompile)或直接解释执行。被消除的对象在去优化瞬间由 Deoptimization::realloc_objects/reassign_fields 恢复成真实堆对象(入口在内存区域单元的逃逸分析段),所以"编译热路径零分配"只在编译假设成立的窗口内成立。
原码:陷阱原因是一张有限表
原码连续摘录(仅统一公共缩进,截取数组整体):deoptimization.cpp,jdk-17.0.12+7,L2606–L2642。
const char* Deoptimization::_trap_reason_name[] = {
// Note: Keep this in sync. with enum DeoptReason.
"none",
"null_check",
"null_assert" JVMCI_ONLY("_or_unreached0"),
"range_check",
"class_check",
"array_check",
"intrinsic" JVMCI_ONLY("_or_type_checked_inlining"),
"bimorphic" JVMCI_ONLY("_or_optimized_type_check"),
"profile_predicate",
"unloaded",
"uninitialized",
"initialized",
"unreached",
"unhandled",
"constraint",
"div0_check",
"age",
"predicate",
"loop_limit_check",
"speculate_class_check",
"speculate_null_check",
"speculate_null_assert",
"rtm_state_change",
"unstable_if",
"unstable_fused_if",
"receiver_constraint",
#if INCLUDE_JVMCI
"aliasing",
"transfer_to_interpreter",
"not_compiled_exception_handler",
"unresolved",
"jsr_mismatch",
#endif
"tenured"
};图中上半是执行层级阶梯:方法按调用与回边计数上行,阈值乘以负载 scale;下半是去优化路径:投机检查失败触发 uncommon trap,现场回解释器(被消除对象恢复为真实堆对象),随后按新画像重编译或保持解释执行,旧机器码经 not entrant 到 zombie 两阶段退役。读图先定位“这段代码此刻在哪层执行”,再看“哪个假设被谁打穿”,不要把箭头读成一次性单行道——重编译后还可能再次去优化。
这张表是排障的词汇表:JFR jdk.Deoptimization 事件的 reason 字段就取这些值。本页实验实测命中的 predicate/profile_predicate 表示"基于调用画像的投机类型检查失败";unstable_if 表示"从未走过的分支第一次走了";class_check/bimorphic 与单形态内联相关;speculate_* 前缀说明这是编译器主动登记的投机假设被推翻。action 字段(reinterpret 回解释器、maybe_recompile 允许重编译、make_not_entrant 停用该编译产物)说明去优化的处置力度不同——去优化频率高≠程序错误,它可能是画像被多态负载自然打穿;但单点在短时间内反复 trap 重编译,就是真实的性能问题信号,要结合编译队列与编译线程 CPU 判断。
三个常被混淆的边界:去优化改变执行速度,不改变结果语义(本页实验四种配置校验和完全一致);类加载可以触发依赖该类层次的编译产物失效(CHA 失效批量去优化,本票未驱动复现,按源码机制推导标注);made not entrant 之后旧机器码要等所有在途执行退出才真正回收(zombie),所以代码缓存占用不随去优化立即下降。
可运行实验:四配置对照与去优化实测
入口是 labs/jvm-senior 的 run.sh classjit 与 run.sh classjit-tiers(本机 macOS x86_64、Oracle JDK 17.0.12,需显式 JAVA_HOME 指向 JDK 17;每次运行有 alarm 上限与唯一 TMPDIR)。classjit 模式断言类初始化触发边界、重入零值、标量替换分配对照与去优化事件,并在 stdout 混入 -Xlog:class+init=info 真实开关输出;classjit-tiers 用四个独立 JVM 分别以 -Xint、-XX:TieredStopAtLevel=1、默认、-XX:-DoEscapeAnalysis 跑同一负载,每个配置只断言自身校验和,跨配置数字是观察值。
export JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home
bash labs/jvm-senior/run.sh classjit
bash labs/jvm-senior/run.sh classjit-tiers实测摘录(完整输出与退出状态见证据文件):
# classjit(默认分层):
ok: Sub.PARENT_FIELD initializes the declaring class Parent, not Sub
ok: reading a constant variable is inlined and does NOT initialize the declaring class
ok: reentrant readY() during <clinit> observes y's default 0, so x=0 and y=7
observed (not a contract): noEscapeBytes=0 escapeBytes=46583352
observed deopt (not a contract): run reason=predicate action=maybe_recompile
observed deopt (not a contract): run reason=profile_predicate action=maybe_recompile (共 5 条)
observed (not a contract): addMs=0 mulFirstMs=11 mulSecondMs=4 ourDeopts=5 ourCompilations=18
[0.081s][info][class,init] 589 Initializing 'ClassJitProbe$LazyForName' (0x...) ← forName(true) 断言处
# classjit-tiers(同一负载、四个独立 JVM):
-Xint pairLoop ms=581 allocatedBytes=64000000
-XX:TieredStopAtLevel=1 pairLoop ms=30 allocatedBytes=64000000
默认(分层到 C2) pairLoop ms=0 allocatedBytes=0
-XX:-DoEscapeAnalysis pairLoop ms=31 allocatedBytes=64000000读数方法:tiers 表不能读成"编译快 581 倍"——这是单机单轮观察值,且 -Xint 档故意不代表生产。它稳定证明的只有两类事实:其一,四种配置三个校验和全部等于闭式值,解释执行与 C1/C2 执行结果一致;其二,分配字节的台阶——C1 不做逃逸分析(200 万个 32 字节 record 照常分配 64,000,000 字节),默认 C2 标量替换把同一循环的分配降为 0 字节,单独关闭逃逸分析后 C2 也恢复 64,000,000 字节——把"哪一层优化改变了分配"从推断变成了可指认的观察。去优化段的读法同理:mulFirstMs=11 与 mulSecondMs=4 是"首次遇到未画像类型触发 trap 回退、随后按新画像恢复"的时序观察;ourDeopts 在 5–6 间波动、reason 组成逐轮略异,正说明这类计数是观察值不是契约。
排障迁移:怀疑"C2 优化改变了行为"时,用 -XX:TieredStopAtLevel=1 或 -Xint 复跑同一负载比对结果(本实验给出结果不变的样例);怀疑分配来自被消除路径时,用 getCurrentThreadAllocatedBytes 差量对照开关 EA;怀疑热路径反复去优化时,订阅 jdk.Deoptimization 按 reason 聚合,再决定是拆分调用点形态还是接受多态成本。JFR 采样没看到某方法,不等于它没被编译或没被去优化——采样有盲区。
反例:四个被实测推翻的说法
- "通过子类名读静态字段会初始化子类":
Sub.PARENT_FIELD(字段声明在 Parent)只初始化 Parent,Sub未初始化;触碰Sub自有静态才初始化Sub。红测实跑推翻后按 JLS §12.4.1 落位。 - "读 static final 常量一定触发初始化":
static final int K = 42是常量变量,编译器内联,读取不触发<clinit>;static final Integer BOXED不是常量变量,读取即触发。同一行里的两个 final 行为不同,判据是类型与常量表达式,不是 final 关键字。 - "方法被 JIT 编译后就不会再回退":单形态预热后传入第二实现,实测当轮出现 5–6 条
jdk.Deoptimization事件(reason=predicate/profile_predicate,action=maybe_recompile)。 - "每次 new 都有真实堆分配":C2 编译后的不逃逸 record 循环分配差量为 0 字节;同一循环塞进共享集合(逃逸)立即恢复千万级字节分配。"对象一定在堆上"对源码语义成立,对编译后的机器执行不成立;反过来若未进入 C2 或被 NSR 传播否决,分配照常发生——条件缺一不可。
高级口述与三轮追问
主问:"分层编译是什么,去优化什么时候发生、发生了会怎样?"答:解释器即启即用,C1 快速带画像,C2 深度投机;升级按调用/回边计数乘负载 scale 判定。画像或类层次假设被新输入打穿时触发 uncommon trap,现场回解释器、结果语义不变,随后按新画像重编译或保持解释执行。
第一轮:"-Xint 慢多少?"答:本机单轮观察 200 万次 record 循环 581ms 对 C2 约 0ms,但这是观察值不是倍数承诺;-Xint 的正当用途是隔离编译变量,不是调优选项。评分点:观察值口径 + 开关的定位。
第二轮:"怎样证明某个内联优化被去优化撤销了?"答:订阅 jdk.Deoptimization,按方法名过滤,核对 reason(如 profile_predicate)与 action(maybe_recompile),并用 -Xlog:compilation*=debug 看 made not entrant 与重编译行;单看耗时波动不能下这个结论。评分点:事件字段 + 两个通道互证 + 不把耗时当证据。
第三轮:"上线后偶发慢查询,怎么排除/坐实 JIT 因素?"答:先取同一窗口的 JFR jdk.Deoptimization 与 jdk.Compilation 事件、编译线程 CPU 和 Compiler.queue 深度;若慢点与 trap/recompile 时间吻合,再在预发用 TieredStopAtLevel=1 复跑对照。不能只凭"P99 抖动"与"存在去优化事件"同时出现就断因果——抖动窗口要先对齐。评分点:时间线对齐 + 对照实验 + 因果口径。
架构师变化条件:预热窗口与诊断预算
假设算例(非实测):一个按 200 QPS 稳态容量申请的实例,重启后前 30 秒解释执行只有约 40 QPS 处理能力,且滚动发布时 10% 实例同时处于冷窗口。若入口层不设预热,均衡器按均分路由会把队列压在冷实例上造成超时。可选方案:A,网关/注册中心按启动时间加权放量(慢启动),成本是实现与配置责任在流量层,验证方式是灰度发布时观察冷实例队列深度与错误率;B,应用预热任务(启动后自造合成负载触发编译),成本是预热流量本身要真实触发相同分支画像,否则画像错位反而引导错误内联,验证方式是比对预热后首个真实请求延迟分布。两案都要求把"预热完成"定义为可观测指标(如 jdk.Compilation 事件速率回落 + 首请求 P99 达标),不能拿进程 Running 冒充可服务——这与Boot 生产就绪的就绪探针边界同源。诊断预算同理:常开 JFR 的 Tracing 记账(NMT 可见约 15 MiB 级缓冲,见诊断工具单元)与编译日志 verbose 档都要计入容量,不因"JIT 神秘"豁免资源账单。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| JLS SE 17 §12.4.1 何时初始化 | Java SE 17 | 常量变量、数组、声明类等触发边界(实验断言的规范依据) |
| CompilationMXBean | Java SE 17 | 累计编译耗时 API 承诺 |
| ThreadMXBean.getCurrentThreadAllocatedBytes | JDK 17(com.sun.management) | 线程分配字节差量的 HotSpot 扩展口径 |
| OpenJDK 17u compilationPolicy.cpp | jdk-17.0.12+7 | CallPredicate::apply_scaled(L270–L286):Tier3/Tier4 阈值与 scale |
| OpenJDK 17u compiler_globals.hpp | jdk-17.0.12+7 | Tier3/Tier4 阈值默认值(与本机 PrintFlagsFinal 核对一致) |
| OpenJDK 17u deoptimization.cpp | jdk-17.0.12+7 | _trap_reason_name 全表(L2606–L2642);去优化现场恢复入口互链内存单元 |
本机 jfr metadata、-Xlog:help、-XX:+PrintFlagsFinal | Oracle JDK 17.0.12+8 | jdk.Deoptimization/jdk.Compilation 事件存在性、无 deopt 标签、阈值默认值实测 |
本地 ClassJitProbe classjit/classjit-tiers 实验 | Oracle JDK 17.0.12 | 四配置对照与去优化实测输出(证据) |
访问日期:2026-10-25。源码行号以 jdk-17.0.12+7 标签为准;实验数字来自本机 Oracle JDK 17.0.12(macOS x86_64)单轮或多轮观察,更换 JDK、平台或启动参数后需重跑,结论不构成性能承诺。