文章目录
- [第11章 后端编译与优化](#第11章 后端编译与优化)
-
- [11.0 个人感悟](#11.0 个人感悟)
- [11.1 概述](#11.1 概述)
- [11.2 即时编译器](#11.2 即时编译器)
-
- [11.2.1 解释器与编译器](#11.2.1 解释器与编译器)
- [11.2.2 编译对象与触发条件](#11.2.2 编译对象与触发条件)
-
- [(1)方法调用计数器(Invocation Counter)](#(1)方法调用计数器(Invocation Counter))
- [(2)回边计数器(Back-Edge Counter)](#(2)回边计数器(Back-Edge Counter))
- [11.2.3 编译过程](#11.2.3 编译过程)
- [11.2.4 实战:查看及分析即时编译结果](#11.2.4 实战:查看及分析即时编译结果)
- [11.3 提前编译器](#11.3 提前编译器)
-
- [11.3.1 提前编译器的优劣得失](#11.3.1 提前编译器的优劣得失)
- [11.3.2 实战:Jaotc的提前编译](#11.3.2 实战:Jaotc的提前编译)
- [11.4 编译器优化技术](#11.4 编译器优化技术)
-
- [11.4.1 优化技术概览](#11.4.1 优化技术概览)
- [11.4.2 方法内联(最重要的优化技术之一)](#11.4.2 方法内联(最重要的优化技术之一))
- [11.4.3 逃逸分析(最前沿的优化技术之一)](#11.4.3 逃逸分析(最前沿的优化技术之一))
-
- [(1)栈上分配(Stack Allocation)](#(1)栈上分配(Stack Allocation))
- [(2)标量替换(Scalar Replacement)](#(2)标量替换(Scalar Replacement))
- (3)同步消除(锁消除)
- [11.4.4 公共子表达式消除(语言无关的经典优化技术之一)](#11.4.4 公共子表达式消除(语言无关的经典优化技术之一))
- [11.4.5 数组边界检查消除(语言相关的经典优化技术之一)](#11.4.5 数组边界检查消除(语言相关的经典优化技术之一))
- [11.5 实战:深入理解Graal编译器(本章略过)](#11.5 实战:深入理解Graal编译器(本章略过))
第11章 后端编译与优化
11.0 个人感悟
1. JIT为Java提速。 早年Java总被吐槽"运行慢",根本原因就是字节码解释执行天然比编译型语言的机器码慢。 HotSpot虚拟机通过JIT编译器将热点代码编译为本地机器码,这种优化让Java在长期运行的服务端场景下获得了接近C++的峰值性能。
2. 均衡,存乎万物之间-C1与C2的分工合作。 C1编译快但优化保守,C2编译慢但代码质量极高。如果只有C2,程序启动时用户得等半天;如果只有C1,长期运行的后台服务又达不到性能要求。分层编译的引入,把C1的快速启动和C2的高峰值性能揉在了一起------代码先用C1快速编译跑起来,跑热了再交给C2深度优化。
3. 时间积累的力量,编译器优化的威力远超想象。 以前学Java,"所有对象都在堆上分配",这话在JIT介入后就不绝对了。逃逸分析让那些只在方法内部使用的对象可以被"标量替换"或"栈上分配",锁消除让没有竞争风险的同步代码直接被优化掉。方法内联、逃逸分析、公共子表达式消除------这些技术的共同前提都是基于运行时的Profiling数据,而不是编译期的类型推导。人也一样,需要不断积累,进步。
11.1 概述
在第10章中我们讨论了前端编译器------把Java源码转变成Class字节码的过程。如果把字节码看作是程序语言的一种中间表示形式,那么无论在何时、何种状态下把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,这个过程都可以视为整个编译过程的后端。
后端编译主要有两种形式:
- 即时编译(Just-In-Time,JIT):在程序运行期间,将热点代码编译为本地机器码。
- 提前编译(Ahead-Of-Time,AOT):在程序运行之前,直接将字节码编译为本地机器码。
无论是提前编译器还是即时编译器,都不是Java虚拟机必须的组成部分,《Java虚拟机规范》并没有强制规定虚拟机必须包含这些编译器。但后端编译器性能的好坏、优化代码质量的高低,却是衡量一款商用虚拟机优秀与否的关键指标之一,它们也是商业Java虚拟机中最能体现技术水平与价值的功能。
11.2 即时编译器
目前主流的两款商用虚拟机(HotSpot、OpenJ9)中,Java程序最初都是通过解释器解释执行的。当虚拟机发现某个方法或代码块的运行特别频繁时,就把这些代码认定为**"热点代码"(Hot Spot Code)。为了提高热点代码的执行效率,在运行期间虚拟机会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,完成这个任务的后端编译器被称为即时编译器**。
11.2.1 解释器与编译器
(1)为何采用解释器与编译器并存的架构?
主流的商业虚拟机(HotSpot、OpenJ9等)内部都同时包含解释器与编译器,两者各有优势:
| 执行方式 | 优势 | 劣势 |
|---|---|---|
| 解释器 | 启动快、内存占用小、无需等待编译 | 重复执行的代码效率低 |
| 编译器 | 热点代码执行效率高(接近本地代码) | 编译耗时、内存占用大 |
解释器与编译器并存的设计带来了三个核心好处:
-
兼顾启动速度与峰值性能:程序启动时,解释器立即发挥作用,省去编译时间;程序运行一段时间后,编译器逐渐接手,把热点代码编译为本地代码,减少解释执行的中间损耗,获得更高的执行效率。
-
适应不同的资源约束:当运行环境内存资源紧张时,可以使用解释器执行以节约内存;反之可以使用编译执行来提升效率。
-
作为激进优化的"逃生门" :解释器让编译器可以大胆尝试"激进优化"------即那些不能保证在所有情况下都正确、但在大多数情况下能显著提速的优化手段。当激进优化的假设不成立(如运行时加载了新类导致类型继承结构发生变化、出现"罕见陷阱"),可以通过逆优化(Deoptimization) 退回到解释状态继续执行,而不是让程序崩溃。
(2)为何要有多个即时编译器?
HotSpot虚拟机中内置了三个即时编译器:
| 编译器 | 简称 | 定位 | 特点 |
|---|---|---|---|
| 客户端编译器 | C1 | 快速响应 | 启动快,优化保守,关注局部优化 |
| 服务端编译器 | C2 | 极致性能 | 编译慢,优化激进,性能比C1高30%以上 |
| Graal编译器 | Graal | 下一代替代方案 | JDK 10引入,Java编写,长期目标是替代C2 |
C1编译器的优化策略相对简单可靠,主要关注方法内联、常量传播、死代码消除等局部优化。C2编译器则专注于全局优化,编译时间更长,但生成的机器码质量更高,甚至会根据性能监控(Profiling)数据进行激进优化,如复杂的内联决策、逃逸分析、循环优化、向量化等。C2编译器的性能通常比C1高出30%以上,因此更适合长时间运行的后台程序。
(3)分层编译
由于即时编译需要占用程序运行时间,优化程度越高的代码,编译耗时也越长。为了在启动速度和峰值性能之间找到最佳平衡,从Java 7开始引入并在Java 8中成为默认策略的分层编译(Tiered Compilation),将编译过程划分为5个层次:
| 层次 | 执行方式 | 说明 |
|---|---|---|
| 第0层 | 解释执行 | 解释器执行,收集性能监控数据(Profiling) |
| 第1层 | C1(无Profiling) | 简单可靠的优化,快速编译,不收集监控数据 |
| 第2层 | C1(轻量Profiling) | 仅收集方法和回边次数统计 |
| 第3层 | C1(完整Profiling) | 收集全部分支跳转、类型信息等详细数据,为C2做准备 |
| 第4层 | C2 | 利用C1收集的详尽数据,进行最大程度的优化编译 |
分层编译的核心思想是渐进式优化:代码先快速编译跑起来,边跑边收集运行数据,热到一定程度再升级到更高层次的优化。这种设计让JVM在启动速度和峰值性能之间达到了近乎完美的平衡。
(4)运行模式
在分层编译出现前,HotSpot通常采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机的运行模式:
-client:强制使用C1编译器-server:强制使用C2编译器-Xint:强制纯解释模式,编译器完全不介入-Xcomp:强制编译模式,优先采用编译执行
可以通过java -version命令查看当前虚拟机的运行模式。自Java 9起,-server模式(启用C2或分层编译)已成为默认选项。
11.2.2 编译对象与触发条件
JIT编译器并不会编译所有代码,只编译那些被认定为"热点"的部分。HotSpot采用基于计数器的热点探测方式,为每个方法建立两个计数器:
(1)方法调用计数器(Invocation Counter)
统计方法被调用的次数。需要注意的是,该计数器统计的不是绝对次数,而是相对的执行频率 。当超过一定的时间限度,如果方法的调用次数仍不足以触发即时编译,这个方法的调用计数会被减少一半 ,这个过程称为热度衰减(Counter Decay)。这种设计确保只有真正被频繁调用的方法才会触发编译,避免一过性的高频调用浪费编译资源。
在传统模式下,Client模式的默认编译阈值约为1500次,Server模式约为10000次。在分层编译模式下,阈值是动态调整的。
(2)回边计数器(Back-Edge Counter)
统计方法中循环体的执行次数。每当程序执行一次循环的回边(即从循环末尾跳回到循环开头),计数器就会增加。当回边计数器达到阈值时,会触发栈上替换(On-Stack Replacement,OSR)------将正在解释执行的循环体在运行时替换为编译后的机器码,而不需要等整个方法执行完毕。
11.2.3 编译过程
JIT编译的过程大致可以分为以下几个阶段:
- 字节码解析:将字节码转换为便于优化的中间表示(IR)。
- 优化阶段:对IR应用各种优化技术(内联、逃逸分析、循环优化等)。
- 代码生成:将优化后的IR生成为目标平台的本地机器码。
- 代码缓存(Code Cache) :将编译后的机器码存入Code Cache,后续调用直接跳转到机器码执行。
11.2.4 实战:查看及分析即时编译结果
本节涉及通过JVM参数观察即时编译行为的具体操作,感兴趣的读者可参考原书第11.2.4节。
11.3 提前编译器
11.3.1 提前编译器的优劣得失
提前编译(AOT)是在程序运行之前,直接将字节码编译为本机机器码。与JIT相比,AOT各有优劣:
AOT的优势:
- 消除预热时间:程序启动即可直接运行优化后的机器码,没有JIT的预热延迟。
- 降低内存占用:不需要在运行时保留JIT编译器本身和编译中间数据。
- 适合短生命周期程序:对于启动后很快退出的工具类程序,AOT的收益大于JIT。
AOT的劣势:
- 静态信息有限:无法像JIT那样基于运行时Profiling数据做精准优化。
- 跨平台支持复杂:需要为每个目标平台单独编译。
- 不支持动态特性:动态加载的类、反射生成的方法等无法被提前编译。
权衡结论:AOT和JIT并非互斥关系,而是可以互补的。GraalVM的Native Image就是两者结合的典范------核心代码通过AOT提前编译,运行时需要动态优化的部分仍可借助JIT。
11.3.2 实战:Jaotc的提前编译
本节涉及Jaotc工具的具体使用方法,感兴趣的读者可参考原书第11.3.2节。
11.4 编译器优化技术
11.4.1 优化技术概览
JIT编译器的优化技术非常丰富,本节重点介绍四种最具代表性的优化技术。在讨论具体优化之前,需要先理解一个重要概念:在即时编译器中,方法内联具有首位的重要性------它不仅消除了方法调用的开销,更关键的是为其他优化技术(如逃逸分析、常量传播、死代码消除等)打开了大门。
11.4.2 方法内联(最重要的优化技术之一)
方法内联(Method Inlining) 是指将目标方法的代码直接复制到调用方法中,消除方法调用的开销(压栈、跳转、返回等)。更重要的是,内联后编译器可以获得更大的代码上下文,从而应用更多后续优化。
java
// 内联前
public int calculate() {
return add(1, 2);
}
private int add(int a, int b) {
return a + b;
}
// 内联后(编译器视角)
public int calculate() {
return 1 + 2; // 常量折叠后直接变成 3
}
在Java中,很多方法的调用都是虚方法调用(通过invokevirtual和invokeinterface指令)。对于虚方法,编译器无法在编译期确定接收者的实际类型,因此内联面临挑战。JIT通过类层次分析(Class Hierarchy Analysis,CHA) 来解决这个问题:如果经过分析发现某个虚方法在当前加载的类中只有一个实现版本,就可以将其当作非虚方法进行内联,这种技术称为去虚拟化(Devirtualization) 。即使后续加载了新的子类,也可以通过逆优化机制回退到解释执行。
11.4.3 逃逸分析(最前沿的优化技术之一)
逃逸分析(Escape Analysis) 是目前Java虚拟机中比较前沿的优化技术。它的核心任务是:分析对象的作用域,判断对象是否会"逃逸"出当前方法或当前线程。
逃逸分析的结果分为三种情形:
| 逃逸等级 | 说明 | 可进行的优化 |
|---|---|---|
| 未逃逸(No Escape) | 对象完全局限在当前方法内 | 栈上分配、标量替换、锁消除 |
| 方法逃逸(Method Escape) | 对象作为参数传递给其他方法 | 有限的优化 |
| 线程逃逸(Thread Escape) | 对象被其他线程访问 | 无法进行逃逸相关优化 |
基于逃逸分析的信息,JIT可以执行以下优化:
(1)栈上分配(Stack Allocation)
传统的Java对象都在堆上分配,需要经过GC回收。如果逃逸分析发现一个对象不会逃逸出方法,理论上可以直接在栈上分配------随着方法结束自动销毁,完全不需要GC介入。
注意 :HotSpot虚拟机实际上并未实现真正的"栈上分配",而是通过标量替换来达到类似的效果。这是因为在栈上直接分配完整对象需要修改JVM底层的对象布局和GC逻辑,代价过高,而标量替换只需在编译器层面修改,实现成本低且效果相近。
(2)标量替换(Scalar Replacement)
标量 是指一个无法再分解成更小数据的数据,如Java中的基本数据类型。聚合量则是指可以继续分解的数据,Java中的对象就是最典型的聚合量。
如果逃逸分析发现一个对象没有逃逸,且可以被拆散,JIT就不会真正创建这个对象,而是直接在栈上分配它的成员变量(标量)。例如:
java
void test() {
Point point = new Point(1, 2);
System.out.println(point.x + point.y);
}
// 经过标量替换后,相当于
void test() {
int x = 1;
int y = 2;
System.out.println(x + y);
}
(3)同步消除(锁消除)
线程同步是一个相对耗时的操作。如果逃逸分析能确定一个变量不会被其他线程访问,那么对这个变量的读写就没有竞争,编译器可以消除掉针对它的同步措施。
java
void doSomething() {
Object obj = new Object();
synchronized(obj) {
// obj 没有逃逸出方法,synchronized 可以被消除
}
}
11.4.4 公共子表达式消除(语言无关的经典优化技术之一)
公共子表达式消除(Common Subexpression Elimination) 是指:如果一个表达式已经被计算过,并且两次计算之间表达式中涉及的变量都没有发生变化,那么第二次计算可以直接复用之前的结果,无需重新计算。
java
int a = b * c + g;
int d = b * c * e;
// 消除公共子表达式 b * c 后
int t = b * c;
int a = t + g;
int d = t * e;
11.4.5 数组边界检查消除(语言相关的经典优化技术之一)
Java作为一门安全语言,每次数组访问都会自动进行边界检查------检查索引是否在[0, length-1]范围内。如果越界,抛出ArrayIndexOutOfBoundsException。这个安全检查保证了程序的健壮性,但也带来了性能开销。
java
int sum(int[] arr) {
int result = 0;
for (int i = 0; i < arr.length; i++) {
result += arr[i]; // 每次循环都会检查 i 是否越界
}
return result;
}
JIT编译器可以通过数据流分析发现:在循环体内i始终在[0, arr.length-1]范围内,因此可以将边界检查提升到循环外,或者直接消除。这种优化对于循环密集型代码的效果非常显著。
11.5 实战:深入理解Graal编译器(本章略过)
书中11.5节详细介绍了Graal编译器的架构设计、与C2的对比分析以及使用方式。Graal是用Java编写的JIT编译器,自JDK 10起以实验性特性引入HotSpot虚拟机,长期目标是替代已有二十余年历史的C2编译器。
如果对Graal编译器的深入原理或Native Image技术有专门兴趣,建议直接阅读原书11.5节,或查阅GraalVM官方文档获取最新信息。
如果对你有帮助,请点赞,关注,收藏。感谢,共勉,祝好!