一篇文章彻底搞懂CMS与G1

今天看到一篇博客上面说面试大厂过程中在多次面试中被问到CMS与G1的区别与原理,今天我们也来学习下他们的之间的区别。

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于并发“标记清理”实现,在标记清理过程中不会导致用户线程无法定位引用对象。仅作用于老年代收集。它的步骤如下:

  1. 初始标记(CMS initial mark):独占CPU,stop-the-world, 仅标记GCroots能直接关联的对象,速度比较快;
  2. 并发标记(CMS concurrent mark):可以和用户线程并发执行,通过GCRoots Tracing 标记所有可达对象;
  3. 重新标记(CMS remark):独占CPU,stop-the-world, 对并发标记阶段用户线程运行产生的垃圾对象进行标记修正,以及更新逃逸对象;
  4. 并发清理(CMS concurrent sweep):可以和用户线程并发执行,清理在重复标记中被标记为可回收的对象。

优缺点

CMS的优点:

  • 支持并发收集.
  • 低停顿,因为CMS可以控制将耗时的两个stop-the-world操作保持与用户线程恰当的时机并发执行,并且能保证在短时间执行完成,这样就达到了近似并发的目的.

CMS的缺点:

  • CMS收集器对CPU资源非常敏感,在并发阶段虽然不会导致用户线程停顿,但是会因为占用了一部分CPU资源,如果在CPU资源不足的情况下应用会有明显的卡顿。
  • 无法处理浮动垃圾:在执行‘并发清理’步骤时,用户线程也会同时产生一部分可回收对象,但是这部分可回收对象只能在下次执行清理是才会被回收。如果在清理过程中预留给用户线程的内存不足就会出现‘Concurrent Mode Failure’,一旦出现此错误时便会切换到SerialOld收集方式。
  • CMS清理后会产生大量的内存碎片,当有不足以提供整块连续的空间给新对象/晋升为老年代对象时又会触发FullGC。且在1.9后将其废除。

使用场景

它关注的是垃圾回收最短的停顿时间(低停顿),在老年代并不频繁GC的场景下,是比较适用的。

G1收集器

G1收集器的内存结构完全区别去CMS,弱化了CMS原有的分代模型(分代可以是不连续的空间),将堆内存划分成一个个Region(1MB~32MB, 默认2048个分区),这么做的目的是在进行收集时不必在全堆范围内进行。它主要特点在于达到可控的停顿时间,用户可以指定收集操作在多长时间内完成,即G1提供了接近实时的收集特性。它的步骤如下:

  1. 初始标记(Initial Marking):标记一下GC Roots能直接关联到的对象,伴随着一次普通的Young GC发生,并修改NTAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,此阶段是stop-the-world操作。
  2. 根区间扫描,标记所有幸存者区间的对象引用,扫描 Survivor到老年代的引用,该阶段必须在下一次Young GC 发生前结束。
  3. 并发标记(Concurrent Marking):是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行,该阶段可以被Young GC中断。
  4. 最终标记(Final Marking):是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,此阶段是stop-the-world操作,使用snapshot-at-the-beginning (SATB) 算法。
  5. 筛选回收:筛选回收(Live Data Counting and Evacuation):首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,回收没有存活对象的Region并加入可用Region队列。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

G1的特点

  • 并行与并发:G1充分发挥多核性能,使用多CPU来缩短Stop-The-world的时间,
  • 分代收集:G1能够自己管理不同分代内已创建对象和新对象的收集。
  • 空间整合:G1从整体上来看是基于‘标记-整理’算法实现,从局部(相关的两块Region)上来看是基于‘复制’算法实现,这两种算法都不会产生内存空间碎片。
  • 可预测的停顿:它可以自定义停顿时间模型,可以指定一段时间内消耗在垃圾回收商的时间不大于预期设定值。

使用场景

G1 GC切分堆内存为多个区间(Region),从而避免很多GC操作在整个Java堆或者整个年轻代进行。G1 GC只关注你有没有存货对象,都会被回收并放入可用的Region队列。G1 GC是基于Region的GC,适用于大内存机器。即使内存很大,Region扫描,性能还是很高的。

Remembered Set

我们之前说过,G1在回收每个Region上的垃圾时,每个Region之间又有相互依赖引用关系,想要做到对全部Region进行扫描清理,那么不得不做一次全堆扫描。这样就降低了垃圾回收的效率。所以HotSpot引入了Remembered Set来专门存储于管理对象的引用依赖关系,这样当每次回收时,只需要根据Remembered Set上面的对应关系找到相对的区域进行清理,这样就可以避免扫描整个堆内存又不会遗漏某一个区域。

OopMap

我们都知道在GC之前要做一次GC Roots来查找对象的存活情况,一边在GC时候正确的回收。那么每次GC时候遍历所有的引用是不现实的,那么这之后就引入了OopMap,它里面记录了一些类加载时候的类型与偏移量地址等信息生成一张映射表放在OopMap中。GC开始的时候,就通过OopMap这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。

安全点/安全区域(Safepoint/Safe Region)

上面为了快速的分析可达性,使用了一个引用类型映射表,可以快速的知道对象内或者栈和寄存器中哪些位置引用了。那么在方法执行过程中,这些引用关系可能会随时发生变化,那么OopMap是不是也要跟着变呢?如果没出引用变化就更新OopMap那么也是不现实的,这时候就引入了安全点的概念。OopMap的作用就是在每次GC前保证是最新的就可以了。OopMap只需要在预先选定的一些位置上记录变化的OopMap就行了。在这个状态下虚拟机堆栈不在发生变化。而安全点的选定是以程序‘是否具有让程序长时间执行的特征’为标准选定的。‘长时间执行’的明显特征就是指令序列复用,例如:方法调用(方法临返回前/调用方法的call指令后),循环跳转(循环的末尾),异常跳转(可能抛异常的位置)等,具有这些功能的指令才再回产生安全点。大白话就是在程序中寻找一个安全点,当GC触发时,为了线程状态和数据的一致性,让线程都跑到这个安全点停顿下来后再执行GC。至于安全区域你可以认为在这个区域的任何位置都可以GC,即点.线,面的关系。基于安全点中断GC的方式有两种:

  1. 抢先式中断(Preemptive Suspension):抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应GC事件。
  2. 主动式中断(Voluntary Suspension):主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

卡表(Card Table)

有个场景,老年代的对象可能引用新生代的对象,由于新生代的垃圾收集通常很频繁,那标记存活对象的时候,需要扫描从老年代到新生代的所有引用对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为GC Roots。那不是每次YGC时又得做全堆扫描?显然不是,对于HotSpot JVM,使用了卡标记(Card Marking)技术来解决老年代到新生代的引用问题。具体是,使用卡表(Card Table)和写屏障(Write Barrier)来进行标记并加快对GC Roots的扫描。卡表的设计师将堆内存平均分成2的N次方大小(默认512字节)个卡,并且维护一个卡表,用来储存每个卡的标识位。当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为脏页。在YGC只需要扫描卡表中的脏卡,将脏中的对象加入到YGC的GC Roots里面。当完成所有脏卡扫描时候,虚拟机会将卡表的脏卡标志位清空。

在高并发环境下,每次对引用的更新,无论是否更新了老年代对新生代对象的引用,都会进行一次写屏障操作,频繁的写屏障很容易发生虚共享(false sharing),从而带来性能开销。举 个例子:假设CPU缓存行大小为64字节,由于一个卡表项占1个字节,这意味着,64个卡表项将共享同一个缓存行。HotSpot每个卡页为512字节,那么一个缓存行将对应64个卡页一共 64*512=32KB。如果不同线程对对象引用的更新操作,恰好位于同一个32KB区域内,这将导致同时更新卡表的同一个缓存行,从而造成缓存行的写回、无效化或者同步操作,间接影响程序 性能。

在JDK 7中引入了VM参数-XX:+UseCondCardMark ,意思就是现在不采用无条件写屏障,而是先检查此卡是否已经是脏页,如果是将不再标记。这样就减少了并发下的虚共享问题。但是这样却不能避免对未标记的页进行并发标记。

结尾

这一篇关于Jvm两个收集器的相关概念与知识点就基本讲完了,看了这篇文章你基本就知道他们是怎么回事了,文中要是写的有什么问题欢迎大家指出,由于参考的文章和资料比较多,这里就不一一列出了。

原文始发于微信公众号(架构拾遗)

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/21384.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!