你一定要知道的JVM逃逸分析

3,461 阅读6分钟

提到JVM,相信大家一定知道JVM是什么?但是,提到逃逸分析,相信大多数人都可能一脸懵逼,逃逸分析到底是什么呢?接下来给大家分享一下。

在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经历两段编译,第一段编译就是通过javac命令把java文件编译成JVM可以识别的class文件,第二段编译就是把class文件翻译成字节码指令,让计算机识别。

在第二段编译中,JVM通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。很显然,经过解释执行,其执行速度必然会比执行二进制字节码程序慢很多。这就是传统的JVM的解释器的功能。为了解决这种效率问题,引入了JIT(即时编译)技术。

引入JIT技术,Java程序虽然还是通过解释器进行解释执行,但是,当某个方法执行调用的次数比较多的时候,就会被JVM认为是个"热点代码"。那么,如果是你,你会想到怎么做呢?诶,没错,把这些"热点代码"缓存起来。JIT也是这么做的,JIT会把部分"热点代码"翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

JVM内存分配策略

在JVM中,JVM管理的内存包括方法区,虚拟机栈,本地方法栈,堆,程序计数器等。(这里就不一一介绍了,感兴趣的同学我之后会写JVM的文章)

一般情况下,JVM运行时数据都存储在栈和堆中。栈用来存放一些基本变量和对象的引用(当然这不是绝对的后面会介绍到),堆用来存放数组的元素和对象,也就是new出来的具体实例。

随着JIT编译器的发展与逃逸分析的技术成熟。栈上分配,标量替换优化技术就会导致对象都分配到堆上这个说法变的不是那么绝对了。

什么是逃逸分析?

逃逸分析就是,当一个对象被new出来之后,它可能被外部所调用,如果是作为参数传递到外部了,就称之为方法逃逸。

例如:

非方法逃逸:

public static void returnStr(){
	User user = new User();
	user.setId(1);
    user.setName("张三");
    user.setAge(18);
}

public static String returnStr(){
	User user = new User();
	user.setId(1);
    user.setName("张三");
    user.setAge(18);
    return user.toString();//这里User要实现get,set方法,还要实现toString方法
}

方法逃逸:

public static User returnStr(){
	User user = new User();
	user.setId(1);
    user.setName("张三");
    user.setAge(18);
    return user;//这里User要实现get,set方法
}

大家应该看出区别了吧,这里第一段的两个方法均没有逃逸,而第二段的方法却逃逸了,这说明,想要逃逸方法的话,需要让对象本身被外部调用。

使用逃逸分析,编译器可以对代码做以下优化:

  1. 同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  2. 将堆分配转换为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
  3. 分离对象或标量替换,有点对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

这里开启和关闭逃逸分析用这个:

-XX:+DoEscapeAnalysis : 表示开启逃逸分析

-XX:-DoEscapeAnalysis : 表示关闭逃逸分析 从jdk 1.7开始已经默认开始逃逸分析,如需关闭,需要指定-XX:-DoEscapeAnalysis

实战:通过打印GC来观察是否是栈上分配

public class Test {

    public static void main(String[] args) {
        Test test = new Test();
        test.createUser();
        System.out.println("11");
    }

    public void createUser(){
        int i = 0;
        while(true){
            User user = new User();
            user.setId(i);
            user.setName("张三");
            user.setAge(18);
            i++;
        }
    }
}

public class User {

    private int id;
    private String name;
    private int age;

    public int getId() {
        return id;
    }

    public void setId(int id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

这里有两个类,第一个是Test类,一个是User实体类,Test类中,死循环来进行创建User对象,在idea中配置 -XX:+PrintGC参数来负责打印GC信息,启动类

结果: 这里可以看到程序一直没有结束,但是GC信息一直没有打印,这时候我们把逃逸分析关闭 在idea中配置这两个-XX:+PrintGC -XX:-DoEscapeAnalysis

再次启动类 这里可以看到控制台一直在输出GC信息,这样是不是也可以得出结论,开启逃逸分析之后如果不是逃逸方法,那么对象就是在栈上分配。

同步省略

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有发布到其他线程。

如果同步块所使用的锁对象通过这种分析被证明只能被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消这部分代码的同步,这个取消同步就叫做同步省略,也叫锁消除。

标量替换

通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数-XX:+EliminateAllocations,JDK7之后默认开启

标量与聚合量

标量即不可被进一步分解的量,而Java的基本数据类型就是标量(比如int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在Java中对象就是可以被进一步分解的聚合量

在JIT阶段,如果经过逃逸分析,发现一个对象不被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含若干个成员变量来替代。

public static void main(String[] args) {
   alloc();
}

private static void alloc() {
   Point point = new Point1,2);
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

以上代码中,point对象并没有逃逸出alloc方法,并且point对象是可以拆解成标量的。那么,JIT就会不会直接创建Point对象,而是直接使用两个标量int x ,int y来替代Point对象。

替换后:

private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}

这种替换可以大大减少堆内存的占用,因为一旦不需要创建对象了,那么就不需要分配堆内存了。