G1 面向服务端(多 CPU)应用的垃圾回收器
概要
总则:首先收集尽可能多的垃圾(Garbage First),
一定程度上,可以理解为 是 CMS 在全局不分区的一种改进。G1 并不会等内存耗尽(串行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找出具有高收集收益的分区进行收集。
特点:
- 并发与并行:G1 能充分的利用多 CPU,多核环境下的硬件优势,使用多个 CPU 来缩短 STW 停顿时间。部分收集器需要停顿 Java 线程来执行 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续运行。 
- 分代收集:G1 可以独自管理整个 Java 堆,只进行逻辑上的年轻代与老年代的区别。采用不同的方式去处理新创建的对象和已经存活了一段时间的对象和已经熬过多次 GC 的旧对象以获得更好的收集效果。 
- 空间整合:G1 运作期间不会产生空间锁片,收集后能提供规整可用的内存。G 1 将内存划分为一个个相等大小的内存分区,回收时则以分区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位进行操作,因此 G1 天然就是一种压缩方案(局部压缩); 
- 可预测的停顿:G1 除了追求低停顿外,还能建立可以预测停顿时间的模型。能让使用者明确指定在一个长度为 M 毫秒的时间段,消耗在垃圾回收上的时间不超过 N 毫秒。可以根据用户设置的暂停时间目标自动调整年轻代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大; 
G1 模型
内存模型
 
 - 分区(Region) 
G1 采用了内存分区的概念,将整堆分为若干大小相等的区域逐步使用;G1 仅要求对象逻辑上连续。区域也不会跟代进行绑定,可以切换代所属。可以通过 -XX:G1HeapRegionSize=n 设置整堆的大小(1-32mb,2^n),默认将整堆划分为 2048 个分区。说白了就是-Xms /2048 ,如果这个值小于 1,则取值为 1,大于 32 则取值 32;其它值则取与 2,4,8,16 相近的。
- 卡片(card) 
每个分组内部划分多个大小为 512byte 的卡片。同时 G1 GC 为每个区间设置了一个全局内存块表(Global Card Table)来帮助维护所有的堆内存块。内存回收是对卡片进行处理的。
- 堆 (head) 
代表整体空间总大小。可用-Xms/-Xmx 来指定。在发生年轻代收集或混合收集的时候,会通过计算 GC 与应用的耗费时间比,自动调整堆空间。GC 频率高则增大堆空间,GC 占用时间高则减小堆空间。GC 时间与应用耗时比默认为 9。空间不足时会先尝试扩容,失败则进行 Full GC。
分代模型
 
 - 分代的分割 
更关心最近被分配的对象,避免对长生命周期的对象进行改动。借鉴了分代思想,将内存区逻辑区分为年轻代、幸存代、老年代。但是 JVM 会动态的调整空闲区间到年轻代空间。
年轻代会在初始空间(-XX:G1NewSizePercent 默认 5%)到最大空间(-XX:G1MaxNewSizePercent 默认 60%) 之间动态变化,且由参数目标暂停时间(-XX:MaxGCPauseMillis 默认 200ms)
- 本地分配缓冲 Local allocation buffer (Lab) 
由于是分区的内存,所以可以每个线程领取一部分内存使用。这样领取后的内存与 GC 所进行任务的内存都是独立进行的,从而减少同步时间,提高 GC 时的效率。称这种线程领取后的分区称之为本地分配缓存。
分区模型
 
 G1 对内存的分配是以“分区(Region)”为单位,针对区内对“对象”的分配是以“卡片(Card)”为单位。
- 巨型对象区域 (Humongous Region) 
大于了分区大小一半以上的对象成为巨型对象。由于巨型对象的移动成本很高,甚至分区无法容纳对象,所以线程并不会直接在 TLAB 中创建对象。针对巨型对象,会直接分配在老年代的连续空间中,所占用的连续空间叫做“巨型分区(Humongous Region)”。优化: 巨型对象如果没有指向巨型对象,直接会在年轻代收集周期中被回收。
巨型对象会独占一个、或多个物理连续分区,其中第一个分区被标记为开始巨型(StartsHumongous),相邻连续分区被标记为连续巨型(ContinuesHumongous)。由于无法享受 Lab 带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应避免生成巨型对象。
- 已记忆集合 Remember Set (RSet) 
Serial 和 Parllel GC 的时候是扫描整堆来确认可达性。G1 通过为每个分区建立一个已记忆集合 (RSet)记录引用分区内对象的卡片索引(反向索引,谁引用了我),当要回收该分区时,通过扫描分区的 RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。
RSet 空了,说明这个 Region 中的中的对象已经没有被其他对象引用了。
两种场景下依赖 Rset 加速(由于年轻代会被完整回收,同时因为写屏障性能消耗,所以不记录年轻代饮用)。
- 老年代引用老年代对象,Rset 保存在老年代中 
- 老年代引用年轻代对象,Rset 保存在年轻代中 
- Per Region Table (PRT) 
RSet 通过 PRT 记录分区的引用情况。当一个指针引用到 Rset 中的一个区间时(图右上角),包含该指针的堆块就会被 PRT 标记。PRT 需要内存空间来存储这些引用关系,根据引用的数量,PRT 有三种的记录引用的模式,会根据调用次数转变: 稀疏(hash)->细粒度->粗粒度。
- 稀疏表:通过哈希表来存储,key 是 region index,value 是 card 数组 
- 细粒度 PerRegionTable:当稀疏表指定 region 的 card 数量超过阈值时,则在细粒度 PRT 中创建一个对应的 PerRegionTable 对象。 一个 Region 地址链表,维护当前 Region 中所有 card 对应的一个 BitMap 集合。 
- 粗粒度位图:当细粒度 PRT size 超过阈值时,所有 region 形成一个 bitMap。如果有 region 对当前 Region 有指针指向,就设置其对应的 bit 为 1 
- CSet 
 
 收集集合(CSet)代表每次 GC 暂停时回收的一系列目标分区。在收集暂停中,CSet Region 都会被释放,存活的对象会被分配到空闲分区中。G1 的收集都是根据 CSet 进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。
- 年轻代收集集合: 
当年轻代空间增长到 Eden 已经满了的时候,便会触发一次 STW 式的年轻代收集。Eden 分区存活的对象将被拷贝到 Survivor 分区;原有 Survivor 分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到 PLAB 中、新的 survivor 分区和老年代分区。而原有的年轻代分区将被整体回收掉。
- 混合收集集合: 
老年代的空间被逐渐填充。当老年代占用空间超过整堆比 IHOP 阈值-XX:InitiatingHeapOccupancyPercent(默认 45%)时,G1 就会启动一次混合垃圾收集周期。为了减小暂停目标,混合收集会分批次,与用户线程交替执行,每次 STW 的混合收集与年轻代收集过程相类似。
G1 的活动周期
工作流程:
 
 RSet 的维护
G1 通过维护 RSet 来记录对象分区之间的引用,避免全量扫堆。维护 RSet 通过两个方面:
- 写屏障(Write Barrier) 
- 并发优化线程(Concurrence Refinement Threads) 
- 写屏障 
 
 屏障指在原生代码片段中,当某些语句被执行时,栅栏代码也会被执行(类似 aop)。G1 主要在赋值语句中,使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。注意: 写栅栏的指令序列开销非常昂贵,应用吞吐量也会根据栅栏复杂度而降低。
- 写前屏障: 在进行等式赋值前,等式左侧原先的引用就会失去一个引用,所以 jvm 就需要在语句执行前记录丧失的引用对象。(并不是立即执行,是后续批量执行,使用 SATB 方法) 
- 写后屏障: 赋值等式执行后,右侧的引用多了一个引用,需要确实否是需要增加引用,JVM 也不会立即处理,会先进行记录,然后会在后续进行批量处理(并发优化线程)。 
SATB + RSet 解决了什么问题?
主要是为了解决并发标记过程中,出现的漏标,误标等问题。
- 起始快照算法(Snapshot at the beginning (SATB))-写前屏障处理 
G1 SATB 和 Incremental Update 算法的理解
SATB 是一种增量式完全并发标记算法,针对并发标记阶段,适用于 G1 的分块堆结构。
 
 SATB 算法认为开始标记的都认为是活的对象,SATB 会创建一个对象图,相当于堆的逻辑快照。所以对象可按图结构遍历(用 RSet 可以加速)。当发生已扫描对象引用未扫描对象时(黑色引用白色),通过 write barrier 写屏障技术,会把 B 到 D 的引用推到 gc 遍历执行的堆栈上,这样在后续会继续针对 D 进行扫描。
Snapshot at the beginning,可以理解为,在进行扫描之前,将整个对象引用关系都认为是 存活的节点进行扫描,如果在扫描过程中把 B = null,这是 D 其实已经是垃圾了,但是在后续流程中仍然会处理 D 并标记为黑色。D 在本轮本该清除,但是会暂时保留,在 remark 阶段处理。
每个线程有一个 256 条记录的 SATB 缓存区,当满了之后会将数据加入到全局列表中中并重新申请新的一批 256 缓存。还会定期检查和处理全局缓冲区列表的记录,然后根据标记位图分片的标记位,扫描引用字段来更新 RSet。此过程又称为并发标记/SATB 写前栅栏。
- 并发优化线程(Concurrence Refinement Threads)- 写后屏障处理 
写后屏障(需要 2 个额外指令)会更新一个 Card Table Type 结构来跟踪代间引用。触发写屏障时,会过滤是否为本分区的对象,如果发生跨区应用更新,则将更新对象的卡片加入到缓冲(新日志缓冲或者脏卡片)。一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。
并发优化线程会一直活跃,当全局列表中有数据就会处理来更新 RSet。如果处理不过来,就会让更多线程来处理全局表;如果还处理不过来,会暂停应用线程来处理全局列表。(对象变更过于频繁)
并发标记周期
并发标记周期(Concurrent Marking Cycle)需要会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别可能存活对象、计算分区活跃从而确定 GC 效率等级。
触发条件: 达到 IHOP 阈值(-XX:InitiatingHeapOccupancyPercent 老年代整堆比,默认 45%),
步骤(STW):
- 初始标记(Initial Mark) 
- 根分区扫描(Root Region Scanning) 
- 并发标记(Concurrent Marking) 
- 重新标记(Remark) 
- 清除(Cleanup) 
并发标记线程 (Concurrent Marking Threads)
 
 并发标记阶段,会针对分区进行数据标记,一个分区会会建立两个位图来记录标记结果:Previous Bitmap、Next Bitmap。
- Previous Bitmap 为上一次的标记(已经完成标记) 
- Next Bitmap 为本次的标记结果(即将进行标记) 
对应的,针对标记的位置利用两个变量记录(TAMS: Top-At-Mark_Start)分别是:
- Previous TAMS(PTAMS) 前一次 
- Next TAMS(NTAMS) 后一次 
总的来说: 区间内的数据用两个 Bitmap 两个‘指针’进行滚动回收,当回收完毕后,区间回收。
- 初始标记结束后:会先将 Next Bitmap 设置为空,并将 N TAMS 设置到区间顶部。此时 P TAMS 仍然为上一大轮时标记的位置。 
- 并发循环: 会对 PTAMS 到 NTAMS 中间的数据进行标记,将本段标记的结果+上一次标记的结果一同更新到 Next Bitmap。 
- 一次循环结束: 将 Next Bitmap 更新到 Previous Bitmap 中,同时,更新 PTAMS 与 NTAMS 的位置。 
在并发标记阶段,G1 根据参数(-XX:ConcGCThreads(默认 GC 线程数的 1/4,即-XX:ParallelGCThreads/4))分配线程,每个线程一次流程只进行一个分区的扫描。
并发标记循环流程 (Concurrent Marking Cycle)
并发循环的流程为:初始标记、根分区扫描、并发标记、重新标记、清除阶段。
- 初始标记(Initial Mark): 
独占,会触发 STW,然后标记 GC ROOT 可达的所有对象。
当 IHOP 触发阈值的时候,G1 并不会立即开启循环,而是等待下一次年轻代收集(同样需要 STW),和年轻代收集一起在一次 STW 之间执行(借道(Piggybacking))。
在初始标记暂停中,分区的 NTAMS 都被设置到分区顶部 Top,初始标记是并发执行,会处理所有的分区。
- 根分区扫描(Root Region Scanning) 
当 STW 结束,年轻代收集和初始标记完成后。基于标记算法,拷贝到幸存者区间的区间也需要被当作根元素进行标记,同时 G1 会扫描这个区间,然后将幸存者区间所引用的对象进行标记。所以幸存者区间也被称为根区间。
特别的: 因为 并发循环流程中会多次执行年轻代收集(被年轻代收集打断),所以需要在下一次被打断前完成。
- 并发标记(Concurrent Marking) 
该阶段和应用县城并发执行,每个线程一次只扫描一个分区,标记出存活对象图。在这一阶段会处理 Previous/Next 标记位图,扫描标记对象的引用字段。同时并发标记线程还会处理 SATB 的全局列表信息。
- 重新标记(Remark) 
最后一个标记动作,会是一个独占(STW)阶段,可以并行执行。在整个阶段中,会处理所有遗留的 SATB 日志。找出所有未被访问的存活对象。
注意:引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。
- 清理(Cleanup) 
清理结算会识别并清理完全空闲的区域(RSet 中引用计数空了)并清理空闲的区域。这个阶段会处理 Previous/Next 标记位图、以及 PTAMS/NTAMS。主要进行的操作有一下几种:
- RSet 梳理(根据扫描后的结果,确认 RSet 各个粒度中的数据是否维护的正确) 
- 整理堆分区,为分区确定访问热度,为后续确认回收收益高的分区。 
- 识别空闲区间直接回收。 
年轻代收集/混合收集周期
年轻代收集(Young Collection)
年轻代由:Eden 和 Survivor 组成,当 Eden 分配失败的时候会触发,执行年轻代收集的时候会进行中断(STW)
- 对象复制 Object Copy: 根据 CSet 会将 Eden 中的存活对象复制到 Survivor 区间。注意:会将所有的年轻代对象(Eden 和 Survivor)拷贝到新的 Survivor 区间。 
- 对象提升: 如果对象经历的幸存次数达到阈值,会提升到老年代中,这个阈值经过计算配置得到。 
- 分区调整: 收集期间,G1 会计算当前年轻代需要扩容或者压缩的总量例如: 
- 空闲区间 
- RSet 大小 
- 当前最大可用年轻代 
- 当前最小可用年轻代 
- 停顿时间 
- 信息维护: 记录收集对象的年龄信息"Age Info",便于后续是否晋升到老年区、时候在混合收集阶段进行回收。 
- 更新 RSet:会处理还没有提交到全局列表中的本地缓冲区中的 RSet 更新日志。 
- 扫描 RSet: 在收集当前 CSet 之前,考虑到分区外的引用,必须扫描 CSet 分区的 RSet。 
- 释放分区: 释放 Free CSet 
混合收集周期(Mixed Collection Cycle)
当一次并发标记循环完成了之后,会开始一次混合收集周期,在周期中 G1 不仅收集年轻代,也同时收集老年代,而收集那些老年代,则是根据老年代中的垃圾数量确定的。
单轮的混合收集与年轻代收集无区别,但是因为老年代垃圾可能比较多,为了满足暂停的需求,可能会连续的进行多次的混合收集。再过程中,G1 会计算下一次处理的 CSet 集合的分区数量,是否本次收集之后需要结束周期。
Full GC
当无法申请新的空间时,会执行一次 STW 式的、单线程的 Full GC。Full GC 会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。触发 Full GC 的方式:
- 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区 
- 从老年代分区转移存活对象时,无法找到可用的空闲分区 
- 分配巨型对象时在老年代无法找到足够的连续分区 
流程简述:
 
 最后
垃圾回收器可以说事 Java 的基石之一,垃圾回收器的实现充满了大量的实现细节,对于一些优化十分具有参考价值。
版权声明: 本文为 InfoQ 作者【蜜糖的代码注释】的原创文章。
原文链接:【http://xie.infoq.cn/article/d6b975a2585b1e72f3494ecb3】。文章转载请联系作者。












 
    
评论