一网打尽JVM垃圾回收知识体系

垃圾回收的区域

  • 堆:Java 中绝大多数的对象都存放在堆中,是垃圾回收的重点
  • 方法区:此中的 GC 效率较低,不是重点

由于虚拟机栈的生命周期和线程一致,因此不需要 GC

对象判活

在垃圾收集器对堆进行回收之前,首先要做的就是判断对象是否还存活,哪些已经成为垃圾。判活算法主要有两种:

  • 引用计数法
  • 可达性分析算法

前者基本没有什么应用,不过 Python 还在使用。JVM 使用的都是可达性分析算法

引用计数法

给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收

  • 优点:原理简单、效率高
  • 缺点:对象之间循环引用(A.instance = B, B.instance = A),很难判断

可达性分析算法

通过一系列的称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。从图论的角度来说,就是 GC Roots 到这些对象是不可达的。所以此算法叫做可达性分析算法,如下图所示:

在 Java 中,可固定作为 GC Roots 的对象主要包括:

  • 当前虚拟机栈中局部变量表中的引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象
作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15811468.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

finalize的自我救赎

Java提供 finalize() 方法,垃圾回收器准备释放内存的时候,会将所有需要调用 finalize() 的对象放入一个 F-Queue,让 Finalizer线程 来执行这些 finalize 方法,每个对象的 finalize 方法最多只能被执行一次。在其中可以实现对象的拯救(让它被别的对象引用即可)

不过目前 Java 官方已经不推荐使用 finalize 方法了

作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15811468.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

各种引用

JDK 1.2 之前,引用中存储的就是某个对象的起始内存地址,在这种定义之下,对象要么被引用、要么不被引用,对于一些“食之无味,弃之可惜”的对象就显得无能为力。于是 JDK 1.2 之后对引用的概念进行了扩充,将引用分为了四种类型,强引用 > 软引用 > 弱引用 > 虚引用

强引用(Strongly Reference)

就是最传统的引用,比如 Object obj = new Object() 中,obj就是一个强引用

如果对象被强引用标记,那么垃圾回收器绝对不会回收它,遇到内存不足宁愿抛出 OOM

软引用(Soft Reference)

对于软引用标记的对象,垃圾回收器只有在内存不足的时候才会对其进行回收,在内存充足的情况下不会回收

示例代码如下:

public static void main(String[] args) {
    String str = new String("Bird"); // 强引用
    SoftReference<String> soft = new SoftReference<String>(str);
    str = null; // 消灭强引用,让其只有软引用
    System.out.println(soft.get());	// Bird
    System.gc(); // 执行一次Full GC
    System.out.println(soft.get());	// 还是Bird,此时内存是充足的,不会对其进行回收
}

软引用可以用来标记一些内存敏感的对象,最典型的就是缓存功能,只要内存充足就保留,内存不足就进行回收

弱引用(Weak Reference)

当垃圾回收器扫描到弱引用所标记的对象时,无论内存是否充足,都会将其回收

示例代码如下:

public static void main(String[] args) {
    String str = new String("Bird"); // 强引用
    WeakReference<String> soft = new WeakReference<String>(str);
    str = null; // 消灭强引用,让其只有软引用
    System.out.println(soft.get());	// Bird
    System.gc(); // 执行一次Full GC
    System.out.println(soft.get());	// null,虽然内存是充足的,也会对其进行回收
}

实际应用:WeakHashMap、ThreadLocal

虚引用(PhantomReference)

也称幽灵引用,是最弱的一种引用,被虚引用标记的对象和没有任何引用一样,任何时候都可能被回收

虚引用主要是用来跟踪对象被垃圾回收的活动

作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15811468.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

垃圾回收理论前置知识

这些前置知识都是 HotSpot 中的实现细节,为后面介绍垃圾回收器做铺垫。这里我写的简单一点,自己看懂就行

根节点枚举

迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,存在“Stop the World”(STW)。虽然后续耗时最长的可达性分析算法耗可以与用户线程一起并发,但根节点枚举不能并发,这是为了保证正确性

OopMap 的协助下,HotSpot 可以快速准确地完成 GC Roots 枚举,OopMap详细知识可参见:JVM中的OopMap

安全点和安全区域

引用关系变化会导致 OopMap 内容变化,这样的的指令非常多,如果为每一条这样的指令都生成 OopMap,会消耗大量的内存。实际上 HotSpot 也的确没有为每条指令都生成 OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点(Safepoint)

有了安全点的设定,也就决定了用户程序执行时,并非在代码指令流的任意位置都能够停顿下来进行垃圾收集,而是强制要求必须执行到达安全点后才能够停顿。安全点不能太少以至于让收集器等待时间过长,也不能太多以至于增大运行时的内存负荷。安全点位置的选取以“是否具有让程序长时间执行的特征”为标准选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点

如何在垃圾收集发生时让所有线程都跑到最近的安全点,然后停顿下来?HotSpot 的实现方式是:当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现标志为真就自己在最近的安全点上主动中断挂起。轮询操作在代码中会频繁出现,不过 HotSpot 中轮询指令实现得十分精简,不会影响性能

有些情况下,线程不在执行(即没有分配处理器时间,最典型的就是处于Sleep状态,或被阻塞),这时候线程无法响应虚拟机的中断请求,不能主动走到安全点去中断挂起自己,这就必须引入安全区域来解决。安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。可以把安全区域看作被扩展拉伸了的安全点。当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,当这段时间虚拟机发起垃圾收集,就不必在意这些安全区域内的线程是否到达安全点了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举,如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止

记忆集与卡表

解决对象的跨代引用,引入了记忆集这一结构,用以避免把整个老年代加入 GC Roots 集合。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。具体实现有三种,根据记录精度的不同,可以分为字长精度、对象精度、卡精度。比如卡精度就是记录了一块内存区域中的对象是否含有跨代引用,对应的具体实现就是卡表,这也是 HotSpot 的实现方式

卡表的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页。在 HotSpot 中卡页的大小为512字节。一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代引用,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中

写屏障

卡表如何维护,即卡表元素何时变脏、怎样变脏?这个问题就是通过写屏障来解决的

卡表元素何时变脏是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻

假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中

在 HotSpot 虚拟机里是通过写屏障技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障。写后屏障更新卡表的操作如下:

void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令。一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多

除了写屏障的开销外,卡表在高并发场景下还面临着伪共享问题。JDK 7 之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。先检查再更新卡表的操作逻辑如下:

if (CARD_TABLE [this address >> 9] != 0)
	CARD_TABLE [this address >> 9] = 0;

并发的可达性分析

GC Roots 枚举在 OopMap 的加持下,停顿时间已经是非常短暂了。但从 GC Roots 再继续往下遍历对象图,这里的停顿时间就与 Java 堆容量成正比例关系。堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间就越长。这个问题的解决方案就是——并发的可达性分析

在可达性分析和用户线程并发执行的过程中,可能会出现两种错误:

  • 浮动垃圾:把原本消亡的对象错误标记为存活,这是可以容忍的错误,留到下一次回收即可
  • 对象消失:把原本存活的对象错误标记为已消亡,这就是非常致命的后果,会导致程序错误!

经过证明,当且仅当以下两个条件同时满足时,会产生对象消失的问题:

  • 赋值器插入了一条或多条从黑色对象(被垃圾回收器扫描过,认为是存活对象,且该对象所有的引用都被扫描过)到白色对象(尚未被扫描过)的新引用
  • 赋值器删除了全部从灰色对象(被扫描过,但该对象的引用还没有全部被扫描过)到该白色对象的直接或间接引用

解决并发的可达性分析时的对象消失问题,只需破坏这两个条件的任意一个即可。由此分别产生了两种解决方案:增量更新(Incremental Update)和原始快照(Snapshot At The Beginning,SATB

  • 增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了
  • 原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现

作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15811468.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

分代收集理论

理论假说

  • 弱分代假说:绝大多数对象都是朝生暮死的
  • 强分代假说:熬过越多次垃圾回收过程的对象越难以消亡

根据这两个假说,就奠定了多款垃圾收集器的一致的设计原则:根据回收对象的年龄,将Java堆分出不同的区域。有了这些不同区域之后,就让垃圾收集器针对特定区域进行收集,因而有了垃圾回收的分类和垃圾回收器的分类

不过这些内存区域在垃圾回收的逻辑上是隔离的,程序逻辑上却并不隔离。如果要对单个区域进行收集,必须考虑跨代引用,如果是对新生代进行垃圾回收,最简单的做法就是遍历老年代,看看哪些老年代对象引用了新生代对象,将这些老年代对象也加入 GC Roots,不过这样做的效率很低。随后引出了第三条假说:

  • 跨代引用假说:跨代引用相对于同代引用来说,仅占极小部分

基于这条假说,就可以不同再为了少量的跨代引用去遍历整个老年代的对象,只需要在新生代上建立一个全局的数据结构——记忆集,标识出老年代的哪一块内存存在跨代引用。之后,就可以根据记忆集直接查找存在跨代引用的对象,而不用遍历整个老年代。虽然在运行时需要在对象改变引用时维护这个记忆集,但相比于遍历整个老年代,这部分多余的开销是划算的

垃圾回收分类

有了分代之后,相应的垃圾回收分类也被分为以下几种:

Minor GC / Young GC

针对新生代的垃圾回收,发生频率较高,执行速度也很快

触发条件:Eden 空间不足,会将 Eden 中幸存的对象复制到 To Survivor 区,如果 To Survivor 区容纳不下,会发生空间分配担保,直接将对象进入老年代。另外,Survivor 区空间不足不会触发 Minor GC

Major GC / Old GC

针对老年代的垃圾回收,发生频率很低。目前只有 CMS 收集器会有单独收集老年代的行为(?)

Mixed GC

混合收集,针对整个新生代以及部分老年代的垃圾回收。目前只有 G1 收集器会有这种行为

Full GC

整堆收集,针对整个 Java 堆和方法区的垃圾回收

垃圾回收算法

标记-清除算法(Mark-Sweep)

首先标记所有需要回收的对象,然后统一清除这些对象所在的区域

优点:

  • 空间利用率是 100%

缺点:

  • 如果需要回收的对象有很多,需要大量的标记、清除动作,效率不高
  • 会产生大量的内存碎片,导致以后可能因大对象分配而提前触发垃圾收集

标记-复制算法(Mark-Copy)

将内存划分成两等份,使用时只用一半。当这一半使用完,就将其中尚且存活的对象复制到另一个半区,并一次性清理使用的那半边内存

优点:

  • 不会出现内存碎片

缺点:

  • 内存利用率低
  • 会有大量的复制开销

新生代对象 90% 都是朝生暮死,所以回收只需要使用 10% 的空间即可,不用按照 1:1 划分空间,而是将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。 HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1,也就是每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10% 的内存会被“浪费”

标记-整理算法(Mark Compact)

首先标记出所有需要回收的对象,等标记完成后,不是直接对垃圾对象进行清理,而是让所有存活的对象都想内存空间的一端移动,最后直接清理掉边界之外的内存

优点:

  • 不用为了垃圾回收而预留额外的空间,空间利用率为 100%
  • 不会留下内存碎片

缺点:

  • 需要大量的标记、移动(复制)操作,效率不高
作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15811468.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

垃圾收集器

垃圾收集算法是内存回收的方法论,垃圾收集器是内存回收的实践者,不同版本的垃圾收集器差别较大。接下来介绍的垃圾收集器,是 JDK 7 Update 4 之后,JDK 11 之前,OcracleJDK 中的 HotSpot 虚拟机包含的全部可用的垃圾收集器(不是 OpenJDK)

总览

收集器 收集区域 收集算法 工作方式 特点
Serial 新生代 标记-复制 单线程 简单高效、内存消耗低,适合单核CPU场景。但是有 STW 问题
ParNew 新生代 标记-复制 并行 Serial 的并行版本,适合多核 CPU,单核不如 Serial,因此适合服务端;
除了 Serial 之外只有 ParNew 可以和 CMS 配合工作,是激活 CMS 后的默认新生代收集器
Parallel Scavenge 新生代 标记-复制 并行 并行收集器,面向可控的吞吐量,适用于后台计算任务;
可以控制吞吐量和最大停顿时间;
支持自适应的调节策略;
不能配合 CMS 工作
Serial Old 老年代 标记-整理 单线程 Serial 的老年代版本
Parallel Old 老年代 标记-整理 并行 Parallel Scavenge 的老年代版本,专门用于和其配合工作
CMS(Conc Mark Sweep) 老年代 标记-清除 并行、并发 并发标记和清除阶段都可以和用户线程并发,停顿时间非常短;
对处理器资源非常敏感,并发时会占用部分线程而降低总的吞吐量;
无法处理浮动垃圾,预留的内存如果不能满足程序运行的需要,会产生并发失败问题,这时会启动后备预案 Serial Old 来执行一次 Major GC;
由于是基于标记-清除算法,清除阶段可以并发执行,但会产生内存碎片,可以开启内存整理功能
G1(Garbage First) 跨新生代、老年代 标记-复制(局部上) + 标记-整理(整体上) 并行、并发 基于 region 的堆内存划分,执行回收价值优先的收集策略,实现了可预测的停顿时间模型;
新生代、老年代并不是固定的,而是多个 region 的动态组合;
和 CMS 追求尽可能低的停顿时间不同,G1 追求在可控的停顿时间下,获得尽可能高的吞吐量,因此 G1 不会一次性回收所有垃圾

注:

  • 并行:多个垃圾收集线程同时开展工作,此时用户线程处于等待状态
  • 并发:垃圾收集的单/多线程与用户的多线程同时运行
  • 使用 jps -v 可以看到当前 JVM 进程使用的垃圾收集器,例如:-XX:+UseConcMarkSweepGC 表示使用了 CMS

垃圾收集器之间的组合使用关系

注:连线代表可以组合的新生代、老年代垃圾回收器,红叉表示 JDK 9 及之后取消了这种组合使用关系

Serial / Serial Old

Serial 是最古老的垃圾收集器,单线程,简单高效,内存占用(Memory Footprint)最少,适合单 CPU 服务器。新生代、老年代的垃圾回收都会存在 STW

Serial Old 就是 Serial 的老年代版本,主要用途:

  • 和 Serial 或者 Parallel Scavenge 搭配使用,不过 JDK 6 出现了 Parallel Old 之后,Parallel Scavenge 一般只与 Parallel Old 组合使用了
  • 作为 CMS 收集器发生并发失败(Concurrent Mode Failure)时,作为后备预案

Serial + Serial Old 组合:

使用方式:

  • -XX:+UseSerialGC:使用 Serial + Serial Old 的组合
  • -XX:+UseParNewGC:使用 ParNew + Serial Old 的组合
  • 由于 JDK 6 出现了 Parallel Old,Parallel Scavenge 就不再和 Serial Old 组合使用了
  • 作为 CMS 发生失败的后备预案,这个是不用手动配置的

ParNew

ParNew 和 Serial 没啥区别,唯一在于可以多线程并行垃圾回收,停顿时间比 Serial 更短。同样会有存在 STW,但是更短暂。在单核 CPU 上不如 Serial,因为存在线程交互的开销,但是多 CPU 下更加高效

ParNew + Serial Old 组合:

使用方式:

  • -XX:+UseParNewGC:使用 ParNew + Serial Old 的组合
  • -XX:+UseConcMarkSweepGC:使用 ParNew + CMS 的组合

Parallel Scavenge / Parallel Old

可以利用多线程并行收集,但和其他并行收集器的最大区别在于,它的设计目标是吞吐量可控,而其他并行收集器(如 ParNew、CMS)的设计目标是缩短用户线程的停顿时间

吞吐量(Throughout)计算公式:

追求低停顿时间适用于需要用户交互的程序,提升用户体验;追求高吞吐量适合后台计算任务,不需要交互,尽快完成运算

Parallel Scavenge 可以精准控制吞吐量:

  • -XX:MaxGCPauseMillis:控制最大垃圾回收停顿时间。不是设置得越小越好,因为系统会将新生代调小,这样停顿时间少了,但垃圾收集会更加频繁,导致总的吞吐量下降
  • -XX:GCTimeRatio:直接设置吞吐量大小

Parallel Scavenge 还有一个最大的特点——自适应的调节策略,使用 -XX:+UseAdaptiveSizePolicy 开启。如果开启了该功能,就不需要指定新生代大小(-Xmn)、Eden 和 Survivior 区的比例(-XX:SurvivorRatio)、晋升老年代对象的大小(-XX:PretenureSizeThreshold)等,虚拟机会根据当前系统运行情况收集性能监控信息,动态调整这些参数以达到用户设定的优化目标,是更关注停顿时间-XX:MaxGCPauseMillis)还是更关注吞吐量-XX:GCTimeRatio),用户另外只需设置基本的内存参数即可(如 -Xmn 设置堆最大容量),其他参数由虚拟机自适应调节

Parallel Scavenge 不能与 CMS 配合工作,有两点原因:

  • 设计目标不同:CMS 是面向低停顿时间,而 Parallel Scavenge 是面向高吞吐量
  • 技术上不同:Parallel Scavenge 收集器(也包括 G1)都没有使用分代框架,而选择另外独立实现

JDK 5 之前能和 Parallel Scavenge 配合的只有 Serial Old,但 JDK 6 出现了 Parallel Old,它其实就是 Parallel Scavenge 的老年代版本,支持多线程并发收集,Parallel Scavenge 就和它组合使用,专门用于注重吞吐量的场合

Parallel Scavenge + Parallel Old 组合:

使用方式:

  • -XX:+UseParallelGC:使用 Parallel Scavenge + Parallel Old 的组合
  • 由于 JDK 6 之后出现了 Parallel Old,Parallel Scavenge 就不再和 Serial Old 组合使用了

CMS

CMS 和大多数并行收集器一样,追求最短的停顿时间,比如互联网站、B/S 系统上的应用。CMS 是基于标记-清除算法,相比于基于标记-整理算法的老年代收集器,过程更加复杂,但是最大的好处在于,垃圾回收过程不存在 STW,可以和用户线程并发执行

ParNew + CMS 组合:

整个过程分为四步:
1、初始标记:标记 GC Roots 能够直接关联到的对象,会 STW,但由于速度很快(得益于OopMap),所以停顿时间很短
2、并发标记:从 GC Roots 直接关联的对象开始进行可达性分析,过程较长,但可以和用户线程并发执行,不存在STW
3、重新标记:修正并发标记期间,因用户程序运行而导致标记产生变动的对象的标记,CMS 使用增量更新技术。会 STW,但可并行处理,所以停顿时间也很短
4、并发清除:删除掉判断为垃圾的对象,由于不需要像标记-整理算法一样移动存活对象,所以可以和用户线程并发执行,不存在 STW 问题

CMS 的优点

  • 整个过程中耗时很长的并发标记、并发清除中,垃圾收集线程都可以和用户线程并发执行,不存在 STW 问题。另外两个阶段不可以并发,但很短暂。因此总的来说,CMS 的内存回收可以和用户线程并发执行,停顿时间很短

CMS 的缺点

  • 对处理器资源比较敏感,在并发阶段会占用一部分线程而导致程序变慢,降低总的吞吐量
  • 无法处理浮动垃圾,可能出现并发失败(Concurrent Mode Failure),而导致另一次完全 STW 的 GC:因为并发阶段用户线程还会产生新的垃圾,而它们可能会躲过标记过程而无法被回收,这部分垃圾就是浮动垃圾。由于浮动垃圾的存在,CMS 不能等老年代完全被填满才开始 GC,必须预留足够的空间给并发阶段的用户线程使用。可以通过 -XX:CMSInitiatingOccupancyFraction 调节 CMS 的触发百分比,这个数值越大,回收频率越低,性能越好。但如果太大,又会面临另外一个风险:如果 CMS 预留的内存无法满足程序运行的需要,就会出现并发失败,此时JVM必须启动后备预案:停顿用户线程(STW),启动 Serial Old 收集器对老年代进行垃圾收集,这样会导致 STW。因此,CMS 触发百分比不宜设置过高,会导致出现大量的并发失败,降低性能
  • 由于 CMS 基于标记-清除算法,会产生内存碎片,给大对象分配带来困难,因而提前触发 Full GC。为解决该问题,CMS 提供 -XX:+UseCMSCompactAtFullCollection开关参数,用于在 CMS 不得不进行 Full GC 时开启内存碎片的整理过程,但这时需要移动存活对象,无法和用户线程并发执行,所以存在 STW。为此又提供了一个参数 -XX:CMSFullGCsBeforeCompaction,要求 CMS 在执行一定次数不整理内存的 Full GC 之后,下一次进入 Full GC 之前先进行内存碎片的整理工作(默认为 0,表示每次进入 Full GC 都要进行碎片整理)

使用方式:

  • -XX:+UseConcMarkSweepGC:使用 ParNew + CMS 的组合
  • CMS 收集器发生并发失败时,自动使用 Serial Old 作为后备预案

G1

可预测的停顿时间模型:在一个长度为 M 毫秒的时间内,消耗在 GC 上的时间大概率不超过 N 毫秒

G1 的分代模型——基于 region 的堆内存布局
实现支持该模型的垃圾回收器,关键在于 G1 开创了基于 region 的堆内存布局。之前的垃圾回收是要么针对新生代,要么针对老年代,要么直接整堆收集。而 G1 可以针对堆中任何 region 进行回收,这就是 G1 的 Mixed GC 模式

基于 region 的堆内存布局如下图所示:

将连续的堆空间划分为多个大小相等的 region,每个 region 都可以根据需要作为新生代的 Eden 空间、Survivor 空间,或老年代空间。收集器能够对扮演不同角色的 region 采用不同的策略去处理,这样无论是新生代对象,还是老年代对象,都能取得很好的收集效果

region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 region 容量一半的对象,即可判定为大对象。每个 region 的大小可以通过参数 -XX:G1HeapRegionSize 来设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。而对于那些超过了整个 region 容量的超级大对象,将会被存放在 N 个连续的 Humongous region 之中,G1 的大多数行为都把 Humongous region 作为老年代的一部分来看待

总之,虽然G1仍保留新生代和老年代的概念,但新生代和老年代不再是固定的,它们都是一系列 region(不需要连续)的动态集合

G1 的回收策略——回收价值优先
有了基于 region 的堆内存布局后,G1 就可以支持可预测的停顿时间模型。G1 会跟踪各个 region 里面垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的 GC 停顿时间(-XX:MaxGCPauseMillis 来设定,默认 200 毫秒),优先处理回收价值大的 region,这也是“Garbage First”名字的由来

G1 要解决的问题

  • 跨 region 引用问题:让每个 region 维护各自的记忆集——双向卡表结构。但是这种记忆集结构更加复杂,且由于 region 数量众多,导致 G1 的内存占用率更高,经验上讲 G1 的垃圾回收至少要消耗堆容量的 10%~20%。而 CMS 只要维护一份卡表,记录老年代到新生代的引用即可,且卡表结构更加简单,因此内存占用率更低
  • 并发标记阶段,用户线程会更新对象引用,打破原本的对象图结构:CMS 使用增量更新算法,而G1使用原始快照表(SATB)算法
  • 筛选回收阶段,会有新的对象创建出来:G1 为每个 region 都设计了两个 TAMS(Top At Mark Start)指针,将 region 中的一部分空间划分出来用于并发回收过程中的新对象分配,新对象的地址必须再这两个指针位置以上,G1会默认这个地址以内的对象是被隐式标记过的,即认为它们是存活的,不会纳入回收范围。不过如果内存回收的速度赶不上内存分配的速度,和 CMS 一样,G1 也会出现并发失败而导致 Full GC,进而产生长时间的 STW

G1 的回收过程

1、初始标记:标记 GC Roots 能够直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 region 中分配新对象。会 STW,但由于速度很快,且这一步是借用 Minor GC 时同步完成的,实际上没有额外的停顿
2、并发标记:从 GC Roots 直接关联的对象开始进行可达性分析,过程较长,但可以和用户线程并发执行,不存在 STW
3、最终标记:对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后少量的 SATB 记录,即修正并发标记期间的标记变动情况,类似于 CMS 的重新标记阶段。这一步会 STW,但可以并行执行,停顿时间很短暂
4、筛选回收:对各个 region 的回收价值进行排序,根据用户期望的停顿时间来制定回收计划,可自由选择任意多个 region 构成回收集,然后把决定回收的 region 中的存活对象复制到空的 region 中,再清理掉整个旧 region 的全部空间。此操作涉及存活对象的移动,不能并发,必须将用户线程停顿,再由多个 GC 线程并行回收

相对于 CMS ,G1 在回收阶段并不是并发的,这是因为 G1 并不是纯粹地追求低停顿时间,而是追求在可控的停顿时间下,获得尽可能高的吞吐量。G1 在回收阶段只会回收部分 region,因此可以做到停顿时间可控。并且为了最大化垃圾收集效率,保证吞吐量,所以选择完全停顿用户线程。这一点 G1 和 CMS 是有很大区别的

不过期望停顿时间不能设置得过小(-XX:MaxGCPauseMillis,默认 200 毫秒),一般来说,回收阶段占到几十到一百甚至接近 200 毫秒都很正常,但如果把停顿时间调得非常低,可能导致每次只能回收一小部分 region,垃圾回收速率跟不上内存分配速率,最终引发 Full GC,反而降低性能。从 G1 开始,垃圾回收器就开始追求回收速率迎合内存分配速率,而不追求一次性清空整个堆空间,所以说 G1 是 GC 发展的一个 milestone

G1 与 CMS 对比

  • 相同点:它们都是设计目标都是:低停顿时间
  • 不同点
    • G1 的优势:
      • 可以指定期望最大停顿时间-XX:MaxGCPauseMillis
      • 整体上 G1 是基于标记-整理算法,不会产生内存碎片
      • 基于 region 的内存布局 + 回收价值优先收集,垃圾回收策略更加合理
    • G1 的劣势:
      • 内存占用率更高:G1 使用的卡表更加复杂,且每个 region 都必须维护一个卡表,导致 G1 的记忆集庞大,内存占用率高;而CMS卡表简单,且只用维护一份老年代到新生代的引用,内存占用率低
      • 执行负载更高:CMS 使用写后屏障来更新卡表;G1 除了这之外,为了实现原始快照搜索(SATB)算法,还使用写前屏障来跟踪并发阶段的指针变化情况
作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15811468.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

内存分配与回收策略

对象优先在 Eden 中分配

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

大对象直接进入老年代

大对象就是指需要大量连续内存空间的 Java 对象。HotSpot 虚拟机提供了 -XX:PretenureSizeThreshold
参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区
之间来回复制,产生大量的内存复制操作

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

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

动态对象年龄判定

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

空间分配担保

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

作者:酒冽        出处:https://www.cnblogs.com/frankiedyz/p/15811468.html
版权:本文版权归作者和博客园共有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

重要的 GC 参数

参数 描述
UseSerialGC 虚拟机运行在 Client 模式下的默认值,使用 Serial + Serial Old 的组合
UseParNewGC 使用 ParNew + Serial Old 的收集器组合进行内存回收
UseConcMarkSweepGC 使用 ParNew + CMS + Serial Old 的组合。Serial Old 收集器作为 CMS 收集器出现并发失败后的后备收集器
UseParallelGC 虚拟机运行在 Server 模式下的默认值,使用 Parallel Scavenge + Serial Old(PS MarkSweep) 的组合
UseParallelOldGC 使用 Parallel Scavenge + Parallel Old 的组合
UseG1GC 使用 G1 收集器
SurvivorRatio 新生代中 Eden 区域与 Survivor 区域的容量比值,默认为 8,表示 Eden : Survivor = 8 : 1
PretenureSizeThreshold 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
MaxTenuringThreshold 晋升到老年代的对象年龄,每个对象在坚持过一次 Minor GC 之后,年龄就增加 1,当超过这个参数值时就会晋升到老年代
UseAdaptiveSizePolicy 动态调整 Java 堆中各个区域的大小以及进入老年代的年龄
HandlePromotionFailure 是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个 Eden 和 Survivor 区的所有对象都存活的极端情况
ParallelGCThreads 设置并行 GC 时进行内存回收的线程数
GCTimeRatioGC 时间占总时间的比率,默认值为 99,即允许 1% 的 GC 时间,仅在使用 Parallel Scavenge 收集器生效
MaxGCPauseMillis 设置 GC 的最大停顿时间,仅在使用 Parallel Scavenge 收集器时生效
CMSInitiatingOccupancyFraction 设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集,默认值为 68%,仅在使用 CMS 收集器时生效
UseCMSCompactAtFullCollection 设置 CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理,仅在使用 CMS 收集器时生效
CMSFullGCsBeforeCompaction 设置 CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理,仅在使用 CMS 收集器时生效
MaxGCPauseMillis 设置单次垃圾回收预期的最大停顿时间,默认 200 毫秒,仅在使用 G1 收集器时生效
G1HeapRegionSize 设置每个 region 的大小,取值范围为 1MB~32MB,且应为 2 的 N 次幂,仅在使用 G1 收集器时生效
G1NewSizePercent 设置新生代最小值,默认值 5%,仅在使用 G1 收集器时生效
G1MaxNewSizePercent 设置新生代最大值,默认值 60%,仅在使用 G1 收集器时生效
ConcGCThreads 设置并发标记阶段,并行执行的线程数

其他参数

参数 描述
PrintGCDetails 打印输出详细的 GC 收集日志的信息
posted @ 2022-01-16 23:48  酒冽  阅读(555)  评论(1编辑  收藏  举报