https://javadoop.com/post/g1
G1 垃圾收集算法主要应用在多 CPU 大内存的服务中,在满足高吞吐量的同时,尽可能的满足垃圾回收时的暂停时间。G1采用复制方式收集垃圾,不会产生很多的垃圾碎片;G1的Stop The World(STW)更可控,G1在停顿时间上添加了预测机制,用户可以指定期望停顿时间-XX:MaxGCPauseMillis
特点
- 带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩
- 兼顾吞吐量和停顿时间
- 整体上看是基于“标记整理”算法,局部是基于“复制算法”
- 最大的特点是引入分区的概念,弱化分代概念
- 易预测的GC暂停时间
概念
为了达到可预测的GC暂停时间目标,G1使用了几项技术:
heap region
- 每个region维护一个
Remembered Set
,记录所有region之外引用当前region对象的指针,只需要扫描RSet
就可以对单个region单独回收,这是使用region方式的基础 (谁引用了了我的对象) - 每个region被分成了多个card,在不同region中的card会相互引用
- 在串行和并行收集器中,GC通过整堆扫描,来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描,在每个分区记录了一个已记忆集合(RSet)。当要回收该分区时,通过扫描分区的RSet,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况。
SATB
Mutator说成应用程序。mutator实际上进行的操作有两种
- 生成对象
- 更新指针
是GC开始时存活对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性
-
周期性分析全局可达性,提供completeness(完备性?),即保证所有垃圾最后被识别出来;
-
计算每个region的live数据的数量,G1总是优先收集live数据比较少的region,即优先收集垃圾多的region
-
CMS的incremental update设计使得它在remark阶段必须重新扫描所有线程栈和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satb_mark_queue
SATB算法创建了一个对象图,它是堆的一个逻辑“快照”。标记数据结构包括了两个位图:previous位图和next位图。previous位图保存了最近一次完成的标记信息,并发标记周期会创建并更新next位图,随着时间的推移,previous位图会越来越过时,最终在并发标记周期结束的时候,next位图会将previous位图覆盖掉。
收集集合(CSet)
- 一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。
它同CMS相比,在以下方面表现的更出色:
- G1是一个有整理内存过程的收集器,不会产生很多内存碎片。
- G1的STW更可控,G1在停顿时间上添加了预测机制,用户也可以指定期望停顿时间,关注最小时延
G1收集器的收集活动主要有四种操作:
- YongGC
- 并发标记周期
- MixedGC
- 必要时候的Full GC
YoungGC
- 存活对象拷贝到Survivor 区
- 存活时间达到年龄阈值时,对象晋升到 Old 区
- Young GC:选定所有年轻代里的region。通过控制年轻代的region个数,即年轻代内存大小,来控制young GC的时间开销。
- 停下来所有的工作线程,然后将Eden上存活的对象拷贝到Suvivor区域,这里会将很多个对象从多个不同的区域拷贝到少数的几个区域内,所以这一步在G1中叫做疏散(Evacuation),同时把Suvivor上触及年龄阈值的对象晋升到老年代区域。
- STW (对应上边日志中的`Pause Young)
Evacuation Pauses
满足一定条件后(后面会解释),G1停止所有的线程,开始一次evacuation pause
:选取一个region集合,称为collection set
(后面称为CSet
),然后这个region集合中的活对象拷贝到堆的其他region中,释放CSet
占用的空间。这里需要STW(stop the world)是为了做compact,因为对象的移动对mutator来说必须是原子的,在并发环境下要满足这种原子性代价比较高。
为了尽量缩短evacuation pause
的时间,G1尽量利用多线程去处理。第一步是串行选择CSet
,然后进入主要的并行阶段。GC线程竞争获取任务:扫描remember set log
,更新RSet
;扫描RSet
和其他GC root
,得到活对象;撤离活对象。这些任务只要保证只有一个任务在执行,不需要显式的同步。
CSet
中可能只包含young region
,也可能是所有young region
和部分non-young region
的混合,两者的处理方式是一致的,分别对应fully young
和partially young
的evacuation pause
模式
Concurrent Marking
并发标记很重要,在G1中主要有两个功能:保证收集完备性和存活数据信息。G1使用snatshot at the beginning
(SATB)并发标记算法,基本思想是保证识别出所有标记开始时刻的垃圾,提供那个时间点对象的一个snapshot
。标记过程成新产生的对象之间标记为活的,不需要trace,这大大减少了并发标记的消耗。
- 在并发收集周期中,至少有一次(或多次)新生代垃圾收集
- 一些分区被标记为X,这些分区属于老年代,它们就是标记周期找出的包含最多垃圾的分区
并发标记周期包括多个阶段:
采用的算法是我们前文提到的SATB标记算法,产出是找出一些垃圾对象最多的老年代分区。
- 初始标记 GC pause (young) (initial-mark)
- STW
- 标记根
- 通常初始标记阶段会跟一次新生代收集一起进行,既然这两个阶段都需要暂停应用,G1 GC就重用了新生代收集来完成初始标记的工作
- 在新生代垃圾收集中进行初始标记的工作,会让停顿时间稍微长一点,并且会增加CPU的开销。初始标记做的工作是设置两个TAMS变量(NTAMS和PTAMS)的值,所有在TAMS之上的对象在这个并发周期内会被识别为隐式存活对象;
- 根分区扫描(root-region-scan)
- 在初始标记或新生代收集中被拷贝到survivor分区的对象,都需要被看做是根,这个阶段G1开始扫描survivor分区,所有被survivor分区所引用的对象都会被扫描到并将被标记。
- survivor分区就是根分区,正因为这个,该阶段不能发生新生代收集
- 并发标记阶段(concurrent-mark)
- 默认情况下,G1垃圾收集器会将这个线程总数设置为并行垃圾线程数的四分之一;并发标记会利用trace算法找到所有活着的对象,并记录在一个bitmap中,因为在TAMS之上的对象都被视为隐式存活,因此我们只需要遍历那些在TAMS之下的
- 记录在标记的时候发生的引用改变,SATB的思路是在开始的时候设置一个快照,然后假定这个快照不改变,根据这个快照去进行trace,这时候如果某个对象的引用发生变化,就需要通过pre-write barrier logs将该对象的旧的值记录在一个SATB缓冲区中,如果这个缓冲区满了,就把它加到一个全局的列表中——G1会有并发标记的线程定期去处理这个全局列表。
- 重新标记阶段(remarking),重新标记阶段是最后一个标记阶段,需要暂停整个应用,G1垃圾收集器会处理掉剩下的SATB日志缓冲区和所有更新的引用,同时G1垃圾收集器还会找出所有未被标记的存活对象。这个阶段还会负责引用处理等工作。
- STW
- 清理阶段(cleanup),清理阶段真正回收的内存很小,截止到这个阶段,G1垃圾收集器主要是标记处哪些老年代分区可以回收,将老年代按照它们的存活度(liveness)从小到大排列。
并发标记很重要,在G1中主要有两个功能:保证收集完备性和存活数据信息。G1使用snatshot at the beginning
(SATB)并发标记算法,基本思想是保证识别出所有标记开始时刻的垃圾,提供那个时间点对象的一个snapshot
。标记过程成新产生的对象之间标记为活的,不需要trace,这大大减少了并发标记的消耗。
2019-08-19T20:58:30.327-0800: 4.999: [GC concurrent-root-region-scan-start]
2019-08-19T20:58:30.330-0800: 5.003: [GC concurrent-root-region-scan-end, 0.0037160 secs]
2019-08-19T20:58:30.330-0800: 5.003: [GC concurrent-mark-start]
2019-08-19T20:58:30.343-0800: 5.016: [GC concurrent-mark-end, 0.0127121 secs]
2019-08-19T20:58:30.343-0800: 5.016: [GC remark 2019-08-19T20:58:30.344-0800: 5.016: [Finalize Marking, 0.0002255 secs] 2019-08-19T20:58:30.344-0800: 5.016: [GC ref-proc, 0.0000974 secs] 2019-08-19T20:58:30.344-0800: 5.017: [Unloading, 0.0023743 secs], 0.0029011 secs]
[Times: user=0.01 sys=0.00, real=0.00 secs]
2019-08-19T20:58:30.347-0800: 5.020: [GC cleanup 36M->32M(700M), 0.0006370 secs]
[Times: user=0.01 sys=0.00, real=0.00 secs]
2019-08-19T20:58:30.348-0800: 5.020: [GC concurrent-cleanup-start]
2019-08-19T20:58:30.348-0800: 5.020: [GC concurrent-cleanup-end, 0.0000123 secs]
2019-08-19T20:58:31.580-0800: 6.253: [GC pause (Metadata GC Threshold) (young) (initial-mark), 0.0139133 secs]
MixedGC
回收整个 young region,回收一部分的 old region
- Mixed GC:选定所有年轻代里的region,外加根据global concurrent marking统计得出收集收益高的若干老年代region。在用户指定的开销目标范围内尽可能选择收益高的老年代 region。
- G1的老年代回收是在老年代空间触及一个阈值(Initiating Heap Occupancy Percent)之后,这个回收伴随着年轻代的回收工作
MixedGC时机
-
InitiatingHeapOccupancyPercent 默认45%
-
G1HeapWastePercent
- 在global concurrent marking结束之后,可以知道区有多少空间要被回收,在每次YGC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。
-
G1MixedGCLiveThresholdPercent
- Old区的region被回收的时候的存活对象占比
-
G1MixedGCCountTarget
- 一次global concurrent marking之后,最多执行Mixed GC的次数
-
G1OldGCSetRegionThresholdPercent
- 一次Mixed GC中能被选入CSet的最多old区的region数量
-
混合收集只会回收一部分老年代分区
-
混合收集会执行多次,一直运行到(几乎)所有标记点老年代分区都被回收
-
当整个堆的使用率超过指定的百分比时,G1 GC会启动新一轮的并发标记周期。在混合收集周期中,对于要回收的分区,会将该分区中存活的数据拷贝到另一个分区,这也是为什么G1收集器最终出现碎片化的频率比CMS收集器小得多的原因——以这种方式回收对象,实际上伴随着针对当前分区的压缩
-个假想的混合的STW时间线:
启动程序
-> young GC
-> young GC
-> young GC
-> young GC + initial marking
(.... concurrent marking ...
-> young GC (.... concurrent marking ..
(.... concurrent marking ...
-> young GC (... concurrent marking ...
-> final marking
-> cleanup
-> mixed GC
-> mixed GC
-> mixed GC
-> mixed GC
-> young GC + initial marking
(.... concurrent marking ...
举个例子,一个典型的G1执行过程如下图:
0)当全局并发标记正在工作时,G1不会选择做mixed GC;反之如果有mixed GC正在进行中G1也不会启动initial marking
1)在最开始的3次GC都是young GC,日志中表现为:GC pause (young)
2)堆占用率达到IHOP,开始并发标记阶段,由于初始标记过程借用young GC,日志中表现为:GC pause (young) (initial-mark)
3)并发标记阶段与应用线程并发执行,期间可能会有多次young GC
4)并发标记结束,执行最终标记(GC remark)与清理(GC cleanup)
5)为了在混合收集时尽可能多地回收老年代,在清理暂停之后还会有一次young GC
6)之后就是4次mixed GC过程,日志中表现为:GC pause (mixed)
万物有始有终,混合收集周期结束的条件(以下任一情况满足):
1)mixed GC的次数达到-XX:G1MixedGCCountTarget,默认为8。在标记完所有老年代分区后,G1不是将其一次性全部收集,首先按选项-XX:G1MixedGCLiveThreadsholdPercent指定的存活率阈值筛选一批老年代分区(默认85%),再按剩余分区的收集效率从大到小排序,再除以-XX:G1MixedGCCountTarget,将每次mixed GC收集的老年代Region数量平均拆分
2)上次mixed GC执行完后,剩余老年代垃圾的占比数小于-XX:G1HeapWastePercent,默认为5%。意思是G1会容忍一定的老年代垃圾,从而提升混合收集周期的效率
FullGC
G1的出现就是为了避免这个情况的
如果对象内存分配速度过快,mixed gc 来不及回收,导致老年代被填满,就会触发一次 full gc,G1 的 full gc 算法就是单线程执行的 serial old gc,会导致异常长时间的暂停时间,需要进行不断的调优,尽可能的避免 full gc。
G1的最佳实践
- 关键参数项
-XX:+UseG1GC
,告诉JVM使用G1垃圾收集器-XX:MaxGCPauseMillis=200
,设置GC暂停时间的目标最大值,这是个柔性的目标,JVM会尽力达到这个目标-XX:INitiatingHeapOccupancyPercent=45
,如果整个堆的使用率超过这个值,G1会触发一次并发周期。记住这里针对的是整个堆空间的比例,而不是某个分代的比例。- 添加吞吐量 -XX:GCTimeRatio=99
- 最佳实践
-
避免使用-Xmn、-XX:NewRatio设置年轻代的大小
- 通过
-Xmn
显式设置年轻代的大小,会干扰G1收集器的默认行为: - 这样G1不再以设定的暂停时间为目标
- 这样G1不能按需扩大或缩小年轻代的大小
- 通过
-
停顿时间目标:暂停时间不要太严苛,其吞吐量目标是90%的应用程序和10%的垃圾回收时间,太严苛会直接影响到吞吐量;
-
是否需要切换到G1
- 50%以上的堆被存活的对象占用
- 对象分配和晋升的速度变化非常大
- 垃圾回收时间特别长,超过1秒
- G1适合内存比较大的情况下
- CMS的fullGC会扫描整个老年代
G1调优:
- G1 的调优相对简单、直观,因为可以直接设定暂停时间等目标,并且其内部引入了各种智能的自适应机制
打印日志相关参数:
-XX:+DisableExplicitGC
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=$CATALINA_HOME/logs/
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
-Xloggc:$CATALINA_HOME/logs/gc.log
-Xms128M
-Xmx128M
-XX:+UseG1GC 开启 G1
-XX:G1HeapRegionSize=n, Region 的大小,1-32M,最多2048个
-XX:MaxGCPauseMillis=200 最大停顿时间
-XX:G1NewSizePercent、-XX:G1MaxNewSizePercent
-XX:G1ReservePercent=10 保留防止 to space溢出
-XX:ParallelGCThreads=n SWT线程数
-XX:ConcGCThreads=n 并发线程数=1/4*并行
- 分析调优结果,主要关注:
Full GC 执行时间
Minor GC 执行时间
Full GC 执行间隔
Minor GC 执行间隔
Entire Full GC 执行时间
Entire Minor GC 执行时间
Entire GC 执行时间
Full GC 执行时间
Minor GC 执行时间
参考:https://mp.weixin.qq.com/s/Shw0jtVse1QqNbFCyYmfZA
与CMS比较
这个现在是垃圾回收器的标配,G1和CMS也不例外。但是G1同时回收老年代和年轻代,而CMS只能回收老年代,需要配合一个年轻代收集器。另外G1的分代更多是逻辑上的概念,G1将内存分成多个等大小的region,Eden
/ Survivor
/Old
分别是一部分region的逻辑集合,物理上内存地址并不连续。
CMS在old gc的时候会回收整个Old区,对G1来说没有old gc的概念,而是区分Fully young gc
和Mixed gc
,前者对应年轻代的垃圾回收,后者混合了年轻代和部分老年代的收集,因此每次收集肯定会回收年轻代,老年代根据内存情况可以不回收或者回收部分或者全部(这种情况应该是可能出现)。
1. 如何处理跨代引用
在垃圾回收的时候都是从Root开始搜索,这会先经过年轻代再到老年代,对于年轻代引用老年代的这种跨代不需要单独处理。但是老年代引用年轻代的会影响young gc
,这种跨代需要处理。
为了避免在回收年轻代的时候扫描整个老年代,需要记录老年代对年轻代的引用,young gc
的时候只要扫描这个记录。CMS和G1都用到了Card Table
,但是用法不太一样。JVM将内存分成一个个固定大小的card
,然后有一个专门的数据结构(即这里的Card Table
)维护每个Card
的状态,一个字节对应一个Card
,有点像内存page的概念,只是page是硬件上的,Card Table
是软件上的。当一个Card
上的对象的引用发生变化的时候,就将这个Card
对应的Card Table
上的状态置为dirty,young gc
的时候扫描状态是dirty
的Card
即可。这是基本的用法,CMS基本上就是这么使用。
G1在Card Table
的基础上引入的remembered set
(下面简称RSet
)。每个region都会维护一个RSet
,记录着引用到本region中的对象的其他region的Card
。比如A对象在regionA,B对象在regionB,且B.f = A,则在regionA的RSet中需要记录B所在的Card
的地址。这样的好处是可以对region进行单独回收,这要求RSet不只是维护老年代到年轻代的引用,也要维护这老年代到老年代的引用,对于跨代引用的每次只要扫描这个region的RSet上的Card
即可。
上面说过年轻代到老年代的引用不需要单独处理,这带来了很大的性能上的提升,因为年轻代的对象引用变化很大,如果都需要记录下来成本会很高。同时也说明只需要在老年代维护Card Table
。
2. Full GC
导致CMS Full GC的可能原因主要有两个:Promotion Failure
和Concurrent Mode Failure
,前者是在年轻代晋升的时候老年代没有足够的连续空间容纳,很有可能是内存碎片导致的;后者是在并发过程中jvm觉得在并发过程结束前堆就会满了,需要提前触发Full GC。CMS的Full GC是一个多线程STW的Mark-Compact过程,,需要尽量避免或者降低频率。
G1的初衷就是要避免Full GC的出现,Full GC会会对所有region做Evacuation-Compact,而且是单线程的STW,非常耗时间。导致G1 Full GC的原因可能有两个:1. Evacuation的时候没有足够的to-space来存放晋升的对象;2. 并发处理过程完成之前空间耗尽。这两个原因跟CMS类似。