Skip to content

JIT 编译与优化:Java 如何跑出接近原生的速度

本文是 JVM & GC 系统学习系列的 L2 核心篇。前置:JVM 总览与运行时数据区--先知道方法在栈帧里怎么执行,再看编译层的事。 学完可以配合面试题食用:JIT 编译与逃逸分析编译优化:内联、锁消除、栈上分配

Java 的速度是"翻译"出来的

Java 源码编译成字节码,只完成了跨平台的一半;字节码是给虚拟机看的指令集,CPU 不认识。执行它有两种办法:

  • 解释执行:JVM 拿着字节码逐条翻成机器指令再执行。启动快、内存省,但同一段代码每次执行都要重新翻译,慢
  • 编译执行:把字节码一次性编译成当前 CPU 的机器码,之后直接跑。快,但编译本身要花时间和内存

类比:解释执行像同声传译,说一句翻一句;JIT 编译像提前把讲稿全文翻好,念稿时不用等翻译。HotSpot 的选择是两样都要:代码先解释执行,谁被反复执行(热点代码),就把它编译成机器码。判断热点的依据是两个计数器:方法调用计数器(方法被调多少次)和回边计数器(循环体回跳多少次),阈值在几千次这个量级(-XX:CompileThreshold 控制)。

分层编译:C1 与 C2 的接力

HotSpot 里有两套 JIT 编译器,能力互补:

  • C1(Client):编译快,优化保守,适合刚启动阶段
  • C2(Server):编译慢,优化激进,长期运行后性能天花板更高

JDK 8 起默认开启分层编译(Tiered Compilation),执行路径是接力式的:

mermaid
flowchart LR
    A["Tier 0<br/>解释执行"] -->|"计数达低阈值"| B["C1 编译<br/>Tier 1-3 带 profiling"]
    B -->|"profile 攒够"| C["C2 编译<br/>Tier 4 激进优化"]
    C -.->|"假设破产<br/>去优化"| A

具体分层:Tier 0 解释器;Tier 1-3 都是 C1,差别在带不带性能采集(profiling);Tier 4 才是 C2。方法先被 C1 快速编译顶着用,同时采集"这个调用点实际指向哪个实现""分支哪边走得多"这类 profile;攒够了再交给 C2 做深度优化。C2 编译队列忙不过来时,方法可能长期停留在 C1 层。

这套机制解释了一个日常现象:Java 服务刚发布时接口耗时偏高,跑十几分钟才稳定--不是心理作用,是热点方法还在从解释执行往 C2 迁移。

方法内联:其他一切优化的前提

内联(inlining)就是把被调方法的代码复制进调用点,消灭一次方法调用。调用开销本身(压栈、查虚方法表)不算大头,真正值钱的是内联之后方法体暴露在调用者的上下文里,后续优化才有材料

java
// getter 内联后:
int width = point.getWidth();   // 调用点
// 内联展开等价于:
int width = point.x;            // 字段直接可见 -> 常量传播、公共子表达式消除都能接着做

内联要满足两个条件:热点 + 体积够小。相关参数:-XX:MaxInlineSize=35(字节码不超过 35 条的普通方法可直接内联)、-XX:FreqInlineSize=325(热点方法放宽到 325 条)。方法太大,内联出来的机器码膨胀,指令缓存命中率下降,得不偿失。

虚方法还有第三道坎。person.getName() 编译时不知道指向哪个实现,JVM 靠内联缓存解决:调用点只见过一个实现(monomorphic)就直接内联并留个守卫;见过两个(bimorphic)还能条件分支;三个及以上(megamorphic)放弃内联,老实查虚方法表。这也是 final 方法、私有方法、静态方法容易被内联的原因:不需要查表,目标唯一,守卫都不用留。加 final 不能保证内联,但它去掉了虚分派这层不确定性,给编译器省了事。

逃逸分析:不逃逸的对象有三种待遇

逃逸分析本身不产生优化,它是一个判定:对象会不会跑出当前方法(返回给调用者、存进静态字段、传给未知代码)。判定为"不逃逸"后,C2 会做三件事:

  1. 锁消除(lock elision):对象不出方法,它的锁不可能有第二个线程来抢,同步指令直接删掉。经典案例:方法内部的局部 StringBuffer.append(),每个 append 上的 synchronized 都是白交税
  2. 标量替换(scalar replacement):对象拆散,字段变成局部变量放进寄存器/栈帧。HotSpot 严格说没有"栈上分配",你听到的"栈上分配"绝大多数指的是标量替换--对象根本没被创建,何谈分配在哪
  3. 消除分配:对象创建后只用了部分字段,没用的字段连算都不算
java
// 三种情况一眼分清
Point escape() {
    Point p = new Point(1, 2);
    return p;            // 全局逃逸:对象跑了,三种优化全泡汤
}
int noEscape() {
    Point p = new Point(1, 2);
    return p.x + p.y;    // 不逃逸:标量替换后等价于 return 3,堆上零分配
}

开关是 -XX:+DoEscapeAnalysis(默认开),配 -XX:+PrintCompilation 或 JMH 的 gc profiler 可以验证效果。

去优化:C2 敢激进,因为留了退路

C2 的很多优化是基于 profile 的投机:调用点 99% 走某个实现,就内联它并留守卫;某对象从来没逃逸,就做标量替换。一旦运行时假设破产--加载了新的接口实现类、异常分支第一次被走到--触发去优化(deoptimization,也称逆优化):编译好的机器码作废,回到解释器重新执行,profile 重新积累,之后再重新编译。

另一条路是 OSR(On-Stack Replacement,栈上替换):一个循环跑了十万次没退出,方法体早该编译了,OSR 让 JVM 在循环回边处把栈帧里的解释执行切换成编译代码,人不停脚步换鞋。它就是回边计数器存在的意义。

去优化解释了 C2 为什么"敢"激进,也解释了性能测试为什么必须预热:要测的是 C2 稳态代码,而不是解释器。

动手实操:JMH 实测内联与锁消除

手写 System.nanoTime() 循环测不了 JIT 代码:没有预热、结果会被死代码消除(DCE)清零、还有分层编译干扰。JMH 把这些坑都填了。先加依赖:

xml
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-core</artifactId>
    <version>1.37</version>
</dependency>
<dependency>
    <groupId>org.openjdk.jmh</groupId>
    <artifactId>jmh-generator-annprocess</artifactId>
    <version>1.37</version>
</dependency>

基准一:单态调用 vs 多态调用(内联差异):

java
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 5, time = 1)      // 预热 5 轮:等 JIT 编译稳定,不计分
@Measurement(iterations = 5, time = 1)
@Fork(1)
@State(Scope.Benchmark)
public class InlineBenchmark {

    interface Op { int apply(int x); }
    static final class Add implements Op { public int apply(int x) { return x + 1; } }
    static final class Mul implements Op { public int apply(int x) { return x * 3 + 1; } }

    static final Add ADD = new Add();
    Op[] ops = { new Add(), new Mul() };

    @Benchmark
    public int monomorphic() {
        return ADD.apply(42);          // 单态:内联 + 常量折叠,大概率直接编译成 return 43
    }

    @Benchmark
    public int megamorphic() {
        int r = 0;
        for (Op op : ops) r += op.apply(42); // 两个实现:还能分支内联;再多就只能查虚表
        return r;
    }
}

典型结果:monomorphic 比 megamorphic 快 2-5 倍(纳秒级差异,别期待数量级)。想亲眼看内联决策,加参数 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining,输出会标注 inline (hot)too bigmegamorphic

基准二:锁消除

java
@Benchmark
public String lockElision() {
    // sb 是局部对象且不逃逸 -> synchronized 全部被 C2 删除
    StringBuffer sb = new StringBuffer();
    sb.append("a").append("b").append("c");
    return sb.toString();
}

@Benchmark
public String noElision(Blackhole bh) {
    // sb 传给外部代码,逃逸,锁必须保留
    StringBuffer sb = new StringBuffer();
    sb.append("a").append("b");
    bh.consume(sb);
    return sb.toString();
}

mvn package && java -jar target/benchmarks.jar lockElision -prof gcgc.alloc.rate 差异;再用 -jvmArgsAppend=-XX:-DoEscapeAnalysis 关掉逃逸分析对比一轮,锁消除前后的差距就现形了。

测量谬误清单(每条都坑过人):

  • 不预热就测:测到的是解释器/C1 的性能
  • 结果不消费:DCE 把计算整段删掉,测出的是空循环
  • 只跑一个 fork:单次 JIT 编译的随机波动会盖过差异
  • 基准方法里打印日志:日志的耗时成了真正的被测对象

常见误区与小结

  • "JVM 有栈上分配"--HotSpot 的实现是标量替换,对象压根没创建;真正把对象放栈帧的方案是另一回事
  • "加 final 一定能内联"--final 只是消除虚分派的不确定性,内联还要过热点和体积两关
  • "微基准用 nanoTime 手写循环就够"--没预热、没防 DCE,结论基本不可信,JMH 是唯一正解
  • "有 JIT 就不用关心代码写法"--megamorphic 调用点、超大方法、逃逸的对象,JIT 都救不了
  • "服务慢是 JIT 没生效"--先用 -XX:+PrintCompilation 和 JFR 看编译日志,多数性能问题跟 JIT 无关

这篇把"Java 为什么不慢"讲完了:分层编译打底,内联开路,逃逸分析收拾堆分配,去优化兜底。下一篇 JVM 调优方法论把这些知识串成一套排查与调参的流程。

参考

  • OpenJDK Wiki:HotSpot JIT 与 Tiered Compilation
  • 周志明《深入理解 Java 虚拟机》第 11 章
  • JMH Official Samples(openjdk/jmh 仓库 samples 目录)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1