转载

Java虚拟机05——对象分配与回收策略

对象的内存分配基本规律有以下几条:

  • 大多数情况下就是在堆上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配)。
  • 对象主要分配在新生代的Eden区上。
  • 如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。
  • 少数情况下也可能会直接分配在老年代中。

对象的分配规则不是百分百固定的,其细节取决于当前使用的是哪一种垃圾收集组合,还有虚拟机中与内存相关的参数设置

对象优先在Eden分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时将发起一次Minor GC。

Minor GC指发生在新生代的垃圾收集动作

使用以下代码进行测试:

public class ObjMemoryTest {
    private static  final int _1MB=1024*1024;
    public static void testAllocation() {
        byte[] allocation1,allocation2,allocation3,allocation4;
        allocation1 = new byte[2* _1MB];
        allocation2 = new byte[2* _1MB];
        allocation3 = new byte[2* _1MB];
        allocation4 = new byte[4* _1MB];
    }

    public static void main(String[] args) throws IOException {
        ObjMemoryTest.testAllocation();
    }
}
复制代码

其中,需要设置参数

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8

上述参数解释如下:

  • -verbose:gc -XX:+PrintGCDetails:打印GC详细日志信息。
  • -XX:+UseSerialGC:使用Serial收集器。
  • -Xms20m -Xmx20m:限制Java堆大小为20MB。
  • -Xmn10m:新生代大小为10MB。
  • -XX:SurvivorRatio=8:设置新生代中Eden区与一个Survivor区的空间比例是8:1

设置完后,Java堆共20M,新生代10M,老年代10M。其中新生代里的Eden 8M,两个Survivor各1M。代码运行日志如下:

Java虚拟机05——对象分配与回收策略

解释:运行后新生代进行了GC回收,从8188K->714K。这次回收是给allocation4分配内存的时候,发现Edon区已经占用了6M,剩余空间已经不足分配allocation4的4M。所以执行了Minor GC,GC期间发现1M大小的Survivor无法放入allocaiton1~3,所以只好通过分配担保机制提前转移到老年代去。

GC结束后,从GC日志上可以看到:4MB的allocation4被分配到Eden区,allocation1~3被分配到老年代中

大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的Java对象。虚拟机提供了一个-XX:PretenureSizeThreshold参数,大于这个设置值的对象将直接在老年代分配,从而避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代主要采用复制算法收集内存)。有以下的测试代码: 其中,需要设置参数

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728
public static void testPretenureSizeThreshold() {
        byte[] allocation;
        allocation = new byte[4*_1MB];
    }
复制代码

运行结果:

Java虚拟机05——对象分配与回收策略

从结果上看老年代被使用了4M,而新生代几乎没有使用,这是因为PretenureSizeThreshold被设置成3MB(也就是3145728),因此超过3MB的对象会直接在老年代进行分配

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

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,对象年龄为1,对象在Survivor区中熬过一次Minor GC,年龄增加1。当它的年龄增加到一定程度(默认15),就会被晋升到老年代中。晋升的阈值可以通过参数-XX:MaxTenuringThreshold设置。实例代码如下: 其中,需要设置参数

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8
public static void testMaxTenuredThreshold() {
        byte[] allocation1;
        byte[] allocation2;
        byte[] allocation3;
        byte[] allocation4;

        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation4 = new byte[4 * _1MB];
    }
复制代码

alocation1为256kb内存,Survivor空间可以容纳,而allocation2、allocation3和allocation4需要4MB的空间,并不能被Survivor区容纳。

当设置MaxTenuringThreshold = 1时,内存信息如下

Java虚拟机05——对象分配与回收策略

由于Eden区域的总大小是8MB,因此在分配allocation3时会因为Eden区空闲大小不够而发生一次Minor GC操作,这时allocation1会被移入到Survivor区中,allocation2因Survivor区并不能容纳会被提前提升到老年代。接下来在分配allocation3后分配allocation4还会触发第二次Minor GC操作,这次操作由于allocation1达到了晋升年龄,会被晋升到老年代,而allocation3会被回收,所以第二次Minor GC后新生代的已使用大小会变为0K,最后allocation4会被分配到Eden区,因此得到的最终内存空间的分配是Eden区使用51%(4MB+,用于存放allocation4),Survivor区域已使用全为0,老年代已使用5059K(4MB+,用于存放allocation1和allocation2)。

而设置-XX:MaxTenuringThreshold=15后,将会得到以下的结果:

Java虚拟机05——对象分配与回收策略

注:如果在某些版本的JDK中不生效,可以设置-XX:TargetSurvivorRatio=95参数调大Survivor区域的使用率

可以看到Survivor区不为空,这是由于allocation1还没有被判定为长期存活的对象,还存在与Survivor区导致的。

动态年龄判定

为了能更好地适应不同程序的内存状况,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须达到MaxTenuringThreshold中要求的年龄。

在执行下面的testMaxTenuredThreshold2()方法时,设置了-XX:MaxTenuringThreshold=15参数,会发现运行结果中Survivor的空间占用仍然为0%,而老年代比预期增加了11%,也就是说,allocation1、allocation2对象都直接进入了老年代,而没有等到15岁的临界年龄。因为这两个对象加起来已经到达了512KB,并且它们是同年的,满足同年对象达到Survivor空间的一半规则。我们只要注释掉其中一个对象new操作,就会发现另外一个就不会晋升到老年代中去了。

-verbose:gc -XX:+PrintGCDetails -XX:+UseSerialGC -Xms20m -Xmx20m -Xmn10m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:TargetSurvivorRatio=95
public static void testMaxTenuredThreshold2() {
        byte[] allocation1;
        byte[] allocation2;
        byte[] allocation3;
        byte[] allocation4;

        allocation1 = new byte[_1MB / 4];
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }
复制代码
Java虚拟机05——对象分配与回收策略

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。

新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。

取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(HandlePromotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁。

但是JDK 6 Update 24之后代码中已经不再使用HandlePromotionFailure,JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

原文  https://juejin.im/post/5cac27d46fb9a068726e0149
正文到此结束
Loading...