你好,我是猿java。
网上关于 CMS的文章很多,为什么要重复造车轮?
答:网上很多关于 CMS收集器的文章写得不够具体,有的甚至一知半解,更多的是不假思索的转载,想通过自己对 CMS的理解以及大量资料的佐证,提供更具体形象正确的分析。
CMS已经被弃用,为什么还要分析它?
答:首先,CMS收集器依然是面试中的一个高频问题;其次,CMS作为垃圾收集器的一个里程碑,作为 Java程序员,不了解原理,于情于理说不过去;
JVM已经把垃圾回收自动化了,为什么还要讲解 CMS?
答:排查生产环境的各种内存溢出,内存泄漏,垃圾回收导致性能瓶颈等技术问题,如果不懂原理,如何排查和优化?
温馨提示:如果没有特殊说明,本文提及的虚拟机默认为 HotSpot虚拟机。
首先,了解下 HotSpot虚拟机中 9款垃圾回收器的发布时间及其对应的 JDK版本,如下图:
接着,了解下 CMS垃圾回收器的生命线:
效力 18年,一代花季回收器,从此退出历史舞台;
既然分析的是垃圾回收器,那么,我们首先需要知道:在 JVM 中,什么是“垃圾”?
这里的“垃圾”用了双引号,是因为它和我们生活中理解的垃圾不一样。在 JVM中,垃圾(Garbage)是指那些不再被应用程序使用的对象,也就是说这些对象不再可达,即对象已死。
如何判断对象不可达(已死)?
在 JVM中,通过一种可达性分析(Reachability Analysis)算法来判断对象是否可达。 该算法的基本思路是:通过 GC Roots 集合里的根对象作为起始点,一直追踪所有存在引用关系的对象(这条引用关系链路叫做引用链 Reference Chain), 如果某对象到 GC Roots之间没有引用链,那么该对象就是不可达。 如下图,obj4, obj5,obj6 尽管相互直接关联,但是没有 GC Root连接,所以是不可达,同理 obj7也不可达:
关于可达性分析,还有一种方法是引用技术算法,该方法的思路是:在对象中添加一个计数器,增加一次引用计数器 +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可达对象,上述关系可以描绘成下图:
从 CMS 简介可以知道 CMS是用于老年代的垃圾回收,但是对于这种抽象的文字描述,很多小伙伴肯定还是没有体感, 因此,我们把视角放眼到整个 JVM运行时的内存结构上,从整体上看看垃圾回收器到底回收的是哪些区域的垃圾, CMS 又是回收哪里的垃圾,如下图:
在了解了“垃圾”在 JVM中是如何定义之后,我们不禁会问到:这些“垃圾”存放在哪里呢?
在回答这个问题之前,我们先来了解 JVM的内存结构,根据 Java虚拟机规范,JVM内存包含以下几个运行时区域,如下图:
为了更好地理解 JVM内存结构,这里对各个区域做一个详细的介绍:
关于方法区有一个误区:JDK 8以前,HotSpot虚拟机为了像堆一样管理方法区的垃圾回收,就使用永久代来实现方法区,因此有人就把方法区直接叫做永久代,而其它虚拟机不存在永久代的概念,因此,方法区如何实现属于虚拟机内部的机制,不是 JVM统一规范。另外,HotSpot发现永久代实现方法区这种做法会导致内存溢出,因此从 JDK8开始,把永久代彻底废除,改用和 JRockit一样的元空间。方法区也改用本地内存实现。
通过上述 JVM内存区域的介绍,我们可以发现 JVM各个内存区域都可能产生垃圾,只是程序计算器,本地方法区,虚拟机栈 3个区域随线程而生,随线程而亡,垃圾被自动回收,方法区回收效果比较差,而堆中的“垃圾”才是回收器关注的重点,因此,垃圾收集器重点关注的是 JVM的堆,而 CMS回收的是堆中的老年代,如下图:
到这里为止,我们已经从 JVM内存结构视角上掌握了垃圾收集器回收的区域以及 CMS 负责的区域。
接下来,分析一下 GC回收常用的几个重要技术点:三色标记法(Tricolor Marking),卡表(Card Table),写屏障(Write Barrier),理解它们可以帮助我们更好地去理解 GC回收的原理。
在垃圾收集器中,主要采用三色标记算法来标记对象的可达性:
三色标记算法的工作流程大致如下:
对于分代垃圾回收器,势必存在一个跨代引用的问题,通常会使用一种名为记忆集(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,就能判断出其对应的内存区域存在对象跨代引用,卡表和卡页的关系如下图:
在 HotSpot虚拟机中,写屏障本质上是引用字段被赋值这个事件的一个环绕切面(Around AOP),即一个引用字段被赋值的前后可以为程序提供额外的动作(比如更新卡表),写屏障分为:前置写屏障(Pre-Write-Barrier)和后置写屏障(Post-Write-Barrier)2种类型。
需要注意的是:这里的写屏障和多线程并发中的内存屏障不是一个概念。
分析完几个重要的技术点之后,接下来,我们正式分析 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 垃圾回收主要包含 5个步骤(网上很多 4,6,7个步骤的版本,其实都大差不差,没有本质上的差异):
整个过程可以抽象成下图:
在讲解回收过程之前,先分析三色标记法,这样可以帮助我们更好地去理解 GC的原理。
初始标记阶段会 Stop The World(STW),即所有的应用线程(也叫 mutator线程)被挂起。
该阶段主要任务是:枚举出 GC Roots以及标识出 GC Roots直接关联的存活对象,包括那些可能从年轻代可达的对象。
那么,GC Roots是如何被枚举的?GC Roots的直接关联对象是什么?为什么需要 STW?
通过上文对 GC Roots的描述可知,作为 GC Roots的对象类型有很多种,遍及 JVM中的多个区域,对于现如今这种大内存的 VM,如果需要临时去扫描各区域来获取 GC Roots,那将是很大的一个工程量,因此,JVM采用了一种名为 OopMap(Object-Oriented Programming Map)的数据结构,它用于在垃圾收集期间快速地定位和更新堆中的对象引用(OOP,Object-Oriented Pointer)。
OopMap是在 JVM在编译期间生成的,主要作用是提供一个映射,通过这个映射垃圾收集器可以知道在特定的程序执行点(如safepoint)哪些位置(比如在栈或寄存器中)存放着指向堆中对象的引用,这样就可以快速定位 GC Roots。
使用OopMap的优点包括:
在 HotSpot虚拟机中,OopMap是实现精确垃圾收集的关键组件之一。
所谓直接关联对象就是 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的直接关联对象,而是间接关联对象。 整个关联关系可以描绘成下图:
为什么初始标记阶段需要 Stop The World?这里主要归纳成两个原因:
这里的并发是指应用线程和 GC线程可以并发执行。
在并发标记阶段主要完成 2个事情:
因为应用线程仍在继续工作,因此老年代的对象可能会发生以下几种变化:
为了防止这些并发修改被遗漏,CMS 使用了后置写屏障(Write Barrier)机制,确保这些更改会被记录在“卡表(Card Table)”中,同时将相应的卡表条目标记为脏(dirty),以便后续处理。
如下图:从 GC Roots追溯哦所有可达对象,并将它们修改为已标记,即黑色。
当老年代中,D 到 E到引用被修改时,就会触发写屏障机制,最终 E就会被写进脏页,如下图:
并发标记会出现对象可达性误判问题,如下图:假如对象 D对象被标记成黑色,E对象被标记为灰色(图左半部分),这时,工作线程将 E对象修改成不再指向F,并将 D对象指向 F对象(图右半部分),按照三色标记算法,D对象为黑色,不会再往下追溯,所以 F对象就无法被标记从而变成垃圾,“存活”对象凭空消失了,这是很可怕的问题,那么 CMS是如何解决这种问题的呢?
解决这种问题,通常有两种方案:
当新增黑色对象指向白色对象关系时(D->F),需要记录这次新增,等并发扫描结束后,将这些黑色的对象作为 GC Root,重新扫描一次,也就是把这些黑色对象看成灰色对象,它们指向的白色对象就可以被正常标记。CMS采取的就是这种方式。
当删除灰色对象指向白色对象关系时(E->F),需要记录这次删除,等并发扫描结束后,将这些灰色的对象作为 GC Root,按照删除 E对象指向 F对象前一刻的快照(也就是E->F 还是可达的)重新扫描一次,即不管关系删除与否,都会按照删除前那一刻快照的对象图来进行搜索标记。G1,Shenandoah采取的是这种方式。
重复标记阶段也会 Stop The World,即挂起所有的应用程序线程,该阶段主要完成事情是:
这里的并发也是指应用线程和 GC线程可以并发执行,并发清除阶段主要完成 2个事情:
清理和重置 CMS回收器的内部数据结构,为下一次垃圾回收做准备。
到此,回收过程就分析完毕,接下来总结下 CMS的优点和缺点。
相对 Serial,Serial Old,ParNew,Parallel Scavenge 4款回收器,CMS收集器的主要优势是减少垃圾收集时的停顿时间,特别是减少了Full GC的停顿时间,这对于延迟敏感的应用程序非常有利。
CMS在回收过程中,应用线程和 GC线程可以并发执行,从而减少了垃圾收集对应用程序的影响。
由于CMS利用了并发执行,它能够更好地利用现代多核处理器的能力,将垃圾收集的工作分散到多个CPU核心。
在并发清除阶段,因为应用线程可以并发工作,可能会产生垃圾,这些垃圾在当前 GC无法处理,需要到下一次 GC才能进行处理,因此,这些垃圾就叫做“浮动垃圾”。
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收集器已经被官方废弃了,但是它这种优化思路值得我们日常开发中借鉴。
希望文章可以给你带来收获和思考,如果有任何疑问,欢迎评论区留言讨论。如果本文对你有帮助,请帮忙点个在看,点个赞,或者转发给更多的小伙伴,获取三色标记法相关资料,请关注公众号,回复:三色
CiteSeerX(https://citeseerx.ist.psu.edu)是一个公开的学术文献数字图书馆和搜索引擎,主要集中在计算机和信息科学领域的文献。该网站允许用户搜索、查看和下载相关的学术论文和文献,包括论文、会议记录、技术报告等。
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
A Generational Mostly-concurrent Garbage Collector
The JVM Write Barrier - Card Marking
本文来自微信公众号"猿java"。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。