Java对象一定在堆中分配吗?

学了JVM关于堆内存的分配和管理和堆内存的GC机制,堆内存是我们在开发中要重要监控和调优的内存区域,因为发生GC就会伴随着STW(Stop the world),因此我们期望的是尽量的减少GC的次数,以提供系统性能。

究其发生GC的原因,总的来说就是因为堆空间满。而在堆中最频繁发生GC的区域就是新生代发生的Minor GC,虽然STW的时间较短,但是我们还是希望能够尽可能的减少GC的次数。

由此在《JVM虚拟机》规范中提出了逃逸分析的策略。一个方法中的局部变量对象如果不发生逃逸,那么就将其在栈上分配

这种思想的原因:

  1. 对象不发生逃逸,在方法中创建,在方法中销毁,不会造成多线程问题,那么就可以将其分配在栈上,使用后销毁。
  2. 栈上分配的思想和作法能够减少在堆上分配‘朝生夕死“的对象,从而减少GC的执行次数。提供了系统性能。

在《深入理解JVM虚拟机中》有这样一段描述:

随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变的不那么“绝对”。

逃逸分析

这是一种可以有效减少Java程序员中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

  • 逃逸分析就是HotSpot编译器能够对于一个新对象的引用范围进行分析从而决定是否要分配在堆上。
  • 逃逸分析的基本行为就是分析对象的动态作用域
  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

通过代码分析是否逃逸

/*
*不发生逃逸,没有被外部,在方法内创建在方法内销毁
*/
public void my_method(){
    Person person = new Person();
    //do something
}
//发生了逃逸,StringBuilder的对象实体通过返回值返回,这就有可能被外部使用到
public StringBuilder my_mythod2(){
    StringBuilder str = new StringBuilder();
    //do something
    return str;
}
//不会发生逃逸,我们关注的是在方法中创建的实体本身,也就是str本身是否会被外部的其他方法调用
//显然是不会被外部其他方法调用的
public String my_method3(){
    StringBuilder str = new StringBuilder();
    //do something
    return str.toString();
}
/**
*另外对于成员属性赋值也会发生逃逸
*/
public void setPerson(){
    this.person = new person();
}

小总结:如何快速的判断是否发生了逃逸分析?

判断new的对象是否可能在方法外被调用。

1.栈上分配

将堆分配转化为栈分配。如果一个对象在子程序中被分配,要是指向该对象的指针永远不会发生逃逸,对象可能是栈分配的候选,而不是堆分配。

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有发生逃逸出方法的话,就可能被优化为栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。

常见的栈上分配的场景:

  • 在逃逸分析中,已经说明.分别是给成员变量赋值、方法返回值、实例引用传递。

2.同步省略

如果一个对象被发现只能被一个线程访问到,那这个对象就不需要考虑同步。

  • 线程同步的代价是非常高的,同步会降低并发性和性能。

那么同步省略(同步消除)是如何实现的?

在动态编译同步代码块的时候,JIT编译器可以借助逃逸分析分析同步块使用的锁对象是否只能够被一个线程使用而没有发布到其他线程。如果发先没有发布到其他线程中使用,那么就取消这部分代码的同步,就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,或同步擦除。

/**
*该部分的锁代码是无效的,因为要锁共享变量的,我们是重新向另一个方向分析
*/
public void my_method(){
    Person person = new Person();
    synchronized(person){
        System.out.println(peson);
    }
}

对上述代码进行分析:

在方法中创建了新的对象实体person,该对象每次调用该方法都会重新的创建对象,也就是说该对象不是同一个对象。而在后面的同步代码块中锁的又是该对象,那么这个锁相当于无用的。那么在编译器编译时会消除该部分的同步块。

public void my_method(){
    Person person = new Person();
    System.out.println(peson);
}

3.分离对象或标量替换

有的对象可能不需要作为一个连续的内存结构村塾也可以被访问到,那么对象的部分(或全部)可以不存储在堆,而是存储在栈中。

标量是指一个无法在分解成更小的数据的数据.java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做聚合量,java中的对象就是聚合量,因为她可以分解成其他聚合量和标量。

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

public class Person{
    public String name;
    public int age;
    public Person(Strng name,int age){
        this.name = name;
        this.age = age;
    }
    //此处省略get和set方法
    public static void main(String[] args){
        Person person = new Person("leiyu",18);
        System.out.println(person.getName()+""+person.getAge())
        
    }
}

总结

逃逸分析的技术现在还不是很成熟

  • 其根本原因就是无法保证逃逸分析的性能消耗一定高于他的消耗.虽然经过逃逸分析可以做标量替换/栈上分配和锁擦除。但是逃逸分析本身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
  • 一个极端的例子就是:经过逃逸分析之后,发现没有一个对象不发生逃逸(所有对象都发生逃逸-也就是说还需要在堆上分配内存),那么我们逃逸分析的过程就白白浪费掉了。
  • 并且在Oracle HotPost虚拟机中不使用逃逸分析。
  • 所以可以很明确的知道所有的对象都创建在堆上。
  • JDK 7之后,字符串的缓存和静态变量曾经都被分配在永久代上,而永久代被元数据区取代。但是字符串缓存和静态变量并不是转移到元数据区,而是直接在堆上分配,所以这一点也同样符合前面一点的结论:对象实例都是分配在堆上。