CMS垃圾回收器详解,这个轮子造的真值!

你好,我是猿java。

网上关于 CMS的文章很多,为什么要重复造轮子?CMS已经被弃用,为什么还要分析它?

  1. 网上很多关于 CMS回收器的文章写得不够具体;
  2. CMS回收器依然是面试中的一个高频面试题;
  3. CMS作为垃圾回收器的一个里程碑,不了解原理,于情于理说不过去;
  4. 想通过自己对 CMS的理解,提供更具体形象的分析;

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

背景

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

接着,了解下 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年,一代花季回收器,从此退出历史舞台;

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

通过上图,可以很清晰的看出垃圾回收器回收的区域是 JVM的堆,而 CMS回收的区域是堆中的老年代,是不是一图胜千言?

另外,为了更好的理解 JVM,这里再对 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堆内。
  2. 方法区(Method Area):方法区是堆的一个逻辑部分,用于存储类结构信息,如运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容等。在Java 8及之后的版本中,方法区的实现为元空间。

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

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

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

在这些内存区域中,主要的垃圾回收活动发生在堆空间,包括:年轻代和老年代,永久代(Java 8之前的版本)或元空间(Java 8及之后的版本)。垃圾回收器会定期检查这些区域,回收不再被引用的对象所占用的内存。

好了,到这里为止,我们从全局视角上掌握了垃圾回收器回收的区域以及 CMS 负责的区域,接下来,我们就要深入分析 CMS到底是如何回收的?

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(重置)

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

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

三色标记法

在 CMS中,存活对象标记主要采用三色标记法("tricolor marking")进行标记:

  • 白色:表示对象尚未被访问。初始状态时,所有的对象都被标记为白色。
  • 灰色:表示对象已经被标记为存活,但其引用的对象还没有全部被扫描。灰色对象可能会引用白色对象。
  • 黑色:表示对象已经被标记为存活,并且该对象的所有引用都已经被扫描过。黑色对象不会引用任何白色对象。

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

  1. 初始阶段,所有对象都标记为白色。
  2. 从 GC Roots(如全局变量、活跃线程的栈帧等)开始,将根对象标记为灰色,并放入灰色集合。
  3. 选择一个灰色对象,将其标记为黑色,并将其引用的所有白色对象标记为灰色,放入灰色集合。
  4. 重复步骤3,直到灰色集合为空。
  5. 此时,所有黑色对象都是活跃的,白色对象都是垃圾。

分析完三色标记法后,接下来,我们一步一步分析回收过程:

1. 初始标记

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

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

那么,什么是 GC Roots? 什么又是 GC Roots的直接关联对象呢?

什么是 GC Roots

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

  • 虚拟机栈(Java Stack)中的局部变量表中的引用对象。
  • 方法区(Method Area)中的类静态属性引用的对象。
  • 方法区中常量引用的对象,如字符串常量池(String Constant Pool)中的引用。
  • 本地方法栈(Native Method Stack)中JNI(Java Native Interface)的引用。

这里只是介绍了 GC Root常见的 4种类型,还有一些,比如 活动的Java线程,特殊的系统类,JVM启动时的一些引用等也可以作为 GC Root。

为了更好的解释 GC Root,这里举个简单的例子,如下代码:

java 复制代码
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。 上述 GC Root可以描绘成下图:

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

这里也以一个示例来说明,如下代码:

java 复制代码
	
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 {
    // 其它代码
  }
}

上述例子中,即包含直接关联对象,也包含间接关联对象,整个关联关系是: GC Root obj 直接关联 Associated对象,GC Root bObj 直接关联 BigObject对象, GC Root obj 间接关联 BigObject对象。 描绘成下图:

为什么需要 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追溯哦所有可达对象,并将它们修改为已标记,即黑色。

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

上述两个示例图,GC Roots 是以虚拟机栈中的变量引用为例,这样方便我们有更好的体感。

在对并发标记分析时,我们提到了两个重要的术语:写屏障(Write Barrier)和卡表(Card Table),那么它们是什么呢?

写屏障

在垃圾收集(GC)领域,写屏障(Write Barrier)是一种用于跟踪对象引用更新的机制。在 CMS收集器中,写屏障主要用于解决并发标记过程中应用程序线程对对象引用的修改问题。 当对象的引用字段被更新时,写屏障会被触发,将修改过的对象或其所在的内存区域(如卡表中的卡)标记为脏(Dirty)来实现的。

什么是卡表?

卡表(Card Table)是一种用来记录老年代中对象引用关系的数据结构。卡表是一个字节数组,数组中的每个元素称为一个"卡",对应老年代中的一个固定大小的内存区域(通常是512字节)。卡表的作用是在垃圾回收过程中,快速定位到可能包含跨代引用的内存区域,从而降低标记阶段的开销。

当一个对象在新生代被分配,并且它引用了老年代中的对象时,虚拟机会将对应的卡标记为"脏卡"(Dirty Card)。在垃圾回收的标记阶段,回收器会扫描卡表,只对那些被标记为脏卡的内存区域进行检查,以确定哪些对象是可达的,哪些对象可以被回收。

卡表的使用,大幅减少了垃圾回收中的标记时间,因为它避免了对整个老年代的全面扫描,而只需要检查那些可能包含跨代引用的内存区域。这使得 CMS回收器能够在较短的停顿时间内完成垃圾回收,提高了应用程序的响应速度。

关于 写屏障和卡表,更多细节可以参考:The JVM Write Barrier - Card Marking

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开始废弃。

网站推荐

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

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

好文推荐

相关推荐
金灰几秒前
HTML5--裸体回顾
java·开发语言·前端·javascript·html·html5
菜鸟一皓1 分钟前
IDEA的lombok插件不生效了?!!
java·ide·intellij-idea
爱上语文4 分钟前
Java LeetCode每日一题
java·开发语言·leetcode
bug菌27 分钟前
Java GUI编程进阶:多线程与并发处理的实战指南
java·后端·java ee
程序猿小D40 分钟前
第二百六十九节 JPA教程 - JPA查询OrderBy两个属性示例
java·开发语言·数据库·windows·jpa
Ray Wang1 小时前
3.JVM
jvm
极客先躯2 小时前
高级java每日一道面试题-2024年10月3日-分布式篇-分布式系统中的容错策略都有哪些?
java·分布式·版本控制·共识算法·超时重试·心跳检测·容错策略
夜月行者2 小时前
如何使用ssm实现基于SSM的宠物服务平台的设计与实现+vue
java·后端·ssm
程序猿小D2 小时前
第二百六十七节 JPA教程 - JPA查询AND条件示例
java·开发语言·前端·数据库·windows·python·jpa
潘多编程2 小时前
Java中的状态机实现:使用Spring State Machine管理复杂状态流转
java·开发语言·spring