引言
很多Java开发者写了多年代码,却始终搞不懂这些核心问题:为什么本地测试代码运行正常,线上压测时性能始终上不去?为什么同样的代码,JVM运行几分钟后响应速度会提升数倍?为什么服务平稳运行很久后,会突然出现响应时间飙升、性能暴跌的情况?
这些问题的核心答案,都指向JVM的核心性能引擎——JIT即时编译。JIT是Java能兼顾跨平台特性与原生执行性能的核心支撑,也是区分初级Java开发者与资深性能调优专家的关键知识点。
一、JIT即时编译的核心本质:为什么Java需要JIT?
1.1 Java的双重执行模型
Java是典型的半编译半解释型语言,完整的执行流程分为两个核心阶段:
- 前端编译:
javac编译器将.java源码编译为符合JVM规范的.class字节码文件,这个阶段仅做语法校验、字节码生成,几乎不做任何性能优化,仅保留少量常量折叠等基础优化。 - 运行时执行:JVM加载字节码后,由执行引擎负责执行,执行引擎提供了两种核心执行方式:解释执行与编译执行(JIT)。
1.2 两种执行方式的核心差异
| 执行方式 | 核心原理 | 优势 | 劣势 |
| 解释执行 | 解释器逐条将字节码翻译为机器码执行 | 启动速度快,无需编译等待,无额外内存开销 | 重复执行的代码需反复翻译,执行效率低,峰值性能差 |
| JIT编译执行 | 识别热点代码,一次性编译为本地机器码并缓存,后续直接执行 | 热点代码执行效率接近原生编译语言,峰值性能极高 | 编译需消耗CPU资源,有额外的内存开销,启动阶段性能差 |
1.3 JVM默认的混合执行模式
JDK 17默认开启混合模式(-Xmixed),将解释器与JIT编译器的优势结合:
- 服务启动阶段:采用解释执行,快速完成启动与初始化,降低启动延迟
- 运行过程中:持续识别热点代码,交给JIT编译器逐步编译优化,不断提升执行性能
- 平稳运行阶段:核心热点代码全部完成编译优化,达到峰值性能
1.4 JIT的两大核心编译器
JDK 17的JIT包含两个不同定位的编译器,配合分层编译机制协同工作,64位服务端模式下默认同时启用:
- C1编译器(Client Compiler):面向客户端场景,优化策略保守,编译速度快,CPU占用低,仅做轻量级优化,核心目标是降低启动延迟、减少编译开销
- C2编译器(Server Compiler):面向服务端场景,优化策略激进,编译速度慢,CPU占用高,基于运行时数据做全局深度优化,核心目标是实现极致的峰值性能
❝注意:JDK 8之后,64位JVM已废弃
-client参数,JDK 17中该参数无任何效果,默认启用服务端模式与分层编译。
二、分层编译:JIT的智能调度核心
2.1 分层编译的诞生背景
在分层编译出现之前,JVM只能选择单一编译器:要么用C1牺牲峰值性能换启动速度,要么用C2牺牲启动速度换峰值性能,无法兼顾两者。
JDK 7引入分层编译机制,JDK 8默认开启,JDK 17进一步优化,将C1与C2编译器结合,通过不同的编译层级实现「启动速度+峰值性能」的兼顾,同时通过运行时数据智能调度编译流程,避免资源浪费。
2.2 分层编译的5个层级
基于OpenJDK官方规范,JDK 17的分层编译分为5个固定层级,每个层级有明确的职责与优化策略:
- Tier 0(解释执行层):代码由解释器执行,收集完整的profiling数据(方法调用次数、循环执行次数、分支跳转情况、类型信息等),为后续编译提供精准的数据支撑
- Tier 1(C1全优化层):由C1编译器编译,不带任何profiling信息,执行完整的C1级优化,生成最高质量的C1代码,适用于简单无优化空间的方法,编译后直接进入最终状态,无需再编译到C2
- Tier 2(C1有限profiling层):由C1编译器编译,仅收集部分关键profiling数据,编译速度快于Tier 3,适用于中等热度的方法,在C2队列积压时快速提升性能
- Tier 3(C1全profiling层):由C1编译器编译,收集完整的profiling数据,编译速度慢于Tier 2,是绝大多数热点方法的必经层级,为后续C2的深度激进优化提供完整的运行时数据
- Tier 4(C2终极优化层):由C2编译器编译,基于Tier 3收集的完整profiling数据,执行全局的、激进的深度优化,生成最高性能的本地机器码,是核心热点代码的最终形态
2.3 分层编译的核心流转规则
JDK 17默认的分层编译流转路径严格遵循「热度优先、资源最优」的原则,核心流转逻辑如下:
- 方法初始执行时,进入Tier 0解释执行,收集profiling数据
- 当方法调用次数+循环回边次数达到Tier 3阈值(默认2000次),进入Tier 3由C1编译,收集完整profiling数据
- 当方法在Tier 3的执行次数达到Tier 4阈值(默认15000次),进入Tier 4由C2编译,生成终极优化的机器码
- 对于简单方法(字节码大小小于
-XX:MaxTrivialSize,默认6字节),直接从Tier 0编译到Tier 1,无需profiling,C1编译后即达到最优状态 - 当C2编译队列严重积压时,热点方法先从Tier 0编译到Tier 2,快速提升执行性能,待C2队列空闲后再编译到Tier 4
2.4 分层编译的核心配置参数
JDK 17分层编译的核心参数如下,非特殊场景不建议修改默认值:
| 参数 | 作用 | JDK 17默认值 |
-XX:+TieredCompilation |
开启分层编译 | 默认开启 |
-XX:TieredStopAtLevel=N |
停止编译的最高层级,1=仅C1,4=C1+C2 | 4 |
-XX:Tier3InvokeThreshold |
进入Tier 3的调用次数阈值 | 2000 |
-XX:Tier4InvokeThreshold |
进入Tier 4的调用次数阈值 | 15000 |
-XX:CICompilerCount |
JIT编译线程总数,C2线程数=该值的2/3 | CPU核心数的1/2,最小2,最大8 |
❝注意:非分层编译模式下的
-XX:CompileThreshold参数,在分层编译模式下完全不生效,请勿混淆。
三、代码缓存:JIT编译的核心载体
3.1 代码缓存的核心本质
JIT编译生成的本地机器码,会存储在JVM在堆外申请的一块连续Native内存中,这块内存就是代码缓存(Code Cache)。除了JIT编译的代码,代码缓存还会存储JVM内部的Native方法桩、解释器的辅助代码等固定内容。
代码缓存是JIT运行的核心载体,一旦代码缓存耗尽,JVM会直接关闭JIT编译器,所有代码退回到解释执行,服务性能会出现断崖式下跌。
3.2 JDK 17的分段式代码缓存架构
JDK 9之后引入分段式代码缓存架构,解决了传统单一代码缓存的内存碎片、回收效率低、性能下降的问题,JDK 17延续并优化了该架构,将代码缓存分为三个独立的内存段,每个段有独立的内存管理与垃圾回收机制:
- Non-method段:存储非方法的固定代码,如JVM内部的Native方法桩、解释器辅助代码,默认占总大小的5%,固定不回收
- Profiled-code段:存储带profiling信息的临时编译代码,即Tier 2、Tier 3的C1编译代码,默认占总大小的37%,生命周期短,会被C2编译后的代码替换,支持主动回收
- Non-profiled-code段:存储不带profiling信息的长期优化代码,即Tier 1的C1代码与Tier 4的C2代码,默认占总大小的58%,生命周期长,回收频率极低
3.3 代码缓存的核心配置参数
JDK 17 64位服务端模式下,代码缓存的核心参数如下:
| 参数 | 作用 | 默认值 |
-XX:InitialCodeCacheSize |
代码缓存初始大小 | 2MB |
-XX:ReservedCodeCacheSize |
代码缓存最大可用大小 | 240MB |
-XX:CodeCacheExpansionSize |
代码缓存每次扩容的大小 | 64KB |
-XX:+UseCodeCacheFlushing |
开启代码缓存刷新回收,自动清理无用编译代码 | 默认开启 |
-XX:CodeCacheMinimumFreeSpace |
代码缓存最小剩余空间,低于该值会关闭JIT | 500KB |
3.4 代码缓存的监控与风险防控
代码缓存耗尽是线上最常见的JIT性能坑,当代码缓存剩余空间低于CodeCacheMinimumFreeSpace阈值时,JVM会输出CodeCache is full. Compiler has been disabled.警告,直接关闭JIT编译器,新的热点代码只能解释执行,服务响应时间会飙升数倍甚至数十倍。
3.4.1 代码缓存监控工具类
以下工具类基于JMX实现代码缓存的实时监控,可集成到项目中:
package com.jam.demo.jit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.management.MemoryUsage;
/**
* JIT代码缓存监控工具类
* @author ken
* @date 2026-03-16
*/
@Slf4j
public class CodeCacheMonitor {
private static final String CODE_CACHE_POOL_NAME = "Code Cache";
/**
* 获取Code Cache的内存使用情况
* @return Code Cache内存使用对象,无对应MXBean时返回null
*/
public static MemoryUsage getCodeCacheUsage() {
for (MemoryPoolMXBean poolBean : ManagementFactory.getMemoryPoolMXBeans()) {
if (CODE_CACHE_POOL_NAME.equals(poolBean.getName())) {
return poolBean.getUsage();
}
}
return null;
}
/**
* 打印Code Cache的使用详情
*/
public static void printCodeCacheInfo() {
MemoryUsage codeCacheUsage = getCodeCacheUsage();
if (ObjectUtils.isEmpty(codeCacheUsage)) {
log.warn("未获取到Code Cache的内存池信息");
return;
}
long initSize = codeCacheUsage.getInit() / 1024 / 1024;
long usedSize = codeCacheUsage.getUsed() / 1024 / 1024;
long maxSize = codeCacheUsage.getMax() / 1024 / 1024;
long committedSize = codeCacheUsage.getCommitted() / 1024 / 1024;
double usageRate = (double) usedSize / maxSize * 100;
log.info("==================Code Cache使用详情==================");
log.info("初始大小: {}MB", initSize);
log.info("已用大小: {}MB", usedSize);
log.info("最大可用大小: {}MB", maxSize);
log.info("已提交大小: {}MB", committedSize);
log.info("使用率: {:.2f}%", usageRate);
log.info("=======================================================");
if (usageRate > 90) {
log.error("Code Cache使用率超过90%,存在JIT关闭风险,请及时调整ReservedCodeCacheSize参数");
} else if (usageRate > 80) {
log.warn("Code Cache使用率超过80%,请注意监控");
}
}
public static void main(String[] args) {
printCodeCacheInfo();
}
}
3.4.2 命令行监控方式
JDK自带的jcmd工具是线上代码缓存监控的首选,无需额外安装,核心命令如下:
- 查看代码缓存整体使用情况:
jcmd <pid> Compiler.codecache - 查看所有已编译的代码列表:
jcmd <pid> Compiler.codelist - 查看JIT编译队列与积压情况:
jcmd <pid> Compiler.queue - 查看JIT编译器整体统计信息:
jcmd <pid> Compiler.stat
3.4.3 代码缓存调优最佳实践
- 不盲目调大
ReservedCodeCacheSize,默认240MB足以应对绝大多数业务场景,仅当监控到使用率持续超过80%或出现JIT关闭警告时,再调整为512MB~1GB - 始终保持
-XX:+UseCodeCacheFlushing开启,自动回收无用的编译代码,释放内存空间 - 对于短生命周期的CLI工具,可设置
-XX:TieredStopAtLevel=1,仅使用C1编译,大幅减少代码缓存占用,加快启动速度 - 减少运行时动态生成的类(如大量反射、动态代理、热部署),这类类会生成大量临时方法,占用代码缓存空间
四、JIT的核心优化技术:从原理到可复现实例
JIT的优化不是盲目的,而是基于运行时收集的profiling数据,执行「基于假设的激进优化」:如果运行时假设成立,优化后的代码性能极高;如果假设不成立,会触发去优化(Deoptimization),退回到解释执行,重新收集数据后再次编译。
以下是JIT最核心的优化技术,全部基于JDK 17默认配置,配合可直接运行的基准测试实例,直观展示优化效果。
4.1 方法内联:JIT最重要的优化,没有之一
4.1.1 内联的核心原理
方法内联是JIT最核心的优化,是其他所有优化的基础。所谓内联,就是将被调用方法的代码直接复制到调用方的方法体中,消除方法调用的开销(栈帧创建、参数传递、方法返回等),更重要的是,内联后JIT可以对更大的代码块做更深度的优化,比如常量传播、死代码消除等。
举个通俗的例子,原始代码:
public int add(int a, int b) {
return a + b;
}
public int calculate() {
return add(1, 2) + add(3, 4);
}
内联后的代码:
public int calculate() {
return (1 + 2) + (3 + 4);
}
最终通过常量折叠,直接优化为return 10;,完全消除计算开销。
4.1.2 方法内联的触发条件
JDK 17默认的内联触发规则如下:
- 被调用方法的字节码大小小于
-XX:MaxInlineSize(默认35字节),无论是否热点,都会被内联 - 热点方法(调用次数超过
-XX:InlineFrequencyCount,默认100次),字节码大小小于-XX:FreqInlineSize(默认325字节),会被内联 - 方法必须是非虚方法(private、final、static方法),或JIT能确定唯一实现类的虚方法,否则无法内联
4.1.3 方法内联性能基准测试
以下基准测试基于OpenJDK官方的JMH工具实现,直观对比强制内联与禁止内联的性能差异:
package com.jam.demo.jit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
/**
* 方法内联性能基准测试
* @author ken
* @date 2026-03-16
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 1, jvmArgsPrepend = {
"--add-exports", "java.base/jdk.internal.vm.annotation=ALL-UNNAMED",
"-XX:+TieredCompilation",
"-XX:+Inline"
})
@State(Scope.Benchmark)
public class MethodInlineBenchmark {
private int a = 10;
private int b = 20;
/**
* 强制内联的方法
*/
@jdk.internal.vm.annotation.ForceInline
private int forceInlineAdd(int x, int y) {
return x + y;
}
/**
* 禁止内联的方法
*/
@jdk.internal.vm.annotation.DontInline
private int dontInlineAdd(int x, int y) {
return x + y;
}
/**
* 强制内联的基准测试
* @return 计算结果
*/
@Benchmark
public int forceInlineTest() {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += forceInlineAdd(a, b);
}
return sum;
}
/**
* 禁止内联的基准测试
* @return 计算结果
*/
@Benchmark
public int dontInlineTest() {
int sum = 0;
for (int i = 0; i < 1000; i++) {
sum += dontInlineAdd(a, b);
}
return sum;
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(MethodInlineBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
}
测试结果说明:强制内联的吞吐量是禁止内联的10~20倍,性能差异极其显著。
4.1.4 方法内联的最佳实践
- 尽量编写小方法,避免超大方法,大方法无法被内联,会丧失大量优化空间
- 优先使用private、final、static方法,避免虚方法的动态分派,提升内联成功率
- 不盲目调大
MaxInlineSize与FreqInlineSize,过大会导致编译时间变长、代码缓存占用激增,反而影响整体性能 - 仅在性能瓶颈明确的热点方法上使用
@ForceInline注解,禁止滥用
4.2 逃逸分析:JIT深度优化的基础
4.2.1 逃逸分析的核心原理
逃逸分析是JIT在编译时执行的对象作用域分析,核心是判断一个对象是否会逃逸出当前方法或当前线程,如果对象没有逃逸,JIT就可以对这个对象执行激进的深度优化。
逃逸分为两个级别,逃逸级别越低,优化空间越大:
- 无逃逸:对象仅在当前方法内创建和使用,不会传递到任何外部方法
- 方法逃逸:对象在当前方法内创建,被传递到外部方法(如作为返回值、方法参数)
- 线程逃逸:对象在当前线程内创建,被传递到其他线程(如赋值给静态变量、全局实例变量)
JDK 17默认开启逃逸分析,参数为-XX:+DoEscapeAnalysis,非特殊场景禁止关闭。
4.2.2 基于逃逸分析的三大核心优化
- 标量替换:如果对象无逃逸,JIT不会在堆上创建该对象,而是将对象的成员变量拆解为一个个不可再分的标量(基础数据类型),分配到方法栈帧或CPU寄存器中,完全不需要堆内存分配,也不需要GC回收,性能提升巨大。
❝重要纠正:很多教程声称JVM实现了「栈上分配」,实际上JVM并没有真正的栈上分配,实现对象栈上分配效果的核心是标量替换,这是绝大多数教程都存在的错误认知。
- 同步消除:如果对象无逃逸,仅在当前线程内访问,那么该对象的
synchronized同步操作不存在线程竞争,JIT会直接消除该同步锁,完全避免锁的开销,也叫锁削除。 - 分离对象读写:如果对象仅在方法内部分支使用,JIT会将对象的创建延迟到实际使用的分支,避免无用的对象创建开销。
4.2.3 逃逸分析性能基准测试
以下基准测试直观对比开启与关闭逃逸分析的性能差异,可直接在JDK 17环境下运行:
package com.jam.demo.jit;
import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import java.util.concurrent.TimeUnit;
/**
* 逃逸分析性能基准测试
* @author ken
* @date 2026-03-16
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 1)
@State(Scope.Benchmark)
public class EscapeAnalysisBenchmark {
/**
* 无逃逸的用户对象
*/
static class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
public int getAge() {
return age;
}
public String getName() {
return name;
}
}
/**
* 开启逃逸分析的测试:对象无逃逸,会被标量替换
* @return 年龄总和
*/
@Benchmark
@Fork(value = 1, jvmArgsPrepend = {"-XX:+DoEscapeAnalysis"})
public int escapeAnalysisEnableTest() {
int sum = 0;
for (int i = 0; i < 1000; i++) {
User user = new User("test", i);
sum += user.getAge();
}
return sum;
}
/**
* 关闭逃逸分析的测试:对象必须在堆上分配,会触发GC
* @return 年龄总和
*/
@Benchmark
@Fork(value = 1, jvmArgsPrepend = {"-XX:-DoEscapeAnalysis"})
public int escapeAnalysisDisableTest() {
int sum = 0;
for (int i = 0; i < 1000; i++) {
User user = new User("test", i);
sum += user.getAge();
}
return sum;
}
public static void main(String[] args) throws RunnerException {
Options options = new OptionsBuilder()
.include(EscapeAnalysisBenchmark.class.getSimpleName())
.build();
new Runner(options).run();
}
}
测试结果说明:开启逃逸分析的吞吐量是关闭的50倍以上,因为开启后User对象被标量替换,无堆分配、无GC开销,性能提升极其显著。
4.2.4 逃逸分析的最佳实践
- 尽量缩小对象的作用域,能在方法内创建的对象,不要传递到外部方法,避免方法逃逸
- 尽量不将对象赋值给静态变量或全局实例变量,避免线程逃逸
- 始终保持逃逸分析开启,JDK 17的逃逸分析已经非常成熟,优化效果显著
4.3 栈上替换(OSR):循环体的专属优化
4.3.1 OSR的核心原理
JIT不仅会编译被多次调用的方法,还会编译被多次执行的循环体——即使这个方法只被调用一次,只要循环体的执行次数达到阈值,JIT就会编译该循环体的机器码,然后在循环执行过程中,直接替换栈上的代码,继续执行,不需要等待方法执行结束,这个机制就是栈上替换(On-Stack Replacement, OSR)。
JDK 17分层模式下,循环回边次数达到10000次,就会触发OSR编译。OSR解决了单次调用的大循环方法的性能问题,是大数据量循环计算场景的核心优化。
4.3.2 OSR示例与验证
以下示例可直观看到OSR的触发,启动时添加-XX:+PrintCompilation参数即可查看OSR编译日志:
package com.jam.demo.jit;
import lombok.extern.slf4j.Slf4j;
/**
* OSR栈上替换示例
* @author ken
* @date 2026-03-16
*/
@Slf4j
public class OSRDemo {
/**
* 大循环方法,仅调用一次,触发OSR
*/
public static void bigLoop() {
long sum = 0;
long startTime = System.currentTimeMillis();
// 循环1亿次,触发OSR编译
for (int i = 0; i < 100_000_000; i++) {
sum += i;
}
long endTime = System.currentTimeMillis();
log.info("循环执行结束,sum={},耗时={}ms", sum, endTime - startTime);
}
public static void main(String[] args) {
// 方法仅调用一次,仍会触发OSR
bigLoop();
}
}
启动后,控制台会输出类似以下日志,其中的%符号代表这是一次OSR编译:
118 45 % 3 com.jam.demo.jit.OSRDemo::bigLoop @ 12 (46 bytes)
4.4 其他核心优化技术
4.4.1 常量传播与折叠
常量传播:JIT将运行时确定的常量,传递到所有使用该常量的位置;常量折叠:在编译期直接计算常量表达式的结果,无需运行时计算。 示例:
public int calculate() {
int a = 10;
int b = 20;
return a * b + 5;
}
JIT优化后直接变为return 205;,完全消除运行时计算开销。
4.4.2 死代码消除
JIT会将永远不会执行的代码、执行结果无任何使用的代码直接删除,避免无用的执行开销。 示例:
public void deadCodeTest() {
int a = 10;
int b = 20;
int sum = a + b;
// 永远不会执行的代码,会被直接消除
if (sum > 100) {
System.out.println("sum is too big");
}
// sum无任何使用,整个计算逻辑都会被消除
}
该方法被JIT编译后,方法体内的代码会被全部删除,因为无任何副作用。
4.4.3 循环优化
JIT针对循环提供了多种深度优化,核心包括:
- 循环不变量外提:将循环体中不会变化的代码提到循环外部,避免每次循环重复执行
- 循环展开:将循环的多次迭代合并为一次,减少循环判断与跳转的开销
- 范围检查消除:消除数组访问时不必要的下标范围检查,提升数组访问性能
- 循环剥离:将循环的前几次和最后几次迭代单独处理,优化循环体的核心逻辑
五、JIT监控与线上问题排查实战
5.1 JIT编译日志开启与解读
JDK 17推荐使用统一日志框架(ULF)开启JIT日志,替代老旧的PrintCompilation参数,核心配置如下:
- 输出JIT编译基础信息到控制台:
-Xlog:jit+compilation=info - 输出JIT编译详细信息到文件:
-Xlog:jit+compilation=debug:file=jit.log:utctime,level,tags - 输出去优化日志:
-Xlog:jit+deoptimization=info:file=deopt.log - 老旧的
-XX:+PrintCompilation参数在JDK 17中仍可使用,输出简洁的编译日志,适合快速排查。
编译日志核心字段解读
以一行典型的编译日志为例:
1234 123 4 com.jam.demo.jit.MethodInlineBenchmark::forceInlineTest (28 bytes)
1234:JVM启动后的毫秒数123:编译任务的唯一ID4:编译的层级(Tier 4,C2编译)- 方法名与字节码大小
- 附加标记:
%=OSR编译、!=方法包含同步块、s=同步方法、#=去优化
5.2 线上常见JIT问题与解决方案
问题1:服务启动后前几分钟响应慢,之后逐渐变快
根因:JVM启动时采用解释执行,核心热点代码需要逐步编译到Tier 4,峰值性能需要预热时间,这是Java服务的典型特性。解决方案:
- 流量预热:服务启动后先放小流量,逐步放大,给JIT足够的时间完成热点代码编译
- 提前编译:使用JDK的AOT编译(jaotc)或GraalVM原生镜像,提前将代码编译为本地机器码,实现启动即巅峰
- 调整分层编译阈值,降低Tier 3与Tier 4的触发阈值,让热点代码更早完成编译
问题2:服务平稳运行后,突然出现性能暴跌、响应时间飙升
根因:大概率是代码缓存耗尽,JIT编译器被关闭,新的热点代码只能解释执行,性能出现断崖式下跌。排查步骤:
- 查看JVM日志,是否有
CodeCache is full. Compiler has been disabled.警告 - 使用
jcmd <pid> Compiler.codecache查看代码缓存使用率,是否接近100%解决方案: - 调大
-XX:ReservedCodeCacheSize参数,根据业务规模调整为512MB~1GB - 保持
-XX:+UseCodeCacheFlushing开启,自动回收无用编译代码 - 减少运行时动态生成的类,降低代码缓存的无效占用
问题3:服务性能波动大,时快时慢
根因:大概率是JIT频繁触发去优化(Deoptimization),JIT基于profiling的假设不成立,编译后的代码失效,退回到解释执行,导致性能波动。排查步骤:
- 开启去优化日志,查看是否有频繁的去优化事件
- 分析去优化的原因,常见原因包括:运行时类加载导致类型假设失效、热点代码频繁抛出异常、分支预测频繁失败解决方案:
- 避免运行时动态加载类(如热部署、频繁动态代理),导致类型假设失效
- 避免在热点代码中频繁抛出异常,异常会触发去优化
- 减少热点代码中的复杂分支跳转,提升分支预测成功率
问题4:C2编译队列积压,热点代码迟迟不编译,性能上不去
根因:热点方法过多,C2编译线程数不足,编译队列严重积压,导致核心热点方法长时间等待编译,一直处于解释执行状态。排查步骤:
- 使用
jcmd <pid> Compiler.queue查看编译队列,是否有大量等待的编译任务 - 使用
jcmd <pid> Compiler.stat查看C2编译线程的任务处理情况解决方案: - 调大
-XX:CICompilerCount参数,8核以上的服务器可设置为4~8,提升编译线程数 - 优化代码,减少非核心冷代码的热点化,降低编译任务数量
5.3 JIT调优的黄金法则
- 不调优就是最好的调优:JDK 17的JIT默认参数已经经过了海量场景的优化,适合99%的业务场景,盲目调优大概率会适得其反
- 先监控,再定位,最后调优:所有调优必须有数据支撑,先通过监控找到明确的性能瓶颈,再针对性调优,禁止凭感觉调优
- 只优化核心热点代码:80%的性能提升来自于20%的热点代码,只需要针对核心链路的热点代码优化,无需关注冷代码
六、易混淆概念与常见误区避坑指南
- 误区:javac的前端编译优化越多,程序运行越快纠正:javac几乎不做任何性能优化,Java的核心性能优化全部由JIT在运行时完成,javac生成的字节码越简单,JIT的优化空间越大,性能越好。
- 误区:JIT编译的代码越多,性能越好纠正:JIT编译需要消耗大量CPU资源,编译后的代码会占用代码缓存内存,冷代码编译只会浪费资源,不会提升性能,只有热点代码编译才会带来性能收益。
- 误区:JVM实现了真正的栈上分配,将对象分配到栈上纠正:JVM没有真正的栈上分配,实现对象栈上分配效果的核心是标量替换,将无逃逸的对象拆解为标量分配到栈帧与寄存器中,不会在堆上创建对象。
- 误区:开启
-XX:+AggressiveOpts就能提升性能纠正:该参数在JDK 12之后已被完全废弃,JDK 17中该参数无任何效果,开启不会带来任何性能提升,反而可能引入未知风险。 - 误区:使用
-Xcomp强制全部编译执行,性能会更好纠正:-Xcomp会强制JVM在方法第一次调用时就编译为机器码,会导致服务启动时间大幅变长,冷代码编译浪费大量CPU与代码缓存资源,峰值性能反而远不如默认的混合模式,生产环境绝对禁止使用。 - 误区:C2编译的代码一定比C1快纠正:对于执行次数少的冷方法,C1编译的代码性能更好,因为C1编译速度快、开销小;C2的深度优化需要大量编译开销,只有执行次数足够多的热点方法,才能体现出C2的性能优势。
总结
JIT即时编译是Java语言能兼顾跨平台特性与原生执行性能的核心底气,分层编译让Java实现了启动速度与峰值性能的兼顾,代码缓存是JIT稳定运行的核心载体,而基于运行时数据的激进优化技术,让Java服务能达到接近C++等原生编译语言的性能水平。
对于Java开发者而言,理解JIT的底层原理,不仅能帮助你写出更适合JIT优化的高性能代码,还能快速定位并解决线上的JIT相关性能问题,让你的Java服务达到极致的性能表现。
项目依赖POM文件
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.jam.demo</groupId>
<artifactId>jit-demo</artifactId>
<version>1.0.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<lombok.version>1.18.34</lombok.version>
<jmh.version>1.37</jmh.version>
<spring-core.version>6.1.15</spring-core.version>
<guava.version>33.2.0-jre</guava.version>
<fastjson2.version>2.0.52</fastjson2.version>
<mybatis-plus.version>3.5.7</mybatis-plus.version>
<springdoc.version>2.6.0</springdoc.version>
</properties>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>${spring-core.version}</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>${guava.version}</version>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>${fastjson2.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<source>17</source>
<target>17</target>
</configuration>
</plugin>
</plugins>
</build>
</project>