第11章 后端编译与优化
11.1 概述
在Java技术体系里面,Java程序从编写到最终执行,需要经历两个主要的编译阶段:
前端编译:由Javac编译器将Java源代码(.java文件)编译成字节码(.class文件)。这个过程发生在程序运行之前,因此也称为"早期编译"。
后端编译:由Java虚拟机中的即时编译器(Just In Time Compiler,JIT编译器)将字节码编译成本地机器码。这个过程发生在程序运行期间,因此也称为"晚期编译"或"运行期优化"。
前端编译在上一章已经详细介绍过。本章将重点讨论后端编译,包括即时编译器(JIT)的工作原理、提前编译器(AOT)的技术特点,以及编译器在运行期间采用的各种优化技术。
Java虚拟机设计团队选择把对性能的优化主要集中到后端编译上,这样做的好处在于:
- 平台无关性:字节码作为中间表示,可以在任何支持Java虚拟机的平台上运行
- 动态优化:运行期编译器可以收集程序运行时的统计信息,进行针对性的优化
- 动态扩展:支持运行时代码生成和动态代理等高级特性
11.2 即时编译器
11.2.1 解释器与编译器
Java程序最初是通过解释器(Interpreter)来执行字节码的。解释器逐条将字节码解释为本地机器码并执行,这种方式启动速度快,但执行效率相对较低。
为了提高执行效率,现代Java虚拟机(如HotSpot)引入了即时编译器(JIT Compiler)。当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为"热点代码"(Hot Spot Code),然后将它们编译成本地机器码,并进行各种优化。
HotSpot虚拟机内置了两个(或三个)即时编译器:
| 编译器 | 名称 | 特点 | 适用场景 |
|---|---|---|---|
| C1编译器 | Client Compiler | 编译速度快,优化程度较低 | 客户端模式,注重启动速度 |
| C2编译器 | Server Compiler | 编译速度慢,优化程度高 | 服务端模式,注重峰值性能 |
| Graal编译器 | Graal Compiler | 使用Java编写,模块化程度高 | Java 10+ 实验性替代C2 |
解释器与编译器的协作模式:
HotSpot虚拟机采用解释器与编译器并存的架构,这种混合模式(Mixed Mode)结合了两者的优势:
分层编译(Tiered Compilation):
- 第0层:程序纯解释执行,解释器不开启性能监控功能
- 第1层:使用C1编译器,进行简单可靠的优化,不开启性能监控
- 第2层:使用C1编译器,开启部分性能监控功能
- 第3层:使用C1编译器,开启全部性能监控功能
- 第4层:使用C2编译器,进行更激进的优化
协作优势:
- 程序启动初期,使用解释器快速执行,同时收集性能数据
- 随着热点代码的发现,逐步使用C1、C2编译器进行优化编译
- 编译后的机器码缓存起来,后续直接执行,避免重复编译
相关JVM参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
-Xint | 强制使用解释模式 | 关闭 |
-Xcomp | 强制使用编译模式 | 关闭 |
-Xmixed | 混合模式(默认) | 开启 |
-client | 使用C1编译器 | 根据系统自动选择 |
-server | 使用C2编译器 | 根据系统自动选择 |
-XX:+TieredCompilation | 开启分层编译 | JDK 8+默认开启 |
-XX:TieredStopAtLevel=N | 设置分层编译停止层级 | 4 |
执行模式对比示例:
# 纯解释执行
java -Xint -version
# 纯编译执行(所有方法首次调用即编译)
java -Xcomp -version
# 混合模式(默认)
java -Xmixed -version
# 仅使用C1编译(分层编译第1层)
java -XX:+TieredCompilation -XX:TieredStopAtLevel=1 MyApp
# 使用C1+C2但不使用分层编译(旧模式)
java -XX:-TieredCompilation MyApp分层编译的执行流程:
程序启动
↓
解释执行(第0层)+ 收集性能数据
↓
方法调用次数达到阈值
↓
C1编译(第1-3层)→ 生成带性能监控的机器码
↓
热点代码识别 + 收集更多统计数据
↓
C2编译(第4层)→ 生成高度优化的机器码
↓
后续调用直接执行编译后的机器码11.2.2 编译对象与触发条件
编译对象:
即时编译器的编译对象主要是"热点代码",包括两类:
被多次调用的方法:这是最常见的热点代码形式。当方法调用次数达到一定阈值时,整个方法会被编译。
被多次执行的循环体:对于循环体这种热点代码,虽然它属于某个方法,但编译时依然会以整个方法作为编译对象,这种编译方式称为栈上替换(On Stack Replacement,OSR)。
触发条件:
热点代码的探测主要有两种方式:
基于采样的热点探测(Sample Based Hot Spot Detection):
- 周期性检查各个线程的栈顶方法
- 如果发现某个方法经常出现在栈顶,就认为是热点方法
- 优点:简单高效,容易获取方法调用关系
- 缺点:难以精确确定方法热度,容易受线程阻塞影响
基于计数器的热点探测(Counter Based Hot Spot Detection):
- 为每个方法(或代码块)建立计数器,统计执行次数
- 当计数器超过阈值时,触发即时编译
- 优点:精确统计,不受线程调度影响
- 缺点:需要维护计数器,有一定开销
HotSpot虚拟机采用基于计数器的热点探测,使用两种计数器:
方法调用计数器(Invocation Counter):统计方法被调用的次数
- 默认阈值:Client模式1500次,Server模式10000次
- 可通过
-XX:CompileThreshold参数调整 - 计数器热度会随时间衰减(半衰期)
回边计数器(Back Edge Counter):统计循环体执行次数
- 用于触发OSR编译
- 阈值计算公式:
CompileThreshold * OnStackReplacePercentage / 100 - Client模式默认:13995次
- Server模式默认:10700次
计数器衰减机制:
方法调用计数器存在热度衰减机制:当超过一定的时间限度(半衰周期),如果方法的调用次数仍然不足以触发即时编译,那该方法的调用计数器就会减少一半。这个过程称为方法调用计数器热度的衰减。
时间轴 →
方法调用计数: 0 → 5000 → 8000 → 4000(衰减) → 7000 → 10000(触发编译)
↑ ↑ ↑ ↑
启动 5秒后 10秒后(半衰期) 继续调用热点代码探测参数汇总:
| 参数 | 说明 | 默认值 |
|---|---|---|
-XX:CompileThreshold | 方法调用计数器阈值 | 10000 (Server) / 1500 (Client) |
-XX:OnStackReplacePercentage | OSR编译触发百分比 | 140 (Client) / 107 (Server) |
-XX:CounterHalfLifeTime | 计数器半衰期(秒) | 30 |
-XX:-UseCounterDecay | 关闭计数器衰减 | 开启 |
-XX:CompileCommand=compileonly,*::method | 仅编译指定方法 | - |
热点代码探测示例:
public class HotSpotTest {
private static int value = 0;
// 热点方法 - 会被JIT编译
public static void hotMethod() {
for (int i = 0; i < 100000; i++) {
value += i;
}
}
// 普通方法 - 可能不会被编译
public static void normalMethod() {
System.out.println("Normal method");
}
public static void main(String[] args) {
// 调用1000次 - 可能触发C1编译
for (int i = 0; i < 1000; i++) {
hotMethod();
}
// 继续调用10000次 - 可能触发C2编译
for (int i = 0; i < 10000; i++) {
hotMethod();
}
}
}11.2.3 编译过程
Client Compiler(C1)编译过程:
C1编译器是一个简单快速的三段式编译器,主要关注局部性优化,编译过程包括:
高级中间代码(High-Level Intermediate Representation,HIR)生成:
- 将字节码构造成一种与平台无关的高级中间表示
- 使用静态单赋值(SSA)形式,便于优化
- 进行方法内联、常量传播等基础优化
低级中间代码(Low-Level Intermediate Representation,LIR)生成:
- 将HIR转换为与目标机器相关的低级中间表示
- 进行空值检查消除、范围检查消除等优化
- 进行寄存器分配
机器代码生成:
- 将LIR转换为本地机器码
- 进行简单的窥孔优化
C1编译器编译速度快,但优化程度有限,适合对启动时间敏感的客户端应用。
Server Compiler(C2)编译过程:
C2编译器是专门面向服务端应用的高性能编译器,采用了大量的经典编译优化技术:
理想图(Ideal Graph)构建:
- 基于Sea-of-Nodes中间表示
- 将控制流和数据流统一表示
- 便于进行全局优化
全局优化:
- 方法内联(Method Inlining)
- 逃逸分析(Escape Analysis)
- 循环优化(Loop Optimization)
- 全局代码外提(Global Code Motion)
寄存器分配与代码生成:
- 使用图着色算法进行全局寄存器分配
- 生成高质量的本地机器码
C2编译器编译耗时较长,但生成的代码执行效率极高,适合长时间运行的服务端应用。
C1与C2编译器对比:
| 特性 | C1 (Client Compiler) | C2 (Server Compiler) |
|---|---|---|
| 编译速度 | 快(毫秒级) | 慢(秒级) |
| 优化程度 | 基础优化 | 激进优化 |
| 启动时间 | 快 | 慢 |
| 峰值性能 | 中等 | 极高 |
| 适用场景 | 客户端、GUI应用 | 服务端、长时间运行 |
| 中间表示 | HIR → LIR | Sea-of-Nodes (理想图) |
| 寄存器分配 | 线性扫描 | 图着色 |
| 方法内联 | 有限制(35字节) | 更激进(325字节) |
| 逃逸分析 | 不支持 | 支持 |
| 循环优化 | 基础 | 高级(向量化等) |
编译器选择建议:
# 32位系统或客户端应用(默认C1)
java -client MyApp
# 64位服务端应用(默认C2+分层编译)
java -server MyApp
# 禁用C2,仅使用C1(快速启动,较低性能)
java -XX:+TieredCompilation -XX:TieredStopAtLevel=1 MyApp
# 禁用分层编译,使用传统C2(较慢启动,较高性能)
java -XX:-TieredCompilation -server MyApp11.2.4 实战:查看及分析即时编译结果
查看即时编译状态:
使用-XX:+PrintCompilation参数可以查看方法的即时编译情况:
java -XX:+PrintCompilation MyApplication输出格式说明:
timestamp compilation_id attributes tiered_level method_name例如:
123 45 % 3 java.lang.String::indexOf @ 12 (70 bytes)123:虚拟机启动后的毫秒数45:编译ID%:表示OSR编译3:分层编译层级java.lang.String::indexOf:被编译的方法
使用JITWatch分析:
JITWatch是一个开源的JIT编译日志分析工具,可以可视化地展示:
- 哪些方法被编译了
- 编译耗时
- 内联决策
- 优化失败原因
使用方法:
- 添加
-XX:+LogCompilation参数生成编译日志 - 使用JITWatch打开日志文件进行分析
查看编译后的机器码:
使用-XX:+PrintAssembly参数可以查看编译后的汇编代码(需要安装hsdis插件):
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly MyApplication常用JIT诊断参数汇总:
| 参数 | 说明 |
|---|---|
-XX:+PrintCompilation | 打印方法编译信息 |
-XX:+LogCompilation | 记录详细编译日志到hotspot.log |
-XX:+PrintInlining | 打印方法内联决策 |
-XX:+PrintAssembly | 打印编译后的汇编代码(需hsdis) |
-XX:+PrintOptoAssembly | 打印C2编译器的优化汇编 |
-XX:+PrintCFGToFile | 将控制流图输出到文件 |
-XX:+PrintIdealGraphFile | 将理想图输出到文件 |
-XX:+TraceClassLoading | 跟踪类加载过程 |
-XX:+TraceClassUnloading | 跟踪类卸载过程 |
实战示例:分析热点方法编译过程:
public class JITAnalysisDemo {
private static int sum = 0;
// 热点方法
public static int calculate(int n) {
int result = 0;
for (int i = 0; i < n; i++) {
result += i * i;
}
return result;
}
public static void main(String[] args) {
// 预热
for (int i = 0; i < 100000; i++) {
calculate(100);
}
// 正式测试
long start = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
sum += calculate(1000);
}
long end = System.nanoTime();
System.out.println("Sum: " + sum);
System.out.println("Time: " + (end - start) / 1_000_000 + " ms");
}
}运行并观察编译过程:
# 查看编译信息
java -XX:+PrintCompilation JITAnalysisDemo
# 查看内联决策
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining JITAnalysisDemo
# 生成详细编译日志
java -XX:+LogCompilation -XX:+PrintInlining JITAnalysisDemoPrintCompilation输出解析示例:
123 45 % 3 JITAnalysisDemo::calculate @ 4 (28 bytes)
45 46 3 java.lang.Math::floorMod (20 bytes)
67 47 4 JITAnalysisDemo::calculate (28 bytes)
89 48 1 java.lang.Object::<init> (1 bytes)输出列说明:
- 第1列:虚拟机启动后的毫秒数
- 第2列:编译任务ID
- 第3列:编译属性(
%=OSR编译,s=同步方法,!=有异常处理器,b=阻塞模式) - 第4列:分层编译层级(0-4)
- 第5列:方法名、字节码位置、字节码大小
11.3 提前编译器
11.3.1 提前编译的优劣得失
提前编译(Ahead-of-Time Compilation,AOT)是指在程序运行之前,将字节码编译成本地机器码。JDK 9引入了Jaotc工具,支持提前编译。
提前编译的优势:
- 启动速度快:程序启动时已经是本地代码,无需等待JIT编译
- 内存占用低:不需要加载JIT编译器和缓存编译后的代码
- 可预测的性能:没有JIT编译的预热过程,性能表现更稳定
- 安全性:可以提前进行代码审查和优化
提前编译的劣势:
- 平台相关性:编译后的机器码与特定平台绑定,失去"一次编写,到处运行"的跨平台特性
- 无法动态优化:无法在运行时根据实际执行情况进行优化
- 动态特性受限:反射、动态代理、字节码生成等动态特性难以支持
- 代码膨胀:需要为所有可能的执行路径生成代码,可能导致代码体积增大
适用场景:
- 对启动时间要求极高的场景(如Serverless、微服务)
- 内存资源受限的嵌入式设备
- 需要确定性性能表现的实时系统
11.3.2 实战:Jaotc的提前编译
Jaotc基本用法:
Jaotc是JDK自带的提前编译工具,可以将Java类或模块编译为共享库(.so或.dll文件)。
编译单个类:
jaotc --output libHello.so Hello.class编译整个模块:
jaotc --output libjava.base.so --module java.base使用AOT编译的库:
运行程序时,通过-XX:AOTLibrary参数加载AOT编译的库:
java -XX:AOTLibrary=./libHello.so HelloAOT编译的限制:
- 仅支持64位Linux和64位Windows系统
- 需要与JIT编译器配合使用,AOT编译的代码作为第一层编译
- 不支持某些动态特性,如动态生成的类
Jaotc常用参数:
| 参数 | 说明 | 示例 |
|---|---|---|
--output | 指定输出文件名 | --output libHello.so |
--module | 编译整个模块 | --module java.base |
--class-name | 指定要编译的类 | --class-name Hello |
--search-path | 类搜索路径 | --search-path ./lib |
--compile-commands | 编译命令文件 | --compile-commands commands.txt |
--info | 显示编译信息 | --info |
--verbose | 详细输出 | --verbose |
AOT编译命令文件示例:
# commands.txt
compileOnly java.lang.String.*
compileOnly java.util.ArrayList.*
exclude java.lang.Object.finalize使用命令文件进行选择性编译:
jaotc --output libjava.base.so --module java.base --compile-commands commands.txtAOT与JIT的协作:
JDK采用了AOT与JIT协作的混合模式:
- 程序启动时,优先执行AOT编译的代码
- 运行过程中,JIT编译器继续收集性能数据
- 对于热点代码,JIT编译器可以重新编译,生成更优化的代码
- 最终达到接近纯JIT编译的性能水平
AOT性能对比示例:
public class AOTDemo {
public static void main(String[] args) {
long start = System.currentTimeMillis();
// 执行计算密集型任务
long sum = 0;
for (int i = 0; i < 1000000; i++) {
sum += calculate(i);
}
long end = System.currentTimeMillis();
System.out.println("Result: " + sum);
System.out.println("Time: " + (end - start) + " ms");
}
public static long calculate(int n) {
return n * n + n;
}
}性能对比:
| 模式 | 启动时间 | 峰值性能 | 内存占用 |
|---|---|---|---|
| 纯JIT | 慢(需预热) | 最高 | 较高 |
| 纯AOT | 最快 | 中等 | 最低 |
| AOT+JIT混合 | 快 | 接近纯JIT | 中等 |
11.4 编译器优化技术
11.4.1 优化技术概览
现代Java虚拟机的即时编译器采用了大量经典的编译优化技术,以下是一些主要的优化技术:
| 优化技术 | 描述 | 应用阶段 |
|---|---|---|
| 方法内联 | 将方法调用替换为方法体 | C1/C2 |
| 逃逸分析 | 分析对象是否逃逸出方法或线程 | C2 |
| 常量传播 | 用常量值替换常量表达式 | C1/C2 |
| 公共子表达式消除 | 消除重复计算 | C2 |
| 数组边界检查消除 | 消除不必要的数组边界检查 | C2 |
| 循环优化 | 循环展开、循环不变量外提等 | C2 |
| 锁消除 | 消除不必要的同步 | C2 |
| 栈上分配 | 在栈上分配对象 | C2 |
| 标量替换 | 将对象拆分为标量变量 | C2 |
这些优化技术相互配合,共同提升程序的执行效率。下面重点介绍几种关键的优化技术。
优化技术效果对比:
以下是一个简单的性能测试,展示不同优化技术的效果:
public class OptimizationDemo {
private static final int ITERATIONS = 100_000_000;
// 测试方法内联
public static int testInlining() {
int sum = 0;
for (int i = 0; i < ITERATIONS; i++) {
sum += add(i, i + 1); // 会被内联
}
return sum;
}
private static int add(int a, int b) {
return a + b;
}
// 测试逃逸分析
public static void testEscapeAnalysis() {
for (int i = 0; i < ITERATIONS; i++) {
Point p = new Point(i, i + 1); // 不会逃逸,可能栈上分配
int sum = p.x + p.y;
}
}
static class Point {
int x, y;
Point(int x, int y) {
this.x = x;
this.y = y;
}
}
// 测试数组边界检查消除
public static int testBoundsCheckElimination(int[] arr) {
int sum = 0;
for (int i = 0; i < arr.length; i++) { // 边界检查可被消除
sum += arr[i];
}
return sum;
}
public static void main(String[] args) {
// 预热
for (int i = 0; i < 10000; i++) {
testInlining();
testEscapeAnalysis();
testBoundsCheckElimination(new int[100]);
}
// 正式测试
long start, end;
start = System.nanoTime();
testInlining();
end = System.nanoTime();
System.out.println("Inlining test: " + (end - start) / 1_000_000 + " ms");
start = System.nanoTime();
testEscapeAnalysis();
end = System.nanoTime();
System.out.println("Escape Analysis test: " + (end - start) / 1_000_000 + " ms");
int[] arr = new int[1000];
start = System.nanoTime();
testBoundsCheckElimination(arr);
end = System.nanoTime();
System.out.println("Bounds Check test: " + (end - start) / 1_000_000 + " ms");
}
}优化相关JVM参数:
| 参数 | 说明 | 默认值 |
|---|---|---|
-XX:+DoEscapeAnalysis | 开启逃逸分析 | 开启 |
-XX:+EliminateAllocations | 开启标量替换 | 开启 |
-XX:+EliminateLocks | 开启锁消除 | 开启 |
-XX:MaxInlineSize | 最大内联方法大小 | 35字节 |
-XX:FreqInlineSize | 频繁调用方法的最大内联大小 | 325字节 |
-XX:InlineSmallCode | 小代码内联阈值 | 1000字节 |
-XX:+UseLoopPredicate | 循环优化 | 开启 |
-XX:+RangeCheckElimination | 范围检查消除 | 开启 |
11.4.2 方法内联
方法内联(Method Inlining)是最重要的优化技术之一,它为其他优化技术提供了基础。
基本原理:
将目标方法的代码原封不动地"复制"到发起调用的方法中,避免真实的方法调用开销。
优化前:
public int add(int x, int y) {
return x + y;
}
public int calc() {
int a = 1;
int b = 2;
int c = add(a, b); // 方法调用
return c;
}优化后:
public int calc() {
int a = 1;
int b = 2;
int c = a + b; // 直接替换为方法体
return c;
}优化效果:
- 消除了方法调用的开销(栈帧建立、参数传递、跳转等)
- 扩大了优化范围,便于进行后续的常量传播、死代码消除等优化
内联条件:
并非所有方法都会被内联,HotSpot虚拟机根据以下因素决定是否内联:
- 方法大小:方法字节码大小不能超过阈值(默认35字节,可通过
-XX:MaxInlineSize调整) - 调用频率:热点方法更容易被内联
- 方法修饰符:
private、final、static方法更容易被内联(没有多态性) - 内联深度:防止无限递归内联
多态方法的内联:
对于虚方法(virtual method),由于存在多态性,编译器需要进行类型分析:
- 单态内联(Monomorphic Inline):如果实际只有一种类型,直接内联
- 双态内联(Bimorphic Inline):如果有两种类型,生成类型判断代码
- 多态内联(Megamorphic):如果类型过多,放弃内联
11.4.3 逃逸分析
逃逸分析(Escape Analysis)是C2编译器的一项重要优化技术,用于分析对象的作用域。
基本概念:
逃逸分析判断一个对象是否"逃逸"出方法或线程:
- 方法逃逸:对象被其他方法访问(如作为参数传递、作为返回值返回)
- 线程逃逸:对象被其他线程访问(如赋值给静态变量)
如果对象没有逃逸,编译器可以进行以下优化:
1. 栈上分配(Stack Allocation):
如果对象不会逃逸出方法,可以在栈上分配内存,而不是堆上。
优点:
- 对象随栈帧销毁而销毁,无需垃圾回收
- 减少GC压力
- 提高内存访问局部性
2. 标量替换(Scalar Replacement):
如果对象不会逃逸,且可以被拆分为基本类型(标量),则直接用标量替换对象。
优化前:
public void method() {
Point p = new Point(1, 2); // 创建对象
int x = p.x; // 访问字段
int y = p.y;
System.out.println(x + y);
}优化后:
public void method() {
int x = 1; // 直接用标量替换
int y = 2;
System.out.println(x + y);
}3. 同步消除(Synchronization Elimination):
如果对象不会逃逸出线程,可以消除对该对象的同步操作。
优化前:
public void method() {
StringBuffer sb = new StringBuffer();
sb.append("hello"); // synchronized方法
sb.append(" world");
}优化后:
public void method() {
StringBuffer sb = new StringBuffer();
sb.appendUnsynchronized("hello"); // 消除同步
sb.appendUnsynchronized(" world");
}开启逃逸分析:
JDK 8及以后版本默认开启逃逸分析,可通过以下参数控制:
-XX:+DoEscapeAnalysis # 开启逃逸分析(默认开启)
-XX:-DoEscapeAnalysis # 关闭逃逸分析
-XX:+PrintEscapeAnalysis # 打印逃逸分析结果(诊断模式)11.4.4 公共子表达式消除
公共子表达式消除(Common Subexpression Elimination,CSE)是一种经典的编译优化技术。
基本原理:
如果一个表达式之前已经计算过,且表达式中的变量值没有改变,则后续相同的表达式可以直接使用之前计算的结果,避免重复计算。
优化前:
public int calc(int x, int y) {
int a = (x + y) * 10; // 第一次计算x+y
int b = (x + y) * 20; // 第二次计算x+y
return a + b;
}优化后:
public int calc(int x, int y) {
int t = x + y; // 只计算一次
int a = t * 10;
int b = t * 20;
return a + b;
}数组范围检查消除:
公共子表达式消除也适用于数组范围检查。如果编译器能确定数组访问不会越界,可以消除重复的范围检查。
11.4.5 数组边界检查消除
Java语言规范要求每次数组访问都要进行边界检查,这在循环中会带来较大的性能开销。
基本原理:
编译器通过数据流分析,确定数组访问不会越界时,可以消除边界检查。
优化前:
public void loop(int[] array) {
for (int i = 0; i < array.length; i++) {
// 每次访问都要检查: i >= 0 && i < array.length
array[i] = i;
}
}优化后:
public void loop(int[] array) {
for (int i = 0; i < array.length; i++) {
// 编译器证明不会越界,消除检查
array[i] = i; // 直接访问,无需检查
}
}消除条件:
编译器在以下情况下可以消除边界检查:
- 循环变量范围明确小于数组长度
- 数组访问下标是循环变量的简单函数
- 通过范围分析证明不会越界
11.5 深入理解Graal编译器
11.5.1 历史背景
Graal编译器是Java生态系统中最新的编译器技术,代表了JIT编译器的未来发展方向。
发展历程:
- Maxine项目:Graal的前身,是一个用Java编写的元循环虚拟机研究项目
- Graal项目:2011年启动,目标是使用Java实现高性能JIT编译器
- JDK 9:引入JVMCI(JVM Compiler Interface),允许在JVM中插入用Java编写的编译器
- JDK 10:Graal作为实验性C2替代器引入
- JDK 11+:Graal持续改进,成为AOT编译的基础
设计目标:
- 模块化:易于理解和修改的代码结构
- 语言无关性:不仅支持Java,还支持其他JVM语言
- 高性能:达到或超越C2编译器的性能
- 可扩展性:便于实现新的优化和语言特性
Graal与C2编译器对比:
| 特性 | Graal编译器 | C2编译器 |
|---|---|---|
| 实现语言 | Java | C++ |
| 代码可维护性 | 高 | 较低 |
| 模块化程度 | 高 | 低 |
| 编译速度 | 中等 | 慢 |
| 峰值性能 | 接近C2 | 最高 |
| 内存占用 | 较高 | 中等 |
| 动态编译 | 支持 | 支持 |
| 提前编译(AOT) | 原生支持 | 不支持 |
| 多语言支持 | 优秀(Truffle框架) | 仅Java |
| 调试便利性 | 好(Java代码) | 较难(C++代码) |
GraalVM版本演进:
| 版本 | 特性 |
|---|---|
| JDK 9 | 引入JVMCI接口 |
| JDK 10 | Graal作为实验性JIT编译器 |
| JDK 11 | Graal成为独立项目,支持AOT编译 |
| JDK 17+ | GraalVM Native Image成熟,支持更多语言 |
11.5.2 构建编译调试环境
环境准备:
下载GraalVM:
bash# 使用SDKMAN安装 sdk install java 21.0.1-graalce sdk use java 21.0.1-graalce获取Graal源码:
bashgit clone https://github.com/oracle/graal.git cd graal/compiler构建Graal:
bashmx build
使用Graal作为JIT编译器:
java -XX:+UnlockExperimentalVMOptions \
-XX:+UseJVMCICompiler \
-XX:+EnableJVMCI \
MyApplication11.5.3 JVMCI编译器接口
JVMCI(JVM Compiler Interface)是JDK 9引入的编译器接口,允许用Java编写的编译器与HotSpot虚拟机交互。
JVMCI架构:
┌─────────────────────────────────────────┐
│ Java Application │
├─────────────────────────────────────────┤
│ HotSpot JVM │ JVMCI Compiler (Graal) │
│ - 解释器 │ - 用Java编写 │
│ - C1编译器 │ - 独立类加载器 │
│ - C2编译器 │ - 通过JVMCI接口交互 │
└─────────────────────────────────────────┘JVMCI提供的主要功能:
- 访问JVM内部数据结构:如类、方法、常量池等
- 安装编译后的代码:将编译后的机器码安装到代码缓存
- 性能监控数据:获取方法调用计数、分支频率等运行时信息
- 推测性优化支持:支持基于类层次结构的推测性优化和去优化
11.5.4 代码中间表示
Graal编译器使用**程序依赖图(Program Dependence Graph,PDG)**作为中间表示,这是一种基于图的IR。
图结构特点:
- 节点(Node):表示操作(如加法、加载、调用等)
- 边(Edge):表示数据依赖和控制依赖
- 控制流与数据流统一:在一个图中表示所有信息
主要节点类型:
| 节点类型 | 描述 |
|---|---|
| ConstantNode | 常量值 |
| ParameterNode | 方法参数 |
| LoadFieldNode | 字段读取 |
| StoreFieldNode | 字段存储 |
| InvokeNode | 方法调用 |
| IfNode | 条件分支 |
| LoopBeginNode | 循环开始 |
| ReturnNode | 方法返回 |
优化示例:
通过图变换进行优化,如常量折叠:
优化前:
AddNode
├── ConstantNode(2)
└── ConstantNode(3)优化后:
ConstantNode(5)11.5.5 代码优化与生成
Graal编译器的优化流程:
- 前端:字节码解析,构建高层次的图表示
- 中端:进行各种优化变换
- 后端:指令选择、寄存器分配、代码生成
主要优化阶段:
- Canonicalization:规范化变换,如常量折叠
- Inlining:方法内联
- Escape Analysis:逃逸分析
- Partial Escape Analysis:部分逃逸分析(Graal特有)
- Loop Optimizations:循环优化
- Vectorization:向量化
部分逃逸分析(Partial Escape Analysis):
这是Graal对逃逸分析的改进,允许对象在部分控制流路径上分配在栈上:
public void method(boolean cond) {
Object obj = new Object(); // 可能栈上分配
if (cond) {
global = obj; // 此处逃逸,需要在堆上分配
}
// 其他路径可以栈上分配
}Graal编译器性能测试:
public class GraalPerformanceTest {
private static final int ITERATIONS = 100_000_000;
public static void main(String[] args) {
// 预热
for (int i = 0; i < 10000; i++) {
compute();
}
// 测试性能
long start = System.nanoTime();
long result = 0;
for (int i = 0; i < ITERATIONS; i++) {
result += compute();
}
long end = System.nanoTime();
System.out.println("Result: " + result);
System.out.println("Time: " + (end - start) / 1_000_000 + " ms");
}
private static long compute() {
long sum = 0;
for (int i = 0; i < 100; i++) {
sum += i * i;
}
return sum;
}
}运行对比:
# 使用C2编译器(默认)
java GraalPerformanceTest
# 使用Graal编译器
java -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler GraalPerformanceTest
# 使用GraalVM Native Image(AOT编译)
./graalvm-native-image GraalPerformanceTest
./graalperformancetestGraalVM Native Image:
GraalVM的Native Image功能可以将Java应用编译为本地可执行文件:
# 安装native-image工具
gu install native-image
# 编译为本地可执行文件
native-image -cp . HelloWorld
# 运行(无需JVM)
./helloworld优势:
- 启动时间极快(毫秒级)
- 内存占用极低
- 可打包为容器镜像
限制:
- 反射需要配置
- 动态代理需要配置
- JNI需要配置
- 不支持某些动态特性
代码生成:
Graal使用LIR(Low-level Intermediate Representation)作为机器无关的低层IR,最终生成目标平台的机器码。
11.6 本章小结
本章详细介绍了Java虚拟机的后端编译与优化技术:
即时编译器(JIT):
- HotSpot虚拟机采用解释器与编译器并存的架构
- C1编译器注重编译速度,C2编译器注重峰值性能
- 分层编译结合了两者的优势
- 热点代码探测基于方法调用计数器和回边计数器
提前编译器(AOT):
- Jaotc工具支持将字节码提前编译为本地代码
- 优势是启动快、内存占用低
- 劣势是失去跨平台性和动态优化能力
- 适合Serverless和微服务场景
编译优化技术:
- 方法内联是最重要的基础优化
- 逃逸分析支持栈上分配、标量替换和同步消除
- 公共子表达式消除避免重复计算
- 数组边界检查消除减少运行时检查
Graal编译器:
- 用Java编写的新一代JIT编译器
- 通过JVMCI接口与HotSpot集成
- 使用图结构的中间表示
- 支持部分逃逸分析等高级优化
理解这些编译优化技术,有助于开发者写出更适合JIT编译器优化的代码,充分发挥Java虚拟机的性能潜力。