解释器 · 分层编译 · C1 · C2 · 方法计数器 · 回边计数器 · OSR · profile · 内联 · 去虚化 · 逃逸分析 · 标量替换 · 锁消除 · 去优化 · 代码缓存 · JMH · OmitStackTraceInFastThrow
JIT 与逃逸分析:先解释、边跑边记,热了再按记下的情况大胆编译;逃逸分析靠内联才看得见,看见了,new 就不进堆
Java 方法一开始由解释器一条条执行字节码,同时记下调用次数、走了哪条分支、调用点上来的是哪个实现类。方法热了,C1 先快速编出一版,C2 再按这些记录做激进优化:把被调用的方法内联进来,于是能看清一个对象有没有逃出这段代码;没逃出的,就拆成几个局部变量(标量替换),new 不再发生。记录要是不再成立(新加载了一个实现类),就退回解释器重来(去优化)。交易靶场里实测:算价方法纯解释 358 ns/次,C2 编译后 3.7 ns/次、堆上分配 0 字节;同一个 Money 一旦传给“见过三种支付渠道”的接口调用,就又进了堆。
①为什么需要:算价方法一秒要跑几百万次,字节码不能一直“念”着执行
在全景第 2 站,第 1 站已经把 Order、Money 的类加载好了,下单线程开始反复执行算价代码。这一步要解决两件事:一是快,字节码是给虚拟机看的指令,逐条解释执行比机器码慢上百倍;二是少分配,算价里每一步运算都 new 一个临时 Money,要是每个都进 Eden(第 3 站),堆很快就满,第 5 站的 Young GC 就得频繁出来干活。全景里那组数:循环 5000 万次 new Money,编译之后线程在堆上只分配了 0.7 MB;关掉逃逸分析或者纯解释,都是 1144.4 MB。
358 ns
纯解释(-Xint)跑一次 priceOf,每次在堆上分配 72 字节 实测
17 ns
只用 C1(-XX:TieredStopAtLevel=1):编译了,但 3 个 Money 照样进堆 实测
3.7 ns · 0 B
默认分层编译:C2 接手后,3 个 Money 都被标量替换,堆上 0 字节 实测
24 B
同一个 Money 传给见过 3 种实现的接口方法:C2 不内联,它就进堆 实测
Money 是不可变的金额类,每次运算返回一个新的。代码写得很“面向对象”,一次算价 new 了 3 个 Money。问题是:这样写,线上会不会很慢、会不会给 GC 添很多活?final class Money { // 对象头 12 字节 + long 8 字节 → 24 字节
final long cents;
Money(long cents) { this.cents = cents; }
Money times(int qty) { return new Money(cents * qty); }
Money off(int pct) { return new Money(cents * (100 - pct) / 100); }
}
static long priceOf(int sku, int qty) {
Money unit = new Money(BASE[sku & 1023]);
return unit.times(qty).off(qty >= 10 ? 5 : 0).cents; // 三个 Money 都没离开这个方法
}
②没有它会怎样:只解释,或者只做保守的编译
把 JIT 拿掉一半看看。下面四根条是同一个 priceOf 在本机的稳态耗时(第 3~30 批的中位数,每批 20 万次)和每次在堆上分配的字节。
只解释
- 每条字节码都要“取指令、查表、跳到对应的处理代码”,一次加法变成几十条机器指令
- 慢两个数量级:358 ns 对 3.7 ns,差了将近 100 倍
- 每个
new都真的分配,72 B/次全进 Eden
编译了,但只做保守的优化
- C1 编得快,但不做逃逸分析:3 个
Money照样进堆 - C2 关掉逃逸分析也是 72 B/次:内联做了,对象还是要分配
- 每秒几百万次算价,就是每秒几百 MB 的短命垃圾,Young GC 次数跟着翻倍
缺的是两样:只给热的代码花大力气编译(冷代码解释就够,编译它不划算),以及编译时能看穿方法调用,知道一个对象是不是只在这一小段代码里用。前者是分层编译,后者是“内联 + 逃逸分析”。不懂这一点的典型误用:为了“省 GC”给 Money 这种小对象做对象池,结果对象全变成长寿的、进了老年代,还得自己处理并发,比让 C2 标量替换掉慢得多;见 ⑥ 误区 1。
③相邻方案:什么时候编译、谁来编译
| 做法 | 什么时候编译 | 能用上运行时信息吗 | 启动 | 峰值 | 谁在用 |
|---|---|---|---|---|---|
| 纯解释 | 不编译 | 不需要 | 快 | 很慢 | -Xint,只用来排查 JIT 的问题 |
| JIT 分层编译 | 运行中,热了再编 | 能:调用次数、分支走向、调用点上的实际类型 | 要预热 | 最高 | HotSpot 默认(解释器 + C1 + C2) |
| AOT 提前编译 | 构建时编成机器码 | 只能靠构建时的一次训练运行,或者不用 | 最快 | 通常低一些 | GraalVM Native Image;C/C++、Go 也是这一路 |
| JIT + 提前存档 | 先跑一次训练,把加载、链接、profile 存起来,正式启动直接用 | 能,训练时记下的加上运行时的 | 较快 | 和 JIT 一样 | JDK 24 起的 AOT 缓存(Project Leyden),见 ⑥ 版本差异 |
选择规则:长期运行的后端服务,峰值吞吐最要紧,用默认的 JIT 分层编译,把“要预热”当成发布流程的一部分处理;冷启动比峰值重要的(命令行工具、函数计算、频繁扩缩容的小服务),考虑 Native Image 或 AOT 缓存。本页学 HotSpot 的分层编译,和 C2 里最影响你写代码方式的两件事:内联和逃逸分析。
④原理:分层编译、内联、逃逸分析、去优化,以及为什么偏要在运行时编译
- 字节码是一种栈式指令,比如
lload_0; iload_1; i2l; lmul。javap -c能看到。解释器的工作就是一条条取出来执行。 - 编译好的机器码放在代码缓存(Code Cache),在堆外,有大小上限,见 JVM 内存结构。
- 对象在堆里怎么分配、24 字节是怎么算出来的,见 new 一个对象的全过程。“堆上分配了多少”用
ThreadMXBean.getThreadAllocatedBytes量,它统计的是这个线程在 TLAB 里和 TLAB 外分出去的字节。
④-1 分层编译:一个方法从冷到热,换三次“身体”
为什么要三层,不直接上 C2:C2 编一个方法要几毫秒到几十毫秒、还要一份足够长的 profile,C1 编得快一个量级。先用 C1 让代码“快起来”,同时让 3 级代码继续记 profile,攒够了再交给 C2。编译在后台的编译线程里做(本机 2 核,CICompilerCount=2:C1、C2 各一个),业务线程不用等,期间照常跑旧版本。⑤ 的 -XX:+PrintCompilation 里能看到 priceOf 先出现 3 级,再出现 4 级,接着 3 级那版被标成 made not entrant(不再给新调用用)。
④-2 内联和去虚化:C2 先把调用“抹平”,后面的优化才看得见
Money unit = new Money(BASE[sku & 1023]);
return unit.times(qty)
.off(qty >= 10 ? 5 : 0)
.cents;
long c1 = BASE[sku & 1023]; // unit.cents
long c2 = c1 * qty; // times() 的 new 没了
int p = qty >= 10 ? 5 : 0;
long c3 = c2 * (100 - p) / 100; // off() 的 new 没了
return c3; // 0 次分配
内联本身省的是一次调用的开销,几个纳秒;它真正值钱的地方是让 C2 能把调用方和被调用方放在一起看,常量传播、去掉多余的检查、逃逸分析都要在这之后才做得成。所以 JIT 时代的“小方法、多层调用”并不慢;被调用的方法大到内联不进来,或者调用点见过太多实现,后面的优化才会一起丢掉。
④-3 逃逸分析:对象有三种结局,只有一种能不进堆
逃逸判定台选一种写法,先猜进不进堆,再看本机实测
这样写,C2 编译后,每次调用在堆上分配多少?
数字来自 ⑤ 的 Escape:每种写法先预热 300 万次,再量 1000 万次,取每次的平均分配字节。本机 2 核 ARM,JDK 17.0.20.1。
④-4 推测和去优化:C2 敢赌,是因为输了能退回去
为什么这样设计:编译放在运行时,而不是构建时
被放弃的:构建时一次编完(AOT)
- 编译器只能看源码:
Channel接口将来会有几个实现、哪个分支热,构建时不知道 - 不知道就只能保守:接口调用不敢内联,传进去的
Money一律算逃逸 - Java 允许运行时加载新类(第 1 站、SPI、动态代理),构建时根本看不到全部代码
- 好处是启动就快,没有预热,内存里也不用放编译器
HotSpot 选的:先跑,看清了再编(JIT)
- 解释器和 C1 的 3 级代码顺手记下真实的类型和分支,C2 按它下注
- 赌错了有去优化兜底,所以敢做单态内联、只编热分支、标量替换
- 只编热的那一小部分代码,编译预算花在刀刃上
- 代价:要预热;编译线程占 CPU;代码缓存要内存
决定胜负的约束:Java 是动态的(虚方法默认可重写、类可以运行时加载),而后端服务一跑就是几天。动态让静态编译只能保守,长期运行又让“前几十秒慢一点”摊得很薄。冷启动成了瓶颈的场景(Serverless、弹性扩容),这个账就要重算,这正是 Native Image 和 JDK 24 起 AOT 缓存出现的原因。
基本用法:看 JIT 在干什么的几条参数
-XX:+PrintCompilation // 每编一个方法打一行:级别、% 表示 OSR、made not entrant
-XX:TieredStopAtLevel=1 // 只用 C1(启动快、峰值低)
-Xint // 只解释,对照用
-XX:-DoEscapeAnalysis // 关掉逃逸分析,对照用
-XX:-OmitStackTraceInFastThrow // 热点异常也保留堆栈(⑥ 坑 1)
jcmd <pid> Compiler.codecache // 代码缓存用了多少
jcmd <pid> Compiler.queue // 编译队列里排着谁
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining // 每个调用点内联没有、为什么
阈值、内联大小这些参数为什么一般不该动,放到 ⑥ 第三圈。JMH 怎么写也在 ⑥。
⑤亲眼看到:交易靶场里的三个小实验
一个 Maven 模块,三个 main,不依赖任何第三方库。--release 8 编译,用 JDK 17 运行。完整代码 →
cd java && JAVA_HOME=/usr/lib/jvm/java-17-openjdk-arm64 mvn -q -pl jvm/jvm-jit-escape compile
cd jvm/jvm-jit-escape && J=/usr/lib/jvm/java-17-openjdk-arm64/bin/java; M=labs.jvm.jvm_jit_escape
$J -cp target/classes $M.Warmup # 1. 预热曲线
$J -XX:+PrintCompilation -Dbatches=3 -cp target/classes $M.Warmup | grep -E "priceOf|batch|Money"
$J -cp target/classes $M.Escape # 2. 八种写法进不进堆
$J -XX:-DoEscapeAnalysis -cp target/classes $M.Escape
$J -cp target/classes $M.Myths # 3. 两个误区
$J -XX:-OmitStackTraceInFastThrow -cp target/classes $M.Myths
1. 预热:第一批 20 万次就完成了从解释到 C2
$ java … Warmup 批 1 24.7 ns/次 6.1 B/次 累计 30.5 ms 批 2 4.1 ns/次 0.0 B/次 累计 46.9 ms 批 3 8.5 ns/次 0.0 B/次 累计 49.1 ms 批 4 3.7 ns/次 0.0 B/次 累计 50.4 ms 批 10 3.7 ns/次 0.0 B/次 累计 58.7 ms 批 30 3.7 ns/次 0.0 B/次 累计 84.0 ms $ java -Xint … Warmup 批 1 359.6 ns/次 72.0 B/次 累计 94.1 ms 批 5 353.1 ns/次 72.0 B/次 累计 395.0 ms
$ java -XX:+PrintCompilation … | grep … 时间ms 编号 级别 68 69 3 Money::off (22 bytes) 69 67 3 Warmup::priceOf (40 bytes) 69 66 2 Money::<init> (10 bytes) 69 70 4 Warmup::priceOf (40 bytes) 70 67 3 Warmup::priceOf made not entrant 71 73 % 3 Warmup::batch @ 5 (36 bytes) 72 75 % 4 Warmup::batch @ 5 (36 bytes) 93 143 4 Warmup::batch (36 bytes)
读法:第 1 批的 6.1 B/次说明,这 20 万次里大约前 8% 还在解释器和 C1 里跑(6.1 ÷ 72);从第 2 批起 0 字节。右边,priceOf 在 69 ms 时先被 C1 编成 3 级,几乎同时进了 C2 的 4 级,3 级那版随即作废;% 是 OSR:@ 5 是循环头所在的字节码偏移,batch 的循环转到一半就被换成了编译版,没等它返回。第 3 批 8.5 ns 的那一下,和 93 ms 时 batch 被整个重新编译的时刻对得上。
2. 八种写法,哪些 new 真的发生了
$ java … Escape (默认)
A 只在方法里用 0.0 B/次
B 被返回 24.0 B/次
C 存进静态字段 24.0 B/次
D 传参·调用点只见过 1 种实现 0.0 B/次
E 传参·调用点见过 3 种实现 24.0 B/次
F 两条分支各 new 再合流 24.0 B/次
G 小数组 long[4] 0.0 B/次
H 大数组 long[100] 816.0 B/次
$ java -XX:-DoEscapeAnalysis … Escape
A 只在方法里用 48.0 B/次
B 被返回 24.0 B/次
C 存进静态字段 24.0 B/次
D 传参·调用点只见过 1 种实现 24.0 B/次
E 传参·调用点见过 3 种实现 24.0 B/次
F 两条分支各 new 再合流 24.0 B/次
G 小数组 long[4] 0.0 B/次
H 大数组 long[100] 816.0 B/次
D 和 E 调的是同一个接口、同样的 fee 方法,区别只在调用点的 profile(④-2)。A 关掉 EA 是 48 B:m 和 m.times(2) 两个对象。G 关掉 EA 也是 0 B,这一条和逃逸分析无关:数组的读写在编译时就被折叠成常量,数组成了没人用的死代码,被直接删掉(按现象推断,没有对照编译日志)。这八行就是 ④-3 判定台里的数字。
3. 两个误区,现场看
① 手写微基准(每轮 5000 万次 priceOf)
第 1 轮 结果丢掉 6.47 ms 结果用上 77.09 ms
第 2 轮 结果丢掉 1.37 ms 结果用上 72.01 ms
第 3 轮 结果丢掉 0.00 ms 结果用上 67.65 ms
第 5 轮 结果丢掉 0.00 ms 结果用上 67.05 ms
② 同一行代码反复 NPE:异常的堆栈有几层 第 1 次:2 层,message=Cannot read field "code" because "o.coupon" is null 第 6761 次起:0 层,message=null $ java -XX:-OmitStackTraceInFastThrow … 20 万次里堆栈一直都在
左边:discard 把 priceOf 的结果丢掉,C2 内联后发现整个循环没有任何可见效果,就把它删了,5000 万次“0.00 ms”。拿这种数字说“X 比 Y 快 1000 倍”,测的是编译器有多聪明。右边:同一个位置 NPE 抛了几千次以后,C2 编译的版本改抛一个预先建好的 NPE,没有堆栈、没有 message。
⑥细节:坑、常见的性能误区、规范、更深一层
注意事项
- 现象
- 日志里只有一行
java.lang.NullPointerException,下面一行堆栈都没有;重启后头几次又有堆栈。 - 根源
- C2 编译的代码里,同一个位置反复出 NPE、除零、数组越界、类型转换失败时,改抛 JVM 预先建好的那个异常对象,省掉填堆栈的开销(
OmitStackTraceInFastThrow,JDK 8 起默认开)。⑤ 实测第 6761 次起变成 0 层。这是实现上的取舍,不是能从原理推出来的。 - 做法
- 往前翻日志,找这个异常第一次出现的那条,它有完整堆栈;日志告警按“异常类型 + 第一次的堆栈”聚合。排查期间可以加
-XX:-OmitStackTraceInFastThrow,代价是每次都要填堆栈,异常多时会变慢。
- 现象
- 新实例一接流量,P99 飙高,CPU 也高(编译线程在忙),过一会儿自己好了。压测第一分钟的数据也总是很难看。
- 根源
- 热点代码要先在解释器和 C1 里跑够次数,C2 才接手;真实服务里有成千上万个方法排队等编译,不是 ⑤ 里一个方法 70 毫秒的事。编译线程和业务线程抢 CPU,容器 CPU 限得紧时更明显。
- 做法
- 接流量之前先预热:启动后用回放流量或脚本把核心接口各打几千次,再让就绪探针返回成功;发布时逐步放量;压测丢掉预热阶段的数据。启动时间特别敏感的,看 ⑥ 版本差异里 JDK 24 起的 AOT 缓存。
- 现象
- 代码生成器、规则引擎、几千行的
switch生成的方法,热得发烫却一直很慢;PrintCompilation里找不到它。 - 根源
- 字节码超过 8000 字节的方法默认不编译(
DontCompileHugeMethods=true,HugeMethodLimit=8000,JDK 8 / 17 / 21 一致)。没那么大、但超过 325 字节的,也内联不进调用方,后面的逃逸分析跟着失效。 - 做法
- 把大方法拆开,让热路径落在小方法里;生成代码的工具按方法大小切分。
HugeMethodLimit是开发版才能改的参数,产品版只能用-XX:-DontCompileHugeMethods整体放开,不推荐。
- 现象
- 上了第三个支付渠道,算手续费的那段代码吞吐掉了一截,GC 也变多了;代码本身没改。
- 根源
- 调用点原来只见过一两种实现,C2 内联了它们(④-2);第三种一来,类型检查失败,去优化,重新编译时这个调用点成了多态,不再内联,传进去的
Money也变成逃逸。⑤ 里同一个fee:见过 1 种是 0 B,见过 3 种是 24 B;本机另测见过 2 种也是 0 B。 - 做法
- 真正热的路径上,让一个调用点只面对一两种类型,比如按渠道分开调用;用 JFR 的 Deoptimization 事件或
PrintCompilation里反复出现的made not entrant确认。不热的地方随便多态,不值得为它改设计。
- 现象
- 运行很久以后整体变慢,日志里有上面这句(JDK 8);JDK 9 起代码缓存分了段,写成
CodeHeap 'xxx' is full. Compiler has been disabled.。 - 根源
- 代码缓存默认 240 MB(开分层编译时 48 MB × 5)。动态生成大量类的应用(脚本引擎、大量代理、Groovy 规则)编译出的机器码多,塞满以后编译器停下,新热起来的代码只能解释执行。
- 做法
- 用
jcmd <pid> Compiler.codecache看用量,把它纳入监控;确实不够就调大-XX:ReservedCodeCacheSize,同时查清是谁在不停生成新代码。
常见的性能误区
| 误区 | 实际情况 | 依据 |
|---|---|---|
| 1. 小对象 new 很贵,做个对象池 | new 一个短命对象是在 TLAB 里挪指针,C2 还可能把它整个标量替换掉;死掉的对象 Young GC 不用复制。池化反而让对象变长寿、进老年代,还要加锁。只有贵的东西才池化:连接、线程、大块直接内存。 | ⑤ 实测 A:0 B;new 一个对象的全过程 |
| 2. 逃逸分析就是栈上分配 | HotSpot 不做栈上分配。没逃逸的对象被拆成字段(标量替换),根本不存在;参数逃逸的对象还要分配,只是锁可以去掉。 | Oracle JDK 8 文档 “Escape Analysis”:“It does not replace a heap allocation with a stack allocation” |
| 3. 方法写成 final、手工合并成大方法会更快 | 内联看的是 profile 和大小,不看 final:单态的虚方法照样内联。手工合并出来的大方法超过 325 字节就内联不进来,超过 8000 字节干脆不编译。 | ④-2;坑 3 |
| 4. for 循环跑一百万次、前后各取一次时间,就是性能 | 没预热测的是解释器;结果没用上,整个循环会被删掉;循环里的常量会被提前算好;一个长 main 走的是 OSR 版本,和线上的正常编译版不一样。 | ⑤ 实测:5000 万次 0.00 ms;规范 1 |
| 5. 接口、多态天生比直接调用慢 | 调用点只见过一两种实现时,内联后和直接调用一样。真正慢的是“热点 + 多态”:不能内联,后面的优化一起丢掉。 | ⑤ 实测 D 和 E;坑 4 |
| 6. 调 JIT 参数是常规调优手段 | 阈值、内联大小的默认值是在大量程序上调出来的。改它们之前,先用 JMH 或 JFR 证明瓶颈就在这里;绝大多数时候该改的是代码(拆方法、减少热点多态)或预热流程。 | 规范 4 |
使用规范
JMH 负责预热(@Warmup)、每组新开 JVM(@Fork)、用 Blackhole 吃掉结果防止被删、按时间而不是次数采样。代码评审里出现“我测过,快 10 倍”,先问是不是 JMH。
误区 4:测到的是编译器把代码删掉了,或者测的是解释器。
不用刻意追求,但别把热路径塞进一个几百行的方法;一个热调用点要面对很多实现时,考虑按类型分开。
坑 3、坑 4:内联失败,逃逸分析跟着失效。
就绪探针在预热脚本跑完之后才返回成功;发布、扩容都逐步放量。压测报告注明预热了多久。
坑 2:冷实例一上来就挨满流量。
能留在启动参数里的,是排查用的(-XX:-OmitStackTraceInFastThrow、ReservedCodeCacheSize 调大),而且要写明原因和日期。
误区 6:凭感觉调参,升级 JDK 后反而变慢。
无堆栈的 NPE 不要当成“查不了”,第一次的那条就在日志里。
坑 1。
Micrometer 的 jvm.memory.used 里,非堆区有 CodeHeap '…' 几项(JDK 9 起分段),加一条接近上限的告警。
坑 5:编译器悄悄停了,只能从变慢发现。
更深一层
内联的预算,以及怎么看它为什么没内联
热的调用点内联字节码不超过 FreqInlineSize(x86 和 aarch64 是 325 字节,ppc、s390 是 175);不热的不超过 MaxInlineSize=35;嵌套最多 MaxInlineLevel 层,JDK 8 是 9 层,JDK 14 起改成 15 层(JDK-8234863),所以深调用链的框架代码升级 JDK 后可能自己变快。加 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining,每个调用点后面会写 inline (hot)、too big、hot method too big、not inlineable 这类原因。
逃逸分析的边界:合流、数组、看不见的方法
⑤ 里 F(两条分支各 new 一个再合流)在 JDK 17 和 21 上都会分配:C2 的标量替换不处理“几个对象在 phi 处合流”。JDK 22 加入 ReduceAllocationMerges(JDK-8287061),22 正式版默认关,23 起默认开。数组要长度是常量、不超过 EliminateAllocationArraySizeLimit=64,下标也要是常量。Graal 编译器做的是“部分逃逸分析”:对象只在某条分支上逃逸,就只在那条分支上分配。
锁消除和锁粗化
局部的 StringBuffer、Vector,或者对一个没逃出去的对象 synchronized,C2 会把加锁解锁删掉(EliminateLocks)。参数逃逸的对象,锁也能删。相邻几段对同一把锁的同步块,可能被合并成一段(锁粗化),少几次加解锁。所以“局部 StringBuffer 比 StringBuilder 慢很多”在 C2 编译后通常不成立。
代码缓存里有什么
JDK 9 起分成三段(JEP 197):JVM 自己的桩代码、C1 带 profile 的代码、C2 的最终代码,互不挤占。JDK 20 移除了专门清理冷代码的 sweeper(JDK-8290025),不再使用的机器码改由 GC 卸载类时顺带回收。made not entrant 的旧版本不会马上消失,等到没有线程还在执行它时才回收。
版本差异
| JDK | 变化 | 对你意味着什么 |
|---|---|---|
| 8 | 分层编译默认开(JDK 7 引入);逃逸分析从 6u23 起默认开;MaxInlineLevel=9;代码缓存不分段,满了打印 CodeCache is full | 本页的机制在 8 上都成立;阈值和 17、21 相同 |
| 9 | 分段代码缓存(JEP 197) | 坑 5 的报错信息变成 CodeHeap '…' is full |
| 14 / 15 | MaxInlineLevel 9 → 15(JDK 14);Helpful NPE(JEP 358)14 引入,15 起默认开 | NPE 的 message 会写出是哪个变量为 null,但坑 1 的“热点 NPE”仍然是 message=null(⑤ 实测) |
| 17 | 移除实验性的 AOT(jaotc)和 Graal JIT(JEP 410) | 想用 Graal 编译器,改用 GraalVM 发行版 |
| 20 / 21 | 移除 sweeper(JDK-8290025);21 的分层阈值、内联参数和 17 一致,没有 ReduceAllocationMerges | 17 → 21 本页的实测结论不变(全景里的 0.7 MB 就是在 21 上测的) |
| 22 / 23 | ReduceAllocationMerges(JDK-8287061):22 引入、默认关,23 起默认开 | ⑤ 里 F 那种写法,新版本上可能不再分配 |
| 24 / 25 | AOT 缓存:类的加载和链接提前做(JEP 483,24);方法的 profile 提前存下(JEP 515,25) | 训练一次,正式启动时 JIT 一开始就有 profile,预热变短;坑 2 有了 JVM 层面的解法 |
下一圈
- JMH 实战:
@State、Blackhole、@Fork,以及用-prof gc、-prof perfasm看分配和热点汇编 - JITWatch 读
-XX:+LogCompilation:看清每个方法的内联树和去优化原因 - C2 的循环优化:范围检查消除、循环展开、自动向量化,以及 Vector API
- JDK 24 / 25 的 AOT 缓存实测:训练运行怎么做,启动和预热时间各省多少
- GraalVM:Graal JIT 的部分逃逸分析,和 Native Image 的封闭世界假设
- JVM 故障排查(全景第 9 站,下一个知识点):CPU 飙高时,怎么分清是业务代码、GC 还是编译线程
↺回到⑤:改造你的案例
1. 不让 times 内联
按 ④-2,给 Warmup 加 -XX:CompileCommand=dontinline,labs.jvm.jvm_jit_escape.Money::times。先猜稳态每次分配几字节(原来 0 B),再跑,说出是哪个 Money 进了堆、为什么。
2. 给 E 减掉一个渠道
按坑 4,把 Escape.THREE 改成只有 MockPay 和 AliPay,下标改成 i % 2。先猜 E 那一行是 0 B 还是 24 B,再跑;然后再加回第三种,用 PrintCompilation 找 passMega 的 made not entrant。
3. 救活被删掉的循环
按规范 1,把 Myths.discard 里的结果异或进一个 static volatile long 字段,先猜“结果丢掉”那一列会变成多少,再跑。然后用 JMH 重写一遍(加 jmh-core 依赖),比较两种写法测出来的 ns/次。
自测
1. 用一句话说出这一页的精华。
HotSpot 先解释执行并记下真实的运行情况,热了再让 C2 按这些记录大胆优化:内联让它看清对象的去向,没逃出去的对象被标量替换、不进堆;赌错了就去优化退回解释器,所以结果永远对,代价是要预热。
2. 只解释、或者只用 C1,疼在哪?
只解释慢两个数量级(⑤ 实测 358 ns 对 3.7 ns);只用 C1 快了,但没有逃逸分析,每次算价照样往 Eden 里塞 72 字节,Young GC 次数跟着上去。
3. 线上 NPE 没有堆栈,根源是哪条原理?
它发生在 C2 编译的代码里(④-1):同一个位置的隐式异常抛得多了,C2 改抛 JVM 预先建好的异常对象,省掉填堆栈。这是 HotSpot 的实现取舍(OmitStackTraceInFastThrow),所以第一次出现时的那条日志是有堆栈的。
4. 预测:练习 1 里不让 Money::times 内联,priceOf 稳态每次分配多少字节?
48 B(本机实测)。unit 要当接收者传给一个没内联的方法,参数逃逸,得分配;times 里 new 的那个被返回了,在 times 自己的编译单元里是全局逃逸,也得分配;off 仍被内联进 priceOf,它 new 的那个没逃出去,被标量替换。3 个里进堆 2 个,2 × 24 = 48(④-2、④-3)。
5. 预测:练习 2 里调用点只见过两种渠道,E 那一行是多少?在 JDK 17 上把 F 改写成“先算出 cents,再只 new 一次”呢?
两种渠道是 0 B(本机实测):双态调用点仍然内联,m 看得见、不逃逸(④-2)。F 改成只 new 一次也是 0 B(本机实测):合流的是一个 long,不是两个对象,标量替换不再卡在 phi 上(⑥ 第三圈)。
6. 放回全景:第 2 站的前后各发生了什么?JIT 或逃逸分析失效了,整条链路会怎样?
前面是第 1 站:Order、Money 被加载进元空间,字节码可以执行了;加载新的实现类也可能让第 2 站已经编译好的代码去优化。后面是第 3 站:逃出去的对象在 Eden 的 TLAB 里挪指针分配。第 2 站失效(纯解释、只用 C1、热点多态让逃逸分析失效、代码缓存满了编译器停了)有两种后果:一是 CPU 时间暴涨,下单接口 RT 变长;二是本来不存在的临时对象全进了第 3 站,Eden 满得快,第 5 站 Young GC 次数成倍增加(全景里多出 1144 MB)。单次 Young GC 不会变长,因为这些对象马上就死,所以表现为吞吐下降、CPU 上升,而不是停顿变长。