GC - G1收集器
介绍
G1垃圾收集器在JDK7被开发出来,JDK8功能基本完全实现。并且成功替换掉了Parallel Scavenge成为了服务端模式下默认的垃圾收集器。JDK 9以后默认使用,替代了CMS 收集器。
G1和CMS一样,也是采用三色标记分段式进行回收的算法, 不过它是写屏障 + STAB快照实现,后文详聊
G1 收集器的最大特点
G1 最大的特点是引入分区的思路,弱化了分代的概念。
并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,不会产生空间碎片;从局部上来看是基于“标记-复制”算法实现的。
可预测的停顿:G1垃圾回收器设定了用户可控的停顿时间目标,开发者可以通过设置参数来指定允许的最大垃圾回收停顿时间。G1会根据这个目标来动态调整回收策略,尽可能地减少长时间的垃圾回收停顿。
如何完成可预测的?
G1根据历史数据来预测本次回收需要的堆分区数量,也就是选择回收哪些内存空间。最简单的方法就是使用算术的平均值建立一个线性关系来进行预测。比如:过去10次一共收集了10GB的内存,花费了1s。那么在200ms的时间下,最多可以收集2GB的内存空间。而G1的预测逻辑是基于衰减平均值和衰减标准差来确定的。
CMS 和 G1 的区别
CMS 中,堆被分为 PermGen,YoungGen,OldGen ;而 YoungGen 又分了两个 survivo 区域。在 G1 中,堆被平均分成几个区域 (region) ,在每个区域中,虽然也保留了新老代的概念,但是收集器是以整个区域为单位收集的。
G1 在回收内存后,会立即同时做合并空闲内存的工作;而 CMS ,则默认是在 STW(stop the world)的时候做。
G1 会在 Young GC 中使用;而 CMS 只能在 Old 区使用
分区Region
G1同时回收新生代和老年代,但是分别被称为G1的Young GC模式和Mixed GC模式。这个特性来源于G1独特的内存布局,内存分配不再严格遵守新生代,老年代的划分,而是以Region为单位,G1跟踪各个Region的并且维护一个关于Region的优先级列表。在合适的时机选择合适的Region进行回收。这种基于Region的内存划分为一些巧妙的设计思想提供了解决停顿时间和高吞吐的基础。接下来我们将详细讲解G1的详细垃圾回收过程和里面可圈可点的设计。
G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区
。在分代垃圾回收算法的思想下,region逻辑上划分为Eden,Survivor和老年代。每个分区都可能是eden区,Survivor区也可能是old区,但在一个时刻只能是一种分区。各种角色的region个数都是不固定的,这说明每个代的内存也是不固定的。这些region在逻辑上是连续的,而不是物理上连续,这点和之前的young/old区物理连续很不一样。
G1对内存的使用以分区(Region)为单位
堆内存会被切分成为很多个固定大小区域(Region),每个是连续范围的虚拟内存。
堆内存中一个区域 (Region) 的大小,可以通过 -XX:G1HeapRegionSize 参数指定,大小区间最小 1M 、最大 32M ,总之是 2 的幂次方。
默认是将堆内存按照 2048 份均分。
每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代。存活的对象从一个区域转移(即复制或移动)到另一个区域。区域被设计为并行收集垃圾,可能会暂停所有应用线程。如上图所示,区域可以分配到 Eden,survivor 和老年代。
巨型区域(Humongous Region):如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。如果对一个短期存在的大对象使用复制算法回收的话,复制成本非常高,而直接放进old区则导致原本应该短期存在的对象占用了老年代的内存,更加不利于回收性能。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。
内部数据结构
Card Table卡表
Card Table是Region的内部结构划分。每个region内部被划分为若干的内存块,被称为card。这些card集合被称为card table,卡表。
比如下面的例子,region1中的内存区域被划分为9块card,9块card的集合就是卡表card table。
card表可以记录每一块card内存区域是否dirty。如果在发生YGC时,怎么知道那些是存活对象,并且其它代区域有没有引用这部分对象,于是把region划分了很多card区域, 每个区域大小不超过512b,当该card区域里的对象有引用关系,将当前card置为“dirty”, 并且使用卡表(CardTable)来记录每一块card是否dirty,在进行GC时,不用遍历所有的空间, 只需要遍历卡表中为"dirty"。
Rset记忆集合
除了卡表,每个region中都含有Remember Set,简称RSet。RSet其实是hash表,key为引用本region的其他region起始地址,value为本region中被key对应的region引用的card索引位置。
这里必须讲解一下RSet存在的原因,RSet是为了解决"跨代引用"。想象一下,一个新生代对象被老年代对象引用,那么为了通过引用链找到这个新生代对象,从GC Roots出发遍历对象时必须经过老年代对象。实际上以这种方式遍历时,是把所有对象都遍历了一遍。但是我们的其实只想回收新生代的对象,却把所有对象都遍历了一遍,这无疑很低效。
在YoungGC时,当RSet存在时,顺着引用链查找引用。如果引用链上出现了老年代对象,那么直接放弃查找这条引用链。当整个GC Root Tracing执行完毕后,就知道了除被跨代引用外还存活的新生代对象。紧接着再遍历新生代Region的RSet,如果RSet里存在key为老年代的Region,就将key对应的value代表的card的对象标记为存活,这样就标记到了被跨代引用的新生代对象。它可以使得垃圾收集器不需要扫描整个堆去找到谁的引用了当前分区对象,是G1高效回收的关键点。
当然这么做会存在一个问题,如果部分老年代对象是应该被回收的对象,但还是跨代引用了新生代,会导致原本应该被回收的新生代对象躲过本轮新生代回收。这部分对象就只能等到后续的老年代的垃圾回收mixed GC来回收掉。这也是为什么G1的回收精度比较低的原因之一。
以这幅图为例,region1和region2都引用了region3中的对象,那么region3的RSet中有两个key,分别是region1的起始地址和region2的起始地址。在扫描region3的RSet时,发现key为0x6a的region是一个old区region。如果这时第3,5card对应的对象没有被标记为可达,那么这里就会根据RSet再次标记。同样的,key为0x9b对应的region是一个young区域的region,那么0,2号card的对象则不会被标记。
事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。
Per Region Table (PRT)
RSet在内部使用Per Region Table(PRT)记录分区的引用情况。由于RSet的记录要占用分区的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:
稀少:直接记录引用对象的卡片索引
细粒度:记录引用对象的分区索引
粗粒度:只记录引用情况,每个分区对应一个比特位
由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。
RSet和卡表的区别是什么?
卡表记录的是堆内存中card有没有变成"dirty", 但是它本身不知道dirty里面哪些是引用了的对象,它是一个大维度的一个记录,RSet是记录自身Region中对象引用了其它Region中的那些对象,详细的记录对方引用对象信息,G1使用了两者的结合,实现了增量式的垃圾回收,并优化跨区引用的最终处理。详情可以继续看后文
堆Heap
G1同样可以通过-Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。
CSet
Collection SET用于记录可被回收分区的集合组, G1使用不同算法,动态的计算出那些分区是需要被回收的,将其放到CSet中,在CSet当中存活的数据都会在GC过程中拷贝到另一个可用分区,CSet可以是所有类型分区,它需要额外占用内存,堆空间的1%。
CSet收集示意图
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
年轻代收集集合 CSet of Young Collection
应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。
同时,年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中,再根据年龄表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio(默认50%)、最大任期阈值-XX:MaxTenuringThreshold(默认15),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
混合收集集合 CSet of Mixed Collection
年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。
为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。
Young GC流程
在了解了region的内部结构之后,我们再来看一下G1的young gc的具体流程。
stop the world,整个young gc的流程都是在stw里进行的,这也是为什么young gc能回收全部eden区域的原因。控制young gc开销的办法只有减少young region的个数,也就是减少年轻代内存的大小,还有就是并发,多个线程同时进行gc,尽量减少stw时间。
扫描GCRoots,注意这里扫描的GC Roots就是一般意义上的GC Roots,是扫描的直接指向young代的对象,那如果GC Root是直接指向老年代对象的,则会直接停止在这一步,也就是不往下扫描了。被老年代对象指向的young代对象会在接下来的利用Rset中key指向老年代的卡表识别出来,这样就避免了对老年代整个大的heap扫描,提高了效率。这也是为什么Rset能避免对老年代整体扫描的原因。
排空dirty card quene,更新Rset。Rset中记录了哪些对象被老年代跨带引用,也就是当新生代对象被老年代对象引用时,应该更新这个记录到RSet中。但更新RSet记录的时机不是伴随着引用更改马上发生的。每当老年代引用新生代对象时,这个引用记录对应的card地址其实会被放入Dirty Card Queue(线程私有的,当线程私有的dirty card queue满了之后会被转移到全局的dirty card queue,这个全局是唯一的),原因是如果每次更新引用时直接更新Rset会导致多线程竞争,因为赋值操作很频繁,影响性能。所以更新Rset交由Refinement线程来进行。全局DirtyCardQueue的容量变化分为4个阶段
- 白色:无事发生
- 绿色:Refinement线程被激活,-XX:G1ConcRefinementGreenZone=N指定的线程个数。从(全局和线程私有)队列中拿出dirty card。并更新到对应的Rset中。
- 黄色:产生dirty card的速度过快,激活全部的Refinement线程,通过参数-XX:G1ConcRefinementYellowZone=N 指定
- 红色:产生dirty card的速度过快,将应用线程也加入到排空队列的工作中。目的是把应用线程拖慢,减慢dirty card产生。
扫描Rset,扫描所有Rset中Old区到young区的引用。到这一步就确定出了young区域哪些对象是存活的。
拷贝对象到survivor区域或者晋升old区域。
处理引用队列,软引用,弱引用,虚引用
以上就是young gc的全部流程。
三色标记算法的漏标问题
知道了Young GC的流程后,接下来我们将学习G1针对老年代的垃圾回收过程Mixed GC,但是在正式开始介绍之前我们先讲解一下可达性分析算法的具体实现,三色标记算法。以及三色标记算法的缺陷以及G1是如何解决这个缺陷的。
在可达性分析的思想指导下,我们需要标记对象是否可达,那么我们采用将对象标记为不同的颜色来区分对象是否可达。可以理解如果一个对象能从GC Roots出发并且遍历到,那么对象就是可达的,这个过程我们称为检查。
- 白色:对象还没被检查。
- 灰色:对象被检查了,但是对象的成员Field(对象中引用的其他对象)还没有被检查。这说明这个对象是可达的。
- 黑色:对象被检查了,对象的成员Fileld也被检查了。
那么整个检测的过程,就是从GC Roots出发不断地遍历对象,并且将可达的对象标记成黑色的过程。当标记结束时,还是白色的对象就是没被遍历到的对象,即不可达的对象。
举个例子
第一轮检查,找到所有的GC Roots,GC Roots被标记为灰色,有的GC Roots因为没有成员Field则被标记为黑色。
第二轮检查,检查被GC Roots引用的对象,并标记为灰色
第三轮检查,循环之前的步骤,将被标记为灰色对象的子Field检查。因为这里就假设了3次循环检查的对象,所以是最后一次检查。这一路检查结束,还是白色的对象就是可以被回收的对象。即图例里的objectC
以上描述的是一轮三色标记算法的工作过程,但是这是一个理想情况。但是在标记过程中,标记的线程是和用户线程交替运行的,所以可能出现标记过程中引用发生变化的情况。
- 已经存在的对象被漏标:在第二轮检查到第三轮检查之间,假设发生了引用的变化,objectD不再被objectB引用,而是被objectA引用,而且此时ObjectA的成员已经被检查完毕了,objectB的成员Field还没被检查。这时,objectD就永远不会再被检查到。这就导致了漏标。
- 新产生的对象被漏标:这个对象被已经被标记为黑色的对象持有。比如图例中的newObjectF。因为黑色对象已经被认为是检查完毕了,所以新产生的对象不会再被检查,这也会导致漏标。
有两种被漏标的情况
已经存在的对象被漏标
即图例中被漏标的objectD,要漏标objectD,必须同时满足:
- 灰色对象不再指向白色对象,即objectB.d = null
- 黑色对象指向白色对象,即objectA.d = objectD
要解决漏标,只要打破这两个条件的任意一个即可。由此我们引出两个解决方案。原始快照和增量更新。
- 原始快照(Snapshot At The Beginning,简称SATB):
当任意的灰色对象到白色对象的引用被删除时,记录下这个被删除的引用,默认这个被删除的引用对象是存活的。这也可以理解为整个检查过程中的引用关系以检查刚开始的那一刻为准。 - 增量更新(Incremental Update):
当黑色对象被新增一个白色对象的引用的时候,记录下发生引用变更的黑色对象,并将它重新改变为灰色对象,重新标记。这是CMS采用的解决办法
在上面的两种解决方案里,我们发现,无论如何,都要记录下发生更改的引用。所以需要一种记录引用发生更改的手段,写屏障(write barrier)。写屏障是一种记录下引用发生变更的手段,效果类似AOP,但是其实现远比我们使用的AOP更加底层,可以认为是在JVM代码层面的一段代码。每当任意的引用变更时,就会触发这段代码,并记录下发生变更的引用。
新产生的对象被漏标
新产生的对象被漏标的解决方式则简单一些,在增量更新模式下,这个问题天生就被解决了。在SATB模式下,其实是在检查一开始就确定了一个检查范围,所以可以将新产生的对象放在检查范围之外,默认新产生的对象是存活的。当然这个过程得实际结合卡表来讲解才会更加具体形象。接下来在Mixed GC的过程里再细说。
SATB
Snapshot At The Beginning,G1在分配对象时,会在region中有2个top-at-mark-start(TAMS)指针,分别表示prevTAMS和nextTAMS。对应着卡表上即指向表示卡表范围的的两个编号,GC是分配在nextTAMS位置以上的对象都视为活着的,这是一种隐式的标记(这涉及到G1 MixedGC垃圾回收阶段的细节,很复杂,接下来会详细讨论)。这种解决漏标的方式是有缺陷的,它会造成真正应该被回收的白对象躲过这次GC生存到下一次GC,这就是float garbage(浮动垃圾)。因为SATB的做法精度比较低,所以造成float garbage的情况也会比较多。
为什么G1采用SATB而不用incremental update?
**SATB算法:**是一种基于快照的算法,它可以避免在垃圾回收时出现对象漏标或者重复标记的问题,从而提高垃圾回收的准确性和效率,在垃圾回收开始时,对堆中的对象引用进行快照,然后在并发标记阶段中记录下所有被修改过对象引用,保存到satb_mark_queue中,最后在重新标记阶段重新扫描这些对象,标记所有被修改的对象,保证了准确性和效率。
因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。
也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB配合RSet浑然天成
Mixed GC 流程
Mixed GC从步骤上可以分为两个大步骤,全局并发标记(global concurrent marking),拷贝存活对象(evacuation)。全局并发表的过程涉及到SATB的标记过程,我们将详细讲解。
全局并发标记(global concurrent marking)
G1收集器垃圾收集器的全局并发标记(global concurrent marking)分为多个阶段
初始标记(initial marking):
这个阶段会STW,标记从GC Root开始直接可达的对象,这一步伴随着young gc。之所以要young gc是为了处理跨代引用,老年代独享也可能被年轻代跨代引用,但是老年代不能使用RSet来解决跨代引用。还有就是young gc也会stw,在第一步young gc可以共用stw的时间,尽量减少stw时间。这一步还初始化了一些参数,将bottom指针赋值给prevTAMS指针,top指针赋值给nextTAMS指针,同时清空nextBitMap指针。因为之后的并发标记需要使用到这三个变量。top,prevTAMS,nextTAMS,top都是指向卡表的指针,他们的存在是为了标识哪些对象是可以被回收的,哪些是存活的,这就是SATB机制。而nextBitMap则是记录下卡表中哪些对象是存活的一个数组,当然现在还没开始检查,nextBitMap里的记录都是空。
根分区扫描(root region scan):
这个阶段在stw之后,会扫描survivor区域(survivor分区就是根分区),将所有被survivor区域对象引用的老年代对象标记。这也是上一步需要young gc的原因,处理跨代引用时需要知道哪些old区对象被S区对象引用。这个过程因为需要扫描survivor分区,所以不能发生young gc,如果扫描过程中新生代被耗尽,那么必须等待扫描结束才可以开始young gc。这一步耗时很短。并发标记(Concurrent Marking)
从GC Roots开始对堆中对象进行可达性分析,找出各个region的存活对象信息,耗时较长。粗略过程是这样的,但实际这一步的过程很复杂。因为要考虑在SATB机制之下,各个指针的变化。
假设在根分区扫描后没有引用的改变,那么一个region的分区状态和第一步init marking初始化完一致。此时如果再继续分配对象,那么对象会分配在nextTAMS之后,随着对象的分配,TOP指针会向后移动。
因为这一步是和mutator(用户线程)并发运行的,所以从根节点扫描的时候其实是扫描的一个快照snapshot,快照位置就是prevTAMS到nextTAMS(注意快照位置是不变的,但是prevTAMS到nextTAMS之间的对象在扫描过程中会改变)。
当region中分配新对象时,新对象都会分配在nextTAMS之后,这导致top指向的位置也往后移动,nextTAMS和top之间选哪个都是被认为隐式存活。
还有这期间也有可能应该被扫描的位置prevTAMS和nextTAMS之间的位置引用发生了变化,比如白色对象被黑色对象持有了,这就是三色标记算法的缺陷,需要更改白色对象的状态。这里会将引用被更改的对象放入satb_mark_queue。satb_mark_queue是一个队列,里面记录所有被改变引用关系的白色对象。这里指的satb_mark_queue指的全局的queue。除了全局的queue,每个线程也有自己的satb mark queue,全局的queue的引用是由所有其他线程的satb mark queue合并得来的,线程的satb mark queu满了会被转移到全局satb mark queue。且并发标记阶段会定期检查全局satb mark queue的容量,超过某个容量就concurrent marker线程就会将全局satb mark que和线程satb mark que的对象都取出来全部标记上,当然也会将这些对象的子field全部压栈(marking stack)等待接下来被标记到,这个处理类似于全局dirty card quene。这里注意。
随着并发标记结束nextBitMap里也标记了哪些对象是可以回收的,但注意,不一定每个线程里satb mark queue都被转移到了全局的satb mark queue,因为合并这个过程也是并发的。所以需要下一步最终标记(remark):
标记那些并发标记阶段发生变化的对象,就是将线程satb mark queue中引用发生更改的对象找出来,放入satb mark queue。这个阶段为了保证标记正确必须STW。清点垃圾(cleanup):
对各个region的回收价值和成本进行排序,根据用户期待的GC停顿时间指定回收计划,选中部分old region,和全部的young region,这些被选中的分区称为Collection Set(Cset),还会把没有任何对象的region加入到可用来分配对象的region集合中。注意这一步不是清除,是清点出哪些region值得回收,不会复制任何对象。清点执行完,一个全局并发标记周期基本就执行完了。这时还会将nextTAMS指针赋值给prevTAMS,且nextBitMap赋值给prevBitMap。这里是不是很奇怪为什么要记录本轮标记的结果到prevBitMap,难道下次再来检查本region时还可以再复用这个标记结果吗。
我们知道G1是可以根据内存的变化自己调整内存中E区,O区的容量的,如果其中某些分区容量增长比较快,说明这个分区的内存访问更频繁,在未来也可能更快地达到region的容量限制,那么下次复制转移时就会优先将这块region中的对象转移到更大的region中去。
拷贝存活对象evacuation
标记结束剩下的就是转移evacuation,拷贝存活对象。就是将活着的对象拷贝到空的region,再回收掉部分region。这一步是采用多线程复制清除,整个过程会STW。这也是G1的优势之一,只要还有一块空闲的region,就可以完成垃圾回收。而不用像CMS那样必须预留太多的内存。
G1 的活动周期
G1垃圾收集活动汇总
RSet的维护
由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)
栅栏Barrier
栅栏代码示意
栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。
写前栅栏 Pre-Write Barrrier
即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。
写后栅栏 Post-Write Barrrier
当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。
起始快照算法Snapshot at the beginning (SATB)
Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。
SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。
并发优化线程Concurrence Refinement Threads
G1中使用基于Urs Hölzle的快速写栅栏,将栅栏开销缩减到2个额外的指令。栅栏将会更新一个card table type的结构来跟踪代间引用。
当赋值语句发生后,写后栅栏会先通过G1的过滤技术判断是否是跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与SATB类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。
并发优化线程(Concurrence Refinement Threads),只专注扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads(默认等于-XX:ParellelGCThreads)设置。并发优化线程永远是活跃的,一旦发现全局列表有记录存在,就开始并发处理。如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1会用分层的方式调度,使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量,则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。
并发标记周期 Concurrent Marking Cycle
并发标记周期是G1中非常重要的阶段,这个阶段将会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。
当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,便会触发并发标记周期。整个并发标记周期将由初始标记(Initial Mark)、根分区扫描(Root Region Scanning)、并发标记(Concurrent Marking)、重新标记(Remark)、清除(Cleanup)几个阶段组成。其中,初始标记(随年轻代收集一起活动)、重新标记、清除是STW的,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。
并发标记线程 Concurrent Marking Threads
并发标记位图过程
要标记存活的对象,每个分区都需要创建位图(Bitmap)信息来存储标记数据,来确定标记周期内被分配的对象。G1采用了两个位图Previous Bitmap、Next Bitmap,来存储标记数据,Previous位图存储上次的标记数据,Next位图在标记周期内不断变化更新,同时Previous位图的标记数据也越来越过时,当标记周期结束后Next位图便替换Previous位图,成为上次标记的位图。同时,每个分区通过顶部开始标记(TAMS),来记录已标记过的内存范围。同样的,G1使用了两个顶部开始标记Previous TAMS(PTAMS)、Next TAMS(NTAMS),记录已标记的范围。
在并发标记阶段,G1会根据参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4),分配并发标记线程(Concurrent Marking Threads),进行标记活动。每个并发线程一次只扫描一个分区,并通过"手指"指针的方式优化获取分区。并发标记线程是爆发式的,在给定的时间段拼命干活,然后休息一段时间,再拼命干活。
每个并发标记周期,在初始标记STW的最后,G1会分配一个空的Next位图和一个指向分区顶部(Top)的NTAMS标记。Previous位图记录的上次标记数据,上次的标记位置,即PTAMS,在PTAMS与分区底部(Bottom)的范围内,所有的存活对象都已被标记。那么,在PTAMS与Top之间的对象都将是隐式存活(Implicitly Live)对象。在并发标记阶段,Next位图吸收了Previous位图的标记数据,同时每个分区都会有新的对象分配,则Top与NTAMS分离,前往更高的地址空间。在并发标记的一次标记中,并发标记线程将找出NTAMS与PTAMS之间的所有存活对象,将标记数据存储在Next位图中。同时,在NTAMS与Top之间的对象即成为已标记对象。如此不断地更新Next位图信息,并在清除阶段与Previous位图交换角色。
初始标记 Initial Mark
初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
根分区扫描 Root Region Scanning
在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
并发标记 Concurrent Marking
和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。
存活数据计算 Live Data Accounting
存活数据计算(Live Data Accounting)是标记操作的附加产物,只要一个对象被标记,同时会被计算字节数,并计入分区空间。只有NTAMS以下的对象会被标记和计算,在标记周期的最后,Next位图将被清空,等待下次标记周期。
重新标记 Remark
重新标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
清除 Cleanup
紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行以下操作:
RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。参数-XX:+PrintAdaptiveSizePolicy可以开启打印启发式算法决策细节;
整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;
识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。
年轻代收集/混合收集周期
年轻代收集和混合收集周期,是G1回收空间的主要活动。当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区,前文已述。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)。
GC工作线程数
GC工作线程数 -XX:ParallelGCThreads
JVM可以通过参数-XX:ParallelGCThreads进行指定GC工作的线程数量。参数-XX:ParallelGCThreads默认值并不是固定的,而是根据当前的CPU资源进行计算。如果用户没有指定,且CPU小于等于8,则默认与CPU核数相等;若CPU大于8,则默认JVM会经过计算得到一个小于CPU核数的线程数;当然也可以人工指定与CPU核数相等。
年轻代收集 Young Collection
每次收集过程中,既有并行执行的活动,也有串行执行的活动,但都可以是多线程的。在并行执行的任务中,如果某个任务过重,会导致其他线程在等待某项任务的处理,需要对这些地方进行优化。
并行活动
外部根分区扫描 Ext Root Scanning:此活动对堆外的根(JVM系统目录、VM数据结构、JNI线程句柄、硬件寄存器、全局变量、线程对栈根)进行扫描,发现那些没有加入到暂停收集集合CSet中的对象。如果系统目录(单根)拥有大量加载的类,最终可能其他并行活动结束后,该活动依然没有结束而带来的等待时间。
更新已记忆集合 Update RS:并发优化线程会对脏卡片的分区进行扫描更新日志缓冲区来更新RSet,但只会处理全局缓冲列表。作为补充,所有被记录但是还没有被优化线程处理的剩余缓冲区,会在该阶段处理,变成已处理缓冲区(Processed Buffers)。为了限制花在更新RSet的时间,可以设置暂停占用百分比-XX:G1RSetUpdatingPauseTimePercent(默认10%,即-XX:MaxGCPauseMills/10)。值得注意的是,如果更新日志缓冲区更新的任务不降低,单纯地减少RSet的更新时间,会导致暂停中被处理的缓冲区减少,将日志缓冲区更新工作推到并发优化线程上,从而增加对Java应用线程资源的争夺。
RSet扫描 Scan RS:在收集当前CSet之前,考虑到分区外的引用,必须扫描CSet分区的RSet。如果RSet发生粗化,则会增加RSet的扫描时间。开启诊断模式-XX:UnlockDiagnosticVMOptions后,通过参数-XX:+G1SummarizeRSetStats可以确定并发优化线程是否能够及时处理更新日志缓冲区,并提供更多的信息,来帮助为RSet粗化总数提供窗口。参数-XX:G1SummarizeRSetStatsPeriod=n可设置RSet的统计周期,即经历多少此GC后进行一次统计
代码根扫描 Code Root Scanning:对代码根集合进行扫描,扫描JVM编译后代码Native Method的引用信息(nmethod扫描),进行RSet扫描。事实上,只有CSet分区中的RSet有强代码根时,才会做nmethod扫描,查找对CSet的引用。
转移和回收 Object Copy:通过选定的CSet以及CSet分区完整的引用集,将执行暂停时间的主要部分:CSet分区存活对象的转移、CSet分区空间的回收。通过工作窃取机制来负载均衡地选定复制对象的线程,并且复制和扫描对象被转移的存活对象将拷贝到每个GC线程分配缓冲区GCLAB。G1会通过计算,预测分区复制所花费的时间,从而调整年轻代的尺寸。
终止 Termination:完成上述任务后,如果任务队列已空,则工作线程会发起终止要求。如果还有其他线程继续工作,空闲的线程会通过工作窃取机制尝试帮助其他线程处理。而单独执行根分区扫描的线程,如果任务过重,最终会晚于终止。
GC外部的并行活动 GC Worker Other:该部分并非GC的活动,而是JVM的活动导致占用了GC暂停时间(例如JNI编译)。
串行活动
代码根更新 Code Root Fixup:根据转移对象更新代码根。
代码根清理 Code Root Purge:清理代码根集合表。
清除全局卡片标记 Clear CT:在任意收集周期会扫描CSet与RSet记录的PRT,扫描时会在全局卡片表中进行标记,防止重复扫描。在收集周期的最后将会清除全局卡片表中的已扫描标志。
选择下次收集集合 Choose CSet:该部分主要用于并发标记周期后的年轻代收集、以及混合收集中,在这些收集过程中,由于有老年代候选分区的加入,往往需要对下次收集的范围做出界定;但单纯的年轻代收集中,所有收集的分区都会被收集,不存在选择。
引用处理 Ref Proc:主要针对软引用、弱引用、虚引用、final引用、JNI引用。当Ref Proc占用时间过多时,可选择使用参数-XX:ParallelRefProcEnabled激活多线程引用处理。G1希望应用能小心使用软引用,因为软引用会一直占据内存空间直到空间耗尽时被Full GC回收掉;即使未发生Full GC,软引用对内存的占用,也会导致GC次数的增加。
引用排队 Ref Enq:此项活动可能会导致RSet的更新,此时会通过记录日志,将关联的卡片标记为脏卡片。
卡片重新脏化 Redirty Cards:重新脏化卡片。
回收空闲巨型分区 Humongous Reclaim:G1做了一个优化:通过查看所有根对象以及年轻代分区的RSet,如果确定RSet中巨型对象没有任何引用,则说明G1发现了一个不可达的巨型对象,该对象分区会被回收。
释放分区 Free CSet:回收CSet分区的所有空间,并加入到空闲分区中。
其他活动 Other:GC中可能还会经历其他耗时很小的活动,如修复JNI句柄等。
并发标记周期后的年轻代收集 Young Collection Following Concurrent Marking Cycle
当G1发起并发标记周期之后,并不会马上开始混合收集。G1会先等待下一次年轻代收集,然后在该收集阶段中,确定下次混合收集的CSet(Choose CSet)。
混合收集周期 Mixed Collection Cycle
单次的混合收集与年轻代收集并无二致。根据暂停目标,老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。
转移失败的担保机制 Full GC
转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。
G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:
从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
从老年代分区转移存活对象时,无法找到可用的空闲分区
分配巨型对象时在老年代无法找到足够的连续分区
由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生
使用场景及优缺点
根据经验,在大部分的大型内存(6G以上)服务器上,无论是吞吐量还是STW时间,G1的性能都是要优于CMS。
优点:并行与并发收集,分代分区收集,优先垃圾收集,空间整合,可控或者可预测停顿时间。
缺点:
收集中产生内存,G1的每个region都需要有一份记忆集和卡表记录跨代指针,这导致记忆集可能占用堆空间10-20%甚至更多空间。
执行过程中额外负载开销加大,写屏障进行维护卡表操作外,还需要原始快照能够减少并发标记和重新标记阶段的消耗,避免最终标记阶段停顿过长,运行过程中会产生由跟踪引用变化带来的额外开销负担,比CMS增量算法消耗更多,CMS的写屏障实现直接是同步操作, 而G1是把写屏障和写后屏障中要做的事情放到队列里异步处理。
G1对于Full GC是没有处理流程, 一旦发生Full GC G1的回收执行的是单线程的Serial回收器进行回收。
注意点
G1一定不会产生内存碎片吗
堆内存的动态变化、分配模式以及回收行为等因素影响下,仍然可能出现一些碎片问题。当某些Region中存在多个不连续的小块空闲内存,无法完全满足某些大对象的内存需求时,仍然可以称之为碎片问题。
- 分配模式不规律: 如果应用程序的内存分配模式不规律,频繁地分配和释放不同大小的对象,可能会导致一些小的空闲内存碎片在堆中产生。
- 大对象分配: G1回收器的区域被划分为不同大小的Region,当一个大对象无法在单个Region中分配时,G1可能会在多个Region中分配这个大对象,这可能导致跨多个Region的碎片。
- 并发情况下的内存变化: G1回收器会在后台进行并发的垃圾回收,如果在回收过程中发生了内存变化,如某个区域中的对象被回收,留下一些零散的空闲空间,也有可能会导致内存碎片。
- 频繁的Full GC: 尽管G1垃圾回收器的设计可以减少Full GC(全局垃圾回收)的频率,但如果频繁发生Full GC,可能会导致内存布局的重组,产生一些碎片。