《Java 虚拟机原理与性能调优》4.2 即时编译优化:内联、逃逸分析与去虚拟化

同样的罚金计算,走单态调用是 0.74 ns/op,走四实现的多态调用是 3.44 ns/op。本节用 -XX:+PrintInlining 读内联与拒绝原因,用 -XX:-DoEscapeAnalysis 对照 GC 次数,再用 JMH 1.37 跑单态、双态与巨态分派,最后落到 libraryd 的 FinePolicy 策略设计。

本节目标:知道 C2 的三类核心优化在什么条件下生效,以及怎么用日志和基准测试证明它们生效或失效。
适用版本:JDK 25(Temurin 25.0.5);JMH 1.37;-XX:+PrintInlining 需要 -XX:+UnlockDiagnosticVMOptions。

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

承接的问题

libraryd 的罚金规则从一条变成四条:标准、豁免、封顶、插件自定义。实现方式从「直接调用 FinePolicy.fineCents」改成「注入一个 FineRule 接口,运行时选实现」。功能没问题,压测吞吐却掉了三分之一。

代码审查看不出问题:四种实现的逻辑都只有几行。差异不在算法,而在调用点。原来 C2 看到的是单一接收者,可以把方法内联、把对象标量化、把接口调用变成直接调用;改成四实现后,调用点变成 megamorphic,内联和去虚拟化都失效,每次调用都要做类型检查,甚至要走 vtable。

这一节用三个实验把这条链路拆开:内联日志、逃逸分析开关、JMH 分派基准。所有数字和日志都来自本机。

内联:先看拒绝原因

内联是所有优化的前提。方法不内联,逃逸分析看不到对象的使用方式,去虚拟化也没有意义。打开内联日志需要两个参数,顺序不能反:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining \
     -Xmx512m -cp out lab.IsbnLab 300000 300000

本机输出(节选,同一个 validIsbn13 在不同编译任务里的两种结论):

@ 66   lab.IsbnLab::validIsbn13 (100 bytes)   failed to inline: inlining prohibited by policy
@ 66   lab.IsbnLab::validIsbn13 (100 bytes)   inline (hot)
  @ 8   java.lang.String::length (11 bytes)   inline (hot)
  @ 16  java.lang.String::charAt (25 bytes)   inline (hot)
    @ 1   java.lang.String::isLatin1 (19 bytes)   inline (hot)
    @ 12  java.lang.StringLatin1::charAt (15 bytes)   inline (hot)
      @ 5   jdk.internal.util.Preconditions::checkIndex (18 bytes)   (intrinsic)
    @ 21  java.lang.StringUTF16::charAt (11 bytes)   failed to inline: low call site frequency
@ 8   java.lang.Long::parseLong (7 bytes)   inline
  @ 3   java.lang.Long::parseLong (295 bytes)   failed to inline: callee is too large
@ 40  java.lang.System::nanoTime (0 bytes)   intrinsic

几个结论直接写在日志里:

  • inline (hot):调用点足够热,C2 允许内联。validIsbn13 100 字节,远小于热调用点的 FreqInlineSize=325。
  • failed to inline: callee is too large:Long.parseLong 的 295 字节版本被拒绝。295 小于 325,但那个调用点不热,只能套用冷调用阈值 MaxInlineSize=35,所以太大。
  • failed to inline: low call site frequency:StringUTF16.charAt 是 String.charAt 的另一个分支,当前输入全是 Latin-1,UTF-16 分支几乎不走,调用点频率不够。
  • (intrinsic):System.nanoTime、String.checkIndex 由 JVM 直接替换成机器指令或内建序列,不经过普通内联。

两个阈值可以在启动时确认:

java -XX:+PrintFlagsFinal -version 2>/dev/null \
  | grep -E " (MaxInlineSize|FreqInlineSize|InlineSmallCode) "
     intx FreqInlineSize      = 325    {C2 pd product} {default}
     intx InlineSmallCode     = 2500   {C2 pd product} {default}
     intx MaxInlineSize       = 35     {C2 product} {default}

MaxInlineSize 是冷调用点的字节码上限,FreqInlineSize 是热调用点的上限。InlineSmallCode 还有一条规则:已经编译过且机器码小于 2500 字节的方法,可以直接内联机器码,不再看字节码大小。

同一个方法在不同编译任务里结论不同,这不是矛盾。C1 和 C2 的内联策略不同,同一个 C2 编译在不同阶段看到的 profile 也不同。读日志时要连编译任务一起看,不能只看一行就下结论。

逃逸分析与标量替换

对象如果只在方法内部使用、不逃逸到堆或其他线程,C2 可以把它拆成若干标量放在寄存器里,连分配都省掉。这叫标量替换(scalar replacement),由逃逸分析(escape analysis)驱动。

写一个只读字段的分配回路:

package lab;

public final class EALab {

    public record LoanEvent(long loanId, String isbn, long atMillis) {}

    static volatile long blackhole;

    public static void main(String[] args) {
        long iterations = args.length > 0 ? Long.parseLong(args[0]) : 20_000_000L;
        long t0 = System.nanoTime();
        long sum = 0;
        for (long i = 0; i < iterations; i++) {
            LoanEvent e = new LoanEvent(i, "978-7-111-54743-5", i * 13);
            sum += e.loanId() % 7 + e.isbn().length() + (e.atMillis() & 1);
        }
        long t1 = System.nanoTime();
        blackhole = sum;
        System.out.printf("sum=%d ms=%.1f nsPerOp=%.3f%n",
                sum, (t1 - t0) / 1e6, (t1 - t0) / (double) iterations);
    }
}

LoanEvent 没有写出方法,也没有传给别的线程,C2 可以把它拆成三个标量。用 -XX:-DoEscapeAnalysis 关掉这条优化对照:

java -Xmx2g -cp out lab.EALab 20000000
java -XX:-DoEscapeAnalysis -Xmx2g -cp out lab.EALab 20000000

本机输出(单次运行,未做统计):

sum=409999997 ms=31.5 nsPerOp=1.574
sum=409999997 ms=90.0 nsPerOp=4.501

关闭逃逸分析后慢 2.9 倍。分配量更直观:把堆压到 256 MB,20M 次分配约 640 MB,开启逃逸分析时一次 Young GC 都没有,关闭后出现 4 次:

java -Xmx256m "-Xlog:gc:file=gc-ea.log" -cp out lab.EALab 20000000
java -XX:-DoEscapeAnalysis -Xmx256m "-Xlog:gc:file=gc-noea.log" -cp out lab.EALab 20000000
grep -c "Pause Young" gc-ea.log gc-noea.log
gc-ea.log:0
gc-noea.log:4

-XX:-DoEscapeAnalysis 只用于实验,不要放进生产参数。它关闭的是「优化」,不是「功能」;关闭后程序结果不变,只是慢。反过来,也不能假设所有临时对象都会被标量化:只要对象被放进集合、赋给字段、传给未知方法或跨线程,它就逃逸,照常分配。

去虚拟化与类型 profile

接口调用在字节码里是 invokeinterface,运行时要把接收者类型解析到具体实现。如果 C2 发现某个调用点只见过一种接收者,它可以推测「以后也只有这一种」,把接口调用换成直接调用并内联。这叫去虚拟化(devirtualization)。见过两种接收者时,可以生成两次类型检查;超过两种就退化成 megamorphic,只能查表或走 vtable。

用 JMH 1.37 比较三种情况。四个实现类做同样的小动作,只有接收者数量不同:

package lab;

import org.openjdk.jmh.annotations.*;

import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
@State(Scope.Thread)
public class DispatchBenchmark {

    interface IsbnValidator {
        boolean valid(String isbn);
    }

    static final class V1 implements IsbnValidator {
        public boolean valid(String isbn) { return isbn.length() == 17; }
    }

    static final class V2 implements IsbnValidator {
        public boolean valid(String isbn) { return isbn.length() == 18; }
    }

    static final class V3 implements IsbnValidator {
        public boolean valid(String isbn) { return isbn.length() == 19; }
    }

    static final class V4 implements IsbnValidator {
        public boolean valid(String isbn) { return isbn.length() == 20; }
    }

    private final IsbnValidator one = new V1();
    private final IsbnValidator two = new V2();
    private final IsbnValidator[] many = { new V1(), new V2(), new V3(), new V4() };
    private final String isbn = "978-7-111-54743-5";
    private int index;

    @Benchmark
    public boolean monomorphic() {
        return one.valid(isbn);
    }

    @Benchmark
    public boolean bimorphic() {
        return one.valid(isbn) ^ two.valid(isbn);
    }

    @Benchmark
    public boolean megamorphic() {
        return many[index++ & 3].valid(isbn);
    }
}

构建与运行:

mvn -q -DskipTests package
java -jar target/benchmarks.jar -wi 3 -i 5 -f 2 -w 1s -r 1s

本机结果:

Benchmark                      Mode  Cnt  Score   Error  Units
DispatchBenchmark.bimorphic    avgt   10  0.743 ± 0.026  ns/op
DispatchBenchmark.megamorphic  avgt   10  3.441 ± 0.116  ns/op
DispatchBenchmark.monomorphic  avgt   10  0.742 ± 0.118  ns/op

单态和双态都是 0.74 ns/op,几乎没有差别:两种接收者都能被内联,比较和分支被折叠掉了。巨态是 3.44 ns/op,慢 4.6 倍。这个差距不是「接口调用天生慢」,而是「调用点见过的类型太多,C2 放弃推测」。

代码里 index++ & 3 是在四个实现之间轮换。如果只轮换两个,调用点仍然是 bimorphic,成本会回到 0.74 附近。这也解释了 libraryd 的现象:从一种罚金实现变成四种,调用点的接收者数量跨过了 2 这个门槛。

在 libraryd 里落地

FinePolicy 是 record,直接调用时是单态的:

package com.plume.library.domain;

public final class FineExample {

    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;
        }
    }

    public static long fineCents(long overdueDays) {
        return FinePolicy.DEFAULT.fineCents(overdueDays);
    }
}

一旦引入「运行时选择实现」的接口,调用点就可能变成多态。设计上有两条路:

第一条是把内置规则做成枚举加 switch,让分派发生在同一个方法里:

package com.plume.library.domain;

public final class FineRules {

    public enum FineRule { STANDARD, WAIVED, CAPPED }

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

    public static long fineCents(FineRule rule, FinePolicy policy, long overdueDays) {
        return switch (rule) {
            case STANDARD -> policy.fineCents(overdueDays);
            case WAIVED -> 0L;
            case CAPPED -> Math.min(policy.fineCents(overdueDays), 5_000L);
        };
    }
}

枚举 switch 编译成 tableswitch,分支目标在编译期已知,不依赖接收者类型 profile。内置规则数量固定,这是最稳的写法。

第二条是插件策略。插件实现数量不可控,调用点注定是 megamorphic,这时不要假装它会被去虚拟化:

  • 把插件调用放在冷路径或批量路径上,不要放在每个请求的核心计算里。
  • 用 FineRule 先把请求分流,只有真的命中插件规则时才走接口。
  • 如果插件调用频率高,考虑把结果缓存成 long 或预算好的策略对象,而不是每次请求重新分派。

第二个落点是 LoanState。sealed 接口加模式匹配的 switch 在编译期知道所有子类型,C2 可以生成类型判断链,不需要 vtable:

package com.plume.library.domain;

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

描述器在应用层,和领域类型分开:

package com.plume.library.app;

import com.plume.library.domain.LoanState;

public final class LoanDescriber {

    public static String describe(LoanState state) {
        return switch (state) {
            case LoanState.Active a -> "active until " + a.dueOn();
            case LoanState.Overdue o -> "overdue " + o.days() + " days";
            case LoanState.Returned r -> "returned, fine " + r.fineCents();
        };
    }
}

sealed 的价值不只是「穷尽检查」,它还给了 JIT 一个封闭的类型集合。开放接口做不到这一点。

常见错误与边界

「-XX:+PrintEscapeAnalysis 没有输出。」 这个参数在 product JDK 上直接拒绝启动:

Error: VM option 'PrintEscapeAnalysis' is develop and is available only in debug version of VM.
Improperly specified VM option 'PrintEscapeAnalysis'
Error: Could not create the Java Virtual Machine.

它只在 develop 版本(debug build)里存在。product JDK 上观察逃逸分析只能间接来:对比 -XX:-DoEscapeAnalysis 前后的分配量、GC 次数或 jdk.ObjectAllocationSample。不要伪造 PrintEscapeAnalysis 的输出。

「-XX:+PrintInlining 单独用。」 它是 diagnostic 参数,必须先解锁:

Error: VM option 'PrintInlining' is diagnostic and must be enabled via -XX:+UnlockDiagnosticVMOptions.
Error: The unlock option must precede 'PrintInlining'.
Improperly specified VM option 'PrintInlining'

顺序也重要:-XX:+UnlockDiagnosticVMOptions 必须在 -XX:+PrintInlining 之前。写成一行时从左到右生效,写成两行时顺序同样保留。

「用一次手写循环证明内联生效。」 没有预热、没有消费返回值、没有独立进程的测量,测到的可能是解释器、可能是被删掉的计算。本节的内联结论来自日志(inline (hot) 是编译器的决定),吞吐结论来自 JMH(预热、fork、Blackhole 都受控)。两者不能互相替代:日志证明「编译成了什么」,基准证明「快了多少」。

小结

  • 内联是逃逸分析和去虚拟化的前提;MaxInlineSize=35 管冷调用点,FreqInlineSize=325 管热调用点。
  • PrintInlining 的拒绝原因直接可读:callee is too large、low call site frequency、inlining prohibited by policy。
  • 逃逸分析把不逃逸的对象拆成标量;关闭后同一回路慢 2.9 倍,20M 次分配多出 4 次 Young GC。
  • 单态和双态调用都能被内联,JMH 都是 0.74 ns/op;四实现的巨态调用是 3.44 ns/op,慢 4.6 倍。
  • 内置规则用枚举 switch,插件策略放在冷路径;sealed 接口给 JIT 一个封闭类型集合。
  • PrintEscapeAnalysis、PrintDeoptimizationDetails 是 develop-only 参数,product JDK 上会启动失败。

优化建立在「编译器对代码形状的假设」上。假设一旦被推翻,代码要退回解释器重来。下一节讲反优化、uncommon trap 和 OSR。

阅读导航:上一节:4.1 解释执行与分层编译:C1、C2 与阈值 · 下一节:4.3 反优化与 OSR:代码什么时候退回解释器 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「java」更多文章

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