Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:分配内存给对象、回收分配给对象的内存。

关于第二点的回收内存,在之前的博文中已经讲解过虚拟机中的垃圾收集体系以及运作原理,而此篇文章就来探讨学习有关分配内存给对象,相关知识点如下:

  • 对象内存分配、回收解析
  • Minor GC 与 Full GC概念
  • 五大策略解析
  • 策略应用到代码实践原理解析

JVM高级特性与实践(一):Java内存区域 与 内存溢出异常
JVM高级特性与实践(二):对象存活判定算法(引用) 与 回收
JVM高级特性与实践(三):垃圾收集算法 与 垃圾收集器实现
JVM高级特性与实践(五):实例探究Class类文件 及 常量池
JVM高级特性与实践(六):Class类文件的结构(访问标志,索引、字段表、方法表、属性表集合)
JVM高级特性与实践(七):九大类字节码指令集(实例探究 )
JVM高级特性与实践(八):虚拟机的类加载机制
JVM高级特性与实践(九):类加载器 与 双亲委派模式(自定义类加载器源码探究ClassLoader)
JVM高级特性与实践(十):虚拟机字节码执行引擎(栈帧结构)
JVM高级特性与实践(十一):方法调用 与 字节码解释执行引擎(实例解析)
JVM高级特性与实践(十二):Java内存模型 与 高效并发时的内外存交互(volatile变量规则)
JVM高级特性与实践(十三):线程实现 与 Java线程调度
JVM高级特性与实践(十四):线程安全 与 锁优化


一. 内存分配 和 回收策略

1. 准备知识

(1)概述

首先来初步了解一下有关对象内存分配的概念,往大方向讲,它就是在上分配(但也可能经过JIT编译后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存有关的参数设置。

(2)Java堆内存

此点在上篇博文已经详细讲过,可后续讲解大量涉及到Java堆内存中的Eden区Survivor区等类似概念及分配,所以在此复习,查看以下图解了解即可:

这里写图片描述

(3)重要概念需知

下面将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。以下的讲解会涉及到两个重要的概念,需提前了解:

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

(4)代码实战设置环境条件

注意:下面代码测试是在Client模式虚拟机运行,未收工指定收集器组合,也就是说验证的是 Serial/ Serial Old收集器(ParNew / Serial Old收集器组合的规则也基本一致)下的内存分配和回收策略。以下代码测试都将加上了以下参数:

-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8

以上设置意味着将Java堆内存大小限制设置为20M,由于新生代和老年代各占一半,所以新生代占10M内存。Eden区Survivor区的比例是8,在新生代中由一块Eden区和两块大小相等的Survivor区组成,所以Eden区内存为8M,每个Survivor区大小为1M。



2. 五大策略

2.1 对象优先在 Eden 分配

(1)策略解析

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

(2)代码实践与日志展示

【新生代 Minor GC】

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
  */
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];  // 出现一次Minor GC
 }

运行结果:

[GC [DefNew: 6487K->152K(9216K), 0.0040116 secs] 6487K->6296K(19456K), 0.0040436 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  
        Heap 
         def new generation   total 9216K, used 4576K [0x32750000, 0x33150000, 0x33150000) 
          eden space 8192K,  54% used [0x32750000, 0x32ba1fa8, 0x32f50000) 
          from space 1024K,  14% used [0x33050000, 0x33076150, 0x33150000) 
          to   space 1024K,   0% used [0x32f50000, 0x32f50000, 0x33050000) 
         tenured generation   total 10240K, used 6144K [0x33150000, 0x33b50000, 0x33b50000) 
           the space 10240K,  60% used [0x33150000, 0x33750030, 0x33750200, 0x33b50000) 
         compacting perm gen  total 12288K, used 376K [0x33b50000, 0x34750000, 0x37b50000) 
           the space 12288K,   3% used [0x33b50000, 0x33bae2c0, 0x33bae400, 0x34750000) 
            ro space 10240K,  55% used [0x37b50000, 0x380d1140, 0x380d1200, 0x38550000) 
            rw space 12288K,  55% used [0x38550000, 0x38bf44c8, 0x38bf4600, 0x39150000) 

(3)结果分析

testAllocation() 方法中,尝试分配3个2MB大小和1个4MB大小的对象。从输出结果中可以发现“eden space 8192K、from space 1024K、to space 1024K”的信息,新生代总可用空间为9216KB(Eden区 + 1个 Survivor区的总容量)。执行testAllocation() 方法中的分配 allocation4 对象的语句会发生一次 Minor GC,这次GC的结果是新生代6651KB变为148KB,而总内存占用量则几乎没有减少,因为allocation1 、allocation2 、allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象。

这次GC发生的原因给allocation4 分配内存时,发现Eden区 已经被占用了6MB,剩余空间已不足以分配allocation4 所需的4MB内存,因此发生了 Minor GC。GC期间虚拟机又发现已有的3个2MB大小的对象全部无法放入 Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代去。

此次GC结束后,4MB的 allocation4 对象顺利分配在 Eden中,因此程序执行完后的结果是 Eden区占用4MB(被allocation4占用 ),Survivor空间处于空闲状态,老年代被占用6MB(被allocation1、allocation2、allocation3占用)。



2.2 大对象直接进入老年代

(1)大对象

大对象”是代表需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组(例如如下代码中的byte[]数组)。

(2)策略解析

大对象对虚拟机的内存分配就是一个坏消息(拓展一下:对Java虚拟机而言,比遇到一个大对象更坏的情况时遇到一群“朝生夕灭”的“短命大对象”,编写程序时应当避免此现象产生),经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

(3)测试环境设置

大体的新生代老年代内存大小设置同以上一样,只是这里多设置了一个限制:虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置的对象直接在老年代分配。目的是为了避免在Eden区及两个Survivor区之间发生大量的内存复制(注意:新生代采用复制算法)。

(4)代码实践与日志展示

【大对象直接进入老年代】

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8
 * -XX:PretenureSizeThreshold=3145728
 */
public static void testPretenureSizeThreshold() {
    byte[] allocation;
    allocation = new byte[4 * _1MB];  //直接分配在老年代中
}

运行结果:

 Heap 
def new generation   total 9216K, used 507K [0x32750000, 0x33150000, 0x33150000) 
 eden space 8192K,   6% used [0x32750000, 0x327cef38, 0x32f50000) 
 from space 1024K,   0% used [0x32f50000, 0x32f50000, 0x33050000) 
 to   space 1024K,   0% used [0x33050000, 0x33050000, 0x33150000) 
tenured generation   total 10240K, used 4096K [0x33150000, 0x33b50000, 0x33b50000) 
  the space 10240K,  40% used [0x33150000, 0x33550010, 0x33550200, 0x33b50000) 
compacting perm gen  total 12288K, used 376K [0x33b50000, 0x34750000, 0x37b50000) 
  the space 12288K,   3% used [0x33b50000, 0x33bae3b8, 0x33bae400, 0x34750000) 
   ro space 10240K,  55% used [0x37b50000, 0x380d1140, 0x380d1200, 0x38550000) 
   rw space 12288K,  55% used [0x38550000, 0x38bf44c8, 0x38bf4600, 0x39150000) 

(5)结果分析

执行完testPretenureSizeThreshold() 方法后,查看打印日志的“the space 10240K, 40% used”,可以发现Eden空间几乎没有被使用,而老年代的10MB空间被使用了40%,也就是4MB的allocation 对象直接被分配到老年代中,因为PretenureSizeThreshold被设置为3M(就是3145728,此参数不能像 -Xmx之类的参数那样写成3MB),因此超过3MB的对象都会直接在老年代进行分配。



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

(1)对象年龄(Age)计数器

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别对象应放在新生代还是老年代。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。

(2)策略解析

如果对象在Eden出生并经过第一次 Minor GC后仍然存活,并且能被Survivor 容纳的话,将被移动到 Survivor空间中,并且对象年龄设为1。对象在Survivor 区 每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就会晋升到老年代中。

(3)测试环境设置

大体的新生代老年代内存大小设置都是一样,这里多出现了一种参数:-XX:MaxTenuringThreshold,可通过它来设置对象晋升老年代的年龄阀值。

(4)代码实践与日志展示

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

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold() {
    byte[] allocation1, allocation2, allocation3;
    allocation1 = new byte[_1MB / 4];  // 什么时候进入老年代决定于XX:MaxTenuringThreshold设置
    allocation2 = new byte[4 * _1MB];
    allocation3 = new byte[4 * _1MB];
    allocation3 = null;
    allocation3 = new byte[4 * _1MB];
}

以 MaxTenuringThreshold=1 参数来运行的结果:

[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 1 (max 1) 
- age   1:     418144 bytes,     418144 total 
: 4695K->408K(9216K), 0.0054252 secs] 4695K->4504K(19456K), 0.0054708 secs] [Times: user=0.02 sys=0.00, real=0.01 secs]  
[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 1 (max 1) 
- age   1:        136 bytes,        136 total 
: 4668K->0K(9216K), 0.0013601 secs] 8764K->4504K(19456K), 0.0013867 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  
Heap 
 def new generation   total 9216K, used 4260K [0x32750000, 0x33150000, 0x33150000) 
  eden space 8192K,  52% used [0x32750000, 0x32b78fe0, 0x32f50000) 
  //☆☆☆
  from space 1024K,   0% used [0x32f50000, 0x32f50088, 0x33050000) 
  to   space 1024K,   0% used [0x33050000, 0x33050000, 0x33150000) 
 tenured generation   total 10240K, used 4504K [0x33150000, 0x33b50000, 0x33b50000) 
   //☆☆☆
   the space 10240K,  43% used [0x33150000, 0x335b60a0, 0x335b6200, 0x33b50000) 
 compacting perm gen  total 12288K, used 377K [0x33b50000, 0x34750000, 0x37b50000) 
   the space 12288K,   3% used [0x33b50000, 0x33bae5c0, 0x33bae600, 0x34750000) 

以 MaxTenuringThreshold=15 参数来运行的结果:

  ......
  from space 1024K,   39% used [0x32f50000, 0x32f50088, 0x33050000) 
  ......
   the space 10240K,  40% used [0x33150000, 0x335b60a0, 0x335b6200, 0x33b50000) 
  ......

(5)结果分析

以上分别将参数 -XX:MaxTenuringThreshold设置成1 和15来进行测试代码中的 testTenuringThreshold() 方法,此方法中的allocation1 对象需要256KB内存,Survivor空间可以容纳。

  • MaxTenuringThreshold = 1 时,allocation1 对象在第二次GC 发送时进入老年代,新生代已使用的内存GC 后非常干净地变成 0KB。
  • MaxTenuringThreshold = 15 时,在第二次GC后,allocation1 对象还留在新生代 Survivor空间,此时新生代仍然有404KB 被占用。


2.4 动态对象年龄判断

(1)策略解析

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

(2)测试环境设置

大体的新生代老年代内存大小设置都是一样,这里将参数-XX:MaxTenuringThreshold(可通过它来设置对象晋升老年代的年龄阀值)设置为15。

(3)代码实践与日志展示

【动态对象年龄判断】

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15
 * -XX:+PrintTenuringDistribution
 */
@SuppressWarnings("unused")
public static void testTenuringThreshold2() {
    byte[] allocation1, allocation2, allocation3, allocation4;
    allocation1 = new byte[_1MB / 4];   
    // allocation1+allocation2大于survivo空间一半
    allocation2 = new byte[_1MB / 4];  
    allocation3 = new byte[4 * _1MB];
    allocation4 = new byte[4 * _1MB];
    allocation4 = null;
    allocation4 = new byte[4 * _1MB];
}

运行结果:

[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 1 (max 15) 
- age   1:     680304 bytes,     680304 total 
: 4951K->664K(9216K), 0.0033210 secs] 4951K->4760K(19456K), 0.0033442 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  
[GC [DefNew 
Desired survivor size 524288 bytes, new threshold 15 (max 15) 
- age   1:        136 bytes,        136 total 
: 4924K->0K(9216K), 0.0011772 secs] 9020K->4760K(19456K), 0.0011987 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  
Heap 
 def new generation   total 9216K, used 4260K [0x32750000, 0x33150000, 0x33150000) 
  eden space 8192K,  52% used [0x32750000, 0x32b78fe0, 0x32f50000) 
  from space 1024K,   0% used [0x32f50000, 0x32f50088, 0x33050000) 
  to   space 1024K,   0% used [0x33050000, 0x33050000, 0x33150000) 
 tenured generation   total 10240K, used 4760K [0x33150000, 0x33b50000, 0x33b50000) 
   the space 10240K,  46% used [0x33150000, 0x335f60b0, 0x335f6200, 0x33b50000) 
 compacting perm gen  total 12288K, used 377K [0x33b50000, 0x34750000, 0x37b50000) 
   the space 12288K,   3% used [0x33b50000, 0x33bae5c0, 0x33bae600, 0x34750000) 

(4)结果分析

查看日志可知,执行完代码后,结果中的 Survivor空间占用仍为 0%,而老年代比预期增加了 6%,也就是说:allocation1 、allocation2 对象都直接进入老年代,而没有 等到15岁的临界年龄。因为这两个对象加起来已达了512KB,并且它们是同年的,满足同年对象达到Survivor 空间的一半规则(只需注释掉其中一个对象的new操作,就会发现另外一个就不会晋升到老年代中)



2.5 空间分配担保

(1)策略解析

在发生 Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。

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

(2)“冒险”概念解析

上小点中提到了“冒险”,来解释其内涵:在前面提过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor空间作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor无法容纳的对象直接进入老年代

与生活中的“贷款”场景类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。

(3)测试环境设置

大体的新生代老年代内存大小设置都是一样,新加了一个参数:-XX:HandlePromotionFailure,用来设置是否允许担保失败。

(4)代码实践与日志展示

【空间分配担保】

private static final int _1MB = 1024 * 1024;

/**
 * VM参数:-Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:-HandlePromotionFailure
 */
@SuppressWarnings("unused")
public static void testHandlePromotion() {
    byte[] allocation1, allocation2, allocation3, allocation4, allocation5, allocation6, allocation7;
    allocation1 = new byte[2 * _1MB];
    allocation2 = new byte[2 * _1MB];
    allocation3 = new byte[2 * _1MB];
    allocation1 = null;
    allocation4 = new byte[2 * _1MB];
    allocation5 = new byte[2 * _1MB];
    allocation6 = new byte[2 * _1MB];
    allocation4 = null;
    allocation5 = null;
    allocation6 = null;
    allocation7 = new byte[2 * _1MB];
}

HandlePromotionFailure = false参数来运行的结果:

 [GC [DefNew: 6487K->152K(9216K), 0.0040346 secs] 6487K->4248K(19456K), 0.0040639 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  
[GC [DefNew: 6546K->6546K(9216K), 0.0004896 secs] 10642K->4248K(19456K), 0.0005141 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

HandlePromotionFailure = true参数来运行的结果:

 [GC [DefNew: 6487K->152K(9216K), 0.0040346 secs] 6487K->4248K(19456K), 0.0040639 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]  
[GC [DefNew: 6546K->152K(9216K), 0.0004896 secs] 10642K->4248K(19456K), 0.0006143 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

(5)结果分析

从日志可看出,设置HandlePromotionFailure 参数不同的值,影响到虚拟机的空间分配担保原则,当参数为true时,即允许担保失败,会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,来决定后续是 Minor GC 还是 Full GC。




二. 小结

此篇博文通过代码实践的方式验证了Java虚拟机中自动内存分配及回收的主要五大规则。学到这里可以发现,前面几篇博文中讲解的垃圾收集算法、各个收集器的知识等都是一层层的铺垫,显现在真正的实践当中。因此,学习虚拟机内存知识,如果要到实践调优阶段,那么必须了解每个具体收集器的行为、优势和劣势、调节参数,这些知识点都是环环相扣,缺一不可,攀上JVM山峰这条路,还是得扎扎实实一步一步地爬,与之共勉~


若有错误,欢迎指教~


本文转载:CSDN博客