G1 面向服务端(多CPU)应用的垃圾回收器

开发 前端
垃圾回收器可以说事Java的基石之一,垃圾回收器的实现充满了大量的实现细节,对于一些优化十分具有参考价值。

总则:首先收集尽可能多的垃圾(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的基石之一,垃圾回收器的实现充满了大量的实现细节,对于一些优化十分具有参考价值。


责任编辑:武晓燕 来源: 蜜糖的代码注释
相关推荐

2009-08-14 08:56:49

Java垃圾回收器G1

2009-07-24 09:41:45

Java 7 G1垃圾回收器

2024-10-22 16:26:11

2009-06-02 10:18:43

Java垃圾回收器垃圾回收器Java垃圾回收

2023-11-16 08:00:56

Java11G1

2020-06-01 20:08:47

垃圾G1回收器

2009-06-18 13:59:33

Java SE 6垃圾回收器

2024-12-03 09:01:33

2021-08-10 07:00:02

Java8 G1垃圾回收器

2021-08-15 18:59:13

垃圾收集器JDK

2015-06-17 14:10:52

OracleJava 9垃圾收集器

2022-03-21 11:33:11

JVM垃圾回收器垃圾回收算法

2022-01-20 10:34:49

JVM垃圾回收算法

2017-08-04 10:53:30

回收算法JVM垃圾回收器

2020-05-14 13:39:19

Java 垃圾回收机制

2024-03-15 08:04:30

G1CMSJVM

2024-03-11 16:27:02

垃圾回收器JVM

2021-01-04 10:08:07

垃圾回收Java虚拟机

2011-06-28 12:39:34

Java垃圾回收

2022-08-29 08:01:43

G1CMS回收器
点赞
收藏

51CTO技术栈公众号