返回文章列表
JVM虚拟机
JVMJITC1编译器C2编译器

16JIT 编译器

1.C1、C2 优化器与分层编译

在 Java 中,JIT(Just-In-Time)即时编译器是一项用来提升应用程序代码执行效率的技术。Java 源码经 javac 编译后得到的是平台无关的字节码指令,运行时由 JVM 解释执行。解释执行的优势是启动快、不挑平台,但纯解释的执行效率远低于原生机器码。JIT 的作用正是弥补这一差距:当某些字节码指令执行频率较高、被识别为热点代码(Hot Spot Code)后,JIT 即时编译器会把这些字节码编译成与底层硬件对应的机器码,并在编译过程中做一系列优化,最终把优化后的机器码缓存到内存中,后续调用直接读取机器码运行在硬件上。

如上图所示,同一份 Java 字节码,JVM 在 Windows 上将其翻译为 Windows 机器码,在 Linux 上翻译为 Linux 机器码。这种"运行时再翻译 + 缓存"的模式,使 Java 既能跨平台,又能逐渐逼近 C/C++ 的执行效率。

1.1 三款即时编译器:C1、C2、Graal

在 HotSpot 虚拟机中,一共内置了三款即时编译器:C1(Client Compiler)、C2(Server Compiler) 和 Graal。其中 Graal 是 JDK 10 之后引入的实验性编译器,使用 Java 编写,在 GraalVM 章节中已经介绍过,本章重点关注 C1 与 C2。

C1 与 C2 的定位有显著差异:

  • C1 编译器:编译速度快,但优化幅度较小,适合桌面应用或运行时间较短的程序。
  • C2 编译器:编译耗时较长,但优化更深入、性能更高,适合服务端长时间运行的应用程序。

无论选择哪个编译器,编译后的机器码都会写入一块名为 Code Cache 的内存区域,下次执行时直接从 Code Cache 中取机器码运行。上图中的"优化/取消优化"双向箭头,意味着机器码并非一成不变——当某些优化前提(如类型假设、分支频率)不再成立时,JIT 会触发逆优化(Deoptimization),回退到解释执行或低层级编译代码,再重新收集运行信息。

1.2 分层编译的 5 个等级

JDK 7 之后,HotSpot 默认采用分层编译(Tiered Compilation),让 C1 与 C2 协同工作,取两者之长。分层编译将方法的执行状态划分为 5 个等级:

等级 使用的组件 描述 保存的内容 性能打分(1 - 5)
0 解释器 解释执行,记录方法调用次数及循环次数 无 1
1 C1 即时编译器 C1 完整优化,不收集额外信息 优化后的机器码 4
2 C1 即时编译器 C1 完整优化,记录方法调用次数及循环次数 优化后的机器码 + 部分额外信息(方法调用次数、循环次数) 3
3 C1 即时编译器 C1 完整优化,记录所有额外信息 优化后的机器码 + 所有额外信息(分支跳转次数、类型转换等) 2
4 C2 即时编译器 C2 完整优化 优化后的机器码 5

可以看到,等级 0 是纯解释执行,性能最低;等级 4 是 C2 完整优化,性能最高。中间的 1、2、3 级都由 C1 处理,区别仅在于收集多少运行时信息。等级 3 收集的信息最全,是后续触发 C2 深度优化的关键依据。

1.3 C1 / C2 的线程模型与任务队列

C1 和 C2 各自拥有独立的编译线程,JVM 内部维护一个编译任务队列。当解释器或低层级的编译代码检测到某方法/某循环达到编译阈值时,就把任务投递到队列中,由编译线程异步消费。

从图中可以看到,C1 与 C2 都有多个编译线程并发处理任务队列,编译产物统一写入 Code Cache。需要特别说明的是:JIT 编译通常是方法级别的,但 HotSpot 也支持OSR(On-Stack Replacement,栈上替换)——针对循环体的编译优化,让长循环在执行过程中直接从解释模式切换到编译模式。

1.4 C1 与 C2 的协作场景

分层编译下,方法并不是简单地"先 C1 后 C2",JVM 会根据当前的运行状态动态决策。常见有以下 4 种协作路径:

场景 1:方法调用频繁,先由 C1(通常是 3 级)执行过程中收集所有运行时信息——方法执行次数、循环执行次数、分支执行次数等等,等触发阈值(分层即时编译的阈值由 JVM 动态计算)之后,进入 C2 进行深层次优化(路径:0 → 3 → 4)。

场景 2:方法字节码执行数目过少,JVM 判断 C1 和 C2 的优化效果差不多,那之后转为不收集信息,直接由 C1 的 1 级进行优化即可(路径:0 → 3 → 1)。这样既避免了 C2 编译的开销,又得到了 C1 的优化收益。

场景 3:当所有 C1 线程都忙碌时,JVM 不会再让方法在 C1 队列里等待,而是直接由 C2 进行优化(路径:0 → 4)。这种处理保证了高负载场景下不会因为 C1 队列阻塞而错过优化时机。

场景 4:当 C2 线程忙碌时,方法先由 2 级 C1 编译,收集一些基础信息、多运行一会儿,然后再交由 3 级 C1 处理。由于 3 级 C1 处理效率不高,所以 JVM 会尽量减少这一层停留时间(C2 已经在忙碌,持续收集信息也没有意义)。等 C2 线程空闲后,再交由 C2 进行深度优化(路径:0 → 2 → 3 → 4)。

由此可见,分层编译并非固定的"先 C1 再 C2"流水线,而是一套依据线程负载、方法热度动态调整的策略,目的是在编译开销和执行性能之间取得平衡。

1.5 验证 JIT 优化效果

通过 JMH 性能基准测试可以直观地感受 JIT 带来的提升。我们可以用三组不同的 JVM 参数运行同一段计算密集型代码:

  • 不加参数(默认开启完全 JIT 即时编译)
  • -Xint:关闭 JIT,只使用解释器
  • -XX:TieredStopAtLevel=1:分层编译下只使用 1 级 C1 进行编译
# 完全 JIT
java -jar benchmarks.jar
 
# 仅解释执行
java -Xint -jar benchmarks.jar
 
# 分层编译但只到 C1 的 1 级
java -XX:TieredStopAtLevel=1 -jar benchmarks.jar

通常三组的吞吐量会有数量级差异:完全 JIT 性能最高,纯解释执行最慢,仅用 1 级 C1 居中。这能非常直观地验证 C2 深度优化带来的提升。

2.方法内联

2.1 内联的基本原理

JIT 编译器最常见、收益最大的优化手段是方法内联(Method Inline) 与逃逸分析。

方法内联指的是:把被调用方法的方法体中的字节码指令,直接复制到调用方的字节码指令流中,从而省去一次方法调用所需的栈帧创建、参数压栈、返回值传递等开销。

如上图所示,原始代码中 add 方法被多次调用(这里用源代码示意,实际内联发生在字节码层面)。每次 add(a, b) 调用都会带来一次栈帧的创建与销毁。内联后,方法体被"展开"到调用处,调用开销被彻底消除,更重要的是,JIT 还能基于内联后的更大代码块进行后续优化(如常量折叠、死代码消除)。

2.2 用 JIT Watch 观察内联结果

JIT Watch 是一个开源的可视化工具,可以直观查看 HotSpot 的即时编译过程与产物。下载源码后启动:

git clone https://github.com/AdoptOpenJDK/jitwatch.git
cd jitwatch
mvn clean install -DskipTests
java -jar jitwatch/ui/target/jitwatch-ui.jar

在 JIT Watch 中添加源代码目录,进入 Sandbox 环境点击 RUN,即可观察方法的编译轨迹:

观察结果可以得出几个结论:

  1. 一个方法会先由 C1 调用多次并收集运行信息,等触发 C2 编译阈值后,再进入 C2 进行深度优化。
  2. C2 优化后的机器码大小非常小——这意味着内联 + 后续优化把冗余指令大幅消除了。
  3. 对于简单的累加循环,C2 优化后的汇编代码甚至直接用乘法计算出结果再进行累加(如把循环展开后用 imul 直接算出每次循环的乘积),效率远高于逐次累加。

2.3 方法内联的限制

并不是所有的方法都可以内联,HotSpot 对内联设有严格限制,主要有以下 4 条:

  1. 小方法直接内联:方法编译之后的字节码指令总大小 < 35 字节,可以直接内联。该阈值由 -XX:MaxInlineSize=值 控制。
  2. 热方法稍宽松:方法编译之后的字节码指令总大小 < 325 字节,并且是一个热方法(被频繁调用),也可以内联。该阈值由 -XX:FreqInlineSize=值 控制。
  3. 机器码体积上限:方法编译生成的机器码不能大于 1000 字节,否则即便字节码符合上述规则也不会内联。由 -XX:InlineSmallCode=值 控制。
  4. 接口实现数限制:一个接口的实现必须小于 3 个,如果大于 3 个就不会发生内联(多态场景下无法静态确定调用目标)。

图中的示例:一个 439 字节的热方法超过了 325 字节阈值,无法内联;另一个方法生成的机器码超过了 1000 字节,也无法内联。

2.4 案例:String.toUpperCase 的性能优化

String.toUpperCase 为了适配世界各地不同的语言(如土耳其语、德语等),其内部实现非常复杂,编译后的字节码体量很大,常常超出内联阈值。如果在性能敏感的代码里只需要处理 a-z 的大写转换,完全可以自行实现一个极简版本:

// JDK 原生方法,由于过于复杂难以被内联
String upper = str.toUpperCase();
 
// 自行实现的 a-z 专用版本,字节码极小,可被内联
public static String toUpperAscii(String s) {
    char[] chars = s.toCharArray();
    for (int i = 0; i < chars.length; i++) {
        if (chars[i] >= 'a' && chars[i] <= 'z') {
            chars[i] = (char) (chars[i] - 32);
        }
    }
    return new String(chars);
}

通过 JIT Watch 可以观察到,原生 toUpperCase 因体积超限无法内联,而我们的简化版本可以顺利被内联,性能因此显著提升。这就是"高频使用但过于复杂无法内联的代码,可以自行实现一个特定的优化版本"的典型实践。

3.逃逸分析

3.1 逃逸分析的基本概念

逃逸分析(Escape Analysis)是 JIT 的另一项核心优化。它指的是:如果 JIT 在编译时分析发现,方法内创建的对象不会被外部引用(即不"逃逸"出方法或线程),那么就可以采用锁消除、标量替换、栈上分配等方式对该对象进行优化。

如上图所示,左侧代码中 test 对象只在方法内部使用,不会逃逸到外部,因此可以进行逃逸分析优化。而右侧代码中,test 对象被赋给了某个静态变量,意味着它会逃逸出方法作用域,逃逸分析就无法生效,相关优化都会被取消。

逃逸分析的结果会随程序运行而动态变化——一旦 JIT 发现自己的"不逃逸"假设被打破(例如代码路径变化导致对象被外部引用),就会触发逆优化,把已经做了优化的代码回退到解释执行或低层级编译,再重新收集信息。

3.2 锁消除

逃逸分析中的**锁消除(Lock Elision)**指的是:如果对象被判断不会逃逸出去,那么该对象上就不存在并发访问问题,对象上的锁处理都不会执行,从而提高性能。

例如下面的写法:

public void append(String s1, String s2) {
    // StringBuffer 是同步的,但 sb 不会逃逸出该方法
    StringBuffer sb = new StringBuffer();
    sb.append(s1);
    sb.append(s2);
    System.out.println(sb.toString());
}

StringBuffer 的 append 方法使用了 synchronized,但 sb 对象在方法内创建、方法内消亡,不可能被其他线程访问。JIT 通过逃逸分析识别出这一点后,会消除这些 synchronized 锁操作,省去 monitor enter/exit 的开销。

需要说明的是,锁消除优化在真正的业务代码中并不常见——一般我们加锁的对象本来就是支持多线程访问的,否则不会用 synchronized。它更多是出现在使用 JDK 同步容器(如 StringBuffer、Vector、Hashtable)的本地变量场景。

3.3 标量替换

逃逸分析真正带来较大性能提升的方式是标量替换(Scalar Replacement)。在 Java 虚拟机中:

  • 标量:对象中的基本数据类型字段(如 int、long、boolean 等)
  • 聚合量:对象中引用的其他对象字段

标量替换指的是:如果方法中的对象不会逃逸,那么其中的标量字段就可以直接在栈上分配,甚至完全打散成局部变量,不再以对象形式存在。

如下代码示意:

class Point {
    int x;
    int y;
}
 
public int test() {
    Point point = new Point();
    point.x = 1;
    point.y = 2;
    return point.x + point.y;
}

由于 point 不存在逃逸,标量替换后等价于:

public int test() {
    int x = 1;
    int y = 2;
    return x + y;
}

Point 对象被"拆解"为两个栈上的局部变量,原本的 new、字段读写指令直接挪到了循环/方法体内。这带来了三重收益:

  1. 避免了堆分配:对象不再在堆上创建,省下了内存分配与零值初始化的开销。
  2. 避免了 GC 压力:栈上分配的对象在方法返回时随栈帧一起销毁,不需要 GC 回收。
  3. 为后续优化打开空间:拆解后的标量可以参与常量折叠、死代码消除、寄存器分配等更深入的优化。

对于循环中高频创建临时对象的场景,标量替换带来的性能提升非常明显。

3.4 逃逸分析的优化测试

我们可以用 JMH 编写基准测试,分别在三组参数下对比性能:

  • 开启方法内联和标量替换(默认配置)
  • 关闭标量替换:-XX:-EliminateAllocations(关闭标量替换/栈上分配)
  • 关闭所有优化:-XX:-DoEscapeAnalysis(关闭逃逸分析)
# 默认配置
java -jar benchmarks.jar
 
# 关闭标量替换
java -XX:-EliminateAllocations -jar benchmarks.jar
 
# 关闭逃逸分析(含标量替换)
java -XX:-DoEscapeAnalysis -jar benchmarks.jar

通常三组的吞吐量差异会非常明显:关闭逃逸分析后,循环中创建的临时对象会被分配到堆上,GC 压力陡增,吞吐量随之下降。

3.5 用 JIT Watch 验证逃逸分析

在 JIT Watch 中将待测试代码复制进去,定位到创建对象的那一行源代码,观察其字节码信息:如果对象没有逃离方法的作用域,JIT Watch 会显示该 new 指令被优化掉,对应字段以标量形式参与后续运算。这正是标量替换的可视化证据。

4.JIT 优化的几点建议

根据 JIT 即时编译器优化代码的特性,在编写代码时注意以下几个事项,可以让代码在运行时拥有更好的性能:

  1. 尽量编写比较小的方法,让方法内联可以生效:方法字节码小于 35 字节可直接内联,热方法小于 325 字节也能内联。把长方法拆成多个小方法,既利于阅读,也利于内联。

  2. 高频使用的代码,特别是第三方依赖库甚至是 JDK 中的,如果内容过度复杂无法内联,可以自行实现一个特定的优化版本:典型如 String.toUpperCase、String.split 等,在特定业务场景下用简化版本可以触发内联,性能提升显著。

  3. 注意接口的实现数量,尽量不要超过 2 个,否则会影响内联的处理:接口实现 ≥ 3 时属于多态调用,JIT 无法静态绑定目标方法,内联会被禁用。

  4. 高频调用的方法中创建对象临时使用,尽量不要让对象逃逸:保持对象不逃逸,可以让标量替换、栈上分配生效,避免堆分配和 GC 压力。例如把对象的作用域限制在方法内,不要赋值给静态字段、成员变量或作为返回值返回。

理解了 JIT 即时编译器的工作原理与优化边界,再去阅读那些"为什么这样写更快"的代码建议,就不会只停留在记忆层面,而能从原理上做出判断,写出更高质量的 Java 代码。