1.概述
1.2 垃圾收集器的考量指标
垃圾收集器也有类似CAP理论的矛盾,具体如下面三个考量指标:
吞吐量(Throughput)
响应能力、延迟(Latency)
占用的内存(Capacity)
上面三个考量指标无法同时全部满足最优,只能满足其中的两个,而牺牲其中一个的部分效率。
1.3 吞吐量
吞吐量关注的是,在一个指定的时间范围内,最大化一个应用的工作量。
如下方式来衡量一个系统吞吐量的好坏:
在一个小时内,同一个事务(任务或请求)完成的次数。
数据库一小时内完成多少次查询。
对于关注吞吐量的系统,偶尔卡顿是可以接受的,因为这个系统关注的是长时间大量任务的执行能力,单次快速的响应并不值得考虑。
1.4 响应能力
响应能力是一个应用或者一个系统是否能够及时快速的响应,比如:
一个桌面的UI程序能多快的响应一个事件。
一个网站能多快的返回一个页面请求。
一个数据库能多快的返回一个查询结果。
对于这类响应能力敏感,追求低延迟的场景,长时间的卡顿是不能忍受的。
内存的占用率
为了加快内存扫描的数据,GC垃圾收集器通常会在内存中使用一些数据结构如卡表、记忆集等来存储对象直接的引用,而这些数据本身是需要占用堆的内存空间的。记录的信息越多,扫描时就会越快,同时也越占内存。
1.5 G1的诞生背景
随着硬件的成本越来越低,机器的内存也越来越大,GC收集器占用的内存基本上可以容忍,而吞吐量可以通过集群(增加机器)来解决,所以STW的时间成为JVM急迫解决的问题,如果还是按照传统的分代模型,使用传统的垃圾收集器,那么STW的时间将会越来越长。
在传统的垃圾收集器中,STW的时间是无法预测的,有没有一种办法,能够首先定义一个停顿时间,然后反向推算收集的内容呢?就像是领导在年初制定KPI一样,分配的任务多就多干些,分配的任务少就少干点。
G1的思路说起来也类似,它不要求每次都把垃圾清理的干干净净,它只是努力做它认为对的事情。
G1还有一个及其重要的特性:软实时(soft real-time)。所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。
我们要求G1,在任意1秒的时间内,停顿不得超过10ms,这就是在给它制定KPI。G1会尽量达成这个目标,它能够反向推算出本次要收集的大体区域,以增量的方式完成收集。
这也是使用G1垃圾回收器(-XX:+UseG1GC)不得不设置的一个参数:-XX:MaxGCPauseMillis=10,这个参数默认为200ms。
G1的适用场景:
服务端多核CPU、JVM内存占用较大的应用。
应用程序在运行过程中会产生大量的内存碎片,需要经常压缩空间。
想要更可控、可预期的GC停顿周期,防止高并发下的应用雪崩现象。
2. G1的内存模型
2.1 分区概念
2.1.1 分区Region
G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。
而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的进行回收大多数情况下都把Humongous Region作为老年代的一部分来进行看待。
G1在逻辑上还是划分Eden(所有Eden region之和)、Survivor(所有Survivor region之和)、OLd(所有Old region之和),但是物理上他们不是连续的,而且同一块region在不同时期的角色可以是不一样的,有可能现在这块region存放的是Eden对象,进行一个YGC之后,下一次分配在上面的对象可能是Old对象。
2.1.2 卡片Card
卡表是为了解决跨代引用而诞生的,假设在CMS垃圾收集器中,要回收新生代的对象,那么就必须扫描整个老年代,而引入卡表,就只需要扫描卡表中Dirty区域即可,这样避免了扫描整个老年代,大大加快了并发标记的速度。
每一个Region,又被分成了固定大小的若干张卡(Card)每一张卡,都用一个Byte来记录是否修改过。卡表即这些byte的集合,标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
2.1.3 记忆集RSet
RS(Remember Set)是一种抽象概念,用于记录从非收集部分指向收集部分的指针的集合。
在传统的分代垃圾回收算法里面,RS(Remember Set)被用来记录分代之间的指针。在G1回收器里面,RS被用来记录从其他Region指向一个Region的指针情况。因此,一个Region就会有一个RS。这种记录可以带来一个极大的好处:在回收一个Region的时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用,而这些引用就是initial mark的根之一。
RSet底层使用HashTable实现(可以这么理解,其实这只是一个抽象概念),key为引用对象所在Region的内存起始地址,value为引用对象所在Card Table的index。
同样假设现在Region A中的对象第4块引用了Region B中的对象,那么将Region A的卡表的第3个bit为置为Dirty(1),然后将Region B中的RSet中增加一条记录,其中key为Region A的内存起始地址,value为Region A的对象所在卡表中的索引3,这样在回收Region B时,只需要将Region B的RSet作为GC ROOTS对象扫描即可。
RSet的更新并不是同步完成的,G1会把所有的引用关系都先放入到一个队列中,称为Dirty Card Queue(DCQ),然后使用单独的线程消费这个队列来完成更新,这么做是因为对象的引用变更太频繁,使用队列进行异步削峰,可以使用参数-XX:G1ConcRefinementThreads 这个参数来指定消费线程的数目。
使用空间来换时间,用额外的空间来维护引用信息,通常需要消耗5%~10%的空间。
写屏障(write barrier):当对象的引用发生变化时,插入一个写屏障来维护RSet。这个写屏障与Java内存模型中的写屏障不是一个概念。
事实上,并非所有的引用都需要记录在RSet中,如果一个分区确定需要扫描,那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象,当然不用落入RSet中;同时,G1 GC每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区(an RSet’s owning region)。
在RS的修改上也会遇到并发的问题。因为一个Region可能有多个线程在并发修改,因此它们也会并发修改RS。为了避免这样一种冲突,G1垃圾回收器进一步把RS划分成了多个哈希表。每一个线程都在各自的哈希表里面修改。最终,从逻辑上来说,RS就是这些哈希表的集合。哈希表是实现RS的一种通常的方式之一。它有一个极大的好处就是能够去除重复。这意味着,RS的大小将和修改的指针数量相当。而在不去重的情况下,RS的数量和写操作的数量相当。
图中RS的虚线表名的是,RS并不是一个和Card Table独立的,不同的数据结构,而是指RS是一个概念模型。实际上,Card Table是RS的一种实现方式。
链接:https://www.jianshu.com/p/aef0f4765098
2.1.4 Per Region Table
RSet在内部使用Per Region Table(PRT)记录分区的引用情况。由于RSet的记录要占用分区的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:
- 稀少:直接记录引用对象的卡片索引
- 细粒度:记录引用对象的分区索引
- 粗粒度:只记录引用情况,每个分区对应一个比特位
由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。
2.1.5 堆Heap
G1同样可以通过-Xms/-Xmx来指定堆空间大小。当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比,G1默认为9,而CMS默认为99,因为CMS的设计原则是耗费在GC上的时间尽可能的少。另外,当空间不足,如对象空间分配或转移失败时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。
2.2 分代模型
2.2.1 分代
分代垃圾收集可以将关注点集中在最近被分配的对象上,而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。虽然分区使得内存分配不再要求紧凑的内存空间,但G1依然使用了分代的思想。与其他垃圾收集器类似,G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间。但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。
整个年轻代内存会在初始空间-XX:G1NewSizePercent(默认整堆5%)与最大空间(默认60%)之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis(默认200ms)、需要扩缩容的大小以-XX:G1MaxNewSizePercent及分区的已记忆集合(RSet)计算得到。当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义。
2.2.2 本地分配缓冲
本地分配缓冲 Local allocation buffer (Lab)
值得注意的是,由于分区的思想,每个线程均可以"认领"某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。
其中,应用线程可以独占一个本地缓冲区(TLAB)来创建的对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;而每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分称为晋升本地缓冲区(PLAB)。
2.3 分区模型
G1对内存的使用以分区(Region)为单位,而对对象的分配则以卡片(Card)为单位。
2.3.1 巨形对象Humongous Region
一个大小达到甚至超过分区大小一半的对象称为巨型对象(Humongous Object)。当线程为巨型分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。
巨型对象会独占一个、或多个连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
2.4 收集集合 (CSet)
收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。
候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。
- 在fully-young generational mode下:顾名思义,该模式下CSet将只包含young的Region。G1将调整young的Region的数量来匹配软实时的目标;
- 在partially-young mode下:该模式会选择所有的young region,并且选择一部分的old region。old region的选择将依据在Marking cycle phase中对存活对象的计数。G1选择存活对象最少的Region进行回收。
由上述可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
2.4.1 年轻代收集集合 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),计算出一个恰当的任期阈值,凡是超过任期阈值的对象都会被晋升到老年代。
2.4.2 混合收集集合 CSet of Mixed Collection
年轻代收集不断活动后,老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时,G1就会启动一次混合垃圾收集周期。为了满足暂停目标,G1可能不能一口气将所有的候选分区收集掉,因此G1可能会产生连续多次的混合收集与应用线程交替执行,每次STW的混合收集与年轻代收集过程相类似。
为了确定包含到年轻代收集集合CSet的老年代分区,JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数,确定每次包含到CSet的最小分区数量;根据堆废物百分比,当收集达到参数时,不再启动新的混合收集。而每次添加到CSet的分区,则通过计算得到的GC效率进行安排。
2.4.3 并发标记算法(三色标记法)
CMS和G1在并发标记时使用的是同一个算法:三色标记法,使用白灰黑三种颜色标记对象。白色是未标记;灰色自身被标记,引用的对象未标记;黑色自身与引用对象都已标记。
2.4.5 漏标问题
在remark过程中,黑色指向了白色,如果不对黑色重新扫描,则会漏标。会把白色D对象当作没有新引用指向从而回收掉。
并发标记过程中,Mutator删除了所有从灰色到白色的引用,会产生漏标。此时白色对象应该被回收
产生漏标问题的条件有两个:
1.黑色对象指向了白色对象
2.灰色对象指向白色对象的引用消失
所以要解决漏标问题,打破两个条件之一即可:
- 跟踪黑指向白的增加
incremental update:增量更新,关注引用的增加,把黑色重新标记为灰色,下次重新扫描属性。CMS采用该方法。 - 记录灰指向白的消失
SATB snapshot at the beginning:关注引用的删除,当灰–>白消失时,要把这个 引用 推到GC的堆栈,保证白还能被GC扫描到。G1采用该方法。
为什么G1采用SATB而不用incremental update?
因为采用incremental update把黑色重新标记为灰色后,之前扫描过的还要再扫描一遍,效率太低。
G1有RSet与SATB相配合。Card Table里记录了RSet,RSet里记录了其他对象指向自己的引用,这样就不需要再扫描其他区域,只要扫描RSet就可以了。
也就是说 灰色–>白色 引用消失时,如果没有 黑色–>白色,引用会被push到堆栈,下次扫描时拿到这个引用,由于有RSet的存在,不需要扫描整个堆去查找指向白色的引用,效率比较高。SATB配合RSet浑然天成。
3. G1的活动周期
3.1 G1垃圾收集活动汇总
3.2 RSet的维护
由于不能整堆扫描,又需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来源两个方面:写栅栏(Write Barrier)和并发优化线程(Concurrence Refinement Threads)
3.2.1 栅栏Barrier
我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行。而G1主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上,写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。
写前栅栏 Pre-Write Barrrier
即将执行一段赋值语句时,等式左侧对象将修改引用到另一个对象,那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用,那么JVM就需要在赋值语句生效之前,记录丧失引用的对象。JVM并不会立即维护RSet,而是通过批量处理,在将来RSet更新(见SATB)。
写后栅栏 Post-Write Barrrier
当执行一段赋值语句后,等式右侧对象获取了左侧对象的引用,那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销,写后栅栏发生后,RSet也不会立即更新,同样只是记录此次更新日志,在将来批量处理(见Concurrence Refinement Threads)。
3.2.2 起始快照算法Snapshot at the beginning (SATB)
Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB),主要针对标记-清除垃圾收集器的并发标记阶段,非常适合G1的分区块的堆结构,同时解决了CMS的主要烦恼:重新标记暂停时间长带来的潜在风险。
SATB会创建一个对象图,相当于堆的逻辑快照,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区,初始有256条记录空间。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程(Concurrent Marking Threads)在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。
在并发标记阶段,应用可能修改了原本的引用,比如删除了一个原本的引用。这就会导致并发标记结束之后的存活对象的快照和SATB不一致。G1是通过在并发标记阶段引入一个写屏障来解决这个问题的:每当存在引用更新的情况,G1会将修改之前的值写入一个log buffer(这个记录会过滤掉原本是空引用的情况),在最终标记(final marking phase)阶段扫描SATB,修正SATB的误差。
SATB的log buffer如RS的写屏障使用的log buffer一样,都是两级结构,作用机制也是一样的。
作者:flycash
链接:https://www.jianshu.com/p/aef0f4765098
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
3.2.3 并发优化线程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应用线程)会挂起应用并被加进来帮助处理,直到全部处理完。因此,必须避免此类场景出现。
3.2.4 Marking bitmaps和TAMS
Marking bitmap是一种数据结构,其中的每一个bit代表的是一个可用于分配给对象的起始地址。举例来说:
bitmap
其中addrN代表的是一个对象的起始地址。绿色的块代表的是在该起始地址处的对象是存活对象,而其余白色的块则代表了垃圾对象。
G1使用了两个bitmap,一个叫做previous bitmap,另外一个叫做next bitmap。previous bitmap记录的是上一次的标记阶段完成之后的构造的bitmap;next bitmap则是当前正在标记阶段正在构造的bitmap。在当前标记阶段结束之后,当前标记的next bitmap就变成了下一次标记阶段的previous bitmap。
TAMS(top at mark start)变量,是一对用于区分在标记阶段新分配对象的变量,分别被称为previous TAMS和next TAMS。在previous TAMS和next TAMS之间的对象则是本次标记阶段时候新分配的对象。如图:
previous TMAS 和 next TAMS
白色region代表的是空闲空间,绿色region代表是存活对象,橙色region代表的在此次标记阶段新分配的对象。注意的是,在橙色区域的对象,并不能确保它们都事实上是存活的。
4.算法详解
整个算法可以分成两大部分:
- Marking cycle phase:标记阶段,该阶段是不断循环进行的;
- Evacuation phase:该阶段是负责把一部分region的活对象拷贝到空Region里面去,然后回收原本的Region空间,该阶段是STW(stop-the-world)的;
而算法也可以分成两种模式:
- fully-young generational mode:有时候也会被称为young GC,该模式只会回收young region,算法是通过调整young region的数量来达到软实时目标的;
- partially-young mode:也被称为Mixed GC,该阶段会回收young region和old region,算法通过调整old region的数量来达到软实时目标;
年轻代收集和混合收集周期,是G1回收空间的主要活动。当应用运行开始时,堆内存可用空间还比较大,只会在年轻代满时,触发年轻代收集;随着老年代内存增长,当到达IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1开始着手准备收集老年代空间。首先经历并发标记周期,识别出高收益的老年代分区,前文已述。但随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)。
单次的混合收集与年轻代收集并无二致。根据暂停目标,老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。
有趣的地方是不论处在何种模式之下,yong region都在被回收的范围内。而old region只能期望于Mixed GC。但是,如同在CMS垃圾回收器中遇到的困境一样,Mixed GC可能来不及回收old region。也就说,在需要分配老年代的对象的时候,并没有足够的空间。这个时候就只能触发一次full GC。
算法会自动在young GC和mixed GC之间切换,并且定期触发Marking cycle phase。HotSpot的G1实现允许指定一个参数InitiatingHeapOccupancyPercent,在达到该参数的情况下,就会执行marking cycle phase。
算法并不使用在对象头增加字段来标记该对象,而是采用bitmap的方式来记录一个对象被标记的情况。这种记录方法的好处就是在使用这些标记信息的时候,仅仅需要扫描bitmap而已。G1统计一个region的存活的对象,就是依赖于bitmap的标记。
Marking Cycle Phase
算法的Marking cycle phase大概可以分成五个阶段:
- Initial marking phase:G1收集器扫描所有的根。该过程是和young GC的暂停过程一起的;
- Root region scanning phase:扫描Survivor Regions中指向老年代的被initial mark phase标记的引用及引用的对象,这一个过程是并发进行的。但是该过程要在下一个young GC开始之前结束;
- Concurrent marking phase:并发标记阶段,标记整个堆的存活对象。该过程可以被young GC所打断。并发阶段产生的新的引用(或者引用的更新)会被SATB的write barrier记录下来;
- Remark phase:也叫final marking phase。该阶段只需要扫描SATB(Snapshot At The Beginning)的buffer,处理在并发阶段产生的新的存活对象的引用。作为对比,CMS的remark需要扫描整个mod union table的标记为dirty的entry以及全部根;
- Cleanup phase:清理阶段。该阶段会计算每一个region里面存活的对象,并把完全没有存活对象的Region直接放到空闲列表中。在该阶段还会重置Remember Set。该阶段在计算Region中存活对象的时候,是STW(Stop-the-world)的,而在重置Remember Set的时候,却是可以并行的
4.1 初始标记 Initial Mark
初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集(young gc),利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
这里的young GC实际上是指的就是fully-young generational mode。
4.2 根分区扫描 Root Region Scanning
在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
该过程主要是扫描Survivor region中指向老年代的,在initial mark phase标记的引用及其引用的对象。这是一个很奇怪的步骤,因为在前面不论是Parallel Collector还是CMS,都没有这么一个步骤。
要理解这一点,要注意的是,算法的两种模式,不论是young GC还是mixed GC,都需要回收young region。因为实际上RS是不记录从young region出发的指针,例如,这部分指针包括young region - young region,也包括young-region - old region指针。那么就可能出现一种情况,一个老年代的存活对象,只被年轻代的对象引用。在一次young GC中,这些存活的年轻代的对象会被复制到Survivor Region,因此需要扫描这些Survivor region来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年代的根的一部分。
在理解了这一点的基础上,那么对于阶段必须在下一次young GC启动前完成的要求,也就理解了。因为如果第二次的young GC启动了,那么这个过程中,survivor region就可能发生变化。这个时候执行root region phase就会产生错误的结果。
4.3 并发标记 Concurrent Marking
和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。在这一阶段会处理Previous/Next标记位图,扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。
要标记存活的对象,每个分区都需要创建位图(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位图交换角色。
该图的详细解释如下:
- A是第一次marking cycle的initial marking阶段。next bitmap尚未标记任何存活对象,而此时的previous TAMS被初始化为region内存地址起始值,next TAMS被初始化为top。top实际上就是一个region未分配区域和已分配区域的分界点;
- B是经过concurrent marking阶段之后,进入了remark阶段。此时存活对象的扫描已经完成了,因此next bitmap构造好了,刚好代表的是当下状态中region中的内存使用情况。注意的是,此时top已经不再与next TAMS重合了,top和next TAMS之间的就是在前面标记阶段之时,新分配的对象;
- C代表的是clean up阶段。C和B比起来,next bitmap变成了previous bitmap,而在bitmap中标记为垃圾(也就是白色区域的)的对应的region的区域也被染成了浅灰色。这并不是指垃圾对象已经被清扫了,仅仅是标记出来了。同时next TAMS和previous TAMS也交换了角色;
- D代表的是下一个marking cycle的initial marking阶段,该阶段和A类似,next TAMS重新被初始化为top的值;
- EF就是BC的重复;
4.4 重新标记 Remark
重新标记(Remark)是最后一个标记阶段。在该阶段中,G1需要一个暂停的时间,去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
引入该阶段的目的,是为了能够达到结束标记的目标。要结束标记的过程,要满足三个条件:
- concurrent marking已经追踪了所有的存活对象;
- marking stack是空的;
- 所有的log都被处理了;
前两个条件是很容易达到的,但是最后一个是很困难的。如果不引入一个STW的remark过程,那么应用会不断的更新引用,也就是说,会不断的产生log,因而永远也无法达成完成标记的条件。
4.5 清除 Cleanup
紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行以下操作:
- RSet梳理,启发式算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。参数-XX:+PrintAdaptiveSizePolicy可以开启打印启发式算法决策细节;
- 整理堆分区,为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;
- 识别所有空闲分区,即发现无存活对象的分区。该分区可在清除阶段直接回收,无需等待下次收集周期。
- 把空闲region放到空闲region列表中;
- 存活数据计算(Live Data Accounting)是标记操作的附加产物,只要一个对象被标记,同时会被计算字节数,并计入分区空间。只有NTAMS以下的对象会被标记和计算,在标记周期的最后,Next位图将被清空,等待下次标记周期。
该阶段比较容易引起误解地方在于,Clean up并不会清理垃圾对象,也不会执行存活对象的拷贝。也就是说,在极端情况下,该阶段结束之后,空闲Region列表将毫无变化,JVM的内存使用情况也毫无变化。
Evacuation
Evacuation阶段STW的,大概可以分成两个步骤:第一个步骤是从Region中选出若干个Region进行回收,这些被选中的Region称为Collect Set(简称CSet);而第二个步骤则是把这些Region中存活的对象复制到空闲的Region中去,同时把这些已经被回收的Region放到空闲Region列表中。
这两个步骤又可以被分解成三个任务:
- 根据RS的日志更新RS:只有在处理完了RS的日志之后,RS才能够保证是准确的,完整的,这也是Evacuation是STW的重要原因;
- 扫描RS和其余的根来确定存活对象:该阶段实际上最主要依赖于RS;
- 拷贝存活对象:该阶段只要从2中确定的根触发,沿着引用链一直追溯下去,将存活对象复制到新的region就可以。这个过程中,可能有一部分的年轻代对象会被提升到老年代;
Evacuation的时机
Evacuation的触发时机在不同的模式下会有一些不同。在不同的模式下都相同的是,只要堆的使用率达到了某个阈值,就必然会触发Evacuation。这是为了确保在Evacuation的时候有足够的空闲Region来容纳存活对象。
在young GC的情况下,G1会选择N个region作为CSet,该CSet首先需要满足软实时的要求,而一旦已经有N个region已经被分配了,那么就会执行一次Evacuation。
G1会尽可能的执行mixed GC。唯一的限制就是mix GC也需要满足软实时的要求。
G1触发Evacuation的原则大概是:
- 如果被分配的young region数量满足young GC的要求,那么就会触发young GC;
- 如果被分配的young region数量不满足young GC,就会进一步考察加上old region的数量,能否满足old GC的要求;
为了理解这一点,可以举例来说,假如回收一个old region的时间是回收一个young region的两倍,也就是young region花费时间T,old region花费2T,在满足软实时目标的情况下,GC只能回收8T的region,那么:
- 假如应用现在只分配k(k<8)块young region,没有分配任何old region。这个时候又分配了一个old region,那么这个时候会立刻触发一次mixed GC,此次GC会选择k块young region和一块old region;
- 因此,在这种假设下,只要有可以回收的old region的时候,总是会先回收old region;
- 在没有任何old region的情况下,才有可能触发young region。
转移失败的担保机制 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的发生。
原文链接:https://blog.csdn.net/u022812849/article/details/113822908