Skip to content

即时编译

一、JIT的设计初衷:为什么需要即时编译?

Java的核心优势是“一次编译,到处运行”,其依赖于字节码(Bytecode)作为中间表示——源码编译成字节码后,由JVM解释执行。但解释执行的性能瓶颈非常明显:每执行一条字节码,都需要JVM解析、翻译为机器码,额外开销大(比如栈帧操作、参数传递)。

为了解决这个问题,JIT应运而生:将“热点代码”(频繁执行的代码)动态编译为机器码,直接交给CPU执行,从而将解释执行的“慢”与编译执行的“快”结合,实现性能与跨平台的平衡

与静态编译(如C++)相比,JIT的动态优势在于:

  • 利用运行时信息:比如分支执行频率、对象类型分布、锁竞争情况等,进行针对性优化(静态编译无法预知这些信息);
  • 延迟优化:只编译热点代码,避免编译所有代码带来的时间和内存开销;
  • 自适应优化:根据运行时情况调整优化策略(比如去优化、重新编译)。

二、JIT的触发条件:什么是“热点代码”?

JIT不会编译所有字节码,只会编译热点代码。热点代码的判定依赖于JVM的计数器机制,主要有两个计数器:

1. 方法调用计数器(Invocation Counter)

统计方法被调用的次数。当次数超过阈值时,触发JIT编译。

  • 默认阈值:Client VM(适合桌面应用)为15000次,Server VM(适合服务器应用)为100000次
  • 阈值可通过-XX:CompileThreshold调整(如-XX:CompileThreshold=20000)。

2. 循环回边计数器(Back Edge Counter)

统计循环体的执行次数(循环回边指循环结束后跳回循环开头的指令)。当次数超过方法调用计数器阈值的一半时,触发JIT编译(因为循环是典型的热点,多次迭代的开销远大于编译时间)。

  • 阈值可通过-XX:BackEdgeThreshold调整(如-XX:BackEdgeThreshold=50000)。

3. 计数器衰减(Counter Decay)

为了避免“过时的热点代码”一直占用编译资源,JVM会定期衰减计数器(比如空闲时将计数器值除以2)。这样,长期不执行的方法会从热点列表中移除。

三、JIT的编译流程:从字节码到机器码

JIT的编译过程分为前端优化后端三个阶段,不同的编译器(如C1、C2)在实现上有所差异。

1. 编译器架构:C1与C2的分工

HotSpot VM提供了两种JIT编译器:

  • Client Compiler(C1):轻量级编译器,注重编译速度(快速将字节码编译为机器码),适合桌面应用(对启动时间敏感);
  • Server Compiler(C2):重量级编译器,注重优化深度(通过复杂优化生成高效机器码),适合服务器应用(对长期运行性能敏感)。

2. 分层编译(Tiered Compilation)

为了平衡编译速度与执行性能,JDK 7引入了分层编译(默认开启,-XX:+TieredCompilation),将编译分为5个层级:

  • 0层:解释执行(Interpreted),收集profile信息(如方法调用次数、循环次数、类型分布);
  • 1层:C1编译(无优化,Quick Compile),生成简单机器码,快速替换解释执行;
  • 2层:C1编译(简单优化,Simple Optimize),如方法内联、循环展开;
  • 3层:C1编译(完全优化,Full Optimize),如逃逸分析、同步消除;
  • 4层:C2编译(深度优化,Advanced Optimize),如类型特化、分支预测。

流程示例
一个方法先解释执行(0层),收集profile信息;当调用次数达到阈值,触发C1编译(1层),生成简单机器码;如果继续执行(比如循环次数多),触发C1完全优化(3层);如果还是热点,触发C2深度优化(4层),生成最高效的机器码。

3. 编译阶段细节

以C2编译器为例,编译流程如下:

  • 前端:将字节码转换为高级中间表示(HIR, High-level IR),保留语义信息(如方法调用、循环结构);
  • 优化:对HIR进行一系列优化(如方法内联、逃逸分析、循环优化);
  • 后端:将优化后的HIR转换为低级中间表示(LIR, Low-level IR),接近机器码(如寄存器分配、指令选择);
  • 生成机器码:将LIR转换为目标平台的机器码(如x86、ARM),存储到代码缓存中。

四、JIT的核心优化技术:性能提升的关键

JIT的优化技术是其“魔法”所在,以下是最核心的几种:

1. 方法内联(Method Inlining)

作用:将被调用方法的代码嵌入到调用者方法中,消除方法调用的开销(如栈帧创建、参数传递、返回值处理)。
示例

java
// 原代码:调用getter方法
class User {
    private String name;
    public String getName() { return name; }
}
User user = new User();
String name = user.getName(); // 方法调用

// 内联后:直接访问字段
String name = user.name; // 消除方法调用

内联的条件

  • 方法大小:小方法(字节码长度≤35字节,C1默认;≤325字节,C2默认)优先内联;
  • 调用频率:热点方法(调用次数多)的内联阈值会放宽;
  • 虚方法内联:通过类型profile(如某个虚方法90%的调用是某个子类的实现),做带守护条件的内联(Guarded Inlining)。例如:
    java
    // 虚方法调用
    interface Animal { void eat(); }
    class Dog implements Animal { public void eat() { ... } }
    class Cat implements Animal { public void eat() { ... } }
    Animal animal = new Dog();
    animal.eat(); // 虚方法调用
    
    // JIT内联(带守护条件)
    if (animal instanceof Dog) {
        // 内联Dog.eat()的代码
    } else {
        // 退回到解释执行或重新编译
    }

内联的监控:通过-XX:+PrintInlining参数打印内联信息,例如:

Inline成功:@ 4 com.example.User.getName()V (1 bytes)
Inline失败:@ 8 com.example.Service.process()V (原因:方法太大,超过阈值)

2. 逃逸分析(Escape Analysis)

作用:分析对象的引用是否逃逸(即是否被方法外或线程外的代码访问)。如果对象没有逃逸,可以做以下优化:

(1)栈上分配(Stack Allocation)

对象默认在上分配,需要GC回收。如果对象没有逃逸,可以在上分配(栈帧销毁时自动回收),减少GC压力。
示例

java
// 原代码:堆分配
public void method() {
    User user = new User(); // 没有逃逸(仅方法内使用)
    System.out.println(user.getName());
}

// 栈上分配后:不需要堆分配
public void method() {
    String name = "张三"; // 标量替换(见下文)
    System.out.println(name);
}

(2)标量替换(Scalar Replacement)

将对象的字段拆分为局部变量(标量指不可再分的数据,如int、String)。例如,User对象有nameage字段,若没有逃逸,会被拆分为两个局部变量nameage,避免对象创建。

(3)同步消除(Synchronization Elimination)

如果对象没有逃逸到线程外,同步块(synchronized)的开销可以消除。例如:

java
// 原代码:同步块
public void method() {
    StringBuffer sb = new StringBuffer(); // 没有逃逸
    sb.append("a"); // StringBuffer的append是同步的
    sb.append("b");
}

// 同步消除后:相当于StringBuilder
public void method() {
    StringBuilder sb = new StringBuilder(); // 替换为非同步类
    sb.append("a");
    sb.append("b");
}

逃逸分析的开启:默认开启(-XX:+DoEscapeAnalysis),可通过-XX:+PrintEscapeAnalysis打印分析结果,例如:

Escape Analysis: 对象com.example.User@123456没有逃逸,做栈上分配。

3. 循环优化

循环是程序中最常见的热点(比如遍历数组、集合),JIT对循环的优化能显著提升性能:

(1)循环展开(Loop Unrolling)

将循环体的多次迭代合并为一次,减少循环控制的开销(如条件判断、迭代变量递增)。
示例

java
// 原代码:循环4次
for (int i = 0; i < 4; i++) {
    doSomething(i);
}

// 循环展开后:消除循环控制
doSomething(0);
doSomething(1);
doSomething(2);
doSomething(3);

循环展开的条件:循环次数固定(如常量)或循环次数多(如超过100次)。

(2)循环不变量提升(Loop Invariant Code Motion)

将循环体中不随循环变化的代码移到循环外面,避免重复执行。
示例

java
// 原代码:循环内计算b+c(不变量)
int b = 10, c = 20;
for (int i = 0; i < 100; i++) {
    int a = b + c; // 不变量
    doSomething(a, i);
}

// 提升后:循环外计算一次
int b = 10, c = 20;
int a = b + c; // 移到循环外
for (int i = 0; i < 100; i++) {
    doSomething(a, i);
}

(3)循环边界检查消除(Loop Bounds Check Elimination)

数组访问时,JVM会做边界检查(如array[i]是否越界),避免数组下标越界异常。如果JIT能证明循环中的数组访问不会越界,会消除边界检查。
示例

java
// 原代码:边界检查(i < array.length)
int[] array = new int[100];
for (int i = 0; i < array.length; i++) {
    array[i] = 0; // 边界检查
}

// 消除后:无边界检查
int[] array = new int[100];
for (int i = 0; i < 100; i++) { // 用常量代替array.length
    array[i] = 0; // 无边界检查
}

4. 类型推断与特化(Type Inference & Specialization)

Java的泛型是通过类型擦除实现的(如List<String>编译后变为List<Object>),导致get方法返回Object,需要强制类型转换(checkcast)。JIT通过类型推断(Type Inference)消除这种转换:
示例

java
// 原代码:泛型类型擦除
List<String> list = new ArrayList<>();
list.add("a");
String s = list.get(0); // 字节码:checkcast String

// JIT类型推断后:消除checkcast
String s = (String) list.get(0); // 变为直接访问,无类型转换

类型特化(Type Specialization)则是针对多态方法的优化:如果JIT通过profile信息知道方法参数的实际类型,会生成针对该类型的特化版本。例如:

java
// 原代码:多态方法
public void process(Object obj) {
    if (obj instanceof String) {
        // 处理String
    } else if (obj instanceof Integer) {
        // 处理Integer
    }
}

// 类型特化后:针对String的版本
public void process(String obj) {
    // 处理String(无需instanceof判断)
}

5. 分支预测(Branch Prediction)

CPU的分支预测器(Branch Predictor)会预测分支的执行方向(如if语句的true/false),如果预测正确,CPU会提前加载后续指令,提升执行效率。JIT通过收集运行时分支执行情况,优化分支的布局:

  • 高频分支的代码放在前面(CPU更容易命中);
  • 低频分支的代码放在后面(甚至合并到其他分支)。

示例

java
// 原代码:分支比例9:1(true分支执行90%)
if (condition) {
    // 高频代码
} else {
    // 低频代码
}

// JIT优化后:高频分支在前
if (condition) {
    // 高频代码(CPU预测命中)
} else {
    // 低频代码(预测失败,但概率低)
}

6. 同步优化

同步(synchronized)是线程安全的基础,但开销较大(涉及内核态切换)。JIT通过锁升级(Lock Escalation)和锁粗化(Lock Coarsening)优化同步性能:

(1)锁升级

JVM的锁分为三个级别,根据竞争情况自动升级:

  • 偏向锁(Biased Locking):当一个线程第一次获取锁时,标记为偏向锁(记录线程ID)。后续该线程再次获取锁时,无需任何同步操作(仅检查偏向标记);
  • 轻量级锁(Lightweight Locking):当有另一个线程尝试获取锁时,偏向锁升级为轻量级锁,用**CAS(Compare-And-Swap)**操作获取锁(无内核态切换);
  • 重量级锁(Heavyweight Locking):当竞争激烈时,轻量级锁升级为重量级锁(进入内核态等待,开销大)。

偏向锁的开启:JDK 15之前默认开启(-XX:+UseBiasedLocking),JDK 16之后默认关闭(因为偏向锁在高并发场景下可能导致性能下降)。

(2)锁粗化

多个连续的同步块合并为一个大的同步块,减少锁的获取和释放次数。例如:

java
// 原代码:多次同步
for (int i = 0; i < 10; i++) {
    synchronized (obj) {
        // 小同步块
    }
}

// 锁粗化后:一次同步
synchronized (obj) {
    for (int i = 0; i < 10; i++) {
        // 大同步块
    }
}

五、JIT的代码缓存:编译结果的存储

JIT编译后的机器码存储在代码缓存(Code Cache)中,这是一块独立的内存区域(不属于堆或栈)。代码缓存的大小有限制(默认240MB),如果满了,JIT会停止编译,回到解释执行,导致性能下降。

1. 代码缓存的结构

代码缓存分为三个部分(JDK 8及以上):

  • 非方法代码(Non-Method Code):存储JNI stub、异常处理代码等;
  • Profiled Code:存储C1编译的代码(带profile信息,用于后续优化);
  • Non-Profiled Code:存储C2编译的代码(不带profile信息,优化程度最高)。

2. 代码缓存的调优

  • 增大代码缓存大小:通过-XX:ReservedCodeCacheSize调整(如-XX:ReservedCodeCacheSize=512m);
  • 清理旧代码:通过-XX:+UseCodeCacheFlushing开启(默认开启),当代码缓存快满时,清理不常用的编译代码;
  • 监控代码缓存:通过jstat -compiler <pid>查看代码缓存使用情况,例如:
    Compilation: total=123, compiled=100, invalidated=0, time=1234ms, failed=0
    CodeCache: used=150MB, capacity=240MB, max capacity=240MB

六、JIT的监控与调优工具

要优化JIT性能,需要监控编译情况调整优化参数,以下是常用工具:

1. JDK自带工具

  • jstat:查看编译统计信息(如编译次数、编译时间、代码缓存使用情况);
    bash
    jstat -compiler <pid> # 查看编译总体情况
    jstat -printcompilation <pid> # 打印正在编译的方法
  • jstack:查看线程调用栈(是否有大量解释执行的方法);
  • jmap:生成堆转储文件(分析对象分配情况,判断是否需要调整逃逸分析参数);
  • Java Mission Control (JMC):可视化监控工具(需开启-XX:+UnlockCommercialFeatures -XX:+FlightRecorder),可查看编译时间、优化情况、代码缓存使用情况。

2. 第三方工具

  • VisualVM:免费的可视化工具,可查看JIT编译的方法列表、编译时间、内联情况;
  • JProfiler:商业工具,可分析方法执行时间(是否有热点方法未被编译)、锁竞争情况;
  • HotSpot VM日志:通过以下参数打印JIT日志:
    bash
    -XX:+PrintCompilation # 打印编译信息(方法名、编译时间、编译级别)
    -XX:+PrintInlining # 打印内联信息(哪些方法被内联,原因)
    -XX:+PrintEscapeAnalysis # 打印逃逸分析信息(哪些对象没有逃逸)
    -XX:+PrintLoopOpto # 打印循环优化信息(循环展开、不变量提升)

3. JIT调优参数总结

参数作用默认值
-XX:+TieredCompilation开启分层编译开启(JDK 7+)
-XX:CompileThreshold设置方法调用计数器阈值Client VM: 15000;Server VM: 100000
-XX:BackEdgeThreshold设置循环回边计数器阈值CompileThreshold的一半
-XX:ReservedCodeCacheSize设置代码缓存最大大小240MB
-XX:+DoEscapeAnalysis开启逃逸分析开启
-XX:+EliminateAllocations开启栈上分配开启
-XX:+EliminateLocks开启同步消除开启
-XX:+InlineMethods开启方法内联开启
-XX:+UseBiasedLocking开启偏向锁JDK 15之前开启,JDK 16之后关闭

七、JIT的常见问题与解决

1. 冷启动延迟(Cold Start)

问题:应用启动时,方法未被编译,解释执行导致启动时间长(如Spring Boot应用)。
解决

  • 使用AOT编译(JDK 9+的jaotc工具),将字节码预先编译为机器码;
  • 调整分层编译参数(如-XX:TieredStopAtLevel=1,只用C1编译,减少编译时间)。

2. 代码缓存溢出(Code Cache Overflow)

问题:代码缓存满了,JIT停止编译,回到解释执行,性能下降。
解决

  • 增大代码缓存大小(-XX:ReservedCodeCacheSize=512m);
  • 清理旧代码(-XX:+UseCodeCacheFlushing,默认开启);
  • 减少不必要的编译(如关闭某些优化,或调整编译阈值)。

3. 优化失效(Deoptimization)

问题:JIT做了带守护条件的优化(如虚方法内联),但运行时出现不符合条件的情况(如另一个子类的实现被调用),导致去优化(替换回解释执行),性能下降。
解决

  • 减少虚方法的使用(如用final修饰方法,避免多态);
  • 优化类型设计(如避免频繁切换子类);
  • 调整守护条件的阈值(如-XX:InliningThreshold)。

八、总结:JIT的价值与未来

JIT是Java性能的核心引擎,其通过动态编译自适应优化,将字节码转换为高效的机器码,同时保持了跨平台的特性。理解JIT的工作原理,能帮助我们:

  • 写出高性能代码:比如减少对象逃逸(用局部变量代替对象)、使用小方法(便于内联)、避免过度泛型(减少类型转换);
  • 调优JVM:比如调整编译阈值(适应应用场景)、增大代码缓存(避免溢出)、开启必要的优化(如逃逸分析)。

未来,JIT的发展方向是更智能的优化(如基于机器学习的profile预测)、更高效的编译(如GraalVM的JIT编译器,支持多语言优化),以及与AOT的更好结合(如JDK 19的jlink工具,将AOT编译与模块化结合,进一步减少冷启动时间)。

参考资料