深入JVM七:逃逸分析对对象分配的优化

在了解逃逸分析之前我们先来了解一下JVM的即时编译器,即时编译器的工作流程原理如下:

Java程序最初运行时通过解析器进行解释执行的,当虚拟机发现某个方法或代码块运行的特别频繁,就会把这些代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时,虚拟机会将这些“热点代码”编译成本地机器吗,并且通过各种手段对代码进行各种的优化。那么在运行期间完成上述功能的就是即时编译器。需要注意的是,这里的编译不是将.java文件编译成字节码文件,而是将字节码编译成中间码或者本地机器码。解析器也是解释的字节码并转换成本地机器可运行的本地机器码进行执行的。

在上面叙述了即时编译器的功能职责,但是在虚拟机中,一般的解释器与编译器是同时存在协作工作的。解释器是通过解释字节码然后解释执行,相对来说执行的效率较低,但是不会带来由于编译生成本地机器码从而导致占用存储空间的问题,相反的,编译器是将字节码编译成本地机器码,然后职级执行本地机器码,执行效率高,但是由于编译的本地机器码的存储问题,所以会带来占用大量存储空间问题。针对不同的场景,有的虚拟中只有解析器再工作。一般地,虚拟机中解释器与编译器是同时存在,协作合作运行的。程序运行时,一般由解释器进行解释执行,当遇到“热点代码”时,则给编译器提交一个编译请求,将“热点代码”编译成本地机器码,在后续再次执行时,使用编译好的本地机器码。当然,在编译器编译的过程中,会对代码进行优化,当然也包括激进优化(可以理解成不成熟的优化技术进行优化),当优化失败则退回继续使用解释器进行解释执行。解释器和编译器的合作工作如下:
在这里插入图片描述
VM的运行模式:

  • 解释模式(Interpreted Mode):只使用解释器(-Xint
    强制JVM使用解释 模式),执行一行JVM字节码就编译一行为机器码。
  • 编译模式(Compiled Mode):只使用编译器(-Xcomp JVM使用编译模式),先将所有JVM字节码一次编译为机器码,然
    后一次性执行所有机器码。
  • 混合模式(Mixed Mode):依然使用解释模式执行代码,但是对于一些 “热 点”
    代码采用编译模式执行,JVM一般采用混合模式执行代码。

在了解了虚拟机的运行过程中的解释和编译,再来说一下逃逸分析,逃逸分析是编译器在编译“热点代码”时,进行代码优化的优化参考,逃逸分析主要的做的内容为:

分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中。这种称为逃逸方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称之为线程逃逸;从不逃逸,方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。

需要注意的是:逃逸分析不是直接直接的优化手段,而是为其他优化手段提供依据的分析技术。而依据逃逸分析的优化手段就包括:栈上分配、标量替换和同步消除。而设计的对象分配的优化手段为栈上分配和标量替换,接下来了解一下两个优化技术。

优化不是对字节码的重写,而是在编译过程中对编译的中间码或者机器码进行的优化。

栈上分配

在Java虚拟中,新创建的对象在堆上分配已经成为常识。在堆内存上分配空间,对于对象在不同线程间的共享提供了遍历,但是当对象“无用时”,对对象的收集却是比较棘手的,那么是否可以对那些对象不涉及到线程间共享,或者作用域知识在方法中的新对象,是否可以分配到栈中的栈帧上呢?因为栈帧是随着方法的返回而出栈销毁的,那么对象会随着栈帧的销毁从而回收,避免了对象在堆中分配,占用垃圾收集的资源。而栈上分配就是虚拟机对这种场景的优化,虚拟机基于逃逸分析的结果,从而判定方法中的对象是否有逃逸,从而确定对象是在堆中分配还是在栈中分配。以下通过实例来演示下栈上分配的优化效果(这里是基于JDK1.8,先关闭逃逸分析):

/**
 * @Description: 逃逸分析之栈上分配
 *              VM args: -Xms64m -Xmx64m -XX:+PrintGC -XX:-DoEscapeAnalysis
 * @Author: binga
 * @Date: 2020/8/28 16:45
 * @Blog: https://blog.csdn.net/pang5356
 */
public class StackAllocationTest {

    public static void main(String[] args) {
        while (true) {
            User user = new User();
        }
    }
}

class User {

}

运行结果:

[GC (Allocation Failure)  16384K->736K(62976K), 0.0019939 secs]
[GC (Allocation Failure)  17120K->736K(62976K), 0.0011987 secs]
[GC (Allocation Failure)  17120K->720K(62976K), 0.0010383 secs]
[GC (Allocation Failure)  17104K->688K(62976K), 0.0011617 secs]

可以看到,频发的进行垃圾回收,证明新创建的User对象分配到了堆内存中,接下来把逃逸分析开启:

-XX:+DoEscapeAnalysis

再次运行,则没有了垃圾收集的日志打印,证明对象均在栈中分配。

标量替换

首先来说明两个概念:标量和聚合量。

  • 标量:若一个数据已经无法在分解成更小的数据表示了,如Java中的原始数据类型(int、long以及reference类型)都不能再分解,那么这些数据就称之为标量。

  • 聚合量:聚合量就是包含多个标量的,可以在分的,称之为聚合量。如下一下User类:

    class User {
      	private String name;
      	private int age;
      	private char sex;
    }
    

    该类的实例就是一个聚合量。

标量替换就是将一个对象拆散。根据程序的方法情况,将其用到的成员变量恢复为原始类型来访问,这个过程就是标量替换。来看一下实例代码:

/**
 * @Description: 逃逸分析值标量替换
 *        VM args: -Xms64m -Xmx64m -XX:+PrintGC -XX:-DoEscapeAnalysis 
 * 		  -XX:-EliminateAllocations
 * @Author: binga
 * @Date: 2020/8/28 16:53
 * @Blog: https://blog.csdn.net/pang5356
 */
public class ScalarSubstitutionTest {

    public static void main(String[] args) {
        Test test = new Test();
        while (true) {
            test.test(10);
        }
    }
}

class Test {

    public int test(int value) {
        int value1 = value + 2;
        Point point = new Point(value1, 30);
        return point.getX();
    }
}

class Point {
    private int x;
    private int y;

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() {
        return x;
    }

    public void setX(int x) {
        this.x = x;
    }

    public int getY() {
        return y;
    }

    public void setY(int y) {
        this.y = y;
    }
}

首先关闭逃逸分析和标量替换,运行结果:

[GC (Allocation Failure)  16384K->784K(62976K), 0.0016025 secs]
[GC (Allocation Failure)  17168K->752K(62976K), 0.0008888 secs]
[GC (Allocation Failure)  17136K->720K(62976K), 0.0012225 secs]
[GC (Allocation Failure)  17104K->768K(62976K), 0.0010226 secs]

可以看到频繁的打印垃圾收集日志,证明对象在堆中分配。接下来将逃逸分析和标量替换开启:

-XX:+DoEscapeAnalysis -XX:+EliminateAllocations

这里必须开启逃逸分析,因为标量替换是基于逃逸分析的结果进行优化的。当开启后则垃圾收集日志不再打印,证明对象在栈上分配了。
其对test方法的优化大致如下,首先会对Point的构造方法内联优化:

public int test(int value) {
    int value1 = value + 2;
    Point point = allocate_memory; // 分配内存空间
    point.x =  value1;
    point.y = 30;
    return point.x;   // Point内联后取值
}

通过内联优化后,通过逃逸分析判断Point对象在该方法中不会发生逃逸,那么通过标量替换优化后如下:

public int test(int value) {
    int value1 = value + 2;
    int px = value1;
    int py = 30;
    return px;   // Point内联后取值
}

再次通过数据流分析,去除无效的代码,优化结果如下:

public int test(int value) {
   return value + 2;
}

其上只是一个伪代码流程。

通过逃逸分析提供依据,进行栈上分配和标量替换可以了解到,新生的对象不一定是在堆内存空间分配的,也可能在栈上进行分配,或者说在代码中new的新对象,在运行时可能不会对对象分配内存空间。
示例代码

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值