JVM–基础–19.4–垃圾收集器–G1
1、结构图
2、介绍
- 专为服务器级应用设计,主要面向多核、大内存的服务器环境
- 用于替代CMS
- 是一个高性能、低停顿(兼顾良好的吞吐量),可预测停顿时间的垃圾收集器
- G1收集器引入了记忆集(Remembered Set)和卡表(Card Table)等机制,用于记录跨代引用,进一步提高了垃圾收集的效率。
2.1、开启G1
- JDK9中:默认开启
- JDK8中:使用参数
-XX:+UseG1GC
来开启。
2.2、怎么回收垃圾
将Java堆划分为多个(Region),并根据每个区域中以下这2个选项来优先进行垃圾回收。
- 垃圾对象的数量
- 垃圾对象的大小
2.3、特点
2.3.1、不会产生内存空间碎片
- 从整体来看是基于
标记整理算法
实现 - 从局部来看是基于
复制算法
实现。
2.3.2、可预测的停顿时间模型(停顿时间模型)
停顿时间模型的意思:能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
3、基于Region的内存模型:是建立【停顿时间模型】的前提
- 堆被分成一块块大小相等的区域(Region),一般有2048多块,这些Region在逻辑上是连续的,但在物理内存地址上可能并不连续
- 每一个Region都有对应一个记忆集(Remembered Set,避免全堆扫描,记录每个对象是否可达)
- 每一个Region的大小:通过参数
-XX:G1HeapRegionSize
设定,范围允许为 1Mb 到 32Mb,且是2的指数倍 - 每个Region可以根据需要被标记为不同的类型,收集器能够对不同类型的Region采用不同的策略去处理。类型包含如下
- Eden
- Survivor
- Old
- Humongous
3.1、Region 类型
3.1.1、Eden区
- 新创建的对象首先会被分配到Eden区。
- 当Eden区满时,会触发一次年轻代垃圾收集(Young GC),存活的对象会被复制到Survivor区。
3.1.2、Survivor区
- 用于保存从Eden区经过一次或多次垃圾收集后仍然存活的对象。
- 可用两个Survivor区实现对象的复制和交换。
- Survivor区 的数量 由G1来决定
3.1.3、Old区:老年代
- 用于存储长时间存活的对象。
- 当对象在Survivor区中经过一定次数的垃圾收集后仍然存活,它们会被晋升到老年代。
3.1.4、Humongous区
- 专门用来存储大对象,可以简单理解为对应着老年代。
- 当对象的大小超过Region大小的一半时,它会被认为是大型对象(Humongous Object),并直接在Humongous区中分配。这样做的好处是减少了因为大对象移动而带来的开销,同时也避免了因为大对象导致的内存碎片问题。
- 对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中。
- G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待。分配大对象的时候,因为占用空间太大,可能会过早发生GC停顿。G1在每次分配大对象的时候都会去检查老年代Java堆占用率是查过IHOP(默认45%),当老年代的空间超过45%,G1会启动一次MIXGC(混合GC)
3.2、优点
这种内存结构,垃圾收集时更加高效和灵活,可以根据不同区域的特点采用不同的收集策略,从而实现在保证吞吐量的同时,降低垃圾收集带来的停顿时间。
基于Region的堆内存布局,是建立起停顿时间模型的关键。
4、可预测的停顿时间模型(停顿时间模型)
基于Region的内存模型是建立【可预测的停顿时间模型】的前提。
4.1、单次垃圾回收的最小单元
将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍
4.2、优先级列表
G1收集器会去跟踪各个Region里面的垃圾堆积的价值大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表。
每次根据用户设定的 垃圾收集的最大停顿时间(-XX:MaxGCPauseMillis指定,默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是「Garbage First」名字的由来。
4.3、垃圾收集的最大停顿时间-XX:MaxGCPauseMillis
在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。
然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过MaxGCPauseMillis
的约束下获得最高的收益。
G1收集器会根据这个设定值进行自我调整以尽量达到这个暂停时间目标。例如,如果设定了-XX:MaxGCPauseMillis=200
,那么JVM会尽力保证大部分(但并非全部)的GC暂停时间不会超过200毫秒。
一般MaxGCPauseMillis设置为一两百毫秒或者两三百毫秒会是比较合理的。
4.3.1、MaxGCPauseMillis 过大
"Stop The World"就大,对用户线程影响不好
4.3.2、MaxGCPauseMillis 过小
很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。
应用运行时间一长,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。
4.4、总结
这种使用Region划分内存空间,以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。
所以说G1实现可预测的停顿时间模型的关键就是Region内部模型和优先级队列。
5、线程、Region,Rset、Card Table、Card的关系
5.1、Rset(记忆集,Remembered Set)
5.1.1、简单结构
- G1为每个Region各自分配了一个RSet
- RSet内部类似于一个反向指针,记录了
其它Region
对当前Region
的引用情况 - Rset可以解决跨Region的对象引用问题
5.1.2、Rset作用:避免STW式的整堆扫描
串行和并行收集器:GC时是通过整堆扫描来确定对象是否处于可达路径中。
G1收集器:回收某个Region时,只需扫描它的 RSet
就可以找到外部引用,确外部引用是否存活,进而确定本Region内的对象存活情况,这些本Region内 活的对象引用 就是初始标记
的根之一。这样就避免STW式的整堆扫描
举例:回收Region A时,只需扫描它的RSet就可以找到外部引用对象H2,H3,确定H2,H3对象是否存活,进而确定H1对象存活情况。
5.1.3、并非所有的引用都需要记录在RSet中
- 如果引用源是本Region的对象,不需要记录在 RSet 中
- G1每次GC时,所有的新生代都会被扫描,因此引用源是年轻代的对象,也不需要在RSet中记录
- RSet最终只记录
老年代到新生代之间的引用
。
5.1.4、RSet缺点
- 因为每个Region都有对应的RSet,由于Region数量较多,光是存储RSet这块就要占用相当一部分内存,所以G1比其他圾收集器有着更高的内存占用负担。
- 根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额外内存来维持收集器工作。
5.2、线程、Region,Rset、Card Table、Card的关系
- 一个Region在逻辑上划分为若干个Card(卡片)组成,这些Card区域对应的物理内存是连续的
- 每个Card大小都是一样的,大小范围介于128到512字节之间
- Card 是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个Card,当查找Region内部对象的引用时,便可通过Card 来查找,每次对内存的回收,也都是对指定Region的Card进行处理。
- 一个Region 有一个对应的Card Table,Card Table就是管理Card的
- Card Table内有个
字节数组
,集合的元素是Byte类型,每个元素对应一个Card - 集合的元素:
- 用于来记录对应的Card是否修改过
- 元素值为0:表示 对应的Card被引用,当标记为0是,RSet会将这个数组下标记录下来。
- 元素值为1(默认):表示 对应的Card未被引用
- 可以通过数组下标来获取每个card的空间地址
- Card Table内有个
- 一个RSet有多个HashTable,每个线程对应一个HashTable
- HashTable(K,V)
- K: Region 的起始地址
- V: 是一个集合,里面的元素是Card Table的Index(包含对象的索引)
- HashTable(K,V)
5.2.1、为什么一个RSet有多个HashTable
一个Region可能有多个线程在并发修改,因此也可能会并发修改 RSet。为避免冲突,G1垃圾回收器进一步把 RSet 划分成了多个 HashTable,每个线程都在各自的 HashTable 里修改。
HashTable是实现 RSet 的一种常见方式,它的好处就是能够去除重复,这意味着,RS的大小将和修改的指针数量相当,而在不去重的情况下,RS的数量和写操作的数量相当。
5.2.2、这样设计的好处
如果一个线程修改了Region内部的对象引用,就必须要去通知RSet,更改其中的记录。需要注意的是,如果更改的引用很多,赋值器需要对每个引用做处理,赋值器开销会很大。这个问题可以通过Card Table来解决
因为对象保存在card里面,如果一个线程修改了Region内部的对象引用,那么就修改对应的card的内容就行了。不需要去通知RSet。
6、对象引用关系改变,如何解决?
G1使用【原始快照】来解决这一问题。
7、TAMS
- G1为每一个Region设计了两个名为【TAMS(Top at Mark Start)】的指针,指针对应内存地址,用于并发回收过程中的新对象的内存分配。
- 并发回收时新分配的对象地址都必须要在这两个指针位置以上。
- G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
8、运作过程
从上图可以看出,除了并发标记
外,其余阶段也是要完全暂停用户线程的。
8.1、初始标记(Initial Marking)
仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。
这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
8.2、并发标记(Concurrent Marking)
从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。
当对象图扫描完成以后,还要重新处理SATB(快照搜索)记录下的在并发时有引用变动的对象。
8.3、最终标记(Final Marking)
对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
8.4、筛选回收(Live Data Counting and Evacuation)
负责更新Region的统计数据,对各个Region的回收价值和成本进行排序(优先级列表),根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。
9、垃圾回收算法
G1在逻辑上仍然采用了分代的思想
- 从整体来看:是基于【标记-整理】算法实现的收集器
- 从局部(两个Region之间)来上看:基于【标记-复制】算法实现。
10、CMS VS G1
10.1、垃圾碎片:G1不会产生
- CMS会产生
- G1不会产生
10.2、内存占用:G1高
G1比CMS高,因为G1垃圾收集时,需要额外占用内存10%-20%
就内存占用来说,虽然G1和CMS都使用卡表(Card Table)来处理跨代指针,但G1的每个Region都必须有一份卡表(Card Table),这导致G1的RSet可能会占整个堆容量的20%乃至更多的内存空间,相比起来CMS的卡表就相当简单,全局只有一份。
10.3、执行负载:G1高
在执行负载的角度上,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。
相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。
由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。
10.4、怎么选
- 在小内存应用:CMS的表现 优于G1
- 在大内存应用:G1的表现 优于CMS
- 大小内存平衡点:通常在6GB至8GB之间
11、GC
G1保留了YGC并加上了一种全新的MIXGC用于收集老年代。G1中没有Full GC,G1中的Full GC是采用serial old Full GC。
11.1、YGC
当Eden空间被占满之后,就会触发YGC。在G1中YGC依然采用复制存活对象到survivor空间的方式,当对象的存活年龄满足晋升条件时,把对象晋升到老年代。
G1控制YGC开销的手段是动态改变young region的个数,YGC的过程中依然会STW(stop the world 应用停顿),并采用多线程并发复制对象,减少GC停顿时间。
11.1.1、YGC是否需要扫描整个老年代
G1中YGC不需要扫描整个老年代,只需要扫描Rset就可以知道老年代引用了哪些新生代中的对象。因为RSet最终只记录老年代到新生代之间的引用
。
11.2、MIXGC(混合GC)
当老年代占用堆空间超过整堆比 IHOP 阈值 (默认45%)时,G1就会启动一次混合垃圾回收Mixed GC,Mixed GC不仅进行正常的新生代垃圾收集,同时也回收 部分后台扫描线程 标记的 老年代分区。
11.2.1、Mixed GC步骤主要分为两步:
- 全局并发标记(global concurrent marking):就是垃圾回收过程,简单来说步骤如下
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
- 拷贝存活对象(evacuation)
G1中的MIXGC选定所有新生代里的Region,外加根据全局并发标记统计得出收集收益高的若干老年代Region,在用户指定的开销目标范围内尽可能选择收益高的老年代Region进行回收。所以MIXGC回收的内存区域是 新生代+老年代。
11.3、Full GC
当 G1 无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、serial old Full GC,Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。
参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。
11.3.1、触发 Full GC场景
触发 Full GC,会在日志中记录to-space-exhausted以及Evacuation Failure:
- 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
- 从老年代分区转移存活对象时,无法找到可用的空闲分区
- 分配巨型对象时在老年代无法找到足够的连续分区
由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。
12、全局并发标记(很细,不仅包含运行过程的4个细节,还有其他细节)
在进行混合GC(MIXGC)前,会先进行 全局并发标记,在 G1 GC 中,它并不是一次GC过程的必须环节,主要是为 Mixed GC 提供标记服务的。
全局并发标记的执行过程分为以下五个步骤
12.1、初始标记(initial mark,STW):
会标记出所有 GC Roots 节点以及直接可达的对象,这一阶段需STW,但是耗时很短。
初始标记过程与 young GC 息息相关。事实上,当达到 IHOP 阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道。
12.2、根区域扫描(root region scan):
扫描初始标记的存活区中(即 survivor 区)被老年代区域引用的对象,并标记根对象。该阶段与应用程序并发运行,并且只有完成该阶段后,才能开始下一次 STW 的 young GC。
因为 RSet 是不记录从 young region 出发的引用,那么就可能出现一种情况,一个新生代的存活对象,只被老年代的对象引用。在一次young GC中,这些存活的年轻代的对象会被复制到 Survivor Region,因此需要扫描这些 Survivor region 来查找这些指向老年代的对象的引用,作为并发标记阶段扫描老年老年代的根的一部分。
12.3、并发标记(Concurrent Marking):
从 GC Roots 对堆中的对象进行可达性分析,找出存活的对象,此过程可能被 young GC 中断,并发标记阶段产生的新的引用(或引用的更新)会被 SATB 的 write barrier 记录下来,同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。在此阶段中,如果发现区域中的所有对象都是垃圾,那这个区域会立即被回收。同时,并发标记过程中,会计算每个区域中对象的存活比例。
12.4、重新标记(Remark,STW)
重新标记阶段是为了修正在并发标记期间,因应用程序继续运作而导致标记产生变动的那一部分标记记录,就是去处理剩下的 SATB日志缓冲区和所有更新,找出所有未被访问的存活对象。
CMS收集器中,重新标记使用的增量更新,而 G1 使用的是比 CMS 更快的初始快照算法 SATB 算法:snapshot-at-the-beginning。
SATB 在标记开始时会创建一个存活对象的快照图,从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时,应用将会改变了它的对象图,那么JVM需要记录被覆盖的对象,因此写前栅栏会在引用变更前,将值记录在SATB日志或缓冲区中(每个线程都会独占一个SATB缓冲区,初始有256条记录空间)。当空间用尽时,线程会分配新的SATB缓冲区继续使用,而原有的缓冲去则加入全局列表中。最终在并发标记阶段,并发标记线程在标记的同时,还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新RSet,修正 SATB 的误差。
SATB的 log buffer 如 RSet 的写屏障使用的 log buffer 一样,都是两级结构,作用机制也是一样的。
12.5、清除(Cleanup,STW):
该阶段主要是排序各个 Region 的回收价值和成本,并根据用户所期望的GC停顿时间来制定回收计划。(这个阶段并不会实际去做垃圾的回收,也不会执行存活对象的拷贝)
清除阶段执行的详细操作有一下几点:
- RSet梳理:算法会根据活跃度和RSet尺寸对分区定义不同等级,同时RSet数理也有助于发现无用的引用。
- 整理堆分区:为混合收集识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;
- 识别所有空闲分区:即发现无存活对象的分区,该分区可在清除阶段直接回收,无需等待下次收集周期。