垃圾收集器与内存分配策略

一、概述

程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。垃圾回收主要是针对 Java 堆和方法区进行。

二、对象已死吗

引用计数算法

过程:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。

主流的Java虚拟机里面选用引用技术算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题。

public class ReferenceCountingGC {
    public Object instance = null;

    public static void main(String[] args) {
        ReferenceCountingGC objectA = new ReferenceCountingGC();
        ReferenceCountingGC objectB = new ReferenceCountingGC();
        objectA.instance = objectB;
        objectB.instance = objectA;
    }
}

可达性分析算法

通过 GC Roots 作为起始点进行搜索,能够到达到的对象都是存活的,不可达的对象可被回收。

垃圾收集器与内存分配策略

算法基本思路:通过一系列的称为"GC Roots"的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

引用类型

无论是通过引用计算算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

(一)强引用

​ 被强引用关联的对象不会被垃圾收集器回收

​ 使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();

(二)软引用

​ 被软引用关联的对象,只有在内存不够的情况下才会被回收

​ 使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

(三)弱引用

​ 被弱引用关联的对象一定会被垃圾收集器回收,也就是说它只能存活到下一次 垃圾收集。

​ 使用 WeakReference 类来实现弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

(四)虚引用

​ 又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用 取得一个对象实例。

​ 为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

​ 使用 PhantomReference 来实现虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

finalize方法

使在可达性分析算法中不可达的对象,也并非是要宣判“死亡”的,它们暂时都处于“缓刑”阶段,要真正宣告一个对象“死亡”,首先要经历两次标记过程:

  1. 对象在进行可达性分析后发现没有与 GC Roots相连接的引用链,是否要执行对象的finaliza() 方法。
  2. 当对象没有覆盖finaliza() 方法,或者finaliza() 方法已经被虚拟机调用过,视为“没有必要执行”。

对象复活

定义:如果这个对象被判定为有必要执行finaliza() 方法,那么此对象将会放置在一个叫做 F-Queue 的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发此方法,但并不承诺会等待它运行结束。

原因:如果一个对象在finaliza() 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能导致F-Queue 队列中的其它对象永久处于等待,甚至导致整个内存回收系统崩溃。

finaliza() 方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue 队列中的对象进行第二次小规模的标记。

  • 如果对象想在finaliza() 方法中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,
  • 例如把自己(this关键字)赋值给某个类变量或者对象的成员变量,这样在第二次标记时它将被移出“即将回收”的集合;

如果对象这时候还没有逃脱,基本上它就被回收了。

代码示例:

【一次对象自我拯救的演示】
/**
 * 此代码演示了两点: 
 * 1.对象可以在被GC时自我拯救。 
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 * @author zzm
 */
public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive :)");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }

        // 下面这段代码与上面的完全相同,但是这次自救却失败了
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}

运行结果:

finalize method executed!
yes, i am still alive :)
no, i am dead :(

由以上结果可知,SAVE_HOOK 对象的finalize() 方法确实被GC收集器触发过,并且在收集前成功逃脱了。

另一个值得注意的地方,代码中有两段一模一样的代码段,执行结果却是一次逃脱成功,一次失败。

这是因为任何一个对象的finalize() 方法都只会被系统调用一次,如果对象面临下一次回收,它的finalize() 方法不会再被执行,因此第二次逃脱行动失败。

执行过程:

垃圾收集器与内存分配策略

有关finaliza()方法的建议:

  • 需要特别说的是,finalize() 方法,不建议开发人员使用这种方法拯救对象。

  • 应当尽量避免使用它,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做的一个妥协。

  • 它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。

  • 有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对此方法用途的一种自我安慰。

  • finalize() 能做的工作,使用try-finally 或者其它方法都更适合、及时,所以作者建议大家可以忘掉此方法存在。

回收方法区

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

  • 废弃常量的回收:回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例:

      例如一个字符串“abc”已经进入常量池,但是无任何String对象引用常量池的此常量,也无其它引用此字面量,“abc”常量会被系统清理出常量池。
    
  • 无用类的回收:常量池中的其他类(接口)、方法、字段的符号引用也是如此。

判定一个类是否是“无用类”的3个条件:

  • 该类的所有实例已被回收,也就是Java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader已被回收。
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法通过反射访问该类的方法。

三、垃圾收集算法

标记-清除算法

垃圾收集器与内存分配策略

将存活的对象进行标记,然后清理掉未被标记的对象。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存。

标记-整理算法

垃圾收集器与内存分配策略

让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

复制算法

垃圾收集器与内存分配策略

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

主要不足是只使用了内存的一半。

现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

分代收集算法

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

一般将 Java 堆分为新生代和老年代。

  • 新生代使用:复制算法
  • 老年代使用:标记 - 清理 或者 标记 - 整理 算法

四、垃圾收集器

垃圾收集器与内存分配策略

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与并行(多线程):单线程指的是垃圾收集器只使用一个线程进行收集,而并行使用多个线程。
  • 串行与并发:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并发指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

Serial收集器

单线程的新生代收集器

垃圾收集器与内存分配策略

Serial 翻译为串行,也就是说它以串行的方式执行。

它是单线程的收集器,只会使用一个线程进行垃圾收集工作。

它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。

它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

ParNew收集器

Serial收集器的多线程版本,多线程的新生代收集器

垃圾收集器与内存分配策略

它是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。

默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

Parallel Scavenge 收集器

多线程的新生代收集器,关注吞吐量

其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为“吞吐量优先”收集器。这里的吞吐量指 CPU 用于运行用户代码的时间占总时间的比值。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数(值为大于 0 且小于 100 的整数)。缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。

Serial Old收集器

Serial收集器的老年代版本,单线程收集器

垃圾收集器与内存分配策略

是 Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old收集器

Parallel Scavenge收集器的老年代版本,多线程的老年代收集器

垃圾收集器与内存分配策略

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

CMS收集器

垃圾收集器与内存分配策略

CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记 - 清除算法。

特点:并发收集、低停顿。

分为以下四个流程:

  • 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。
  • 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除:不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将启动后备预案:临时启用 Serial Old 来替代 CMS。
  • 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

G1收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

Java 堆被分为新生代、老年代和永久代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。

垃圾收集器与内存分配策略

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

垃圾收集器与内存分配策略

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

垃圾收集器与内存分配策略

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记
  • 并发标记
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
  • 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

具备如下特点:

  • 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

比较

收集器 单线程/并行 串行/并发 新生代/老年代 收集算法 目标 适用场景
Serial 单线程 串行 新生代 复制 响应速度优先 单 CPU 环境下的 Client 模式
Serial Old 单线程 串行 老年代 标记-整理 响应速度优先 单 CPU 环境下的 Client 模式、CMS 的后备预案
ParNew 并行 串行 新生代 复制算法 响应速度优先 多 CPU 环境时在 Server 模式下与 CMS 配合
Parallel Scavenge 并行 串行 新生代 复制算法 吞吐量优先 在后台运算而不需要太多交互的任务
Parallel Old 并行 串行 老年代 标记-整理 吞吐量优先 在后台运算而不需要太多交互的任务
CMS 并行 并发 老年代 标记-清除 响应速度优先 集中在互联网站或 B/S 系统服务端上的 Java 应用
G1 并行 并发 新生代 + 老年代 标记-整理 + 复制算法 响应速度优先 面向服务端应用,将来替换 CMS

五、内存分配与回收策略

测试时使用Client模式虚拟机运行,即使用Serial/Serial Old收集器的内存分配与回收策略

Full GC 与 Minor GC

  • Minor GC 表示新生代GC:指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生熄灭的特性,所以Monor GC会比较频繁,一般速度也比较快。

  • Full GC(Full GC/Major GC) 表示老年代GC:指发生在老年代的GC,出现了Major GC经常会伴随至少一次的Minor GC。

对象优先在Eden分配

一般小对象的内存分配过程为先分配给新生代的Eden区,当Eden区不够存放时,则发生一次Minor GC,然后检查survivor区是否够存放一些小对象,够则进行内存分配,不够则需在Minor GC时将一些对象分配到老年代中。

新生代的Minor GC 代码测试样例:

package 对象优先在Eden分配;

public class MyTest {

	private static  final int _1MB = 1024 * 1024;
	
	/**
	 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
	 * -XX:SurvivorRatio=8 -XX:+UseSerialGC
	 */
	/**
	 * VM参数解释:
	 * -verbose:gc 表示输出虚拟机中GC的详细情况,输出像: [Full GC 168K->97K(1984K), 0.0253873 secs]
	 * -Xms20M -Xmx20M -Xmn10M 表示限制堆不能扩展,只能为20M,其中新生代占10M
	 * -XX:+PrintGCDetails 打印GC的一些具体信息
	 * -XX:SurvivorRatio=8 表示Eden区占新生代的80%,其他两个survivor各占10%
	 * -XX:+UseSerialGC 表示虚拟机运行在Client模式下的默认值,Serial+Serial Old。
	 */
	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) {
		testAllocation();
	}
}

运行结果:

[GC (Allocation Failure) [DefNew: 7292K->562K(9216K), 0.0252599 secs] 7292K->6706K(19456K), 0.0844557 secs] [Times: user=0.00 sys=0.01, real=0.09 secs] 
Heap
 def new generation   total 9216K, used 4740K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
  from space 1024K,  54% used [0x00000000ff500000, 0x00000000ff58c9c0, 0x00000000ff600000)
  to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
 tenured generation   total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)
 Metaspace       used 2714K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 302K, capacity 386K, committed 512K, reserved 1048576K

结果分析:

Eden区的内存大小为8M,allocation1,allocation2,allocation3起初都分配在Eden区,共占6M。allocation4需要占用内存4M,Eden区不够存放allocation4且survivor区只有1M也不够存放其余三个对象。故allocation1,allocation2,allocation3分配到老年代中,Eden存放4M的allocation4。

大对象直接进入老年代

所谓的大对象是指需要大量连续的内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组

代码示例

public class MyTest {
	
	private static  final int _1MB = 1024 * 1024;
	
	/**
	 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
	 * -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 -XX:+UseSerialGC
	 */
	/**
	 * VM参数解释:
	 * -verbose:gc 表示输出虚拟机中GC的详细情况,输出像: [Full GC 168K->97K(1984K), 0.0253873 secs]
	 * -Xms20M -Xmx20M -Xmn10M 表示限制堆不能扩展,只能为20M,其中新生代占10M
	 * -XX:+PrintGCDetails 打印GC的一些具体信息
	 * -XX:SurvivorRatio=8 表示Eden区占新生代的80%,其他两个survivor各占10%
	 * -XX:PretenureSizeThreshold=3145728(3M) 表示大于3M的对象直接存储在老年代中
	 */
	public static void testPretenureSizeThreshold() {
		byte[] allocation;
		allocation = new byte[4 * _1MB];
	}
	
	public static void main(String[] args) {
		testPretenureSizeThreshold();
	}
}

运行结果

Heap
 def new generation   total 9216K, used 1312K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  16% used [0x00000000fec00000, 0x00000000fed48130, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  40% used [0x00000000ff600000, 0x00000000ffa00010, 0x00000000ffa00200, 0x0000000100000000)
 Metaspace       used 2717K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 302K, capacity 386K, committed 512K, reserved 1048576K

结果分析

运行参数中使用了-XX:PretenureSizeThreshold=3145728(3M) 表示大于3M的对象直接存储在老年代中,allocation的大小为4M故直接存储在老年代中,占老年代的40%的空间大小。

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

年龄(Age)

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

对象进入老年代的阈值,可以通过参数-XX:MaxTenuringThreshold来设置。

代码示例1:

public class MyTest {
	private static  final int _1MB = 1024 * 1024;
	/**
	 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
	 * -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=1 -XX:+UseSerialGC
	 * -XX:+PrintTenuringDistribution
	 */
	/**
	 * VM参数解释:
	 * -XX:MaxTenuringThreshold=1 设置对象的年龄为1
	 * -XX:+PrintTenuringDistribution 打印对象的年龄信息等
	 */
	public static void testTenuringThreshold() {
		byte[] allocation1, allocation2, allocation3, allocation4;
		allocation1 = new byte[_1MB / 4];	//Minor GC后从Eden区进入老年代
		allocation2 = new byte[4 * _1MB];	//Minor GC后从Eden区进入老年代
		allocation3 = new byte[4 * _1MB];	//Minor GC后进入Eden区(发生一次Minor GC)
		allocation3 = null;
		allocation3 = new byte[4 * _1MB];//(发生一次Minor GC)
	}
	public static void main(String[] args) {
		testTenuringThreshold();
	}
}

运行结果

[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
- age   1:     838064 bytes,     838064 total
: 5500K->818K(9216K), 0.0147569 secs] 5500K->4914K(19456K), 0.0536315 secs] [Times: user=0.00 sys=0.00, real=0.05 secs] 
[GC (Allocation Failure) [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 1)
: 4914K->0K(9216K), 0.0014999 secs] 9010K->4913K(19456K), 0.0015246 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4178K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  eden space 8192K,  51% used [0x00000000fec00000, 0x00000000ff014930, 0x00000000ff400000)
  from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
  to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
 tenured generation   total 10240K, used 4913K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
   the space 10240K,  47% used [0x00000000ff600000, 0x00000000ffacc5c0, 0x00000000ffacc600, 0x0000000100000000)
 Metaspace       used 2714K, capacity 4486K, committed 4864K, reserved 1056768K
  class space    used 302K, capacity 386K, committed 512K, reserved 1048576K

代码示例2:

public class MyTest {
	private static  final int _1MB = 1024 * 1024;
	/**
	 * VM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails
	 * -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+UseSerialGC
	 * -XX:+PrintTenuringDistribution
	 */
	/**
	 * VM参数解释:
	 * -XX:MaxTenuringThreshold=1 设置对象的年龄为15
	 * -XX:+PrintTenuringDistribution 打印对象的年龄信息等
	 */
	public static void testTenuringThreshold() {
		byte[] allocation1, allocation2, allocation3, allocation4;
		allocation1 = new byte[200];	//Minor GC后从Eden区进入老年代
		allocation2 = new byte[4 * _1MB];	//Minor GC后从Eden区进入老年代
		allocation3 = new byte[4 * _1MB];	//Minor GC后进入Eden区(发生一次Minor GC)
		allocation3 = null;
		allocation3 = new byte[4 * _1MB];//(发生一次Minor GC)
	}
	public static void main(String[] args) {
		testTenuringThreshold();
	}
}

运行结果

[GC [DefNew
Desired survivor size 524288 bytes, new threshold 8 (max 8)
- age   1:     418144 bytes,     418144 total
: 4695K->408K(9216K), 0.0036693 secs] 4695K->4504K(19456K), 0.0036983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC [DefNew
Desired survivor size 524288 bytes, new threshold 8 (max 8)
- age   1:        136 bytes,        136 total
- age   2:     417936 bytes,     418072 total
: 4668K->408K(9216K), 0.0010034 secs] 8764K->4504K(19456K), 0.0010296 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
def new generation   total 9216K, used 4668K [0x32750000, 0x33150000, 0x33150000)
eden space 8192K,  52% used [0x32750000, 0x32b78fe0, 0x32f50000)
from space 1024K,  39% used [0x32f50000, 0x32fb6118, 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 377K [0x33b50000, 0x34750000, 0x37b50000)
the space 12288K,   3% used [0x33b50000, 0x33bae5b8, 0x33bae600, 0x34750000)
ro space 10240K,  55% used [0x37b50000, 0x380d1140, 0x380d1200, 0x38550000)
rw space 12288K,  55% used [0x38550000, 0x38bf44c8, 0x38bf4600, 0x39150000)

动态对象年龄判定

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

代码示例

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];
}

代码分析

代码运行到allocation4的首次创建时会发生一次Minor GC,此时会将对象allocation1,
allocation2存放进Survivor中,将allocation3存放进老年代中。第二次创建allocation4
时再次发生一次Minor GC,因为allocation1和allocation2为相同年龄且大小大于Survivor
空间的一半,故将俩放进老年代中。

运行结果

[GC [DefNew
Desired survivor size 524288 bytes, new threshold 1 (max 15)
- age   1:     718424 bytes,     718424 total
: 5491K->701K(9216K), 0.0028433 secs] 5491K->4797K(19456K), 0.0028685 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:        232 bytes,        232 total
: 5209K->0K(9216K), 0.0005955 secs] 9305K->4797K(19456K), 0.0006094 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 9216K, used 4234K [0x00000000f9a00000, 0x00000000fa400000, 0x00000000fa400000)
  eden space 8192K,  51% used [0x00000000f9a00000, 0x00000000f9e227e8, 0x00000000fa200000)
  from space 1024K,   0% used [0x00000000fa200000, 0x00000000fa2000e8, 0x00000000fa300000)
  to   space 1024K,   0% used [0x00000000fa300000, 0x00000000fa300000, 0x00000000fa400000)
 tenured generation   total 10240K, used 4797K [0x00000000fa400000, 0x00000000fae00000, 0x00000000fae00000)
   the space 10240K,  46% used [0x00000000fa400000, 0x00000000fa8af558, 0x00000000fa8af600, 0x00000000fae00000)
 compacting perm gen  total 21248K, used 3460K [0x00000000fae00000, 0x00000000fc2c0000, 0x0000000100000000)
   the space 21248K,  16% used [0x00000000fae00000, 0x00000000fb161418, 0x00000000fb161600, 0x00000000fc2c0000)
No shared spaces configured.

空间分配担保(未懂)

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
下面解释一下“冒险”是冒了什么风险,前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把Survivor无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了HandlePromotionFailure失败,那就只好在失败后重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁,参见如下代码,请读者在JDK 6 Update 24之前的版本中运行测试。

代码示例

空间分配担保:
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: 6651K->148K(9216K), 0.0078936 secs] 6651K->4244K(19456K), 0.0079192 secs] [Times: user=0.00 sys=0.02, real=0.02 secs]
[GC [DefNew: 6378K->6378K(9216K), 0.0000206 secs][Tenured: 4096K->4244K(10240K), 0.0042901 secs] 10474K->4244K(19456K), [Perm : 2104K->2104K(12288K)], 0.0043613 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

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

[GC [DefNew: 6651K->148K(9216K), 0.0054913 secs] 6651K->4244K(19456K), 0.0055327 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC [DefNew: 6378K->148K(9216K), 0.0006584 secs] 10474K->4244K(19456K), 0.0006857 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]

在JDK 6 Update 24之后,这个测试结果会有差异,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,如下所示,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

HotSpot中空间分配检查的代码片段
bool TenuredGeneration::promotion_attempt_is_safe(size_t
max_promotion_in_bytes) const 
{
   // 老年代最大可用的连续空间
   size_t available = max_contiguous_available();  
   // 每次晋升到老年代的平均大小
   size_t av_promo  = (size_t)gc_stats()->avg_promoted()->padded_average();
   // 老年代可用空间是否大于平均晋升大小,或者老年代可用空间是否大于当此GC时新生代所有对象容量
   bool   res = (available >= av_promo) || (available >=max_promotion_in_bytes);
   return res;
}