JVM学习笔记(12) 第四部分 程序编译与代码优化 第11章 后端编译与优化

文章目录

  • [第11章 后端编译与优化](#第11章 后端编译与优化)
    • [11.0 个人感悟](#11.0 个人感悟)
    • [11.1 概述](#11.1 概述)
    • [11.2 即时编译器](#11.2 即时编译器)
    • [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等)内部都同时包含解释器与编译器,两者各有优势:

执行方式 优势 劣势
解释器 启动快、内存占用小、无需等待编译 重复执行的代码效率低
编译器 热点代码执行效率高(接近本地代码) 编译耗时、内存占用大

解释器与编译器并存的设计带来了三个核心好处:

  1. 兼顾启动速度与峰值性能:程序启动时,解释器立即发挥作用,省去编译时间;程序运行一段时间后,编译器逐渐接手,把热点代码编译为本地代码,减少解释执行的中间损耗,获得更高的执行效率。

  2. 适应不同的资源约束:当运行环境内存资源紧张时,可以使用解释器执行以节约内存;反之可以使用编译执行来提升效率。

  3. 作为激进优化的"逃生门" :解释器让编译器可以大胆尝试"激进优化"------即那些不能保证在所有情况下都正确、但在大多数情况下能显著提速的优化手段。当激进优化的假设不成立(如运行时加载了新类导致类型继承结构发生变化、出现"罕见陷阱"),可以通过逆优化(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编译的过程大致可以分为以下几个阶段:

  1. 字节码解析:将字节码转换为便于优化的中间表示(IR)。
  2. 优化阶段:对IR应用各种优化技术(内联、逃逸分析、循环优化等)。
  3. 代码生成:将优化后的IR生成为目标平台的本地机器码。
  4. 代码缓存(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中,很多方法的调用都是虚方法调用(通过invokevirtualinvokeinterface指令)。对于虚方法,编译器无法在编译期确定接收者的实际类型,因此内联面临挑战。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官方文档获取最新信息。


如果对你有帮助,请点赞,关注,收藏。感谢,共勉,祝好!

相关推荐
栈溢出了3 小时前
Redis 分片集群与哈希槽笔记
java·redis·笔记·spring
我命由我123454 小时前
复利极简理解
经验分享·笔记·学习·职场和发展·求职招聘·职场发展·学习方法
AOwhisky4 小时前
Python 学习笔记(第三期)——流程控制核心知识点自测与详解
开发语言·笔记·python·学习·云原生·运维开发·流程控制
tyqtyq224 小时前
药品说明书解读 —— AI 安全用药助手,鸿蒙原生应用深度解析
人工智能·学习·华为·生活·harmonyos
小心亦新5 小时前
STM32学习13 定时器1中断
stm32·嵌入式硬件·学习
我命由我123455 小时前
72 法则极简理解
经验分享·笔记·学习·职场和发展·求职招聘·职场发展·学习方法
2601_949817726 小时前
C++指针与引用深度精讲:底层原理、差异对比与高阶实战陷阱
java·jvm·c++
qq_263_tohua6 小时前
第112期 CNN学习,不错的文章
学习
我想我不够好。6 小时前
满级的惩戒在范围
学习
LiaoWL1236 小时前
【SpringCloud合集-04】Sentinel 流量控制与熔断降级 学习笔记
学习·spring cloud·sentinel