JVM 垃圾回收 (CMS 和 G1) 篇

本贴最后更新于 1858 天前,其中的信息可能已经时移俗易

GC Roots 对象的包括如下几种:

  1. 虚拟机栈 (栈桢中的本地变量表) 中的引用的对象 ;
  2. 方法区中的类静态属性引用的对象 ;
  3. 方法区中的常量引用的对象 ;
  4. 本地方法栈中 JNI 的引用的对象;

CMS 垃圾回收器

分代算法结构

新生代:eden space + 2 个 survivor ; 老年代:old space
永久代:1.8 之前的 perm space ; 元空间:1.8 之后的 metaspace

  • 1.初始化标记

    暂停应用程序线程,遍历 GC ROOTS 直接可达的对象并将其压入标记栈。标记完之后恢复应用程序线程。

  • 2.并发标记

    这个阶段虚拟机会分出若干线程 (GC 线程) 去进行并发标记那些 GC ROOTS 最终可达的对象。具体做法是推出标记栈里面的对象,然后递归标记其直接引用的子对象,同样的把子对象压到标记栈中,重复推出,压入。。。直至清空标记栈。这个阶段 GC 线程和应用程序线程同时运行。

当 GC 线程进行并发操作时,应用程序可能会进行新增对象、删除对象、变更对象引用等一系列操作。这种条件下可能会出现活动对象的漏标的情况,

比如下面场景:

活动对象被遗漏标记

A 是活动对象,A->B,标记 B 可达,将其压入标记栈,此时 A 所有直接子对象遍历完,A 出栈,标记线程将不会再访问 A。
同时应用程序移掉了 B 对 C 的引用,让 A 重新引用 C。
B 出栈时无法标记 C 可达,A 虽然引用 C 但标记线程不会再访问 A,此时 C 会被当成不可达对象。

为了解决这个问题,还需要额外的操作,这个操作就是 write barrier。

当赋值引用时,如果赋值的对象还没有被标记,将标记该对象将其压入标记栈。

在使用 Write bariier 之后同样的情景就不会出现活动对象被遗漏的情况了.
活动对象不会被遗漏

  • 3.重新标记

    整个过程是 STW。在并发标记其间,应用程序不断变更对象引用,此时的 GC ROOTS 有可能会发生变化,这个时候需要同步更新这个增量变化。于是重新从当前的 GC ROOTS 和指针更新的区域出发再进行一次标记,所以这个过程被叫作重新标记。

    需要注意的是:已经标记的对象是不会再遍历一次,标记线程识别对象在并发阶段已经标记过了,就会跳过该对象。所以重新标记只会遍历那些新增没有标记过的活动对象和其间有指针更新的活动对象,如果指针更新频繁,重新标记很有可能会遍历新生代中的大部分甚至全部对象。

  • 4.并发清除

    重新标记结束后,应用程序继续运行,此时分出一个处理器去进行垃圾回收工作。

    老年代的对象通常是存活时间长,回收比例低,所以采用的回收算法是标记 - 清除。这个阶段 GC 回收线程是遍历整个老年代,遇到没有被标记的对象 (垃圾) 就清空掉相应的内存块,并加入可分配列表。遇到被标记的对象保持原来的位置不动,只是重置其标记位,用于下一次 GC。

  • 5.并发重置

    重新调整堆的大小,并为下一次 GC 做好数据结构支持。

Card Table

YGC 时为了标记活动标记对象除了 tracing GC ROOTS 之外,老年代里也可能会引用新生代对象。
所以正常来说还要扫描一次老年代,如果是扫描整个老年代这将会随着堆的增大变得越来越慢,特别是现在内存都越来越大了。所以为了提升性能就引入卡表。

卡表提升性能的原理:逻辑上把老年代内存分成一个个大小相等的卡片,然后对每个卡片准备一个与其对应的标记位,并将这些位集中起管理就好像一个表格 (mark table) 一样,当改写对象引用是从老年代指向新生代时,在老年代对应的卡片标记位上设置标志位即可,通常这样的卡片我们称之为 dirty card。

这项操作可以通过上面的提到的 write barrier 来实现,这样就算对象跨多张卡片也不会有什么问题。 卡表通常是用 byte 数组实现的,byte 的值只能取 [0,1] 这两种。所以 btye [i] = 1 就表示第 i + 1 卡片所在内存上有指向新生代引用的老年代对象,这时只要 tracing 这个卡片上的对象即可。背后思想就是典型以空间换时间的思路!

卡片标记

G1 垃圾回收器

G1 将整个堆划分为一个个大小相等的小块(每一块称为一个 region),每一块的内存是连续的。和分代算法一样,G1 中每个块也会充当 Eden、Survivor、Old 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。
G1 的堆结构

  1. 整个堆默认 region 的数量是 2048 个。
  2. 每个 Region 被标记了 E、S、O 和 H,其中 H (humongous)表示这些 Region 存储的是巨型对象,当新建对象大小超过 Region 大小一半时,直接在新的一个或多个连续 Region 中分配,并标记为 H。
  3. Region 的大小只能是 1M、2M、4M、8M、16M 或 32M

G1 工作流程

G1 收集器主要包括了以下 4 种操作:

  1. 年轻代收集;
  2. 全局并发标记;
  3. 混合式垃圾收集;
  4. 必要时的 Full GC;
STAB

STAB 全称为 snapshot-at-the-beginning,其目的是了维持并发 GC 的正确性。GC 的正确性是保证存活的对象不被回收,换句话来说就是保证回收的都是垃圾。如果标记过程是 STW 的话,那 GC 的正确性是一定能保证的。但如果一边标记,一边应用在变更堆里面对象的引用,那么标记的正确性就不一定能保证了。

为了解决这个问题,STAB 的做法在 GC 开始时对内存进行一个对象图的逻辑快照 (snapshot),通过 GC Roots tracing 参照并发标记的过程,只要被快照到对象是活的,那在整个 GC 的过程中对象就被认定的是活的,即使该对象的引用稍后被修改或者删除。

同时新分配的对象也会被认为是活的,除此之外其它不可达的对象就被认为是死掉了。这样 STAB 就保证了真正存活的对象不会被 GC 误回收,但同时也造成了某些可以被回收的对象逃过了 GC,导致了内存里面存在浮动的垃圾 (float garbage)。

一、Young GC

Young GC

二、Global Concurrent Marking

Global Concurrent Marking 全局并发标记

全局并发标记过程分为五个阶段

(1) 初始标记

初始标记是一个 STW 事件,其完成工作是标记 GC ROOTS 直接可达的对象。并将它们的字段压入扫描栈(marking stack)中等到后续扫描。因为 STW,所以通常 YGC 的时候借用 YGC 的 STW 顺便启动 启动全局并发标记,但是全局并发标记与 YGC 在逻辑上独立。

(2) Root Region Scanning 根区域扫描

根区域扫描是从 Survior 区的对象出发,标记被引用到老年代中的对象,并把它们的字段在压入扫描栈中等到后续扫描。与 Initial Mark 不一样的是,Root Region Scanning 不需要 STW 与应用程序是并发运行。Root Region Scanning 必须在 YGC 开始前完成。

(3) Concurrent Marking 并发标记

不需要 STW。不断从扫描栈取出引用递归扫描整个堆里的对象。每扫描到一个对象就会对其标记,并将其字段压入扫描栈。重复扫描过程直到扫描栈清空。过程中还会扫描 SATB write barrier 所记录下的引用。该过程可以被 YGC 中断。

(4) Remark 最终标记

  • STW 操作。在完成并发标记后,每个 Java 线程还会有一些剩下的 SATB write barrier 记录的引用尚未处理。这个阶段就负责把剩下的引用处理完。
  • 注意这个暂停与 CMS 的 remark 有一个本质上的区别,那就是这个暂停只需要扫描 SATB buffer,而 CMS 的 remark 需要重新扫描 mod-union table 里的 dirty card 外加整个根集合,而此时整个 young gen(不管对象死活)都会被当作根集合的一部分,因而 CMS remark 有可能会非常慢。

(5) Cleanup 清除

STW 操作,清点出有存活对象的 Region 和没有存活对象的 Region (Empty Region)

主要完成了垃圾定位的工作,定位出了哪些分区是垃圾最多的。

三、MIXED GC

并发周期结束后是混合垃圾回收周期,不仅进行年轻代垃圾收集,而且回收之前标记出来的老年代的垃圾最多的部分区块。

MIXED GC 周期会持续进行,直到几乎所有的被标记出来的分区(垃圾占比大的分区)都得到回收,然后恢复到常规的年轻代垃圾收集,最终再次启动并发周期。

四、 Full GC

下面我们来介绍特殊情况,那就是会导致 Full GC 的情况,也是我们需要极力避免的:

  1. concurrent mode failure:并发模式失败,CMS 收集器也有同样的概念。G1 并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。这个时候说明

    • 堆需要增加了,
    • 或者需要调整并发周期,如增加并发标记的线程数量,让并发标记尽快结束
    • 或者就是更早地进行并发周期,默认是整堆内存的 45% 被占用就开始进行并发周期。
  2. 晋升失败:并发周期结束后,是混合垃圾回收周期,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。

    说明混合垃圾回收需要更迅速完成垃圾收集,也就是说在混合回收阶段,每次年轻代的收集应该处理更多的老年代已标记区块。

  3. 疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。这种情况肯定是非常致命的,因为基本上已经没有多少空间可以用了,这个时候会触发 Full GC 也是很合理的。

    最简单的就是增加堆大小

  4. 大对象分配失败,我们应该尽可能地不创建大对象,尤其是大于一个区块大小的那种对象。


问题整理

问题 1:CMS 和 G1 的重新标记,最终标记的目的是什么?

原因:并发过程中用户程序修改了对象引用关系!就是你说的改动!

目标:即便对象引用关系修改,也能让 GC 收集器正确回收垃圾对象!

问题 2:CMS 和 G1 是如何解决并发标记过程中 对象引用被更改的问题?
  1. 并发标记产生的可到达对象关系 Rcon; R 是 Relationship 关系;
  2. 用户程序修改的对象引用关系,Ruser;

把 Ruser 和 Rcon 合并一下,形成一个新的,完整的可到达对象关系 Rfinal,交给 GC 程序。

CMS 和 G1 都采取一种方式 Write barrier+log,3 个步骤:

  1. Write barrier 监听用户修改对象引用关系
  2. 监听同时写日志用户程序修改引用关系时候,监听并记录 Ruser 关系到日志 Rslog(Remember Set log)
  3. 然后合并后期处理 Rslog 的过程中,把 Ruser 这部分信息与 Rcon 合并成 Rfinal,然后用 Rfinal 进行 GC 这里的后期处理。
问题 3:CMS 和 G1 的 Rslog 中记录的是什么?

Rslog 的作用就是记录用户程序对对象关系的修改;
用户程序的修改只能有 2 种:

  1. 增加了引用 - 对象关系;Object o = new Object (); 或者 o1 = new Object ();
  2. 删除了引用 - 对象关系; o = null;
  • 对于 CMSRslog 记录的新增关系;删除关系会被忽略,变成浮动垃圾,下一次 GC 回收;barrier 会监听新增的引用关系,并记录日志所以 CMS 会处理新增关系,忽略删除关系
  • 对于 G1Rslog 记录的删除关系;新增关系会被忽略,变成浮动垃圾,以后 GC 处理;barrier 会监听引用关系删除,并记录日志所以 G1 会处理删除关系,忽略新增关系,

总结:宁可放过,下一次处理,也不错杀

参考:CMS 收集器原理理解与分析

参考:G1 收集器原理理解与分析

公众号

  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖
  • GC
    17 引用 • 45 回帖
  • 垃圾回收
    5 引用 • 1 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...