内存分配与回收策略

在jvm中,垃圾回收的主要区域为堆,一般情况下,对象是分配在堆中,那么对象在堆上是怎样分配的呢,对象又是怎样被回收的?

分代模型

jvm内存分配与回收策略_老年代


现在的垃圾回收器都会在物理上或者逻辑上把对象进行划分为两个区域,一类死的快的对象所占的区域,叫作年轻代(Young generation)。把其他活的长的对象所占的区域,叫作老年代(Old generation)。

年轻代由于对象的生命周期比较短,所以采用复制算法,这里的复制算法不像之前的复制算法,直接将内存对半分为两个区域,而是分为:一个伊甸园空间(Eden),两个幸存者空间(Survivor)。

当年轻代中的Eden区分配满的时候,就会触发年轻代的GC(Minor GC)。具体过程如下:

  • 执行了第一次Minor GC之后,存活的对象会从Eden复制到其中一个Survivor分区(以下简称From,也叫S0);
  • 当新生代再次发生Minor GC,这时会把Eden和From中存活的对象复制到To区;最后清空Eden和From区的对象。

所以在这个过程中,总会有一个Survivor分区是空置的。Eden、from、to的默认比例是8:1:1,所以只会造成10%的空间浪费。这个比例是由参数-XX:SurvivorRatio进行配置的(默认为8)。

老年代一般使用“标记-清除”、“标记-整理”算法,因为老年代的对象存活率一般是比较高的,空间又比较大,拷贝起来并不划算,还不如采取就地收集的方式。

对象的分配策略如下:

jvm内存分配与回收策略_逃逸分析_02

对象分配在栈

通过逃逸分析(Escape Analysis),JVM能够分析出一个新对象的使用范围,从而决定是否要将这个对象分配到堆上。

对象的三种逃逸状态:

  • GlobalEscape(全局逃逸):一个对象的引用逃出了方法或者线程。例如,一个对象的引用是复制给了一个类变量,或者存储在在一个已经逃逸的对象当中,或者这个对象的引用作为方法的返回值返回给了调用方法。
  • ArgEscape(参数逃逸):在方法调用过程中传递对象的引用给调用方法。
  • NoEscape(没有逃逸):该对象只在本方法中使用,未发生逃逸。

下面用一段代码来说明对象的三种逃逸状态:

package com.morris.jvm.gc;

public class EscapeStatus {
    private Object o;

    /**
     * 给全局变量赋值,发生逃逸(GlobalEscape)
     */
    public void globalVariablePointerEscape() {
        o = new Object();
    }

    /**
     * 方法返回值,发生逃逸(GlobalEscape)
     */
    public Object methodPointerEscape() {
        return new Object();
    }

    /**
     * 实例引用传递,发生逃逸(ArgEscape)
     */
    public void instancePassPointerEscape() {
        Object o = methodPointerEscape();
    }

    /**
     * 没有发生逃逸(NoEscape)
     */
    public void noEscape() {
        Object o = new Object();
    }
}

可以用JVM参数-XX:+DoEscapeAnalysis来开启逃逸分析,JDK8默认开启。

逃逸分析的性能测试:

package com.morris.jvm.gc;

/**
 * 演示逃逸分析的标量替换
 * VM args:-Xmx50m -XX:+PrintGC -XX:-DoEscapeAnalysis --> 682ms+大量的GC日志
 * VM args:-Xmx50m -XX:+PrintGC --> 4ms,无GC日志
 */
public class EscapeAnalysisDemo {

    public static void main(String[] args) {

        long start = System.currentTimeMillis();
        for (int i = 0; i < 1_0000_0000; i++) {
            allocate();
        }
        System.out.println((System.currentTimeMillis() - start) + " ms");
    }

    private static void allocate() {
        new Person(18, 120.0);
    }

    private static class Person {
        int age;
        double weight;

        public Person(int age, double weight) {
            this.age = age;
            this.weight = weight;
        }
    }

}

使用jvm参数-Xmx50m -XX:+PrintGC -XX:-DoEscapeAnalysis运行程序耗时682ms,控制台会打印大量的GC日志。

使用jvm参数-Xmx50m -XX:+PrintGC运行程序耗时4ms,控制台没有打印GC日志,也就是没有发生GC。由此可以发现开启逃逸分析后,对象分配的性能显著提升。

标量:一个数据无法再分解为更小的数据来表示,Java中的基本数据类型byte、short、int、long、boolean、char、float、double以及reference类型等,都不能再进一步分解了,这些就可以称为标量。

标量替换:如果一个对象只是由标量属性组成,那么可以用标量属性来替换对象,在栈上分配。

例如上面的Persion只是由int和double类型的属性构成,可以进行标量替换,替换后变成类似如下的代码:

private static void allocate() {
        int age = 18;
        double weight = 120.0;
    }

变成上面的代码后,这样基本数据类型就可以在栈上分配了。

而下面的Person类无法进行标量替换,只能在堆上分配了:

private static class Person {
        byte[] bytes = new byte[1024]; // 不是标量
        String name;
        int age;
    }

逃逸分析除了可以用于使用标量替换让对象在栈上分配外,还可以用于锁的消除,如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步,例如StringBuffer就可以退化为StringBuilder,值得注意的是,逃逸分析是在运行时进行优化,而不是在编译期,所以这些优化不会体现在编译后的字节码上。

关于标量替换和锁消除的几个jvm参数配置:

  • -XX:+EliminateAllocations:开启标量替换,JDK8默认开启。
  • -XX:+EliminateLocks:开启锁消除,JDK8默认开启。

TLAB

TLAB(Thread Local Allocation Buffer):JVM默认给每个线程开辟一个buffer区域,用来加速对象分配。这个buffer就放在Eden区中,避免了各个线程对堆中分配内存的CAS竞争操作。

TLAB配置的几个参数:

  • -XX:+UseTLAB:是否使用TLAB,JDK8默认开启。
  • -XX:+TLABSize:设置TLAB大小,如果这个参数为0,JVM会自动初始化这个参数。
  • -XX:TLABRefillWasteFraction:设置进入TLAB空间的单个对象大小,是一个比例值,默认为64,如果,对象小于整个空间的1/64,则放在TLAB区,如果,对象大于整个空间的1/64,则放在Eden。
  • -XX:+PrintTLAB:打印TLAB日志信息。
  • -XX:ResizeTLAB:表示自动调整TLABRefillWasteFraction阈值。

对象优先分配在Eden空间

大多数情况下,对象优先分配在新生代Eden区中,当Eden区没有足够空间进行分配时,JVM将发起一次Minor GC(新生代GC),如果发现存活的对象无法全部放入Survivor空间,只好通过分配担保机制提前转移到老年代。

大对象直接进入老年代

大对象指需要大量连续内存空间的Java对象,如数组。经常出现大对象容易导致内存还有不少空间就提前触发GC以获取足够的连续空间来存放它们,所以应该尽量避免使用创建大对象。

-XX:PretenureSizeThreshold:大于这个参数值的对象直接在老年代分配;默认为0(无效),且只对Serail和ParNew两款收集器有效。

长期存活的对象将进入老年代

JVM给每个对象定义一个对象年龄计数器,位于对象头的MarkWord中,其计算流程如下:在Eden中分配的对象,经Minor GC后还存活,就复制移动到Survivor区,年龄为1;而后每经一次Minor GC后还存活,在Survivor区复制移动一次,年龄就增加1岁;如果年龄达到一定程度,就晋升到老年代中。

-XX:MaxTenuringThreshold:设置新生代对象晋升老年代的年龄阈值,默认为15,CMS垃圾回收器中此值为6。

动态对象年龄判定

JVM为更好适应不同程序,不是永远要求等到-XX:MaxTenuringThreshold中设置的年龄才晋升老年代。

如果在Survivor空间中相同年龄的所有对象大小总和大于Survivor空间的一半,大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

当新生代内存空间不足时,不会立马进行Minor GC,首先需要确保老年代的连续空间大于新生代对象总大小,因为新生代进行Minor GC后所有的对象有可能全部进入老年代,这样这些存活对象才有空间存放。当然这是一种非常极端的情况,尝试Minor GC前面,无法知道存活的对象大小,所以使用历次晋升到老年代对象的平均大小作为经验值,假如尝试的Minor GC最终存活的对象高于经验值的话,会导致担保失败(Handle Promotion Failure),失败后只有重新发起一次Full GC,这绕了一个大圈,代价较高。

更多精彩内容关注本人公众号:架构师升级之路

jvm内存分配与回收策略_jvm_03