JVM编译优化

即时编译器

HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或者简称为C1编译器和C2编译器。Java8默认开启Server模式。用户可以使用“-client”或“-server”参数去指定编译模式。

C1编译器启动速度快,关注局部简单可靠的优化,比如方法内联、常量传播等。C2编译器 关注一些编译耗时较长的全局优化,甚至会根据性能监控(profiling)进行一些不可靠的激进优化。它的性能通常比 C1编译器 高30%以上,适用于长时间运行的后台程序。
在这里插入图片描述

C1和 C2编译器是由C++写成,目前还有用Java 编写的即时编译器Graal。

Java 7 引入了分层编译的概念,综合了C1的启动性能优势和 C2 的峰值性能优势。分层编译主要分为如下层级:

1)解释执行。

2)执行C1代码,根据运行情况进行profiling。

3)执行C2代码,根据profiling进行激进优化。

profiling 是指在程序执行过程中,收集能够反映程序执行状态的数据 。profiling越多,其额外的性能开销越大。其中最基本的统计数据是方法的调用次数以及循环回边的执行次数,用于判断热点代码,并触发即时编译。计数默认阈值在Client模式下是1500次,在Server模式下是10000次。
在这里插入图片描述

方法调用计数器

方法调用计数器(Invocation Counter),顾名思义,这个计数器就是用于统计方法被调用的次数。需注意该 计数器统计的非绝对次数,而是一个相对的执行频率。当超过一定的时间限度,如果方法的调用次数仍不足以触发即时编译,那这个方法的调用计数会被减少一半,这个过程称为热度的衰减 (Counter Decay),而这段时间就称为此方法统计的半衰周期 (Counter Half Life Time)。

// 假设input这个Http接口单位时间内调用doSomething方法一万次以上
@RequestMapping(value = "/input")
CommonResponse input(@RequestBody InputRequest request){
    // 将doSomething进行方法内联编译优化
    return CommonResponse.ok(doSomething(request));
}

 void doSomething() {
   // 将当前代码编译成本地机器码
    ......
}
循环回边计数器
void loop() {
    int sum = 0;
    for (int i = 0; i < 10; i++) {
        sum += i;
    }
}

上面这段代码经过编译生成下面的字节码:

  public void loop();
    Code:
       0: iconst_0
       1: istore_1
       2: iconst_0
       3: istore_2
       4: iload_2
       5: bipush        10
       7: if_icmpge     20
      10: iload_1
      11: iload_2
      12: iadd
      13: istore_1
      14: iinc          2, 1
      17: goto          4
      20: return

在上述字节码中,循环回边计数器被存储在第7行的if_icmpge指令中。if_icmpge指令用于接收两个操作数用于比较计算,以决定循环体 跳转 的位置。在解释执行时,每当运行一次该指令,该方法的循环回边计数器加1。

循环回边计数器(Loop BackEdge Counter)触发的优化技术 叫作栈上替换 (On Stack Replacement , OSR) 。假设有 一个 方法 只被 调用一次,但却包含超过一万次以上循环迭代次数,这个循环方法无法以方法调用计数来统计。而 栈上替换技术 解决了这个问题。当编译器检测到一个循环已经迭代足够次数,它会将循环中的代码动态编译成机器代码,并在适当的时机进行切换。

void largeLoop() {
    // 假设largeLoop是一个只被调用一次,但包含100百万次循环迭代
    // 1)循环回边计数器通过迭代计数统计,触发即时编译
    // 2)将循环中的代码编译成本地机器码
    for (int i = 0; i < 1000000; i++) {
        ......
    }
}
提前编译器

提前 编译器 (Ahead Of Time, AOT ),是与即时编译器相对立的一个概念,指在程序运行之前将字节码转换为本地机器码,从而减少了运行时的编译开销, 提高启动速度。属于一种静态编译手段。

但Java 语言本身的动态特性带来了额外的复杂性,影响了程序静态编译代码的质量。例如 Java 语言的运行时动态类加载,因为 提前 编译器 是在程序运行前进行编译的,所以无法获知这一信息。

另外尽管 提前 编译器可以将整个程序的代码编译成机器码 ,但在编译质量上比不上即时编译器通过运行时 profiling对热点代码的编译优化 。其存在的目的在于避免即时编译器的运行时性能消耗或内存消耗,或者避免解释程序的早期性能开销。

在运行速度上来说, 提前 编译器 编译出来的代码比即时编译器慢,但是比解释执行快。而编译时间上, 提前 编译器也要占据更多时间 。因此, 提前 编译器 是Java虚拟机牺牲质量换取性能的一种策略。

对于这解释执行,即时编译器,提前编译的编译速度和编译质量如下:

编译速度:解释执行 > 提前编译器 > 即时编译器。

编译质量:即时编译器 > 提前编译器 > 解释执行。

中间表达形式

通常将编译器分为前端和后端。前端对源代码进行词法分析和语法语义分析,生成中间表达形式,也就是 IR(Intermediate Representation )。前端生成的IR叫 高级中间表达形式HIR ,这种优化主要跟代码本身相关。后端将HIR(High Intermediate Representation ) 转换成 低级中间表达形式 L IR(Low Intermediate Representation )进行进一步优化,这种优化与机器硬件有关。最后 L IR会被翻译成目标机器代码。

不考虑解释执行,从源代码到最终的机器码会经过两轮编译:

1)Java 编译器(比如javac)将源代码编译成字节码。

2)即时编译器将字节码编译成机器码。

对于即时编译器,是直接将字节码作为一种 IR。不过字节码的结构复杂,字节码这样代码形式的IR也不适合做全局的分析优化。现代编译器一般采用图结构的IR,静态单赋值(Static Single Assignment,SSA)IR是目前比较常用的一种。HotSpot的C2采用的是一种名为 Sea-of-Nodes 的 SSA IR。

关于中间表达形式的具体实现本文不将阐述,读者有兴趣可以查阅相关资料。本文将举例讲解一些后端的编译优化方法。

机器无关的编译优化

编译优化的方法主要可以分为机器无关与机器相关的优化。

1)机器无关的优化与硬件特征无关,比如把常数值在编译期计算出来(常数折叠)

2)机器相关的优化则需要利用某硬件特有的特征,比如 SIMD 指令等。

值编号

值编号(Value Numbering)用于消除冗余的计算。编译器通过跟踪每个计算的值,如果发现两个计算的值相同,就可以将其中一个计算替换为另一个计算的结果。

// 值编号前的代码
int a = 5;
int b = 10;
int c = a + b;
int d = a + b;

// 值编号后的代码
int a = 5;
int b = 10;
int c = a + b;
int d = c;
常数折叠

常量折叠(Constant Folding) 通过在编译时计算常数表达式的值,将这些表达式替换为它们的计算结果。

// 常量折叠前的代码
int a = 5 * 10;

// 常量折叠后的代码
int a = 50;

``

常数传播

常数传播(Constant Propagation)它通过分析代码中的常数赋值和使用,将常数值直接传播到使用它们的表达式中。

// 常量传播前的代码
int a = 10;
int b = 20;
int c = a + b;

// 常量传播后的代码
int c = 10 + 20;
公共子表达式消除

公共子表达式消除(Common Subexpression Elimination,CSE)通过识别并消除重复的子表达式,避免在运行时多次计算相同的子表达式。

// 公共子表达式消除前的代码
int a = x * y;
int b = x * y;

// 公共子表达式消除后的代码
int a = x * y;
int b = a;
null判断消除

null判断消除(Null Check Elimination)通过在编译时分析代码,确定某些引用不可能为null,从而消除不必要的null检查。

// null判断消除前的代码
String str = "Hello, World!";
// Null检查
if (str != null) {
    System.out.println(str);
}

// null判断消除后的代码
// 编译器可以确定str不可能为null,从而消除null检查
String str = "Hello, World!";
System.out.println(str);
边界检查消除

边界检查消除(Bounds Check Elimination)通过在编译时分析代码,判断数组访问是否越界,从而在运行时避免不必要的边界检查。

// 边界检查消除前的代码
// for循环中的数组访问array[i]需要进行边界检查
int[] array = new int[10];
for (int i = 0; i < array.length; i++) {
    array[i] = i * 2;
}

// 边界检查消除后的代码
// 编译器消除了for循环中的边界检查,因为它可以在编译时确定i的值不会越界
int[] array = new int[10];
for (int i = 0; i < 10; i++) {
    array[i] = i * 2;
}
循环展开

循环展开(Loop Unrolling)通过减少循环次数并在每次循环中执行更多的操作,以减少循环控制开销。 同时它还会增加一个基本块中的指令数量,从而为指令排序的优化算法创造机会。在循环展开的基础上,可以实现把多次计算优化成一个向量计算。

// 循环展开前的代码
int sum = 0;
for (int i = 1; i <= 10; i++) {
    sum += i;
}

// 循环展开后的代码
// 优化后将循环次数减少到了5次
int sum = 0;
for (int i = 1; i <= 10; i += 2) {
    sum += i;
    sum += i + 1;
}

``

机器相关的编译优化

与机器相关的编译优化常见的有指令选择(Instruction Selection),寄存器分配(Register Allocation),窥孔优化(Peephole Optimization)等。这些优化通常在编译阶段进行,而不是在源代码级别,因此不能直接展示代码示例。本文不将阐述,读者有兴趣可以查阅相关资料。

向量化计算

向量化计算(Vectorization) 允许在单个操作中对多个数据元素执行相同的操作,从而提高代码的运行效率。向量化计算的性能取决于底层硬件指令支持。比如SMID指令( Single Instruction Multiple Data )。 SIMD表示单指令多数据 ,它是指将多个数据放到到单个专门的寄存器,然后用一条指令完成计算。SIMD的性能取决于底层硬件的支持,一般来说,越新的 SIMD 指令,它所支持的寄存器长度越大,功能也越强。

Java中的向量化计算可以通过使用Vector API(JEP 338,目前为实验性功能)来实现。

// 向量化计算前的代码
int[] a = {1, 2, 3, 4, 5, 6, 7, 8};
int[] b = {8, 7, 6, 5, 4, 3, 2, 1};
int[] result = new int[8];
for (int i = 0; i < a.length; i++) {
    result[i] = a[i] * b[i];
}

// 向量化计算后的代码
// Vector API来实现向量化计算。允许在单个操作中计算两个数组的元素乘积
int[] a = {1, 2, 3, 4, 5, 6, 7, 8};
int[] b = {8, 7, 6, 5, 4, 3, 2, 1};
int[] result = new int[8];
VectorSpecies<Integer> species = IntVector.SPECIES_256;
IntVector va = IntVector.fromArray(species, a, 0);
IntVector vb = IntVector.fromArray(species, b, 0);
IntVector vr = va.mul(vb);
vr.intoArray(result, 0);
方法内联

方法内联(Method Inlining)是指在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段 , 是编译优化里最为重要的一环。

Java程序的方法 调用 会涉及到如下步骤:

1) 保存当前方法的执行位置 。

2) 创建并压入用于 调用 方法的栈帧 。

3) 执行运算调用方法的程序逻辑 。

4) 弹出栈帧,再恢复当前方法的执行 。

void a() { b();}
void b() { c();}
void c() { d();}
void d() {}
// 对于如上方法调用,Java虚拟机会创建:
// 栈帧d -> 栈帧c -> 栈帧b -> 栈帧a

``

每一个方法从调用开始到结束, 对应着一个栈帧从入栈到出栈的过程。每个栈帧需要内存分配,频繁创建栈帧(比如递归)也会引发栈内存溢出异常(StackOverFlow Exception)。总之方法调用对程序性能影响很大,因此方法内联可认为是性能优化之母。

void test() {
    int a = 10;
    int b = 20;
    // int sum = add(a, b); // 原始方法调用
    int sum = a + b; // 方法内联
}

// 这个方法在内联后将不再被使用
int add(int x, int y) {
    return x + y;
}

方法内联除了消除方法调用 本身带来的性能开销 ,更重要的意义在于为后续其他优化建立良好的基础。例如下面这段代码,如果不做方法内联,无法发现这两个方法的代码都是没有意义的,也就无法做无用代码消除的优化。

void print(Object o) {
    if (o != null) {
        System.out.print(o);
    }
}

void testPrint() {
    Object o = null;
    print(o);
}

一般来说 , 内联的方法越多,生成代码的执行效率越高。但是 同时 内联的方法越多,编译时间也就越长 。即时编译器默认内联层级在9层。

另外方法的类型也会影响 方法内联 。如果方法是 final、private 、static, 不会被子类重载 , 可以大胆 进行 内联。但对于需要动态绑定的虚方法调用,即时编译器则需要先对虚方法调用进行去虚化(Devirtualize)。

Java虚拟机引入一种称为“类型继承关系分析”(Class Hierarchy Analysis,CHA)的技术。如果是虚方法,则会向CHA查询此方法是否存在多个版本。如果只有一个版本,那么可以进行内联。但是这种内联属于激进优化,后续程序执行过程中,如果该方法接受者继承类的关系没有发生变化,这个内联优化就可以一直 使用下去。反之就要放弃这个内联优化,通过 逆优化(Deoptimization) 退回到解释执行,或者重新编译。

逃逸分析

逃逸分析(Escape Analysis) 用于分析对象的作用域和生命周期。通过逃逸分析,即时编译器可以确定对象是否只在创建它的方法中使用,还是在方法之外的其他地方使用。基于这些信息,即时编译器可以执行一些优化,例如栈上分配(Stack Allcotion)、同步锁消除( Synchronization Elimination )和标量替换( Scalar Replacement )。

即时编译器判断对象是否逃逸的依据主要有:

1)作为方法的返回值。

2)赋值给全局变量。

3)作为参数传递给其他方法。

以上情况无法判断对象是否会被其他方法访问使用,因此对象被认为是逃逸。下面代码的是对象未逃逸的例子:

// add方法中创建了一个名为NonEscapeObject的对象。
// 这个对象仅在add方法中使用,用于计算两个整数的和。
// 这个对象没有作为方法的返回值、赋值给全局变量或作为参数传递给其他方法。
// 因此它被认为是未逃逸的。
int add(int a, int b) {
  NonEscapeObject o = new NonEscapeObject(a, b);
  return obj.getX() + obj.getY();
}

class NonEscapeObject {
    private int x;
    private int y;
}
同步锁消除

线程同步是一个相对耗时的过程,如果逃逸分析能确定一个 共享变量 不会逃出线程,无法被其他线程访问,那这个 共享 变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。

标量替换

标量(scalar)是指一个无法再分解成更小的数据的数据。Java 中的基本数据类型就是标量。

相对的Java 中的对象就是聚合量(Aggregate),因为它可以分解成其他聚合量和标量。

标量替换可以减少堆内存的占用。因为一旦不需要创建对象,就不再需要分配堆内存。

void test() {
   Point point = new Point(1,2);
   System.out.println("point.x" + point.x + ";point.y" + point.y);
}

class Point {
    private int x;
    private int y;
}

标量替换后的代码

void test() {
    int x = 1;
    int y = 2;
    System.out.println("point.x = " + x + "; point.y=" + y);
}
栈上分配

Java的对象是在堆上分配的,Java虚拟机对堆内存的垃圾对象回收是一个耗时的过程。 在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁,垃圾收集系统的压力将会小很多。

逃逸分析自身也需要进行一系列复杂的分析,这其实也是一个相对耗时的过程。因此无法确保逃逸分析的性能收益一定能高于他的消耗。Hotspot虚拟机,并没有进行实际的栈上分配,而是使用了标量替换这一技术。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段

Java虚拟机编译优化总结

在本文中,我们对Java虚拟机编译优化进行系统的概述。Java虚拟机通过 基于栈的 字节码 解释执行 实现了Java程序的跨平台性 。 Java编译器将源代码编译成字节码,这增加了一层间接性, 然而 这种间接性也为 Java虚拟机 提供了更多的优化机会。

由于Java程序需要在Java虚拟机上运行,其运行时性能可能不如直接编译成机器码的语言(如C++)。然而随着 Java虚拟机 的发展,通过即时编译等技术,Java的运行时性能已经得到了很大的提升。 并且即时编译通过运行时 性能监控 ,对局部热点代码的特定优化,其性能甚至可以超过C++。

另外Java是动态的类型安全的语言,因此Java虚拟机会在编译和运行时频繁检查。比如 空指针、数组越界、类型转换等。尽管这种检查会带来性能损耗,但有助于程序的稳定和可靠,从而提高开发的效率。

Java语言的很多优势也是由动态性带来的,静态编译器无法进行以运行期时性能监控为基础的优化,如调用频率预测(Call Frequency Prediction) 、分支频率预测 (Branch Frequency Prediction)等,这些都会成为Java语言独有的性能优势。

总的来说,Java虚拟机的编译优化是提高Java程序性能的重要手段。通过深入理解这些技术,可以更好地编写高效的Java代码,同时也可以更好地理解Java虚拟机的工作原理。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值