G1收集器

G1之前垃圾收集器预习:

https://www.cnblogs.com/fengtingxin/p/13966982.html

GC统一语义:

  • 部分收集(Partial GC)
    • 新生代收集(Minor GC / Young GC)
    • 老年代收集(Major GC / Old GC):目前只有CMS收集器才有单独老年代的收集
    • 混合收集(Mixed GC)收集整个新生代和部分老年代
  • 整堆收集(Full GC)

分代收集理论:

  1. 弱分代假说:绝大多数对象都是朝生夕死
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
  3. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数(例如:年轻代中的对象有可能被老年代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而年龄晋升到老年代。为了避免少量的跨代引用去扫描整个老年代,在新生代中建立了全局的数据结构(记忆集 Remembered Set))

G1收集器是垃圾收集器的里程碑式的成果,它开创了收集器面向具备收集的设计思路和基于Region的内存布局形式。

Region(区域)

传统的GC收集器将连续的内存空间划分为新生代、老年代和永久代(JDK 8去除了永久代,引入了元空间Metaspace),这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。如下图所示:

在G1收集器之前的所有其他收集器,包括CMS在内,垃圾收集器的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC),要么就是整个Java堆(Full GC)。而G1跳这个樊笼,他可以面向堆中任何部分来组成回收集(Collection Set ,一般简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是那块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式

G1也是遵循分代收集理论设计的,但G1不再坚持固定大小和固定数量的分代区域划分,而是把连续的Java堆划分为多个相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden区和Survivor区或者老年代区。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数 -XX:G1HeapRegionSize设定(若是不设置,默认),取值范围为1MB~32MB,且应为2的N次幂。对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。

G1之所以能够建立可预测的停顿的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划的避免在整个Java堆中进行全区域的垃圾收集。具体的处理思路是:让G1收集器区跟踪各个Region里面的垃圾堆积的“价值”的大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的手机停顿时间(使用参数 -XX:MaxGCPauseMillis指定,默认值是200毫),优先处理回收价值受益最大的Region。

(图片参考:https://www.infoq.com/articles/G1-One-Garbage-Collector-To-Rule-Them-All)

记忆集(Remembered Set)-来自于跨代引用假说

是一种用于记录从非收集区域指向收集区域的指针集合的抽象的数据结构(简单理解为一个映射关系吧,可以通过年轻代)。

为了解决对象跨代引用带来的问题 - 把老年带内存划分为若干小块,标识哪一块内存会存在若干引用

记忆集几种具体的实现:

1.字长精度:每个都精确到了一个机器字长(就是处理器的寻址位数)

2.对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针

3.卡精度(卡表(Card Table)):每个记录精确到一块内存区域,该区域内有对象含有跨代指针。(卡表是最常用的记忆集的一种实现形式

卡表最简单的形式可以只是一个字节数组(HotSpot也是这样做的,默认值为0),字节数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为“卡页”(Card Page),HotSpot默认卡页大小为512字节。

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或多个)对象的字段存在跨代指针,那就将对应卡表的数据元素的值标识为1(称这个元素变脏)。垃圾收集时,就筛选卡表中变脏的元素,就把他们加入GC Root一并扫描。

问题来了?怎么解决卡表中元素如何维护的问题?

什么时候变脏的?有其他分区对象引用了卡表中该区域的对象。老年代对象赋值给新生代对象的某个属性

怎么变脏的?写屏障 写屏障可以看作在虚拟机层面对“引用类型字段赋值”动作的AOP切面(Around)写后屏障把元素变脏;其次卡表在高并发下还有“伪共享”的问题(出现该问题是因为现在CPU中缓存是以缓存行为单位):先检查卡表是否标记过,未标记过再更新为脏。

新的参数:-XX:+UseCondCardMark -是否开启卡表更新的条件判断。

G1中每个Region都有维护自己的记忆集。而且是双向的卡表结构。

SATB(原始快照)

提到原始快照,就要先说下并发的可达性分析为了缩短停顿时长,引入了三色标记。

三色标记法:

  • 白色:对象尚未被垃圾收集器访问过。在可达性分析刚开始的时候,所有对象都是白色,在分析结束后,仍然是白色的对象,即代表不可达。
  • 黑色:对象已经被垃圾收集器访问过,而且他的所有引用都被访问过。黑色对象不能不经过灰色对象直接指向某个白色对象
  • 灰色:对象已经被垃圾收集器访问过,但是它还有一些引用没有被访问。

并发标记阶段,用户线程和收集器是并发工作的,会存在什么问题?

收集器在对象图上标记颜色,同时用户在修改引用关系-即修改图的结构,这样可能会出现两种后果,一种就是把原本已经标记为回收的对象标记为存活,这种问题还好,只是会造成一些浮动垃圾,下次回收就好;另一种是将存活的对象标记为回收,程序可能会报错。 

如下两种情况:

 - 图片来自《深入理解JVM虚拟机》

当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:

  1.赋值器插入了一条或多条从黑色对象到白色对象的新引用

  2.赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

怎么解决这个问题呢?把这两个条件中的任意一个就可以了,对此产生了两种方案:

  1.增量更新 - 破坏第一个条件,当黑色对象插入新的指向白色对象的指针时,将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以简单理解为,黑色对象一旦新插入指向白色对象的引用后,它就变为灰色了

  2.原始快照 - 破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,将这个要删除的引用记录下来,在并发扫描结束之后,再将记录过的灰色对象为根,重新扫描一次。简单理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

CMS是基于增量更新做并发标记的;G1、Shenandoah是用原始快照来实现

此外,G1在垃圾收集时,为每一个Region设计了两个名为TAMS的指针(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认他们存活,不纳入回收范围。同CMS中的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收跟不上内存分配的速度,也会Full GC。

停顿预测模型

可以通过-XX:MaxGCPauseMillis参数指定停顿时间,默认值200ms,这只是一个期望时间,但G1要怎么实现用户的期望呢?

G1的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。换句话说,统计信息越新越能决定回收的价值。

G1收集器的运作过程

  1. 初始标记 - 需要STW,仅仅只是标记下GC Roots能直接关联到的对象,并且修改TAMS的指针位置,使新对象能够在可用的Region中分配(同Minor GC一同完成)
  2. 并发标记 - 同用户线程并发执行,从GC Roots开始对堆中的对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象。
  3. 最终标记 - 需要STW,用于处理并发阶段结束后仍遗留下来的最后少量的SATB(原始快照)记录。
  4. 筛选回收 - 需要STW(涉及到存活对象的移动),由多个线程并行完成,负责更新Region的统计数据,根据各个Region的回收价值和成本排序,根据用户期望的停顿时间来执行回收计划,自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,在清理掉整个旧的Region的全部空间。

(G1收集器运行示意图)

CMS与G1比较:

  CMS是基于“标记-清除”算法,G1从整体上看是基于“标记-整理算法”,从局部(两个Region)之间看又是基于“标记-复制”算法;G1在运作期间不会产生内存碎片,垃圾收集完成之后能提供规整的可用内存。

  G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(OverLoad)都要比CMS高。内存占用中,由于G1的卡表实现更为负载而且每一个Region中都有一份卡表,导致G1的记忆集可能占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要。在执行负载中,CMS是写后屏障来更新维护卡表;G1也是写后屏障维护卡表,但是为了实现SATB算法,还使用了写前屏障来跟踪并发时的指针变化情况。SATB能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的特点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。

实践经验中:小内存CMS要优于G1,中间范围6-8G,大内存G1优于CMS

G1收集器的收集模式分为:YoungGC和Mixed GC

当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区,前文已述。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed GC)。

低延迟垃圾收集器

衡量垃圾收集器的3个重要指标:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency)。 

 

由图可见,在CMS和G1之前,工作的所有步骤都会STW;CMS和G1分别使用增量更新和原始快照,实现了标记阶段的并发,不会因管理的堆内存,要标记的对象变化而导致停顿时间随之增长。但是对于标记阶段之后的处理,仍未得到妥善解决。CMS使用标记-清除算法,虽然避免了整理阶段收集器带来的停顿,但是清除算法不论如何优化都会产生内存碎片,随着空间碎边不断堆积,仍然逃不过STW。G1虽然可以按更小的粒度进行回收,但是还是要暂停的。

 这里不会针对低延迟垃圾收集器做重点讲述,只是描述下大概过程及思想

Shenandoah收集器

过程:

  1. 初始标记:需要STW,首先标记与GC Roots直接关联的对象,同G1相同;
  2. 并发标记:与用户线程并发,遍历对象图,标记所有可达的对象,同G1相同;
  3. 最终标记:需要STW,时间很短,处理剩余SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region沟通一组回收集(Collection Set),同G1相同;
  4. 并发清理:这个阶段用于清理哪些整个区域内连一个存活对象都没有找到的Region(这类Region称为:Immediate Garbage Region)
  5. 并发回收:Shenandoah要把回收集中存活对象先复制一份到其他未被使用Region中。由于该阶段是和用户线程并发执行,所有Shenandoah通过读屏障和"Books Pointers"的转发指针来解决。该阶段时间长短取决于回收集的大小。
  6. 初始引用更新:需要STW,时间很短,并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。未做具体处理,确保所有并发回收阶段中进行的收集器线程都已完成分配给他们的对象移动任务。
  7. 并发引用更新:与用户线程一起并发执行,真正开始引用更新操作,不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性的搜索出引用类型,把就只改为新值即可。
  8. 最终引用更新:需要STW,停顿时间只和GC Roots数量有关,解决了堆中的引用更新后,还要修正GC Roots中的引用。
  9. 并发清理:经过上述步骤,整个回收集中所有的Region已无存活对象,这些Region都变成Immediate Garbage Region,最后回收这些空间,提供给以后的新对象使用。

来自于:https://shipilev.net/talks/devoxx-Nov2017-shenandoah.pdf

与G1的主要区别:

  1. 支持了并发的整理,可以同用户线程并发
  2. 默认不使用分代收集,换言之,不会有专门的新生代Region或者老年代Region的存在
  3. 摒弃了G1中的记忆集,改用“连接矩阵”的全局数据结构来几率跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享的发生概率。

连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向Region M,就在表格的N行M列中打上一个标记,如果Region 5 中的对象Baz引用了Region 3 的Foo,Foo又引用了Region 1 的Bar,那连接矩阵的5行3列、3行1列就应该被打上标记。在回收时,通过这张表格就可以得出哪些Region之间产生了跨代引用。

Brooks Pointers 的转发指针 

在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己

 每次对象访问都会带来一次额外的转向开销,这个开销只通一条mov指令就可以完成对新对象的访问了,如图:

  

收集器是通过比较并交换(Compare And Swap)操作来保证并发时对象的访问的正确性。

参考:https://tech.meituan.com/2016/09/23/g1.html

https://blog.csdn.net/coderlius/article/details/79272773

一个入行不久的Java开发,越学习越感觉知识太多,自身了解太少,只能不断追寻
原文地址:https://www.cnblogs.com/fengtingxin/p/14058734.html