[JVM]1.3 自动内存管理机制:内存分配策略

本篇博客内容基本出自《深入理解java虚拟机》
  Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来。

内存分配与回收策略

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存以及回收分配给对象的内存。
  对象的内存分配,从大方向讲就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB 上分配。少数情况下也可能会直接分配在老年代一中;当然分配的规则并不是固定的,其细节取决于使用的是哪一种收集器组合,还有虚拟机中与内存相关的参数的设置。垃圾收集器组合一般就是Serial+Serial Old和Parallel+Serial Old,前者是Client模式下的默认垃圾收集器组合,后者是Server模式下的默认垃圾收集器组合。
  
名词解释

  • 新生代GC(Minor GC):指发生在新生代的垃圾收集动作,因为Java对象大多数都具有朝生夕灭的特性,多以Minor GC非常频繁,一般回收速度也比较快。
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常都伴随着至少一次的Minor GC(但并非绝对的,在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。MajorGC的速度一般会比Minor GC慢10倍以上。

1. 对象优先在Eden分配

一般情况下对象优先在Eden上分配,如果Eden空间不足,就进行一次MinorGC。

如下代码中,尝试分配3个2MB大小和1个4MB大小的对象。在运行通过-Xms20M、-Xmx20M和-Xmn10M这三个参数限制Java堆大小为20MB,且不可扩展,其中10MB分配给新生代剩下的10MB分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8比1,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)

private static iinal int 1MB = 10241024;
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];
    }
}

执行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占用)。

[GC (Allocation Failure) [DefNew: 5238K->655K(9216K), 0.0029147 secs] 5238K->3727K(19456K), 0.0029604 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 5857K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  // eden中存放着a4
  eden space 8192K,  63% used [0x00000000fec00000, 0x00000000ff114930, 0x00000000ff400000)
  from space 1024K,  64% used [0x00000000ff500000, 0x00000000ff5a3e00, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 // 老生代使用的空间正好为3m,也就是a1、a2、a3移入了老年代
 tenured generation   total 10240K, used 3072K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  30% used [0x00000000ff600000, 0x00000000ff900030, 0x00000000ff900200, 0x0000000100000000)
 Metaspace       used 3494K, capacity 4498K, committed 4864K, reserved 1056768K
  class space    used 387K, capacity 390K, committed 512K, reserved 1048576K

GC日志表现得和推论差不多。程序运行中的内存分配主要是这样一个步骤:

  1. 一些未知的东西在eden中分配占用了2m多。
  2. a1分配进入eden。
  3. a2分配进入eden。
  4. a3分配进入eden。
  5. eden剩余空间不足安置a4,发起一次Minor GC。
  6. 未知的东西一部分被回收,一部分被移入Survivor(小于1m的部分)。
  7. a1存在引用,不会被回收,但是Survivor装不下,通过分配担保机制直接进入老年代。
  8. a2存在引用,不会被回收,但是Survivor装不下,通过分配担保机制直接进入老年代。
  9. a3存在引用,不会被回收,但是Survivor装不下,通过分配担保机制直接进入老年代。

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之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间:

  • 如果大于,则此次Minor GC是安全的
  • 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。 如果HandlePromotionFailure=true,那么会继续检查老年代大可用连续空间是否大于历次晋升到老年代的 对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者 HandlePromotionFailure=false,则改为进行一次Full GC。

上面提到了Minor GC依然会有风险,是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活( 极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担 保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些 对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对 象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。
  
  取平均值仍然是一种概率性的事件,如果某次Minor GC后存活对象陡增,远高于平均值的话,必然导致担保失 败,如果出现了分配担保失败,就只能在失败后重新发起一次Full GC。虽然存在发生这种情况的概率,但大部分时候都是能够成功分配担保的,这样就避免了过于频繁执行Full GC。

版权声明:本文为qq_41655934原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/qq_41655934/article/details/89461869