开篇诗两句:

春眠不觉晓,处处闻啼鸟。

夜来风雨声,花落知多少。 ——孟浩然《春晓》



学习G1收集器过程中总结的相关知识。

概述

G1(Garbage First)是当今垃圾回收机制中最前沿的成功之一,在JDK7中就已经开始引入G1,并作为重点发展的垃圾回收器。G1最大的特点是引入了分区的思想,一定程度上弱化了分代的概念,同时解决和很多其他收集器存在的缺陷。

GC收集器回顾与比较

当前JVM主流的收集器如下:

各个收集器对比:

收集器   组合               流程图 优劣势
串行收集器 Serial + Serial
OldSerial用于回收新生代
Serial Old用于回收老年代。
优势:由于串行收集器采用的是单线程的处理方式,所有复杂度更低,占用内存少
劣势:回收速度不高,STW(Stop The World)导致应用停顿时间过长。
并行收集器 Parallel Scavenge + Parallel Old
Parallel Scavenge回收新生代
Parallel Old回收老年代
优势:GC过程是多线程并发执行,回收速度相对于串行收集器更快,适合高吞吐场景
劣势:占用资源相对于串行收集器要更多一点,并且不适合延迟要求高的场景
新生代:复制整理算法,老年代:标记整理算法
本身是追求高吞吐的收集器
CMS ParNew + CMS + Serial Old
ParNew回收新生代
CMS回收老年代
Serial Old兜底策略
优势:GC过程是多线程执行,以关注延迟为目标
劣势:吞吐量方面不如并行收集器CMS在进行内存回收时会增加堆内存的使用量,所以CMS进行回收时,必须要在老年代堆内存用尽之前,否则就必须通过Serial Old进行串行回收,导致更大的停顿CMS使用的时标记清除算法,会产生内存碎片
新生代:复制整理算法,老年代:(1)初始标记(STW)(2)并发标记(3)重新标记(STW)(4)并发清除
Garbage First Garbage First即收集新生代,又收集老年代 优势:以关注延迟为目标,引入分区思想,进一步降低延迟时间,同时解决CMS内存碎片的问题
劣势:需要更多的内存来完成内存回收,如果内存不足,同样需要使用串行回收作为兜底策略

G1内存模型

相关概念

概念         描述
分区
(Region)
(1)G1引入了分区的思想,将堆内存划分为多个Region(1M ~ 32M,可以配置)
(2)G1同样使用新生代和老年代的概念,但是与CMS不同的是,新生代和老年代不再使用连续的逻辑地址,而是新生代和老年代都会对应一个分区集合,这些分区之间不需要连续的逻辑地址
(3)每个Region不会确定为新生代或老年代服务,随着执行时间,Region所属的代会变化
卡片
(Card)
(1)每个Region内部会划分成多个Card,每个Card默认时512B,可以配置
(2)实际内存分配,内存回收都是按照Card粒度执行的,对象会占用连续的Card
RSet
(Remember Set)
(1)记录谁引用了当前Region内对象(指针)
(2)RSet有三个级别:稀少,细粒度和粗粒度,在回收当前Region时,通过RSet获取需要扫描的Region来判断当前Region中存活的对象
CSet
(Collection Set)
(1)每次GC收集的分区集合,GC之后,CSet中的所有分区都会备回收,存活对象会复制到新的空闲的Region中
(2)Young GC时,CSet只包含新生代的Region,Mixed GC时,CSet中既包括新生代Region,也包括老年代Region,本质上新生代和老年代GC回收的处理方式是相同的
TLAB
(Thread Local Allocation Buffer)
新生对象都分配到Eden区中,Eden是全局的概念,所以进行内存分配的时候会对Eden区进行加锁,增加强锁的耗时TLAB是指每个Thread都会有一个Eden区独占的Region进行内存分配,减少抢锁的情况出现,如果对象超过Region大小,还是会分配到全局的Eden区的Region中,还是需要进行加锁
PLAB
(Promotion Thread Local Allocation Buffer)
与TLAB类似,TLAB是应用线程,PLAB是GC线程独占的Eden区的Region,GC线程在进行survivor区拷贝时,就会向自己的私有缓冲区拷贝,目的还是减少并发抢锁带来的耗时

YoungGC流程

Young GC需要STW(Stop the world)

名称   描述 结构图
选择收集分区集合(CSet) Young GC过程的CSet会包括所有的年轻代分区。
根扫描 通过Root判断CSet集合中的分区中哪些是存活对象(包括整个引用路径)
RSet扫描 更新RSet,应为RSet的更新是异步写入的,所以需要保证RSet是最新的,见7.2
通过Region中的RSet判断有哪些对象被引用,并标记成存活对象(包括整个引用路径)
复制整理 将步骤2和步骤3标记的存活对象复制到新的survivor分区中GC线程在进行对象复制时
不是所有GC线程都向同一个Region复制,而是复制到自己的私有缓冲区中(PLAB)
释放分区 将原本年轻代的分区全部释放

MixedGC流程

Mixed GC主要分为如下几个阶段:

(1)初始标记(2)根分区扫描(3)并发标记(4)重新标记(5)清理(6)内存回收

初始标记(Initial Marking)

初始标记的目的:标记所有root object

当 Old Generation 空间占用整个 Heap 比例超过目标值(-XX:InitiatingHeapOccupancyPercent, IHOP)后,开始 OGC 过程,最开始执行的是初始标记阶段。

但是不是触发IHOP之后就立刻执行初始标记,而是等待下次Young GC执行时,同时执行初始标记。

因为初始标记需要STW,所以为了减少暂停时间,初始标记会和young GC同时执行(young GC同样需要STW)

根分区扫描(Root Region Scanning)

扫描Young GC过程拷贝到survivor区的所有对象,并标记为live (Young GC存活下来的对象都认为是live对象)

该阶段需要在下一次Young GC执行之前完成,否则该阶段执行失败需要重新执行(survivor中的对象变化,需要重新扫描)

并发标记(Concurrent Marking)

并发标记目标:标记所有存活对象

并发标记线程每次扫描一个Region,并根据Region的RSet来标记Region中存活对象(标记在一个全局的Bitmap中)

并发标记线程还会定时消费SATB缓冲区,标记存活对象(见Pre-Write Barrrier

重新标记(Remark)

重新标记阶段需要将SATB缓冲区消费干净,并且处理完成所有其他的引用更新,找出所有未被标记的存活对象。因为如果应用线程持续运行,引用信息会持续变化,所以该阶段也是STW的

清理(Clean)

该阶段不是真正执行内存回收的阶段

(1)根据每个Region的garbage占用情况,和RSet活跃程度评估每个Region的GC效率,并根据GC效率将Region排序。

(2)发现没有存活对象的Region时直接将其清理。

(3)对每个Region的RSet进行清理,比如发现一个card中指向当前Region的Object都dead了,就直接清理这个RSet内的记录。因为之后无论是YGC还是Mixed GC都会扫描这个RSet,将其清理一下有助于提升之后清理过程中RSet扫描效率。

内存回收(Mixed GC)

Mixed GC本质上就是Young GC,通常Young GC时CSet(被回收分区的集合)只会包含新生代Region,但是Mixed GC时,CSet中会添加老年代的Region,实际回收方式都是一样的(复制整理)。

RSet记录与更新

并发标记阶段在标记所有存活对象时,需要扫描每个Region的RSet表,来判断有哪些Region的Card引用了当前Region,以此来判断当前Region中的存活对象。

RSet的主要作用是记录有哪些Region引用了当前Region中的对象,减少在GC过程中标记对象时扫描的内存大小,进而减少GC过程的停顿时间。

RSet记录

RSet会根据分区引用活跃度(其实就是引用数量)分为三个等级,每个等级都有会一个PRT表来存储相关内容:(1)稀少(2)细粒度(3)粗粒度

(1)稀少

该等级的RSet会直接记录引用当前Region的Card的指针(Region : Card)

通过这种方式,当需要回收当前Region时,只需要根据RSet中记录的指针,扫面对应的Card来判断当前Region中存活的对象即可。

(2)细粒度

该等级中的RSet不再直接保存Card的指针,而是为Region B创建一个位图(Bitmap B),然后A中的RSet中保存该位图和Region B的指针。

Bitmap B中每个Bit表示Region B中的一个Card,通过这种方式记录Region B中各个Card对Region A的引用关系。

这种存储方式相较于上面稀少等级,需要占用更多的存储空间,而且是间接保存的引用关系,所以扫描耗时会相对更长。

(3)粗粒度

该等级的RSet本身就是Bitmap,Bitmap中每个Bit代表堆内存中的一个Region,通过这种方式保存其他分区对当前分区的引用关系。

这种方式带来的缺点就是,需要扫描整个Region才能找到各个Card的引用关系,耗时会更长。

RSet更新

RSet更新是通过写栅栏(write barrier)实现的。其中G1实现了两个barrier,(1)pre-write barrier (2)post-write barrier

(1)写前栅栏(pre-write barrier):写前栅栏不是用来更新RSet,具体见并发标记流程

(2)写后栅栏(post-write barrier):

该post-write barrier在每次写入引用时都会被调用,但是实际应用修改引用的频率非常多,所以该barrier必须保证性能。post-write barrier只作如下两件事:

(1)判断当前写入的reference是不是跨Region的(当前Region引用当前Region内部的对象是不需要记录到RSet中)

(2)判断是不是old-to-old,old-to-young(young-to-old,young-to-young是不记录的)

如果满足上面两个条件,就会将引用修改的相关信息(引用所在的Card,被引用的Region等信息)写入到update log buffer中(可以简单认为是一个队列),如果当前update log buffer写满了,会重新申请一个新的update log buffer,并将原有的放入到全局的list中。

之后会有concurrent refinement threads去消费update log buffer,并更新对应Region的RSet信息,以及RSet粒度调整的相关工作。

concurrent refinement threads是可以多线程并发执行的,具体数量是根据update log buffer中积压的情况动态调整的,但是concurrent refinement threads的数量不是无限增大的,当update log buffer堆积的数量过多导致消费不完时,此时应用线程会协助concurrent refinement threads来完成RSet更新操作,此时会影响应用线程的效率。

标记流程

G1 的这套 Marking 算法借鉴了Snapshot-at-the-beginning (SATB) 算法

SATB 内部会对 Heap 维护一个 Snapshot,标记工作也是在这个 Snapshot 上进行。SATB 保证:

对象和参数介绍:

(1)PrevBitmap,NextBitmap

SATB 会维护两个Bitmap,PrevBitmap和NextBitmap。PrevBitmap存的是上一次完成的标记信息,当前标记阶段会创建并更新NextBitmap。随着标记阶段的进行,NextBitmap会逐渐被完善,当NextBitmap拥有整个堆的标记信息后,NextBitmap会替代PrevBitmap。

(2)top-at-mark-start (TAMS)

在 G1 Region上,有两个top-at-mark-start (TAMS) 标记位,一个是标记上一次标记阶段使用的TAMS,也称为PrevTAMS;另一个用来标记本次标记阶段,也称为NextTAMS。

SATB算法执行流程

阶段A:

(1)PrevBitmap和NextBitmap都是空的,说明这是个新Region,没有经历过标记阶段。

(2)Bottom和Top之间是Region当前已经分配的空间。

(3)因为没有经历过标记,PrevTAMS指向 Bottom。NextTAMS不管Region之前是否经历过标记,initial marking的时候都会指向Top。

阶段B:

(1)在Remark阶段结束之后,来到了图中B指示的阶段。

(2)NextBitmap已经被标记了哪些是存活对象。

(3)NextTAMS到Top之间的object是并发标记阶段,因为业务线程跟标记线程并发运行而新产生的 object。这部分object全部认为是live的。(只针对PrevTAMS到NextTAMS之间的区域进行标记)

阶段C:

(1)Clean阶段,NextBitmap替换PrevBitmap

注意:NextBitmap和PrevBitmap 实际都是全局的一个Bitmap,是标识整个Heap的。上图中Bitmap看上去跟Region绑定只是为了方便看,便于理解。

阶段D:

(1)G1的Mixed GC不是一定要在整个Heap上所有dead object都被收集干净了才停止,而是只要根据标记提供的dead object占用的空间在整个Heap中占比小于一定值后,就停止收集。所以完全是有可能存在能经历两次甚至更多次标记的Region。

(标记整理,正常被回收的Region会被整体释放掉)

(2)从D能看到Top相对于C增长了一些,说明上次标记结束后,这个Region上又有新晋升上来的object。

(3)跟A一样,PrevBitmap、PrevTAMS保持不变,创建NextBitmap,NextTAMS指向Top。并且看到PrevBitmap没有变化,因为不经历标记这个bitmap是不可能变化的。

阶段E:

(1)跟B一样,Remark结束,Bottom到NextTAMS之间所有存活的object都被标识出来

(2)NextTAMS到Top之间是本轮并发标记阶段新晋升的object,直接被标记为live。

阶段F:

(1)跟C一样,NextBitmap替换PrevBitmap,NextBitmap被清理。

从上面看到标记阶段实际就是为了维护PrevBitmap,通过这个Bitmap,就能知道一个Region上有多少存活object,从而能够根据dead object空间占比来排序,找出GC效率最高的Region来GC。

写前栅栏(pre-write barrier)

写前栅栏只会在并发标记阶段生效,而写后栅栏是持续生效的

例如如下伪代码:

//java 代码
x.f = y

//pre-write barrier
if (is-marking-active) {
  prev = x.f;
  if (prev != Null) {
    satb_enqueue(prev);
  }
}

写前栅栏会将被删除的引用投递到SATB缓存区中,然后由concurrent marking thread(并发标记线程)消费,将被删除引用的对象标记成live状态。

写前栅栏主要解决的问题是,防止出现漏标的情况出现:

例如:

x.f = z      // 假如一开始 x.f 是指向 z 的
x.f = y      // 修改 x.f 的引用,此时 z 可能确实是死掉了
w.f = z      // 由于某种原因将 z 救活

此时由于应用线程和标记线程是并发执行的,如果没有写前栅栏,很可能会将z标记成dead状态,并在之后将z回收,导致问题出现。

写前栅栏的原则:宁可错标也不能漏标

三色标记法漏标记的情况:

(1)没有写前栅栏,可能出现漏标记的情况:

(2)增加写前栅栏后,当移除B对C的引用后,会将C标记成存活状态