深入理解Java虚拟机-第三章 垃圾收集器与内存分配策略(下)

第三章 垃圾收集器与内存分配策略(下)

3.5.4 CMS(Concurrent Mark Sweep) 收集器

CMS 是一种以尽可能缩短 STW 为目标的收集器,目前很大一部分 B/S 系统的服务端上都应用着 CMS。这类应用重视用户体验,对卡顿尤为敏感。
CMS 主要分为四大步骤:

  • 初始标记(CMS initial mark) :标记GCRoots能直接关联到的对象以及由新生代中存活对象所引用的对象,速度很快但是仍然需要 STW。
  • 并发标记(CMS concurrent mark):并发标记就是 GC Roots Tracing 的过程,也就是根据可达性算法来标记可达的对象。这一步骤时间很长,所以追踪线程和用户应用线程并发执行,不需要 STW。
  • 重新标记(CMS remark):重新标记主要是用来修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间一般会比初始标记时间长,但是仍然需要 STW 。
  • 并发清除(CMS concurrent sweep) :这一步就主要回收内存空间,收集线程和用户应用线程并发执行,时间很长。

这四大步骤是书上简单一说的,但是我在网上找到了另一个版本的比较详细的步骤(原文请戳这里):

初始标记(CMS-initial-mark)–> 并发标记(CMS-concurrent-mark)–>预清理(CMS-concurrent-preclean)–>可控预清理(CMS-concurrent-abortable-preclean)–> 重新标记(CMS-remark)–> 并发清除(CMS-concurrent-sweep)–> 并发重置(CMS-concurrent-reset)

就是在重新标记钱,加入了预清理和可控预清理,并在最后加入了并发重设状态等待下次 CMS 的触发这几个步骤。总结下来就是先2次标记,1次预清理,1次重新标记,再1次清除。
那么这个预清理是干啥的呢,根据文中的说法,预清理就是为了缩减重标记的时间而提前做一些工作,下一步的可控预清理也是同样的操作。但是这个是怎么做到的呢。原来是第二部并发标记的时候,会有一些在变动的实例。可能有些对象会从新生代晋升到老年代、有些老年代的对象引用会被改变、有些对象会直接分配到老年代,这些受到影响的老年代对象所在的 card 会被标记为 dirty 。
那这个 Card 是什么呢?这个 Card 是在并发标记之前,就通过卡片标记(Card Marking),提前把老年代的空间逻辑划分为相等大小的区域,也就是所谓的 Card ,如果在并发标记的过程中引用关系发生改变,JVM会将发生改变的区域标记位“脏区”(Dirty Card),然后再在预清理阶段,把这些脏区中的引用重新整理、标记,清除 Dirty 标记。
可控预清理的作用实际上跟预清理差不多,这一步会不停的进行预清理步骤,段尝试在重标记前尽可能地多做一些工作,以减少应用暂停时间。
加入这一步的意义还在于可以等待下次Minor GC的完成之后再进行重标记。为什么要等待呢,我们分两个方面讨论:

  • 我们知道Minor GC 即 Young GC 也是需要 STW 的,那么等到他之后,再进行重标记(重标记也需要 STW),是不是在一定意义上避免了短时间内连着的两个停顿(合成一个了嘛~)
  • 我们知道标记的时候也会标记年轻代中存活对象所引用的老年代对象,而这时年轻代的存活对象实际上并不一定是可达对象。也就是说是上一次 Minor GC 后被释放的实例。如果我们等待一次Minor GC,把这部分不可达对象清理掉后,老年代的这部分关联对象自然而然的就不被标记了。这样就可以清理出更多的空间。

引用另一篇文章中的几张图即可把这几个步骤说明的很明白(原文在这里):
初始标记
并发标记并发预清理
并发清除
说了这么多,CMS 感觉是一个很高效且实用的收集器,但是其实他还是有三大缺点:

  • CMS 收集器对 CPU 资源特别敏感,在并发阶段虽然不会停止用户线程,但是会抢占 CPU 来使得用户应用程序变慢,总吞吐量会降低。
  • CMS 收集器无法处理浮动垃圾(CMS收集过程中产生的新垃圾,本次清理无法清除只能留待下次),这可能会导致 Concurrent Mode Failure 错误的出现,什么是 Concurrent Mode Failure 呢,就是当 CMS 进行垃圾回收时,Minor GC 产生了新的老年代对象,但老年代剩余空间不足,就抛出此错误。
  • 由于CMS是 标记 - 清除算法,就会产生空间碎片。如果空间碎片过多就会给分配大对象带来困难抛出 Promotion Failed 错误,这个错误是指当 CMS 进行垃圾回收后,Minor GC 产生了新的老年代对象,老年代剩余空间足够但是由于空闲空间的碎片化,导致没有足够大的空间碎片容纳该对象。

无论出现 Concurrent Mode Failure 还是 Promotion Failed ,CMS 就会立刻退化成 Serial Old 进行单线程的全部回收。

3.5.5 G1(Garbage First) 收集器

G1 收集器的主要目的是可以替换掉 CMS 。我就这一句你就能理解这玩意儿有多牛*。下面说说它的优点:

  • 并行并发:老规矩,G1是能并发的,充分利用了多CPU、多核环境下的硬件优势来缩短 STW 的停顿时间。
  • 分代收集:G1针对不同年龄段的实例对象也有不同的收集方式。
  • 空间整合:这是比对 CMS 来说的第一点优势,采用了复制算法和标记整理算法,经过 G1 回收出来的空间都是规整有序的,可直接通过指针碰撞方式分配空间,不会存在 CMS 中的 Promotion Failed 问题。
  • 可预测的停顿:这是第二点优势,G1 除了追求低停顿意外,还能简历可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片内(但是这个时间并不是越短越好,还是要合理)。

在 G1 之前,我们对 Java 堆模型的认知还是以下图为主:
HotSpot 堆结构图
但是在引入G1收集器后,请暂时的把这幅图给忘掉,我们来看下面这幅图:
G1 内存模型
如图所示,G1算法将堆划分为了一个个相等的逻辑连续的内存区域(Region),每个区域都有一个代表分代的角色:Eden、Survivor、Old、Humongous,大家一定发现这里多了一个 Humongous 区,这个区域一定意义上属于普遍理解的老年代,它是用来存放大小超过 Region 50%以上的巨型对象的。Region的大小可以通过-XX:G1HeapRegionSize参数指定,如果没有显示设置,则会自动根据内存计算出一个合理的大小。
说到了大对象就不得不重提对象分配空间,我们之前在第二章讲到分配对象时,会先在 TLAB 中尝试划分区域,如果 TLAB 中剩余的空间不足以放下新申请的对象,那么就重新为这个线程申请一个完整的 TLAB ,如果这个TLAB还放不下,就要申请Eden区,在Eden区锁一块空间来分配,如果Eden区都还放不下的话,那就只能扔进老年代了,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响(它应该被短期回收掉而不是一直占着老年代)。于是 G1 划分了一个 Humongous 区专门存这种对象。如果一个H区还装不下这个对象,那么G1会寻找连续的H区来存储它。为了能找到连续的H区,有时候不得不启动Full GC。
在解释工作原理前,有必要解释一个名词: Remembered Set(下称 RSet) 。借用R神的一段话来解释一下这个名词:

G1 GC的heap与HotSpot VM的其它GC一样有一个覆盖整个heap的card table。

逻辑上说,G1 GC的remembered set(下面简称RSet)是每个region有一份。这个RSet记录的是从别的region指向该region的card。所以这是一种“points-into”的remembered set。

用card table实现的remembered set通常是points-out的,也就是说card table要记录的是从它覆盖的范围出发指向别的范围的指针。以分代式GC的card table为例,要记录old -> young的跨代指针,被标记的card是old gen范围内的。

G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。

这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。

举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。

那么说到底这个 G1 收集器到底怎么工作的呢?他其实一共分两大部分:

  • 全局并发标记(Global Concurrent Marking) :Global Concurrent Marking 是基于SATB(Snapshot At The Begining,初始快照)形式的并发标记。它具体分为下面几个阶段:
    1. 初始标记(Initial Marking):扫描根集合,标记所有从根集合可直接到达的对象。需要 STW。
    2. 并发标记(Concurrent Marking):根据可达性算法来标记可达的对象。这一步虽然耗时长,但跟 CMS 一样,是可以跟用户并行的
    3. 最终标记(Final Marking):这一步可以理解为跟CMS中的重标记一样,是为了修正标记期间用户线程产生的引用变化,JVM会把变化记录在 Remembered Set Logs 里。最终标记就要将 Remembered Set Logs 里的数据合并到 Remembered Set 中,这个阶段也需要 STW 。具体的记录和合并过程这边有篇详细的博客,不在此展开
    4. 清理(Clean Up):最后清点和整理最终需要回收的实例和对象,同样需要 STW 。
  • 拷贝存活对象(Evacuation) :Evacuation 是全程 STW 的,他负责把 Region 中存活的对象拷贝到另一个空的 Region 中,并回收原有的 Region。Evacuation 阶段可以自由选择任意多个 Region 来独立收集构成收集集合(Collection Set,简称CSet)。网上传的两种 GC 模式,其实就是说的 G1 选择 CSet 两种不同方式。下面简介一下这两种不同的方式:
    • G1 Young GC:选定所有年轻代里的 Region。通过控制年轻代的 Region 个数来控制 Young GC 的开销。
    • G1 Mixed GC:选定所有年轻代里的 Region,外加根据 Global Concurrent Marking 统计得出收集收益高的若干老年代 Region。在用户指定的开销目标范围内尽可能选择收益高的老年代 Region。

介绍完了之后不知道大家有没有发现一个问题,年轻代里的 Region 总是在CSet内。但是好像没介绍过Old GC。
这也是我刚学完的疑惑,后看到R大的解释后豁然开朗。此处仍引用原话:

可以看到young gen region总是在CSet内。因此分代式G1不维护从young gen region出发的引用涉及的RSet更新。
分代式G1的正常工作流程就是在young GC与mixed GC之间视情况切换,背后定期做做全局并发标记。Initial marking默认搭在young GC上执行;当全局并发标记正在工作时,G1不会选择做mixed GC,反之如果有mixed GC正在进行中G1也不会启动initial marking。
在正常工作流程中没有full GC的概念,old gen的收集全靠mixed GC来完成。

如果mixed GC实在无法跟上程序分配内存的速度,导致old gen填满无法继续进行mixed GC,就会切换到G1之外的serial old GC来收集整个GC heap(注意,包括young、old、perm)。这才是真正的full GC。Full GC之所以叫full就是要收集整个堆,只选择old gen的部分region算不上full GC。进入这种状态的G1就跟-XX:+UseSerialGC的full GC一样(背后的核心代码是两者共用的)。

顺带一提,G1 GC的System.gc()默认还是full GC,也就是serial old GC。只有加上 -XX:+ExplicitGCInvokesConcurrent 时G1才会用自身的并发GC来执行System.gc()——此时System.gc()的作用是强行启动一次global concurrent marking;一般情况下暂停中只会做initial marking然后就返回了,接下来的concurrent marking还是照常并发执行。

3.5.6 理解 GC 日志 / 垃圾收集器参数总结

个人觉得这两小节没有背的必要,用到了再来比对查看就好。此处略。

3.6 内存分配与回收策略

没啥好说的,引入书上原文:

对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置

3.6.1 对象优先在 Eden 分配

大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC(Young GC)。
如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。

3.6.2 大对象直接进入老年代

一个大对象对虚拟机来说就是个坏消息,尤其是碰上 CMS 这种不太整理空间碎片的垃圾回收期时更是讨厌,他有可能让空间还很充足但不连续时提前触发 GC 。虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。

3.6.3 长期存活的对象将进入老年代

说白了就是给实例加了一个“年龄计数器”,当对象在 Eden 区经历了第一次 Minor GC 后仍然存活,那么它将被放到 Survivor 区中进行 “历练”,并且年龄设置为1岁。之后它每经过一次 Minor GC ,就被视为通过一次“历练”,年龄就加一。直到他经过了足够多次的“历练”(默认为15次,可通过-XX:MaxTenuringThreshold设置)后,就认为他不是“短命鬼”并把它挪入老年代。

3.6.4 动态对象年龄判定

注意:这里书上的原文实际上是不对的,看书还是要辩证的来看
为了更好的适应不同的内存情况,JVM 并不是说一定要年龄满了15岁才给他扔进老年代,只要 Survivor 空间被占用大于一定比例就会给他扔进去。比例是由参数-XX:TargetSurvivorRatio(目标存活率,默认为50%)来控制的。一旦超过,那么就将实例按照年龄从小到大进行累加,当加入某个年龄段后,累加的大小和超过Survivor区域*TargetSurvivorRatio的时候,那么就把大于等于这个年龄的所有对象都扔进老年代。

3.6.5 空间分配担保

我们前面讲复制算法的时候,提到过分配担保的概念。因为 HotSpot 虚拟机的年轻代是以 8:1:1的比例分配,那么就极有可能发生回收后,存活的对象大于1,那么这部分对象将会被存储到老年代,即分配担保。但是分配担保的前提是老年代也得有足够的空间容纳才行,于是虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果没有的话,这里JVM会分为两种情况。一种情况是HandlePromotionFailure设置值是否允许担保失败,如果允许的话,那么虚拟机会再次检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果成立则冒险尝试一次分配。如果设置为不允许担保失败或最大可用连续空间小于平均大小,则直接进行一次Full GC。
这里所说的冒险是指什么呢,我们在完成GC前实际上并不知道一共有多少对象会活下来,也有可能不够的。所以我们只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多空间。如果发生一种情况使得所有新生代的对象全部都保留下来了,这样老年代的空间就不够了,于是晋升失败,然后进行Full GC释放老年代空间。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将HandlePromotionFailure开关打开,避免Full GC过于频繁,

结尾附上R神原文帖和参考博客:
R神原帖子:https://hllvm-group.iteye.com/group/topic/44381#post-272188
G1 深入解析:https://blog.csdn.net/u013380694/article/details/83341913
RSet详解:https://www.jianshu.com/p/870abddaba41
CMS详解:https://blog.csdn.net/ityouknow/article/details/85826170

本章CMS 和 G1 两大收集器仅是简单一学一分析,深入的东西还有很多很多很多。目前深度达不到就没有写,期望以后搞懂后能开个博客。再次膜拜R神。

本文仅是在自我学习 《深入理解Java虚拟机》这本书后进行的自我总结,有错欢迎友善指正。

欢迎友善交流,不喜勿喷~
Hope can help~

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值