《Java 虚拟机原理与性能调优》4.1 解释执行与分层编译:C1、C2 与阈值

同一个 FinePolicy.fineCents,启动时跑在解释器里,几十毫秒后已经被 C2 编译。本节用 -XX:+PrintCompilation 的真实输出拆解列含义与 tier 3/4 迁移,用 jcmd Compiler.queue、Compiler.codecache 看编译队列和代码缓存,并对照 -XX:TieredStopAtLevel=1 的峰值与启动耗时。

本节目标:看懂 -XX:+PrintCompilation 的每一列,知道一个方法在什么条件下从解释器升到 C1、再到 C2。
适用版本:JDK 25(Temurin 25.0.5);默认分层编译;jcmd 25。

本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,默认 G1,压缩 oops,默认 MaxHeapSize 8 GB。

承接的问题

libraryd 刚启动的 30 秒里,/loans 的 p99 是稳定期的三倍;30 秒后自己降下来,没有任何配置变更。监控上 CPU 不高、GC 正常,唯一的解释是执行引擎还没把热代码编译好。

JVM 不会一上来就把所有方法编译成机器码。它先解释执行,统计哪些方法热,再把热方法交给 C1 快速编译,最后把更热的方法交给 C2 做深度优化。这个流水线叫分层编译(tiered compilation)。理解它的价值不是「知道有 C1/C2」,而是能回答三个工程问题:

  • 一个方法现在跑在哪个层级?
  • 它为什么还没升级?
  • 关闭高层编译能不能让启动更快?

这一节用 FinePolicy.fineCents 和 IsbnLab.validIsbn13 两个方法做观测对象,全部输出都能在本机复现。

从解释器到 C2:五层

HotSpot 用 level 表示执行状态,共 5 层:

level执行方式是否采集 profile典型用途
0解释器否冷启动、一次性代码
1C1 无 profile否简单方法,追求编译速度
2C1 轻量 profile是(少量)过渡层
3C1 完整 profile是为 C2 收集类型与分支数据
4C2否(使用已有 profile)峰值性能

默认参数下,方法调用次数达到 CompileThreshold=10000 后进入编译流程;循环回边(backedge)另有 Tier3BackEdgeThreshold=60000。这两个数可以在启动时确认:

java -XX:+PrintFlagsFinal -version 2>/dev/null \
  | grep -E " (CompileThreshold|Tier3BackEdgeThreshold|TieredStopAtLevel|TieredCompilation) "
     intx CompileThreshold                    = 10000     {pd product} {default}
     intx Tier3BackEdgeThreshold              = 60000     {pd product} {default}
     intx TieredStopAtLevel                   = 4         {pd product} {default}
     bool TieredCompilation                   = true      {pd product} {default}

阈值是「计数」不是「时间」。一个每秒调用一百万次的方法几毫秒就到阈值,一个每分钟调用一次的方法可能永远停在解释器。

读 PrintCompilation

-XX:+PrintCompilation 每编译一个方法打印一行。用下面的程序制造热点:

package lab;

public final class JitLab {

    public record FinePolicy(int centsPerDay, int graceDays) {
        public long fineCents(long overdueDays) {
            return Math.max(0, overdueDays - graceDays) * centsPerDay;
        }
    }

    static volatile long blackhole;

    public static void main(String[] args) {
        long warmup = args.length > 0 ? Long.parseLong(args[0]) : 5_000_000L;
        long measure = args.length > 1 ? Long.parseLong(args[1]) : 5_000_000L;

        FinePolicy policy = new FinePolicy(50, 0);
        long sum = 0;
        for (long i = 0; i < warmup; i++) {
            sum += policy.fineCents(i % 30);
        }
        for (long i = 0; i < measure; i++) {
            sum += policy.fineCents(i % 30);
        }
        blackhole = sum;
        System.out.println("sum=" + sum);
    }
}

编译并运行:

javac --release 25 -d out src/lab/JitLab.java
java -XX:+PrintCompilation -Xmx512m -cp out lab.JitLab 5000000 5000000

本机输出中与 FinePolicy 和主循环相关的行:

49   12       3       lab.JitLab$FinePolicy::fineCents (18 bytes)
49   13       4       lab.JitLab$FinePolicy::fineCents (18 bytes)
49   12       3       lab.JitLab$FinePolicy::fineCents (18 bytes)   made not entrant: not used
51   14 %     3       lab.JitLab::main @ 78 (229 bytes)
51   15       3       lab.JitLab::main (229 bytes)
52   16 %     4       lab.JitLab::main @ 78 (229 bytes)
53   14 %     3       lab.JitLab::main @ 78 (229 bytes)   made not entrant: OSR invalidation of lower level
57   16 %     4       lab.JitLab::main @ 78 (229 bytes)   made not entrant: uncommon trap

每一列的含义:

位置示例含义
第 1 列49VM 启动后的毫秒数
第 2 列12编译任务编号,同一方法重编译会换号
标志位%、n、s、bOSR、native、synchronized、blocking
第 4 列3、4编译层级;1 到 3 是 C1,4 是 C2
方法lab.JitLab$FinePolicy::fineCents (18 bytes)方法签名和字节码大小
后缀made not entrant: not used旧版本失效的原因

fineCents 在 49 毫秒内先被 C1 编译成 tier 3,紧接着被 C2 编译成 tier 4。tier 3 的版本随即 made not entrant: not used——它不是出错,而是被更好的版本替代了。made not entrant 的字面意思是「不再作为入口」,已经进入这个方法的栈帧会继续跑完旧代码,新调用走新版本。

% 表示这是一次 OSR(On-Stack Replacement)编译:主循环已经在解释器里跑了很久,JVM 不等方法退出,直接在循环回边上换掉执行版本。main @ 78 里的 78 是循环回边的字节码偏移。4.3 会专门讲 OSR 和反优化。

编译阈值、队列与代码缓存

PrintCompilation 是事后日志,运行中的状态要用 jcmd:

jcmd <pid> Compiler.queue
Current compiles:

C1 compile queue:
Empty

C2 compile queue:
Empty

队列为空说明编译线程跟得上,没有积压。如果压测时看到队列持续非空,说明编译速度成了瓶颈,通常伴随 CPU 被编译线程占满。这时可以增加 -XX:CICompilerCount,或者减少一次性加载的类数量。

代码缓存用同一个命令组里的 Compiler.codecache 看:

jcmd <pid> Compiler.codecache
CodeHeap 'non-profiled nmethods': size=120032Kb used=32Kb max_used=32Kb free=119999Kb
CodeHeap 'profiled nmethods': size=120032Kb used=122Kb max_used=122Kb free=119909Kb
CodeHeap 'non-nmethods': size=5712Kb used=1190Kb max_used=1205Kb free=4521Kb
CodeCache: size=245776Kb, used=1344Kb, max_used=1359Kb, free=244429Kb
 total_blobs=535, nmethods=157, adapters=287, full_count=0

profiled nmethods 放 C1 生成的带 profile 代码,non-profiled 放 C2 生成的代码,non-nmethods 放适配器和运行时桩。full_count=0 表示代码缓存没满过;如果它大于 0,JVM 会清理旧方法甚至停止编译,表现为「跑得越久越慢」。

Compiler.codelist 能看到每个 nmethod 的地址、层级和大小:

jcmd <pid> Compiler.codelist
19 4 0 java.lang.Object.<init>()V [0x000000011c3709c8, 0x000000011c370ac0 - 0x000000011c370b48]
20 1 0 java.lang.Enum.ordinal()I [0x000000011c370b88, 0x000000011c370c80 - 0x000000011c370d60]
31 1 0 jdk.internal.util.StrongReferenceKey.get()Ljava/lang/Object; [0x000000011c371988, 0x000000011c371a80 - 0x000000011c371b60]

三列数字是编译号、层级、是否为 OSR;后面的区间是代码在 CodeHeap 里的地址范围。

C1-only 的启动与峰值

-XX:TieredStopAtLevel=1 只允许 C1 编译,跳过 C2。常见的说法是「这样启动更快」,但需要分场景验证。

先看峰值。IsbnLab.validIsbn13 是一个 13 位 ISBN 校验循环,20M 次调用的结果:

配置第 1 次 ns/op第 2 次 ns/op
默认分层编译31.01427.282
-XX:TieredStopAtLevel=142.39444.134

命令:

java -Xmx512m -cp out lab.IsbnLab 20000000 20000000
java -XX:TieredStopAtLevel=1 -Xmx512m -cp out lab.IsbnLab 20000000 20000000

C1-only 慢约 1.5 倍,因为 C1 不做 C2 的循环优化和内联深度。单次运行,未做统计,数字只用于量级对比。

再看启动。把同一个程序缩到 1000 次调用,测整个进程的墙钟时间,各跑 15 次取中位数:

配置中位数p25p75
默认分层编译98.1 ms90.8 ms113.1 ms
-XX:TieredStopAtLevel=199.1 ms88.0 ms111.4 ms

两者差异落在噪声里。原因是这个进程的启动时间主要花在 JVM 初始化、类加载和 CDS 映射上,C2 编译线程即使启动也还没编译几个方法。TieredStopAtLevel=1 不是通用的启动优化开关;它适合「方法很多、C2 编译很重、进程活得很短」的场景,例如命令行工具或短生命周期批处理。libraryd 是常驻服务,关掉 C2 只会损失峰值。

在 libraryd 里落地

FinePolicy.fineCents 是罚金计算的热点,每个借阅请求至少调用一次:

package com.plume.library.domain;

public record FinePolicy(int centsPerDay, int graceDays) {
    public static final FinePolicy DEFAULT = new FinePolicy(50, 0);

    public long fineCents(long overdueDays) {
        return Math.max(0, overdueDays - graceDays) * centsPerDay;
    }
}

它的字节码只有 18 字节,C2 会把它内联进调用者。设计上要配合这一点:

  • 保持热方法短小。fineCents 只做一次减法和乘法,内联成本低;如果把日志、校验、数据库访问塞进同一个方法,内联会被拒绝。
  • 不要在热路径上用反射或 MethodHandle。它们会阻止内联,还会让调用点退化成 megamorphic。
  • 把「计算」和「副作用」分开。FinePolicy 只计算,审计事件由 LoanEventSink 处理,这样计算部分可以被稳定内联。

第二个落点是 Loan.state。sealed 接口加模式匹配的 switch 在 C2 下会编译成类型判断链或 tableswitch,比多态调用更容易优化:

package com.plume.library.domain;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public sealed interface LoanState
        permits LoanState.Active, LoanState.Overdue, LoanState.Returned {
    record Active(LocalDate dueOn) implements LoanState {}
    record Overdue(LocalDate dueOn, long days) implements LoanState {}
    record Returned(LocalDate returnedOn, long fineCents) implements LoanState {}
}

Loan 单独一个文件,state 方法用穷尽 switch 消费这个封闭层次:

package com.plume.library.domain;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;

public record Loan(long id, String isbn, String memberId,
                   LocalDate borrowedOn, LocalDate dueOn,
                   LocalDate returnedOn, int renewCount) {

    public static final int LOAN_DAYS = 14;
    public static final int MAX_RENEWALS = 1;

    public boolean active() {
        return returnedOn == null;
    }

    public long overdueDays(LocalDate today) {
        LocalDate end = returnedOn != null ? returnedOn : today;
        return Math.max(0, ChronoUnit.DAYS.between(dueOn, end));
    }

    public LoanState state(LocalDate today) {
        if (returnedOn != null) {
            return new LoanState.Returned(returnedOn, 0);
        }
        long overdue = overdueDays(today);
        return overdue > 0
                ? new LoanState.Overdue(dueOn, overdue)
                : new LoanState.Active(dueOn);
    }
}

这段代码的分支在 today 固定时会高度可预测,C2 可以做分支裁剪。若把 state 写成接口的多实现,每次调用都要做接收者类型检查,4.2 会看到代价。

观测手段是启动脚本里加 -XX:+PrintCompilation(仅预发)或用 jcmd Compiler.queue 周期性采样。生产不要长期打开 PrintCompilation,它会输出大量日志并轻微影响编译线程。

常见错误与边界

「把 PrintCompilation 当成性能分析器。」 它只告诉你「编译了什么、什么时候编译的」,不告诉你「哪个方法最耗时」。一个方法被编译很多次不代表它慢,可能只是内联后反复失效。找热点用 JFR 的 jdk.ExecutionSample 或 jdk.Compilation 事件,PrintCompilation 用来验证「有没有编译、升到几层」。

「以为 TieredStopAtLevel=1 一定让启动更快。」 上面的 15 次中位数是 98.1 ms 对 99.1 ms,差异在噪声内。启动时间由 JVM 初始化、类加载、CDS 和首个请求共同决定;只关 C2 通常收益很小,却确定损失峰值吞吐。要先测再改,不要把它当成默认优化。

「把编译阈值当成时间阈值。」 CompileThreshold=10000 是调用计数加回边计数,不是毫秒。一个方法在 1 秒内被调用 100 次、每次循环 1000 次,回边计数很快到 60000,会先触发 OSR;另一个方法每分钟调用一次,可能跑一整天都不编译。调阈值之前先确认瓶颈是「编译太晚」还是「编译太频繁」。

小结

  • 分层编译有 5 层:解释器、C1 的三层(含 profile)和 C2;默认 TieredStopAtLevel=4。
  • CompileThreshold=10000、Tier3BackEdgeThreshold=60000 都是计数阈值,不是时间。
  • PrintCompilation 的列依次是启动毫秒、编译号、标志位、层级、方法和失效原因;% 表示 OSR。
  • made not entrant: not used 是正常替换,不是错误;uncommon trap 才是推测失败后的回退。
  • jcmd Compiler.queue 看积压,Compiler.codecache 看容量,Compiler.codelist 看已编译方法。
  • C1-only 在 20M 次 ISBN 校验上慢约 1.5 倍;在 1000 次短进程上启动时间差异落在噪声内。

方法编译之后,C2 还会做内联、逃逸分析和去虚拟化。下一节看这些优化在真实日志里长什么样,以及它们在什么情况下失效。

阅读导航:上一节:3.3 分配与 TLAB:指针碰撞、线程本地缓冲与大对象 · 下一节:4.2 即时编译优化:内联、逃逸分析与去虚拟化 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发