G1垃圾回收器*
由于G1垃圾回收器比较复杂,所以单拉出一篇来细讲。
本文参考了诸多文章:
G1垃圾回收器详解
G1 垃圾回收器
JVM(四) G1 收集器工作原理介绍
三色标记法与读写屏障
G1垃圾收集器详解
以及b站尚硅谷的课程。
G1回收器:区域化分代式
既然已经有了强大GC,为什么发布G1(Gargage First)?
业务越来越庞大、复杂,用户越来越多。没有GC就不能保证应用程序正常进行。经常STW的GC跟不上实际的需求。
是在Java7 update4后引入的新垃圾回收器
目标是:延迟可控的情况下获得尽可能高的吞吐量。
为什么叫Garbage First(G1)呢?
- 因为G1是一个并行回收器,它把堆内存分隔为很多不相关的区域。使用不同的Region来表示Eden、幸存者0区、幸存者1区、老年代等。
- G1 有计划地避免了在堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小,(回收所得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表。每次根据允许的收集时间,优先回收价值最大的Region。
- 由于侧重点在于回收垃圾最大量的区间,所以称为:垃圾优先。
面向服务端应用,主要针对配备多核CPU及大容量内存的机器。
是JDK9以后的默认垃圾回收器,取代了CMS及parallel+parallel Old组合,被官方称为全功能垃圾收集器。
特点
- 兼顾并行与并发
-
分代收集
- 仍然是分代型垃圾回收器,年轻代依然有Eden区和Survivor区。但从堆结构上看,不要求整个Eden区、年轻代或老年代都是连续的,也不坚持固定大小和固定数量。
- 堆空间分为若干个区域,包含了逻辑上的年轻代和老年代
- 同时兼顾**年轻代和老年代**。
-
空间整合
- CMS: 标记-清除 算法、内存碎片,若干次GC后进行一次碎片整理
- G1将内存划分为一个个的region,内存的回收是以region为基本单位,region之间是复制算法(比如从E区放入S区)。但整体上又可以看做是标记-压缩算法。两种算法都避免了内存碎片。有利于程序长时间运行。
-
可预测的停顿时间模型(软实时soft real-time)
G1相对于CMS另一大优势。除了追求低延迟,还能建立可预测的停顿时间 模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集的实际不超过N毫秒(尽可能)。- 由于分区,G1可以只选取部分区域进行内存回收,缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制。
- G1跟踪各个Region里垃圾堆积的价值大小,维护优先列表,每次根据允许的收集时间,优先回收价值最大的区域。保证高收集效率。
缺点:
相比于CMS,还不具有压倒性优势。比如:内存占用和执行负载都比CMS高。所以G1在大内存应用上优势更大。
指令-XX:G1HeapRegionSize
设置每个region的大小,1-32MB之间。默认是堆内存的1/2000-XX:MaxGCPauseMillis
设置期望达到的最大GC停顿时间指标。默认200ms-XX:ParallelGCThread
设置STW工作线程的值,最多为8-XX:ConcGCThreads
设置并发标记的线程数-XX:InitiatingHeapOccupancyPercent
设置并发GC周期的Java堆占用率阈值。
适用场景
服务器端,大内存、多处理器
替换CMS场景:
- 50%的堆被活动数据占用;
- 对象分配频率和年代提升频率变化很大
- GC停顿时间过长
Region
- 所有的region大小相同,且在JVM生命周期内不会被改变
- 虽然保有新生代老年代的概念,但不再是物理隔离的了,都是一部分region的集合,通过region的动态分配方式实现逻辑上的连续。
- 一个region一次只能为一个类型的区域,但在JVM生命周期内可能发生改变。
- 新增一个内存区域,称为Humogous内存区域,主要用于存储大对象,如果超过1.5region就放到H
为什么设置H?
对于堆中的大对象,默认直接分配到老年代,但如果是一个短期存在的大对象,就会对垃圾收集器产生负面影响。为了解决这个问题,G1划分了Humongous区,专门存放大对象。如果对象超过一个区域的50%,G1会寻找连续的H区来存储。有时候不得不进行Full GC,G1的大多数行为都把H区作为老年代的一部分看待。
回收细节
主要包括:
- 年轻代GC
- 年轻代GC+老年代并发标记过程
- 混合回收Mixed GC (全部年轻代和部分老年代)
- 如果需要,单线程、独占式、高强度的Full GC还是继续存在的。
当Eden区用尽,开始年轻代回收过程,是并行的独占式收集器。多线程、暂停用户线程回收。
当堆内存使用达到一定值(默认45%),开始老年代并发标记过程。
标记完成开始混合回收过程。 从老年代移动存活对象到空闲区间,其也就成了老年代的部分。老年代的G1不需要整个老年代被回收,一次只需要扫描/回收一部分老年代的region。同时,老年代Region和年轻代是一起被回收的。
理解垃圾回收之前
在详解过程前,先要介绍几个结构和方法去1
记忆集
一个对象可能被不同区域的对象引用问题
一个region的对象可能被另一个region中的对象引用,是否需要扫描整个堆?
回收新生代还需要扫描老年代?
所有涉及部分区域收集行为的垃圾收集器,JVM都使用Remenbered Set来避免全局扫描
- 给每一个region都分配一个记忆集
- 每次reference类型数据写操作时,都会产生一个写屏障暂时中断操作。
- 然后检查将要写入的引用指向的对象是否和该Reference类型数据在不同的Region
- 如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remenbered Set中(卡表是RS的一种实现方式,只要卡页内至少有一个对象的字段存在跨带指针,那就将对应卡表的数组元素的值标识为1,称为dirty,没有则标为0)
- 当进行垃圾收集时,在GC root的枚举范围加入RS,保证不进行全局扫描,也不会有遗漏。(即筛选出卡表中变脏的元素)
G1记忆集在存储结构的本质上是一种哈希表,Key是别的region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。
(这一块其实比较模糊,笔者阅读了多个文章,终于得出一个比较合理的解释:)
每一个region被分成了多个卡页,而记忆集中的每个key,其实就对应于一个region,key对应的value中每一个元素,都对应着一个卡页。这个元素的值是否为1,就表示其是否变脏。
所以存在如图所示的两种对应关系,一种是region的卡页中的对象会指向其它region的卡页中的对象,这时,就要到那个region的记忆集中记录:某个region的卡页x指向我这个region的对象。
RS如何辅助GC?
在YGC时,只需要扫描GC roots 加上 RS里面存在的dirty card,而无需扫描整个Old Generation。
Mixed GC时,老年代中记录了old->old的RS,young->old的引用由扫描S区得到。
三色标记法
白色:表示对象尚未被垃圾收集器访问,所有对象都是在最初时都是白色的。若分析结束仍然为白色,则代表不可达。
黑色:表示对象已经被垃圾回收器访问,且该对象的所有直接引用都被扫描过。如果其它对象引用指向了黑色对象,无须重新扫描一遍。
灰色:已经访问过,但至少存在一个引用没有被扫描过。
为什么需要保证一致性的快照?
如果用户线程与收集器并发工作,将可能会出现未扫描和已经扫描过的对象更改了引用,导致本来应该存活的对象,误判成了垃圾对象,或者相反。后者可能会产生浮动垃圾,而前者则不能接受。
两种情况都满足,会产生“对象消失”的问题:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
- 赋值器删除了全部从灰色对象到白色对象的直接或间接引用
如果仅有条件1:
相当于对象多了黑色对象的引用
如果仅有条件2:
对象直接变成了不可达对象,成为了垃圾,与扫描结果一致。
条件1,2同时存在:扫描结果为不存在对象,但实际上有黑色对象引用此对象。
由此引出下面两个解决方案
增量更新(Incremental Update)
破坏了条件1
当黑色对象插入新的指向白色对象的引用关系时。就记录下来。
等并发扫描结束之后,再以记录中的黑色对象为根,重新扫描一次。
可以理解为黑色对象一旦插入对白色对象的引用,就变成灰色对象了。
原始快照(SATB)
破坏了条件2
当灰色对象要删除指向白色对象的引用关系时,就将要删除的引用记录下来,扫描结束后,再以记录中的灰色对象为根,重新扫描一次。
可以理解为,无论引用关系删除与否。都按照刚开始扫描那一刻的对象图快照来进行搜索。
以上两种条件只需要选其一破坏即可,即选用增量更新或者原始快照。
对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:
- CMS:写屏障 + 增量更新
- G1:写屏障 + SATB
- ZGC:读屏障
写屏障
卡表如何维护?何时变脏?
当其它分代的对象引用了本区域对象时,其对应的卡表元素就应该变脏了。但问题是如何在赋值的那一刻更新卡表呢?
在Hotspot中,通过写屏障(Write Barrier)维护卡表状态。在引用对象赋值时会产生一个环形通知,供程序执行额外动作。赋值的前后都在写屏障的覆盖范畴内。赋值前的叫写前屏障,赋值后的叫写后屏障。许多收集器都使用了写屏障,但G1出现之前,其他收集器都只用到了写后屏障。
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // pre-write barrier: for maintaining SATB invariant
*field = new_value; // the actual store
post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference
}
一旦收集器在写屏障中增加了更新卡表操作,无论更新的是否是老年代对新生代对象的引用,每次只要更新引用,就会产生开销。
写前屏障:
等式左侧对象将修改引用到另一个对象,那么原先引用对象所在分区就会失去一个引用,所以jvm就需要记录丧失引用的对象。但不会立即更新,而是记录这次更新日志,在将来批量处理。
写后屏障:
由于左侧对象产生了一个新的引用,所引用对象所在分区的RSet需要被更新。但不会立即更新,而是记录这次更新日志,在将来批量处理。
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
G1通过写前屏障来实现SATB,将变化记录保存在在satb_mark_queue队列中。remark阶段会扫描这个队列,通过这种方式,原来引用指向的对象就会被标记上。
TAMS(Top at Mark Start)
G1为每个region设计了两个名为TAMS的指针,把region中的一部分空间用于并发过程中的新对象分配。G1收集器默认在这个地址上的对象是被隐式标记过的,默认存活,不纳入回收范围。
如果内存分配的速度赶不上回收的速度。G1收集器也会*冻结用户线程,导致full gc而产生长时间STW
G1回收过程
Global Concurrent Marking(全局并发标记)
-
初始标记:STW。扫描根集合可直达对象,并压入栈中等待后续扫描。它是利用了年轻代收集的STW时间段来完成的初始标记,所以并没有额外的停顿。
-
(根区域扫描):初始标记结束后,且年轻代也完成对象复制到S区的工作,为了保证标记算法的正确性,所有新复制到S区的对象都需要被扫描并标记为根。这个扫描必须在下一次YGC前完成(并发过程中可能会被年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
-
并发标记:不断从栈中取出引用,递归扫描堆中的对象图,找出要回收的对象。扫描完成后,还需要重新处理SATB记录下的在并发时有引用变动的对象。
-
最终标记:STW,用于清理遗留的SATB记录,同时也进行弱引用处理。
-
清理:STW,
- 在marking bitmap中统计每个region被标记为活的对象有多少。统计结果会用来排序region,用于下次CSet选择
- 梳理RSet
- 空闲region放入空闲region列表中
但并不会清理垃圾对象,也不会执行存活对象的拷贝。
-
Evacuation, STW
- 从region中选出若干个region进行回收,被选中的region称为 Collect Set(CSet)
- 把这些region中的存活对象复制到空闲region中,同时把被回收的region放到空闲列表中
分解为以下三个行动
- 根据日志来更新RS(这个日志应该就是写前屏障所保留下来的日志)
- 扫描RS和其余根来确定存活对象
- 拷贝存活对象,从2中确定的根触发,沿引用链一直追溯,将存活对象复制到新region。在这个过程中,可能由部分年轻代对象晋升至老年代。
在***深入理解java虚拟机***中,后两步被合并为了一步
G1垃圾回收器本身没有full gc的概念,如果mixed GC无法满足,则会切换到serial old GC来收集整个堆。
总结来看,G1仅有一个阶段是不需要STW的,所以G1并非纯粹地追求低延迟