Skip to content
Go back

JIT编译与逃逸分析——JVM如何让你的Java代码越跑越快?

JIT 编译与逃逸分析:JVM 如何让 Java 越跑越快?

一句话结论(30s)

JIT 让 Java 越跑越快的本质是分层编译 + 逃逸分析 + 方法内联的层层递进:热点方法被 C2 深度编译,未逃逸对象被标量替换免除堆分配、连带锁消除。因为方法内联为逃逸分析提供更大的优化上下文,内联越深、逃逸分析命中率越高,所以”小方法有奇效”。权衡是 JIT 需要预热、启动慢,而 AOT 启动快但受闭世界假设约束,两者是互补而非替代。

核心原理(2min)

HotSpot 采用混合模式:多数方法只解释执行,热点方法才进 JIT。分层编译从 Level 0(解释器)经 C1(无 profiling→完整 profiling)到 Level 4(C2 深度优化),C2 依据 profiling 做去虚化、循环展开、内联。逃逸分析把对象分三类:NoEscape 可标量替换(拆成基本类型局部变量,不进堆)甚至栈上分配,ArgEscape 可锁消除,GlobalEscape 必须堆分配;判定 sb 不逃逸时 JIT 直接消除 synchronized 锁。方法内联不仅省调用开销,更是逃逸分析的原材料——内联越深,可推理范围越大。AOT(GraalVM Native Image)构建期完成编译、启动 0.2s 适合 Serverless,但闭世界假设要求反射/动态代理提前声明。

底层深入(5-10min)

解释执行 vs JIT 编译

Java 字节码在 HotSpot 中有两条执行路径:

  1. 解释执行:模板解释器逐条翻译字节码为机器码执行——启动快,但执行慢(每条指令都要翻译)
  2. JIT 编译:将热点方法编译为机器码缓存——启动需要预热,但执行接近 C++ 速度

HotSpot 采用混合模式:大多数方法只解释执行,只有频繁调用的”热点方法”才被 JIT 编译。

想一想:为什么大多数方法不编译反而更快?因为编译本身要花时间,冷方法解释执行的开销低于”编译一次 + 再执行”的开销;只有被反复调用的热点方法,编译的投入才摊得回来。

分层编译(Tiered Compilation)

JIT 不是一次到位的——它分 5 个层次:

Level 0: 解释器
Level 1: C1 无 profiling(简单方法)
Level 2: C1 + 简单 profiling(计数器)
Level 3: C1 + 完整 profiling(计数器+类型+分支)
Level 4: C2 深度优化编译

大多数方法停在 Level 0-2。真正热的到达 Level 4(C2 编译)。C2 用全面的 profiling 数据做深度优化:虚方法去虚化(如果有类型 profile 知道 99% 调用都是某个具体子类,直接去虚化调用)、逃逸分析、循环展开、内联等。

逃逸分析:对象一定要进堆吗?

public int sum(int n) {
    int result = 0;
    Point p = new Point(0, 0);  // p 只在本方法内使用
    for (int i = 0; i < n; i++) {
        p.x += i;
    }
    return p.x;
}

Point p 是在方法内创建、在方法内使用、不传出方法——它”不逃逸”。C2 的逃逸分析判定它为 NoEscape,做标量替换:把 p.xp.y 拆成两个标量(基本类型局部变量),完全不需要在堆上分配 Point 对象。

想一想:为什么拆成标量就能免除堆分配?因为对象不逃逸时,p.x/p.y 只是两个局部变量的别名,直接当基本类型存进寄存器/栈帧即可——堆上那块内存省了,随之而来的 GC 压力也没了。

三种逃逸状态

NoEscape:     对象不逃出方法 → 可标量替换/栈上分配
ArgEscape:    对象通过参数或返回值传出 → 不能栈上分配,但可锁消除
GlobalEscape: 对象赋值给静态字段/堆字段 → 必须堆分配

锁消除

public String concat(String a, String b) {
    StringBuffer sb = new StringBuffer();  // sb 不逃逸
    sb.append(a);  // append() 内部有 synchronized
    sb.append(b);
    return sb.toString();
}

逃逸分析判定 sb 不逃逸 → 多个线程不可能同时访问同一个 sbsynchronized 永远没有竞争 → JIT 直接消除锁

想一想:为什么可以放心消除 synchronized?因为 sb 不逃逸意味着没有任何其他线程能拿到它的引用,锁竞争永远不可能发生,这时加锁纯属浪费——消除掉不会影响正确性。

方法内联:最重要的 JIT 优化

// 调用点
public int compute() {
    return add(1, 2);  // ← 调用 add 方法
}

public int add(int a, int b) {
    return a + b;
}

// 内联后等效于
public int compute() {
    return 1 + 2;  // add 方法体直接嵌入,省去方法调用开销
}

内联不仅省去了方法调用的开销,更重要的是为后续优化提供了更大的上下文——内联后的代码是逃逸分析的”原材料”,内联越深,逃逸分析能推理的范围越大,栈上分配和锁消除的命中率越高。

想一想:为什么说内联是逃逸分析的”原材料”?因为内联把被调方法体并进调用点后,逃逸分析能在更大的代码范围内追踪对象是否逃出方法,范围越大、“不逃逸”的判定越容易命中,这正是”小方法有奇效”的底层逻辑。

这就是为什么”小方法有奇效”:小方法容易被 C2 内联,内联后触发更多的逃逸分析优化。

AOT(Ahead-of-Time)编译

GraalVM Native Image 把 JIT 的”预热”问题从根本上解决——构建期完成所有编译,运行时直接执行机器码。代价是闭世界假设(Closed World Assumption):构建时不知道的类和方法不存在于运行时,反射/动态代理需要提前声明 Native Hint。

两者不是替代关系,而是互补——长时间运行的服务用 JIT,短生命周期无服务器函数用 AOT。

章末提问

追问 1:Java 代码为什么能”越跑越快”?

回答思路:结论先行——靠分层编译 + 逃逸分析 + 方法内联三者递进。因为热点方法被 C2 深度编译成机器码,未逃逸对象被标量替换免除堆分配并连带锁消除,而方法内联又为逃逸分析提供更大优化上下文、内联越深命中率越高,形成正反馈。

追问 2:逃逸分析的三档状态分别能触发什么优化?

回答思路:结论先行——NoEscape 可标量替换/栈上分配,ArgEscape 可锁消除,GlobalEscape 必须堆分配。因为不逃出方法的对象可被拆成基本类型、连堆都不用进;通过参数/返回值传出的对象虽不能免堆,但可推断锁无竞争而消除;赋值给静态字段的对象任何线程都可能访问,必须老老实实堆分配。

追问 3:JIT 和 AOT 谁更好?为什么?

回答思路:结论先行——不是替代关系而是互补,长驻服务用 JIT、Serverless 用 AOT。因为 JIT 有 profiling 指导、长期性能高但启动慢需预热;AOT 构建期编译、启动 0.2s 适合短生命周期函数,但受闭世界假设约束、反射/动态代理要提前声明。两者各取所长。


Share this post on:

Previous Post
JVM Epsilon回收器:什么也不做的GC
Next Post
GraalVM-Native-Image-AOT编译四阶段