主页 肝了一周,彻底弄懂了 CMS收集器原理,这个轮子造的真值!
文章
取消

肝了一周,彻底弄懂了 CMS收集器原理,这个轮子造的真值!

你好,我是猿java。

网上关于 CMS的文章很多,为什么要重复造车轮?

答:网上很多关于 CMS收集器的文章写得不够具体,有的甚至一知半解,更多的是不假思索的转载,想通过自己对 CMS的理解以及大量资料的佐证,提供更具体形象正确的分析。

CMS已经被弃用,为什么还要分析它?

答:首先,CMS收集器依然是面试中的一个高频问题;其次,CMS作为垃圾收集器的一个里程碑,作为 Java程序员,不了解原理,于情于理说不过去;

JVM已经把垃圾回收自动化了,为什么还要讲解 CMS?

答:排查生产环境的各种内存溢出,内存泄漏,垃圾回收导致性能瓶颈等技术问题,如果不懂原理,如何排查和优化?

温馨提示:如果没有特殊说明,本文提及的虚拟机默认为 HotSpot虚拟机。

背景

首先,了解下 HotSpot虚拟机中 9款垃圾回收器的发布时间及其对应的 JDK版本,如下图:

img.png

接着,了解下 CMS垃圾回收器的生命线:

  • 2002年9月,JDK 1.4.1 版本,CMS实验性引入;
  • 2003年6月,JDK 1.4.2 版本,CMS正式投入使用;
  • 2017年9月,JDK 9 版本,CMS被标记弃用;
  • 2020年3月,JDK 14 版本,CMS从 JDK中移除;

效力 18年,一代花季回收器,从此退出历史舞台;

什么是垃圾

既然分析的是垃圾回收器,那么,我们首先需要知道:在 JVM 中,什么是“垃圾”?

这里的“垃圾”用了双引号,是因为它和我们生活中理解的垃圾不一样。在 JVM中,垃圾(Garbage)是指那些不再被应用程序使用的对象,也就是说这些对象不再可达,即对象已死。

如何判断对象不可达(已死)?

在 JVM中,通过一种可达性分析(Reachability Analysis)算法来判断对象是否可达。 该算法的基本思路是:通过 GC Roots 集合里的根对象作为起始点,一直追踪所有存在引用关系的对象(这条引用关系链路叫做引用链 Reference Chain), 如果某对象到 GC Roots之间没有引用链,那么该对象就是不可达。 如下图,obj4, obj5,obj6 尽管相互直接关联,但是没有 GC Root连接,所以是不可达,同理 obj7也不可达: img.png

关于可达性分析,还有一种方法是引用技术算法,该方法的思路是:在对象中添加一个计数器,增加一次引用计数器 +1,减少一次引用计数器 -1,当计数器始终为 0时代表不被使用,这种方法一般是用于 Python的CPython 和微软的COM(Component Object Model)等技术中,JVM中使用的是可达性分析算法,这点需要特别注意。

哪些对象可以作为 GC Roots?

GC Roots 是 GC Root的集合,本质上是一组必须活跃的对象引用,主要包含以下几种类型:

虚拟机栈中的引用对象:每个线程的虚拟机栈中的局部变量表中的引用。这些引用可能是方法的参数、局部变量或临时状态。

方法区中的类静态属性引用对象:所有加载的类的静态字段。静态属性是类级别的,因此它们在整个Java虚拟机中是全局可访问的。

方法区中的常量引用对象:方法区中的常量池(例如字符串常量池)中的引用。

本地方法栈中的JNI引用:由 Java本地接口(JNI)代码创建的引用,例如,Java代码调用了本地 C/C++库。

活跃的 Java线程:每个执行中的Java线程本身也是一个GC Root。

同步锁(synchronized block)持有的对象:被线程同步持有的对象。

Java虚拟机内部的引用:比如基本数据类型对应的Class对象,一些常见的异常对象(如NullPointerException、OutOfMemoryError)的实例,系统类加载器。

反射引用的对象:通过反射API持有的对象。

临时状态:例如,从Java代码到本地代码的调用。

这里举个简单的例子来解释 GC Root 以及 GC Root可达对象,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class RootGcExample {
    private static Object sObj = new Object(); // 静态字段 sObj是 Gc Root

    private static void staticMethod() {
      Object mObj = new Object(); // 方法局部变量 mObj是 Gc Root
      //  ...
    }

    public static void main(String[] args) {
        Object obj = new Object(); // 局部变量obj 是 Gc Root
        staticMethod();
    }
}

上述例子中,sObj 是一个静态变量引用,指向了一个 Object对象,因此,sObj是一个 Gc Root, 在staticMethod静态方法中,mObj 是一个方法局部变量,它也是一个 Gc Root, 在 main方法中,obj也是一个Gc Root。堆中的 Object对象就是 GC Root可达对象,上述关系可以描绘成下图:

img.png

回收哪里的垃圾?

从 CMS 简介可以知道 CMS是用于老年代的垃圾回收,但是对于这种抽象的文字描述,很多小伙伴肯定还是没有体感, 因此,我们把视角放眼到整个 JVM运行时的内存结构上,从整体上看看垃圾回收器到底回收的是哪些区域的垃圾, CMS 又是回收哪里的垃圾,如下图:

垃圾在哪里?

在了解了“垃圾”在 JVM中是如何定义之后,我们不禁会问到:这些“垃圾”存放在哪里呢?

在回答这个问题之前,我们先来了解 JVM的内存结构,根据 Java虚拟机规范,JVM内存包含以下几个运行时区域,如下图: img.png

为了更好地理解 JVM内存结构,这里对各个区域做一个详细的介绍:

  1. 堆空间(Heap):它是 JVM内存中最大的一块线程共享的区域,用于存放 Java应用创建的对象实例和数组。堆空间进一步细分为几个区域:
  • 年轻代:Young Generation,大部分的对象都是在这里创建。年轻代又分为一个 Eden区和两个 Survivor区(S0和S1)。这里的大部分对象生命周期比较短,会被垃圾回收器快速回收。

  • 老年代:Old Generation 或 Tenured Generation,在年轻代中经过多次垃圾回收仍然存活的对象会被移动到老年代,或者一些大对象会直接被分配到老年代,这里的对象一般存活时间较长,垃圾回收频率较低。

  • 永久代:Permanent Generation,PermGen,Java 8之前版本的叫法,用于存放类信息、方法信息、常量等。在 Java 8及之后的版本,永久代被元空间(Metaspace)所替代。

  • 元空间:Metaspace,Java 8及之后版本的叫法,用于存放类的元数据信息,它使用本地物理内存,不在 JVM堆内。

  1. 方法区(Method Area):方法区是堆的一个逻辑区域,它是线程共享的,用于存储已被 JVM加载的类结构信息,常量、静态变量、即时编译后的代码缓存等数据。为了和堆区分开来,它也被叫做“非堆(Non-Heap)”。这个区域的回收对象主要是常量池和类型的卸载,而且回收的效果比较差。

关于方法区有一个误区:JDK 8以前,HotSpot虚拟机为了像堆一样管理方法区的垃圾回收,就使用永久代来实现方法区,因此有人就把方法区直接叫做永久代,而其它虚拟机不存在永久代的概念,因此,方法区如何实现属于虚拟机内部的机制,不是 JVM统一规范。另外,HotSpot发现永久代实现方法区这种做法会导致内存溢出,因此从 JDK8开始,把永久代彻底废除,改用和 JRockit一样的元空间。方法区也改用本地内存实现。

  1. 程序计数器(Program Counter Register):这是一个较小的线程私有内存空间,用于存储当前线程执行的字节码的行号指示器。每个线程都有自己的程序计数器,但这部分内存通常不涉及垃圾回收。

  2. 虚拟机栈(Java Virtual Machine Stack):每个 Java方法执行时都会创建一个线程私有的栈帧,用于存储局部变量表、操作数栈、动态链接和方法出口信息等。虚拟机栈在方法执行完毕后会自动清理,因此也不是垃圾回收的重点。

  3. 本地方法栈(Native Method Stack):用于支持本地方法的执行(即通过JNI调用的非Java代码),它是线程私有的。本地方法栈也会在方法执行完毕后自动清理。

通过上述 JVM内存区域的介绍,我们可以发现 JVM各个内存区域都可能产生垃圾,只是程序计算器,本地方法区,虚拟机栈 3个区域随线程而生,随线程而亡,垃圾被自动回收,方法区回收效果比较差,而堆中的“垃圾”才是回收器关注的重点,因此,垃圾收集器重点关注的是 JVM的堆,而 CMS回收的是堆中的老年代,如下图:

img.png

到这里为止,我们已经从 JVM内存结构视角上掌握了垃圾收集器回收的区域以及 CMS 负责的区域。

接下来,分析一下 GC回收常用的几个重要技术点:三色标记法(Tricolor Marking),卡表(Card Table),写屏障(Write Barrier),理解它们可以帮助我们更好地去理解 GC回收的原理。

几个重要技术点

三色标记法

在垃圾收集器中,主要采用三色标记算法来标记对象的可达性:

  • 白色:表示对象尚未被访问。初始状态时,所有的对象都被标记为白色。

  • 灰色:表示对象已经被标记为存活,但其引用的对象还没有全部被扫描。灰色对象可能会引用白色对象。

  • 黑色:表示对象已经被标记为存活,并且该对象的所有引用都已经被扫描过。黑色对象不会引用任何白色对象。

三色标记算法的工作流程大致如下:

  1. 初始化时,所有对象都标记为白色。

  2. 将所有的 GC Roots 对象标记为灰色,并放入灰色集合。

  3. 从集合中选择一个灰色对象,将其标记为黑色,并将其引用的所有白色对象标记为灰色,然后放入灰色集合。

  4. 重复步骤3,直到灰色集合为空。

  5. 最后,所有黑色对象都是活跃的,白色对象都是垃圾。

卡表

对于分代垃圾回收器,势必存在一个跨代引用的问题,通常会使用一种名为记忆集(Remembered Set)的数据结构,它是一种用于记录从非收集区指向收集区的指针集合的数据结构。

而卡表就是最常用的一种记忆集,它是一个字节数组,用于记录堆内存的映射关系,下面是 HotSpot虚拟机默认的卡表标记逻辑:

1
2
// >> 9 代表右移 9位,即 2^9 = 512 字节
CARD_TABLE[this address >> 9] = 0;

每个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块叫做“卡页(Card Page)”。因为卡页代表的是一个区域,所以可能存在很多对象,只要有一个对象存在跨代引用,就把数组的值设为1,称该元素“变脏(Dirty)”,该卡页叫“脏页(Dirty Page)”,如下:

1
2
// >> 9 代表右移 9位,即2^9=512
CARD_TABLE[this address >> 9] = 1;

当垃圾回收时,只要筛选卡表中有变脏的元素,即数组值为 1,就能判断出其对应的内存区域存在对象跨代引用,卡表和卡页的关系如下图:

img.png

写屏障

在 HotSpot虚拟机中,写屏障本质上是引用字段被赋值这个事件的一个环绕切面(Around AOP),即一个引用字段被赋值的前后可以为程序提供额外的动作(比如更新卡表),写屏障分为:前置写屏障(Pre-Write-Barrier)和后置写屏障(Post-Write-Barrier)2种类型。

需要注意的是:这里的写屏障和多线程并发中的内存屏障不是一个概念。

分析完几个重要的技术点之后,接下来,我们正式分析 CMS回收器。

CMS 简介

CMS 是 Concurrent Mark Sweep 的简称,中文翻译为并发标记清除,它的目标是减少垃圾回收时应用线程的停顿时间,并且实现应用线程和 GC线程并发执行。

CMS 用于老年代的垃圾回收,使用的是标记-清除算法。通过 -XX:+UseConMarkSweepGC 参数即可启动 CMS回收器。

在 CMS之前的 4款回收器(Serial,Serial Old,ParNew,Parallel Scavenge) ,应用线程和 GC线程无法并发执行,必须 Stop The World(将应用线程全部挂起), 并且它们关注的是可控的吞吐量,而 CMS回收器,应用线程和 GC线程可以并发执行,目标是缩短回收时应用线程的停顿时间,这是 CMS和其它 4款回收器本质上的区别,也是它作为里程碑的一个标志。

CMS 回收过程

从整体上看,CMS 垃圾回收主要包含 5个步骤(网上很多 4,6,7个步骤的版本,其实都大差不差,没有本质上的差异):

  1. Initial Mark(初始标记):会Stop The World
  2. Concurrent Marking(并发标记)
  3. Remark(重复标记):会Stop The World
  4. Concurrent Sweep(并发清除)
  5. Resetting(重置)

整个过程可以抽象成下图:

img.png

在讲解回收过程之前,先分析三色标记法,这样可以帮助我们更好地去理解 GC的原理。

1. 初始标记

初始标记阶段会 Stop The World(STW),即所有的应用线程(也叫 mutator线程)被挂起。

该阶段主要任务是:枚举出 GC Roots以及标识出 GC Roots直接关联的存活对象,包括那些可能从年轻代可达的对象。

那么,GC Roots是如何被枚举的?GC Roots的直接关联对象是什么?为什么需要 STW?

GC Roots是如何被枚举的?

通过上文对 GC Roots的描述可知,作为 GC Roots的对象类型有很多种,遍及 JVM中的多个区域,对于现如今这种大内存的 VM,如果需要临时去扫描各区域来获取 GC Roots,那将是很大的一个工程量,因此,JVM采用了一种名为 OopMap(Object-Oriented Programming Map)的数据结构,它用于在垃圾收集期间快速地定位和更新堆中的对象引用(OOP,Object-Oriented Pointer)。

OopMap是在 JVM在编译期间生成的,主要作用是提供一个映射,通过这个映射垃圾收集器可以知道在特定的程序执行点(如safepoint)哪些位置(比如在栈或寄存器中)存放着指向堆中对象的引用,这样就可以快速定位 GC Roots。

使用OopMap的优点包括:

  • 提高效率:OopMap使得垃圾收集器能够快速准确地找到和更新所有的对象引用,从而减少垃圾收集的时间。

  • 减少错误:手动管理对象引用的位置容易出错,OopMap提供了一种自动化的方式来追踪这些信息。

  • 便于优化:由于 OopMap是在编译时生成的,编译器可以进行优化,比如减少需要记录的引用数量,从而减少垃圾收集的开销。

在 HotSpot虚拟机中,OopMap是实现精确垃圾收集的关键组件之一。

什么是 GC Roots直接关联的对象?

所谓直接关联对象就是 GC Root直接引用的对象,下面以一个示例来说明,如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class AssociatedObjectExample {

  public static void main(String[] args) {
    Associated obj = new Associated(); // Associated 是 GC Root obj 直接关联
    ((Associated) obj).bObj = new BigObject(); // BigObject是 GC Root obj 的间接关联的对象,BigObject是一个大对象,直接分配到老年代
  }

  static class Associated {
    BigObject bObj; // 与Associated对象直接关联的对象
  }

  static class BigObject {
    // 其它代码
  }
}

上述例子中,obj是一个 GC Root,Associated对象就是它的直接关联对象,bObj是一个 GC Root,BigObject对象是它的直接关联对象,obj可以通过 Associated对象间接关联 到 BigObject对象,但 BigObject对象不是 obj的直接关联对象,而是间接关联对象。 整个关联关系可以描绘成下图:

img.png

为什么需要 STW?

为什么初始标记阶段需要 Stop The World?这里主要归纳成两个原因:

  1. 确定 Roots集合:初始标记阶段的主要任务是识别出所有的 GC Roots,这是后续并发标记阶段的起点。 在多线程运行的环境中,如果应用线程和垃圾回收线程同时运行,应用线程可能会改变对象引用关系,导致 Roots集合不准确。 因此,需要暂停应用线程,以确保 GC Roots的准确性和一致性。

  2. 避免并发问题:在初始标记阶段,垃圾回收器需要更新一些共享的数据结构,例如标记位图或者引用队列。 如果应用线程在此时运行,可能会引入并发修改的问题,导致数据不一致。STW可以避免这种情况的发生。

2.并发标记

这里的并发是指应用线程和 GC线程可以并发执行。

在并发标记阶段主要完成 2个事情:

  1. 遍历对象图,标记从 GC Roots可以追踪到所有可达的存活对象;
  2. 处理并发修改

因为应用线程仍在继续工作,因此老年代的对象可能会发生以下几种变化:

  • 新生代的对象晋升到老年代;
  • 直接在老年代分配对象;
  • 老年代对象的引用关系发生变更;

为了防止这些并发修改被遗漏,CMS 使用了后置写屏障(Write Barrier)机制,确保这些更改会被记录在“卡表(Card Table)”中,同时将相应的卡表条目标记为脏(dirty),以便后续处理。

如下图:从 GC Roots追溯所有可达对象,并将它们修改为已标记,即黑色。

img.png

当老年代中,D 到 E到引用被修改时,就会触发写屏障机制,最终 E就会被写进脏页,如下图: img.png

并发标记会出现对象可达性误判问题,如下图:假如对象 D对象被标记成黑色,E对象被标记为灰色(图左半部分),这时,工作线程将 E对象修改成不再指向F,并将 D对象指向 F对象(图右半部分),按照三色标记算法,D对象为黑色,不会再往下追溯,所以 F对象就无法被标记从而变成垃圾,“存活”对象凭空消失了,这是很可怕的问题,那么 CMS是如何解决这种问题的呢?

img.png

解决这种问题,通常有两种方案:

  • 增量更新(Incremental Update)

当新增黑色对象指向白色对象关系时(D->F),需要记录这次新增,等并发扫描结束后,将这些黑色的对象作为 GC Root,重新扫描一次,也就是把这些黑色对象看成灰色对象,它们指向的白色对象就可以被正常标记。CMS采取的就是这种方式。

  • 原始快照(Snapshot At The Beginning,SATB)

当删除灰色对象指向白色对象关系时(E->F),需要记录这次删除,等并发扫描结束后,将这些灰色的对象作为 GC Root,按照删除 E对象指向 F对象前一刻的快照(也就是E->F 还是可达的)重新扫描一次,即不管关系删除与否,都会按照删除前那一刻快照的对象图来进行搜索标记。G1,Shenandoah采取的是这种方式。

3.重新标记

重复标记阶段也会 Stop The World,即挂起所有的应用程序线程,该阶段主要完成事情是:

  1. 并发预清理:在重新标记阶段之前,CMS可能会执行一个可选的并发预清理步骤,以尽量减少重新标记阶段的工作量。(该过程在很多文章中会单独成一个大步骤讲解)

  2. 修正标记结果:由于在并发标记阶段导致的并发修改,导致漏标,错标,因此需要暂停应用线程(STW),确保修正这些标记结果。

  3. 处理卡表:检查并发标记阶段修改的这些脏卡,并重新标记引用的对象,以确保所有可达对象都被正确识别。

  4. 处理最终可达对象:处理那些在并发标记阶段被识别出的“最终可达”(Finalizable)对象。这些对象需要执行它们的 finalize方法,finalize方法可能会使对象重新变为可达状态。

  5. 处理弱引用、软引用、幻象引用等:处理各种不同类型的引用,确保它们按照预期被处理。例如,弱引用在 GC后会被清除,软引用在内存不足时会被清除,而幻象引用则在对象被垃圾收集器回收时被放入引用队列。

4.并发清除

这里的并发也是指应用线程和 GC线程可以并发执行,并发清除阶段主要完成 2个事情:

  1. 清除并发标记阶段标记为死亡的对象;
  2. 并发清除结束后,CMS 会利用空闲列表(free-list)将未被标记的内存(即垃圾对象占据的内存)收集起来,组成一个空闲列表,用于新对象的内存分配;

5.重置

清理和重置 CMS回收器的内部数据结构,为下一次垃圾回收做准备。

到此,回收过程就分析完毕,接下来总结下 CMS的优点和缺点。

CMS 的优点

低停顿时间

相对 Serial,Serial Old,ParNew,Parallel Scavenge 4款回收器,CMS收集器的主要优势是减少垃圾收集时的停顿时间,特别是减少了Full GC的停顿时间,这对于延迟敏感的应用程序非常有利。

并发收集

CMS在回收过程中,应用线程和 GC线程可以并发执行,从而减少了垃圾收集对应用程序的影响。

适合多核处理器

由于CMS利用了并发执行,它能够更好地利用现代多核处理器的能力,将垃圾收集的工作分散到多个CPU核心。

CMS 的缺点

浮动垃圾

在并发清除阶段,因为应用线程可以并发工作,可能会产生垃圾,这些垃圾在当前 GC无法处理,需要到下一次 GC才能进行处理,因此,这些垃圾就叫做“浮动垃圾”。

Concurrent Mode Failure

JDK5 默认设置下,当老年代使用了68%的空间后就会被激活 CMS回收,从JDK 6开始,垃圾回收启动阈值默认提升至92%,我们可以通过 -XX:CMSInitiatingOccupancyFraction 参数自行调节。

如果阈值是 68%,可能导致空间没有完全利用,频繁产生 GC,如果是92%,又会更容易面临另一种风险,要是预留的内存无法满足程序分配新对象的需要,就会出现一次 Concurrent Mode Failure(并发失败),因此会引发 FullGC。

这时候虚拟机将不得不启动后备预案:冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集,但这样停顿时间就很长了。

内存碎片

因为 CMS采用的是标记-清理算法,当清理之后就会产生很多不连续的内存空间,这就叫做内存碎片。如果老年代无法使用连续空间来分配对象,就会出发 Full GC。为了解决这个问题,CMS收集器提供了 -XX:+UseCMS-CompactAtFullCollection 参数进行碎片压缩整理,参数默认是开启的,不过 从JDK 9开始废弃。

总结

  • 本文不仅讲解了 CMS回收器,更是铺垫了很多 GC相关的基础知识,比如 安全点,三色标记法,卡表,写屏障。

  • CMS 是 Concurrent Mark Sweep 的简称,中文翻译为并发标记清除,它的目标是减少垃圾回收时应用线程的停顿时间,并且实现应用线程和 GC线程并发执行。

  • CMS 用于老年代的垃圾回收,使用的是标记-清除算法。通过 -XX:+UseConMarkSweepGC 参数即可启动 CMS收集器。

  • CMS 主要包含:初始标记,并发标记,重新标记,并发清除,重置 5个过程。

  • CMS 收集器使用三色标记法来标记对象,采用写屏障,卡表和脏页的方式来防止并发标记中修改的引用被漏标。

  • CMS 收集器有 3大缺点:浮动垃圾,并发失败以及内存碎片。

尽管 CMS收集器已经被官方废弃了,但是它这种优化思路值得我们日常开发中借鉴。

希望文章可以给你带来收获和思考,如果有任何疑问,欢迎评论区留言讨论。如果本文对你有帮助,请帮忙点个在看,点个赞,或者转发给更多的小伙伴,获取三色标记法相关资料,请关注公众号,回复:三色

网站推荐

CiteSeerX(https://citeseerx.ist.psu.edu)是一个公开的学术文献数字图书馆和搜索引擎,主要集中在计算机和信息科学领域的文献。该网站允许用户搜索、查看和下载相关的学术论文和文献,包括论文、会议记录、技术报告等。

CiteSeerX的特点包括:

  • 自动引文索引:CiteSeerX使用算法自动从文档中提取引文,并创建文献之间的引用链接。

  • 自动元数据提取:它能自动识别文档的元数据,如标题、作者、出版年份等。

  • 相关文档推荐:根据用户的搜索和查看历史,CiteSeerX可以推荐相关的文档。

  • 文档更新:CiteSeerX会自动在网络上查找和索引新文档,以保持数据库的更新。

CiteSeerX由宾夕法尼亚州立大学的信息科学与技术学院维护和管理。该项目是科研人员和学生获取计算机科学和相关学科文献的重要资源之一。

参考

HotSpot Virtual Machine Garbage Collection Tuning Guide

Java Garbage Collection Basics

Why does CMS collector collect root references from young generation on Initial Mark phase?

Memory Management in the Java HotSpot Virtual Machine

Why does Concurrent-Mark-Sweep (CMS) remark phase need to re-examine the thread-stacks instead of just looking at the mutator’s write-queues?

A Generational Mostly-concurrent Garbage Collector

The JVM Write Barrier - Card Marking

交流学习

最后,把猿哥的座右铭送给你:投资自己才是最大的财富。由于水平有限,如果文章存在缺点和错误,欢迎批评指正。感谢转发给更多的好友,关注我:猿java,为你呈现更多的硬核文章。

drawing

9款常见的 JVM垃圾回收器

美团 2面:G1 为什么能替代 CMS收集器?看完这篇就懂了!