JVM — 内存分配与回收策略(五)

引言
JVM中,目前使用的内配管理是分代方式,即把内存分成新生代、老生代和永久代。这里我们讲的分代管理机制是针对线程共享的内存区域,主要是堆,也包括方法区。

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存以及回收分配给对象的内存
JVM — 内存分配与回收策略(五)_第1张图片

1.对象优先在Eden分配

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

如下代码中,尝试分配3个2MB大小和1个4MB大小的对象。在运行通过-Xms20M、-Xmx20M和-Xmn10M这三个参数限制Java堆大小为20MB,切不可扩展,其中10MB分配给新生代剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8比1,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。
JVM — 内存分配与回收策略(五)_第2张图片
执行testAllocation()中分配allocation4对象的语句会发生一次Minor GC,这次GC发生的原因是给allocation4分配内存的时候,发现Eden已经被占用了6MB剩下的空间已经不足够分配allocation4所需要的4MB内存,因此发生Minor GC.GC期间虚拟机又发生已有的3个2MB大小全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代中去。

这次GC结束后,4MB的allocation4对象被顺利分配在Eden,因此程序执行完的结果是Eden占用4MB(被alloction4占用),Survivor空闲,老年代被占用6MB(被alloction1、2、3占用)。

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

2.大对象直接进入老年代

所谓大对象就是需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串及数组。虚拟机提供了一个 -XX:PretenureSizeThreshold参数,令大于这些设置值的对象直接在老年代中分配。这样做的目的是在避免Eden区及两个Survivor区之间发生大量的内存拷贝(新生代采用复制算法手机内存)。

执行下面代码后,可以看到Eden空间几乎没有被使用,而老年代10MB的空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB,因此超过3MB的对象都会直接在老年代中进行分配。

public class EdenAllocationTest
{
    private static final int _1MB = 1024 * 1024;
    public static void testAllocation()
    {
        byte[] allocation = new byte[4 * _1MB];
    }
}

3.长期存货的对象进入老年代

虚拟机既然采用分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设置为1,对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。

我们可以分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行下面的代码中的testTenuringThreshould()方法,此方法中allocation1对象需要分配256KB的内存空间,Survivor空间可以容纳。当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,新生代已使用的内存GC后会非常干净地变成0KB.而MaxTenuringThreshold=15,第二次GC发生后,allocation1对象则还留在新生代Survivor空间。

public class EdenAllocationTest
{
    private static final int _1MB = 1024 * 1024;
    
    public static void testTenuringThreshold()
    {
        byte[] allocation1 = new byte[_1MB/4];
        byte[] allocation2 = new byte[4* _1MB];
        byte[] allocation3 = new byte[4 * _1MB];
        allocation3 =null;
        allocation3 = new byte[4 * _1MB];
    }
}

4.动态对象年龄判定

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

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

public class EdenAllocationTest
{
    private static final int _1MB = 1024 * 1024;
    
    public static void testTenuringThreshold2()
    {
        byte[] allocation1 = new byte[_1MB/4];
        byte[] allocation2 = new byte[_1MB/4];
        byte[] allocation3 = new byte[4 * _1MB];
        byte[] allocation4 = new byte[4 * _1MB];
        allocation4=null;
        allocation4 = new byte[4 * _1MB];
    }
}

5.空间分配担保

在发生Minor GC时,虚拟机就会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间,如果大于,则改为直接进行一次Full GC.如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC:如果不允许,则也要改为进行一次Full GC.

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

取平均值进行比较其实仍然是一种动态概率手段,也就是说如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(HandlePronotion Failuer)。如果出现担保失败,那就只好在失败后重新发起一次Full GC.

例子1:
Serial + Serial Old(UseSerialGC) 或者 ParNew + Serial Old(UseParNewGC)
JVM参数:-Xms200M -Xmx200M -Xmn100M -XX:+PrintGCDetails -XX:SurvivorRatio=8
JVM — 内存分配与回收策略(五)_第3张图片
GC环境:垃圾收集器为Serial + Serial Old(ParNew + Serial Old),年轻代大小为100M,Eden:Survivor = 8 :1,所以Eden为80M,两个Survivor各10M,老年代为100M。

GC分析:allocation1、allocation2、allocation3占用了Eden的60M空间,allocation4分配内存时,Eden的空间已经不足,因此触发第一次Minor GC,Minor GC结束后,allocation1、allocation2、allocation3进入老年代,此时老年代剩余空间为40M,GC后剩余的698k为JVM内部对象进入其中一个Survivor。allocation7分配内存时,此时年轻代已经放了allocation4、allocation5、allocation6,已经无法放下allocation7,因此将触发Minor GC,但在触发前会进行检查,此时老年代剩余空间为不足40M,新生代的对象总大小为60M,历次晋升的平均大小为60M,因此改为进行一次Full GC,Full GC后,年轻代对象被全部晋升,老年代因为allocation1、allocation2、allocation3被赋空,内存得到回收,晋升上来allocation4、allocation5、allocation6,因此内存大小还是60M左右,但是后面的堆总量可以看到由120M降低到了60M。

例子2:
Parallel Scavenge + Parallel Old
JVM参数:-Xms200M -Xmx200M -Xmn100M -XX:+PrintGCDetails -XX:SurvivorRatio=8
JVM — 内存分配与回收策略(五)_第4张图片
GC环境:垃圾收集器为Parallel Scavenge + Parallel Old,年轻代大小为100M,Eden:Survivor = 8 :1,所以Eden为80M,两个Survivor各10M,老年代为100M。

GC分析:allocation1、allocation2、allocation3占用了Eden的60M空间,allocation4分配内存时,Eden的空间已经不足,因此触发第一次Minor GC,Minor GC结束后,allocation1、allocation2、allocation3进入老年代,此时老年代剩余空间为40M,GC后剩余的904k为JVM内部对象进入其中一个Survivor。由于Parallel Scavenge的特殊性(在Minor GC后也会检查老年代的空间),此时检查到剩余空间40M小于历次晋升的平均大小(此时只有1次晋升, 因此晋升的平均大小为60M),因此触发第一次Full GC,将年轻代的所有对象晋升到老年代,因此Full GC后年轻代空间为空,老年代略微增长。allocation7分配内存时,此时年轻代已经放了allocation4、allocation5、allocation6,已经无法放下allocation7,因此将触发Minor GC,但在触发前会进行检查,此时老年代剩余空间为不足40M,新生代的对象总大小为60M,历次晋升的平均大小为60M,因此改为进行一次Full GC,Full GC后,年轻代对象被全部晋升,老年代因为allocation1、allocation2、allocation3被赋空,内存得到回收,晋升上来allocation4、allocation5、allocation6,因此内存大小还是60M左右,但是后面的堆总量可以看到由120M降低到了60M。

你可能感兴趣的:(JVM,java)