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官方文档获取最新信息。


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

相关推荐
zhangchaoxies2 小时前
HTML怎么显示同步最后成功时间_HTML “上次同步:X分钟前”【教程】
jvm·数据库·python
羊群智妍2 小时前
2026年GEO监测工具,AI搜索优化免费指南
笔记
m0_514520572 小时前
mysql服务器如何优化网络传输设置_调整tcp相关内核参数
jvm·数据库·python
m0_640309302 小时前
如何快速重置SQL表中的自增ID_使用ALTER TABLE重置计数
jvm·数据库·python
2301_764150562 小时前
CSS如何制作响应式导航栏_利用Flexbox实现自适应水平排列
jvm·数据库·python
qq_334563552 小时前
HTML怎么创建表格_HTML表格结构与基本语法【教程】
jvm·数据库·python
wangcheng3032 小时前
原创检测到底在检测什么
笔记
yejqvow122 小时前
C#怎么实现缓存功能 C#如何用MemoryCache和Redis实现数据缓存提升访问速度【架构】
jvm·数据库·python
2401_835956812 小时前
如何通过phpMyAdmin修改Laravel用户的密码_使用Bcrypt哈希格式更新User表字段
jvm·数据库·python