解释器 · 分层编译 · C1 · C2 · 方法计数器 · 回边计数器 · OSR · profile · 内联 · 去虚化 · 逃逸分析 · 标量替换 · 锁消除 · 去优化 · 代码缓存 · JMH · OmitStackTraceInFastThrow

JIT 与逃逸分析:先解释、边跑边记,热了再按记下的情况大胆编译;逃逸分析靠内联才看得见,看见了,new 就不进堆

Java 方法一开始由解释器一条条执行字节码,同时记下调用次数、走了哪条分支、调用点上来的是哪个实现类。方法热了,C1 先快速编出一版,C2 再按这些记录做激进优化:把被调用的方法内联进来,于是能看清一个对象有没有逃出这段代码;没逃出的,就拆成几个局部变量(标量替换),new 不再发生。记录要是不再成立(新加载了一个实现类),就退回解释器重来(去优化)。交易靶场里实测:算价方法纯解释 358 ns/次,C2 编译后 3.7 ns/次、堆上分配 0 字节;同一个 Money 一旦传给“见过三种支付渠道”的接口调用,就又进了堆。

全景:一个对象从 new 到被回收 · 第 2 站地图:J03 · JIT、逃逸分析与常见的性能误区

①为什么需要:算价方法一秒要跑几百万次,字节码不能一直“念”着执行

在全景第 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 不内联,它就进堆 实测

场景交易靶场的算价:一个订单行 = 单价 × 数量,满 10 件打 95 折。Money 是不可变的金额类,每次运算返回一个新的。代码写得很“面向对象”,一次算价 new 了 3 个 Money。问题是:这样写,线上会不会很慢、会不会给 GC 添很多活?
交易靶场Warmup.priceOf⑤ 会跑它
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 都没离开这个方法
}
第 1 站类已加载 · 字节码 解释器一条条执行字节码边跑边计数、记 profile 200 次 C1几毫秒编好,优化少继续计数、记 profile 5000 次 C2(本页主角)按 profile 内联 times、off逃逸分析:3 个 Money 没逃拆成局部 long(标量替换) 代码缓存堆外,放机器码之后直接跑它 逃出去的对象才走这条 第 3 站:Eden 里的 TLAB指针往后挪 24 字节 阈值是 JDK 17 默认值的简化说法: 调用 200 次左右交给 C1,5000 次左右交给 C2; 循环转得多的方法,看的是回边计数,见 ④-1。 没逃出去的 Money 不会到第 3 站,也就不会给第 5 站的 Young GC 添活。

②没有它会怎样:只解释,或者只做保守的编译

把 JIT 拿掉一半看看。下面四根条是同一个 priceOf 在本机的稳态耗时(第 3~30 批的中位数,每批 20 万次)和每次在堆上分配的字节。

每次耗时(对数刻度) 1 ns10 ns100 ns1000 ns 纯解释 -Xint 358 ns · 72 B 只用 C1 17 ns · 72 B C2,关逃逸分析 23 ns · 72 B 默认分层编译 3.7 ns · 0 B 本机 2 核 ARM,JDK 17.0.20.1,G1 默认堆。72 B = 3 个 Money × 24 B。只用 C1 和关掉 EA 的两组,单批最慢分别到 41 ns 和 47 ns(Young GC 插进来),这两组谁快不要当结论。

只解释

  • 每条字节码都要“取指令、查表、跳到对应的处理代码”,一次加法变成几十条机器指令
  • 慢两个数量级: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 分层编译:一个方法从冷到热,换三次“身体”

0 级解释器计数 + 记 profile 调用 200 次或回边 6 万次 3 级 C1带完整 profile 的机器码比解释快,比 1 级慢一点 调用 5000 次或回边 4 万次 4 级 C2按 profile 激进优化内联 · 去虚化 · 逃逸分析循环优化 · 锁消除 去优化:假设不成立了,回到解释器,重新攒 profile(④-4) 2 级 C1C2 太忙时先待在这 1 级 C1不记 profilegetter 这种小方法 计数器有两种:方法被调用一次加 1;循环往回跳一次(回边)加 1。 一个循环转 5000 万圈的方法可能只被调用一次,靠回边计数触发 OSR(栈上替换):在循环中途换成编译好的版本。 阈值是 JDK 17 的 Tier3InvocationThreshold=200、Tier4InvocationThreshold=5000、Tier3BackEdgeThreshold=60000、Tier4BackEdgeThreshold=40000(本机 PrintFlagsFinal),实际判定还会按编译队列长度调整。

为什么要三层,不直接上 C2:C2 编一个方法要几毫秒到几十毫秒、还要一份足够长的 profile,C1 编得快一个量级。先用 C1 让代码“快起来”,同时让 3 级代码继续记 profile,攒够了再交给 C2。编译在后台的编译线程里做(本机 2 核,CICompilerCount=2:C1、C2 各一个),业务线程不用等,期间照常跑旧版本。⑤ 的 -XX:+PrintCompilation 里能看到 priceOf 先出现 3 级,再出现 4 级,接着 3 级那版被标成 made not entrant(不再给新调用用)。

④-2 内联和去虚化:C2 先把调用“抹平”,后面的优化才看得见

你写的priceOf
Money unit = new Money(BASE[sku & 1023]);
return unit.times(qty)
           .off(qty >= 10 ? 5 : 0)
           .cents;
C2 内联之后,再标量替换等价于
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 次分配
调用点 channel.fee(m) 的 profile 里记着“来过哪些类” 只见过 MockPay(单态) if (类型 == MockPay) 内联 MockPay.fee否则 → 去优化 m 看得见:不逃逸,0 B 见过两种(双态) 两个类型检查,各自内联都不是 → 去优化 仍然看得见 见过三种以上(多态) 查虚方法表 / 接口表,真的发起调用C2 不知道 fee 里会拿 m 干什么 m 被当成逃逸:24 B(④-3 E) 内联还受大小限制:热的方法字节码不超过 FreqInlineSize=325 字节,不热的不超过 MaxInlineSize=35 字节,嵌套不超过 MaxInlineLevel=15 层(JDK 17 本机默认值);已经编译过的大方法另有 InlineSmallCode 限制。

内联本身省的是一次调用的开销,几个纳秒;它真正值钱的地方是让 C2 能把调用方和被调用方放在一起看,常量传播、去掉多余的检查、逃逸分析都要在这之后才做得成。所以 JIT 时代的“小方法、多层调用”并不慢;被调用的方法大到内联不进来,或者调用点见过太多实现,后面的优化才会一起丢掉。

④-3 逃逸分析:对象有三种结局,只有一种能不进堆

不逃逸 NoEscape 只在这段(内联后的)代码里读写字段 → 标量替换:字段变成局部变量 → 对它加的 synchronized 也去掉 0 B 参数逃逸 ArgEscape 传给一个没被内联的方法,但没有被存起来 → 还要分配 → 对它加的锁可以去掉 24 B 全局逃逸 GlobalEscape 被返回、存进字段 / 静态变量 / 数组、交给别的线程 → 正常在堆上分配 → 走第 3 站的 TLAB 24 B HotSpot 的 C2 不做“栈上分配”:没逃逸的对象不是挪到栈上,而是根本不存在了,只剩它的字段散落在寄存器和栈槽里。 分析是按“这一次编译的范围”做的:没内联进来的方法,C2 只能按最坏情况算。

逃逸判定台选一种写法,先猜进不进堆,再看本机实测

写法—

这样写,C2 编译后,每次调用在堆上分配多少?

先选一种写法,再猜。

数字来自 ⑤ 的 Escape:每种写法先预热 300 万次,再量 1000 万次,取每次的平均分配字节。本机 2 核 ARM,JDK 17.0.20.1。

④-4 推测和去优化:C2 敢赌,是因为输了能退回去

C2 下注 “这里只来过 MockPay” → 只内联它 “这个分支从没走过” → 不编,放陷阱 “这个接口只有一个实现” → 直接调 每个赌注都留了一个检查点 第 1 站又加载了一个类 新上了支付渠道 AliPay 或者冷门分支第一次走到 检查点不通过 去优化(deoptimization) 这一帧按字节码位置还原成解释器的帧 编译版标成 made not entrant 重新攒 profile,过一会儿按新情况再编 结果不会错,只是这段时间变慢 “结果永远对”是 JIT 的硬约束:所有激进优化都必须有一个能精确退回字节码语义的出口。 所以 C2 编出来的代码里到处是“如果不是这样,就退回去”的检查,正常路径上它们几乎不花时间。 偶尔一次去优化没关系;反复“编译 → 去优化 → 再编译”才会伤吞吐,见 ⑥ 坑 4。

为什么这样设计:编译放在运行时,而不是构建时

被放弃的:构建时一次编完(AOT)

  • 编译器只能看源码:Channel 接口将来会有几个实现、哪个分支热,构建时不知道
  • 不知道就只能保守:接口调用不敢内联,传进去的 Money 一律算逃逸
  • Java 允许运行时加载新类(第 1 站、SPI、动态代理),构建时根本看不到全部代码
  • 好处是启动就快,没有预热,内存里也不用放编译器

HotSpot 选的:先跑,看清了再编(JIT)

  • 解释器和 C1 的 3 级代码顺手记下真实的类型和分支,C2 按它下注
  • 赌错了有去优化兜底,所以敢做单态内联、只编热分支、标量替换
  • 只编热的那一小部分代码,编译预算花在刀刃上
  • 代价:要预热;编译线程占 CPU;代码缓存要内存

决定胜负的约束:Java 是动态的(虚方法默认可重写、类可以运行时加载),而后端服务一跑就是几天。动态让静态编译只能保守,长期运行又让“前几十秒慢一点”摊得很薄。冷启动成了瓶颈的场景(Serverless、弹性扩容),这个账就要重算,这正是 Native Image 和 JDK 24 起 AOT 缓存出现的原因。

基本用法:看 JIT 在干什么的几条参数

诊断参数JDK 8 / 17 / 21 都能用排查和实验用,不是调优参数
-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 运行。完整代码 →

怎么跑study-labs/java
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。

到这里已经能交差了:能说清代码是怎么从解释变成 C2 的,能用 PrintCompilation 看到它,能判断一个 new 会不会进堆,也知道手写微基准为什么不可信。已知还没处理的细节:线上偶尔出现的 NPE 没有堆栈怎么查(坑 1);发布后前几十秒为什么 RT 高(坑 2);方法太大不编译(坑 3);新加一种实现以后变慢(坑 4);代码缓存满了(坑 5);该怎么写基准(规范 1)。

⑥细节:坑、常见的性能误区、规范、更深一层

第一圈

注意事项

坑 1线上日志里一堆 NullPointerException,没有堆栈、没有 message← ④-1 C2 编译版⑤ 实测
现象
日志里只有一行 java.lang.NullPointerException,下面一行堆栈都没有;重启后头几次又有堆栈。
根源
C2 编译的代码里,同一个位置反复出 NPE、除零、数组越界、类型转换失败时,改抛 JVM 预先建好的那个异常对象,省掉填堆栈的开销(OmitStackTraceInFastThrow,JDK 8 起默认开)。⑤ 实测第 6761 次起变成 0 层。这是实现上的取舍,不是能从原理推出来的。
做法
往前翻日志,找这个异常第一次出现的那条,它有完整堆栈;日志告警按“异常类型 + 第一次的堆栈”聚合。排查期间可以加 -XX:-OmitStackTraceInFastThrow,代价是每次都要填堆栈,异常多时会变慢。
坑 2刚发布、刚扩容的实例,前几十秒 RT 高,甚至超时← ④-1 分层编译要攒次数⑤ 实测
现象
新实例一接流量,P99 飙高,CPU 也高(编译线程在忙),过一会儿自己好了。压测第一分钟的数据也总是很难看。
根源
热点代码要先在解释器和 C1 里跑够次数,C2 才接手;真实服务里有成千上万个方法排队等编译,不是 ⑤ 里一个方法 70 毫秒的事。编译线程和业务线程抢 CPU,容器 CPU 限得紧时更明显。
做法
接流量之前先预热:启动后用回放流量或脚本把核心接口各打几千次,再让就绪探针返回成功;发布时逐步放量;压测丢掉预热阶段的数据。启动时间特别敏感的,看 ⑥ 版本差异里 JDK 24 起的 AOT 缓存。
坑 3一个超大的方法,永远在解释执行← ④-2 编译和内联都有大小上限源码核对
现象
代码生成器、规则引擎、几千行的 switch 生成的方法,热得发烫却一直很慢;PrintCompilation 里找不到它。
根源
字节码超过 8000 字节的方法默认不编译(DontCompileHugeMethods=true,HugeMethodLimit=8000,JDK 8 / 17 / 21 一致)。没那么大、但超过 325 字节的,也内联不进调用方,后面的逃逸分析跟着失效。
做法
把大方法拆开,让热路径落在小方法里;生成代码的工具按方法大小切分。HugeMethodLimit 是开发版才能改的参数,产品版只能用 -XX:-DontCompileHugeMethods 整体放开,不推荐。
坑 4新加了一个实现类,热点接口反而变慢了← ④-4 推测和去优化⑤ 实测
现象
上了第三个支付渠道,算手续费的那段代码吞吐掉了一截,GC 也变多了;代码本身没改。
根源
调用点原来只见过一两种实现,C2 内联了它们(④-2);第三种一来,类型检查失败,去优化,重新编译时这个调用点成了多态,不再内联,传进去的 Money 也变成逃逸。⑤ 里同一个 fee:见过 1 种是 0 B,见过 3 种是 24 B;本机另测见过 2 种也是 0 B。
做法
真正热的路径上,让一个调用点只面对一两种类型,比如按渠道分开调用;用 JFR 的 Deoptimization 事件或 PrintCompilation 里反复出现的 made not entrant 确认。不热的地方随便多态,不值得为它改设计。
坑 5CodeCache is full. Compiler has been disabled.← ④-1 机器码放在代码缓存源码核对
现象
运行很久以后整体变慢,日志里有上面这句(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
第二圈

使用规范

1. 性能结论用 JMH 测,不用手写循环

JMH 负责预热(@Warmup)、每组新开 JVM(@Fork)、用 Blackhole 吃掉结果防止被删、按时间而不是次数采样。代码评审里出现“我测过,快 10 倍”,先问是不是 JMH。

误区 4:测到的是编译器把代码删掉了,或者测的是解释器。

2. 热路径上的方法保持小,调用点少一点多态

不用刻意追求,但别把热路径塞进一个几百行的方法;一个热调用点要面对很多实现时,考虑按类型分开。

坑 3、坑 4:内联失败,逃逸分析跟着失效。

3. 先预热,再接流量

就绪探针在预热脚本跑完之后才返回成功;发布、扩容都逐步放量。压测报告注明预热了多久。

坑 2:冷实例一上来就挨满流量。

4. JIT 参数默认不动,动了要有证据

能留在启动参数里的,是排查用的(-XX:-OmitStackTraceInFastThrow、ReservedCodeCacheSize 调大),而且要写明原因和日期。

误区 6:凭感觉调参,升级 JDK 后反而变慢。

5. 异常告警按类型聚合,保留第一次的堆栈

无堆栈的 NPE 不要当成“查不了”,第一次的那条就在日志里。

坑 1。

6. 代码缓存进监控

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 / 15MaxInlineLevel 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 一致,没有 ReduceAllocationMerges17 → 21 本页的实测结论不变(全景里的 0.7 MB 就是在 21 上测的)
22 / 23ReduceAllocationMerges(JDK-8287061):22 引入、默认关,23 起默认开⑤ 里 F 那种写法,新版本上可能不再分配
24 / 25AOT 缓存:类的加载和链接提前做(JEP 483,24);方法的 profile 提前存下(JEP 515,25)训练一次,正式启动时 JIT 一开始就有 profile,预热变短;坑 2 有了 JVM 层面的解法

下一圈

↺回到⑤:改造你的案例

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 上升,而不是停顿变长。