引子
最近遇到很多朋友过来咨询G1调优的问题,我自己去年有专门学过一次G1,但是当时只是看了个皮毛,因此自己也有不少问题。总体来讲,对于G1我有几个疑惑,希望能够在这篇文章中得到解决。
在G1提出之前,经典的垃圾收集器主要有三种类型:串行收集器、并行收集器和并发标记清除收集器,这三种收集器分别可以是满足Java应用三种不同的需求:内存占用及并发开销最小化、应用吞吐量最大化和应用GC暂停时间最小化,但是,上述三种垃圾收集器都有几个共同的问题:(1)所有针对老年代的操作必须扫描整个老年代空间;(2)年轻地和老年代是独立的连续的内存块,必须先决定年轻代和老年代在虚拟地址空间的位置。
G1是一种服务端应用使用的垃圾收集器,目标是用在多核、大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间,同时还能保持较高的吞吐量。
G1适用于以下几种应用:
G1采取了不同的策略来解决并行、串行和CMS收集器的碎片、暂停时间不可控制等问题——G1将整个堆分成相同大小的分区(Region),如下图所示。
每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。 年轻代、幸存区、老年代这些概念还存在,成为逻辑上的概念,这样方便复用之前分代框架的逻辑。在物理上不需要连续,则带来了额外的好处——有的分区内垃圾对象特别多,有的分区内垃圾对象很少,G1会优先回收垃圾对象特别多的分区,这样可以花费较少的时间来回收这些分区的垃圾,这也就是G1名字的由来,即首先收集垃圾最多的分区。
新生代其实并不是适用于这种算法的,依然是在新生代满了的时候,对整个新生代进行回收—— 整个新生代中的对象,要么被回收、要么晋升,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。
G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。每个分区的大小从1M到32M不等,但是都是2的冥次方。
一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。
RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。
如下图所示,Region1和Region3中的对象都引用了Region2中的对象,因此在Region2的RSet中记录了这两个引用。
摘一段R大的解释:G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。 这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。 举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。
SATB是维持并发GC的正确性的一个手段,G1GC的并发理论基础就是SATB,SATB是由Taiichi Yuasa为增量式标记清除垃圾收集器设计的一个标记算法。Yuasa的SATAB的标记优化主要针对标记-清除垃圾收集器的并发标记阶段。按照R大的说法:CMS的incremental update设计使得它在remark阶段必须重新扫描所有线程栈和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satbmarkqueue。
SATB算法创建了一个对象图,它是堆的一个逻辑“快照”。标记数据结构包括了两个位图:previous位图和next位图。previous位图保存了最近一次完成的标记信息,并发标记周期会创建并更新next位图,随着时间的推移,previous位图会越来越过时,最终在并发标记周期结束的时候,next位图会将previous位图覆盖掉。 下面我们以几个图例来描述SATB算法的过程:
SATB是一个快照标记算法,在并发标记进行的过程中,垃圾收集器(Collecotr)和应用程序(Mutator)都在活动,如果一个对象还没被mark到,这时候Mutator就修改了它的引用,那么这时候拿到的快照就是不完整的了,如何解决这个问题呢?G1 GC使用了SATB write barrier来解决这个问题——在并发标记过程中,将该对象的旧的引用记录在一个SATB日志对列或缓冲区中。去翻G1的代码,却发现实际代码如下——只该对象入队列,并没有将整个修改过程放在写屏障之间完成。
// hotspot/src/share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.hpp
// This notes that we don't need to access any BarrierSet data
// structures, so this can be called from a static context.
template <class T> static void write_ref_field_pre_static(T* field, oop newVal) {
T heap_oop = oopDesc::load_heap_oop(field);
if (!oopDesc::is_null(heap_oop)) {
enqueue(oopDesc::decode_heap_oop(heap_oop));
}
}
enqueue的真正代码在 hotspot/src/share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.cpp
中,这里使用 JavaThread::satb_mark_queue_set().is_active()
判断是否处于并发标记周期。
void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
// Nulls should have been already filtered.
assert(pre_val->is_oop(true), "Error");
if (!JavaThread::satb_mark_queue_set().is_active()) return;
Thread* thr = Thread::current();
if (thr->is_Java_thread()) {
JavaThread* jt = (JavaThread*)thr;
//将旧值入队
jt->satb_mark_queue().enqueue(pre_val);
} else {
MutexLockerEx x(Shared_SATB_Q_lock, Mutex::_no_safepoint_check_flag);
JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
}
}
stabmarkqueue.enqueue方法首先尝试将以前的值记录在一个缓冲区中,如果这个缓冲区已经满了,就会将当期这个SATB缓冲区“退休”并放入全局列表中,然后再给线程分配一个新的SATB缓冲区。并发标记线程会定期检查和处理那些“被填满”的缓冲区。
G1收集器的收集活动主要有四种操作:
第一、新生代垃圾收集的图例如下:
G1设计了一个标记阈值,它描述的是总体Java堆大小的百分比,默认值是45,这个值可以通过命令 -XX:InitiatingHeapOccupancyPercent(IHOP)
来调整,一旦达到这个阈值就回触发一次并发收集周期。注意:这里的百分比是针对整个堆大小的百分比,而CMS中的 CMSInitiatingOccupancyFraction
命令选型是针对老年代的百分比。并发收集周期的图例如下:
在上图中有几个情况需要注意:
第二、G1的并发标记周期包括多个阶段: 并发标记周期采用的算法是我们前文提到的SATB标记算法,产出是找出一些垃圾对象最多的老年代分区。
-XX:ConcGCThreads
来设置并发线程数,默认情况下,G1垃圾收集器会将这个线程总数设置为并行垃圾线程数( -XX:ParallelGCThreads
)的四分之一;并发标记会利用trace算法找到所有活着的对象,并记录在一个bitmap中,因为在TAMS之上的对象都被视为隐式存活,因此我们只需要遍历那些在TAMS之下的;记录在标记的时候发生的引用改变,SATB的思路是在开始的时候设置一个快照,然后假定这个快照不改变,根据这个快照去进行trace,这时候如果某个对象的引用发生变化,就需要通过pre-write barrier logs将该对象的旧的值记录在一个SATB缓冲区中,如果这个缓冲区满了,就把它加到一个全局的列表中——G1会有并发标记的线程定期去处理这个全局列表。
第三、混合收集只会回收一部分老年代分区,下图是第一次混合收集前后的堆情况对比。
混合收集会执行多次,一直运行到(几乎)所有标记点老年代分区都被回收,在这之后就会恢复到常规的新生代垃圾收集周期。当整个堆的使用率超过指定的百分比时,G1 GC会启动新一轮的并发标记周期。在混合收集周期中,对于要回收的分区,会将该分区中存活的数据拷贝到另一个分区,这也是为什么G1收集器最终出现碎片化的频率比CMS收集器小得多的原因——以这种方式回收对象,实际上伴随着针对当前分区的压缩。
G1收集器的模式主要有两种:
在R大的帖子中,给出了一个假象的G1垃圾收集运行过程,如下图所示,在结合上一小节的细节,就可以将G1 GC的正常过程理解清楚了。
巨型对象:在G1中,如果一个对象的大小超过分区大小的一半,该对象就被定义为巨型对象(Humongous Object)。巨型对象时直接分配到老年代分区,如果一个对象的大小超过一个分区的大小,那么会直接在老年代分配两个连续的分区来存放该巨型对象。巨型分区一定是连续的,分配之后也不会被移动——没啥益处。
由于巨型对象的存在,G1的堆中的分区就分成了三种类型:新生代分区、老年代分区和巨型分区,如下图所示:
如果一个巨型对象跨越两个分区,开始的那个分区被称为“开始巨型”,后面的分区被称为“连续巨型”,这样最后一个分区的一部分空间是被浪费掉的,如果有很多巨型对象都刚好比分区大小多一点,就会造成很多空间的浪费,从而导致堆的碎片化。如果你发现有很多由于巨型对象分配引起的连续的并发周期,并且堆已经碎片化(明明空间够,但是触发了FULL GC),可以考虑调整 -XX:G1HeapRegionSize
参数,减少或消除巨型对象的分配。
关于巨型对象的回收:在JDK8u40之前,巨型对象的回收只能在并发收集周期的清除阶段或FULL GC过程中过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。
G1启动了标记周期,但是在并发标记完成之前,就发生了Full GC,日志常常如下所示:
51.408: [GC concurrent-mark-start]
65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs]
[Times: user=7.87 sys=0.00, real=6.20 secs]
71.669: [GC concurrent-mark-abort]
GC concurrent-mark-start开始之后就发生了FULL GC,这说明针对老年代分区的回收速度比较慢,或者说对象过快得从新生代晋升到老年代,或者说是有很多大对象直接在老年代分配。针对上述原因,我们可能需要做的调整有:调大整个堆的大小、更快得触发并发回收周期、让更多的回收线程参与到垃圾收集的动作中。
在GC日志中观察到,在一次混合收集之后跟着一条FULL GC,这意味着混合收集的速度太慢,在老年代释放出足够多的分区之前,应用程序就来请求比当前剩余可分配空间大的内存。针对这种情况我们可以做的调整:增加每次混合收集收集掉的老年代分区个数;增加并发标记的线程数;提高混合收集发生的频率。
在新生代垃圾收集快结束时,找不到可用的分区接收存活下来的对象,常见如下的日志:
60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]
这意味着整个堆的碎片化已经非常严重了,我们可以从以下几个方面调整:(1)增加整个堆的大小——通过增加 -XX:G1ReservePercent
选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量;(2)通过减少 -XX:InitiatingHeapOccupancyPercent
提前启动标记周期;(3) 你也可以通过增加 -XX:ConcGCThreads
选项的值来增加并发标记线程的数目;
如果在GC日志中看到莫名其妙的FULL GC日志,又对应不到上述讲过的几种情况,那么就可以怀疑是巨型对象分配导致的,这里我们可以考虑使用 jmap
命令进行堆dump,然后通过MAT对堆转储文件进行分析。关于堆转储文件的分析技巧,后续会有专门的文章介绍。
G1的调优目标主要是在避免FULL GC和疏散失败的前提下,尽量实现较短的停顿时间和较高的吞吐量。关于G1 GC的调优,需要记住以下几点:
不要自己显式设置新生代的大小(用 Xmn
或 -XX:NewRatio
参数),如果显式设置新生代的大小,会导致目标时间这个参数失效。
由于G1收集器自身已经有一套预测和调整机制了,因此我们首先的选择是相信它,即调整 -XX:MaxGCPauseMillis=N
参数,这也符合G1的目的——让GC调优尽量简单,这里有个取舍:如果减小这个参数的值,就意味着会调小新生代的大小,也会导致新生代GC发生得更频繁,同时,还会导致混合收集周期中回收的老年代分区减少,从而增加FULL GC的风险。这个时间设置得越短,应用的吞吐量也会受到影响。
针对混合垃圾收集的调优。如果调整这期望的最大暂停时间这个参数还是无法解决问题,即在日志中仍然可以看到FULL GC的现象,那么就需要自己手动做一些调整,可以做的调整包括:
-XX:ConcGCThreads=n
这个参数,可以增加后台标记线程的数量,帮G1赢得这场你追我赶的游戏;-XX:InitiatingHeapOccupancyPercent
这个参数来实现这个目标,如果将这个参数调小,G1就会更早得触发并发垃圾收集周期。这个值需要谨慎设置:如果这个参数设置得太高,会导致FULL GC出现得频繁;如果这个值设置得过小,又会导致G1频繁得进行并发收集,白白浪费CPU资源。通过GC日志可以通过一个点来判断GC是否正常——在一轮并发周期结束后,需要确保堆剩下的空间小于InitiatingHeapOccupancyPercent的值。-XX:G1MixedGCLiveThresholdPercent=n
这个参数表示如果一个分区中的存活对象比例超过n,就不会被挑选为垃圾分区,因此可以通过这个参数控制每次混合收集的分区个数,这个参数的值越大,某个分区越容易被当做是垃圾分区;(2)G1在一个并发周期中,最多经历几次混合收集周期,这个可以通过 -XX:G1MixedGCCountTarget=n
设置,默认是8,如果减小这个值,可以增加每次混合收集收集的分区数,但是可能会导致停顿时间过长;(3)期望的GC停顿的最大值,由 MaxGCPauseMillis
参数确定,默认值是200ms,在混合收集周期内的停顿时间是向上规整的,如果实际运行时间比这个参数小,那么G1就能收集更多的分区。-XX:+UseG1GC
,告诉JVM使用G1垃圾收集器-XX:MaxGCPauseMillis=200
,设置GC暂停时间的目标最大值,这是个柔性的目标,JVM会尽力达到这个目标-XX:INitiatingHeapOccupancyPercent=45
,如果整个堆的使用率超过这个值,G1会触发一次并发周期。记住这里针对的是整个堆空间的比例,而不是某个分代的比例。通过 -Xmn
显式设置年轻代的大小,会干扰G1收集器的默认行为:
不要根据平均响应时间(ART)来设置 -XX:MaxGCPauseMillis=n
这个参数,应该设置希望90%的GC都可以达到的暂停时间。这意味着90%的用户请求不会超过这个响应时间,记住,这个值是一个目标,但是G1并不保证100%的GC暂停时间都可以达到这个目标
参数名 | 含义 | 默认值 |
---|---|---|
-XX:+UseG1GC | 使用G1收集器 | JDK1.8中还需要显式指定 |
-XX:MaxGCPauseMillis=n | 设置一个期望的最大GC暂停时间,这是一个柔性的目标,JVM会尽力去达到这个目标 | 200 |
-XX:InitiatingHeapOccupancyPercent=n | 当整个堆的空间使用百分比超过这个值时,就会触发一次并发收集周期,记住是整个堆 | 45 |
-XX:NewRatio=n | 新生代和老年代的比例 | 2 |
-XX:SurvivorRatio=n | Eden空间和Survivor空间的比例 | 8 |
-XX:MaxTenuringThreshold=n | 对象在新生代中经历的最多的新生代收集,或者说最大的岁数 | G1中是15 |
-XX:ParallelGCThreads=n | 设置垃圾收集器的并行阶段的垃圾收集线程数 | 不同的平台有不同的值 |
-XX:ConcGCThreads=n | 设置垃圾收集器并发执行GC的线程数 | n一般是ParallelGCThreads的四分之一 |
-XX:G1ReservePercent=n | 设置作为空闲空间的预留内存百分比,以降低目标空间溢出(疏散失败)的风险。默认值是 10%。增加或减少这个值,请确保对总的 Java 堆调整相同的量 | 10 |
-XX:G1HeapRegionSize=n | 分区的大小 | 堆内存大小的1/2000,单位是MB,值是2的幂,范围是1MB到32MB之间 |
-XX:G1HeapWastePercent=n | 设置您愿意浪费的堆百分比。如果可回收百分比小于堆废物百分比,JavaHotSpotVM不会启动混合垃圾回收周期(注意,这个参数可以用于调整混合收集的频率)。 | JDK1.8是5 |
-XX:G1MixedGCCountTarget=8 | 设置并发周期后需要执行多少次混合收集,如果混合收集中STW的时间过长,可以考虑增大这个参数。(注意:这个可以用来调整每次混合收集中回收掉老年代分区的多少,即调节混合收集的停顿时间) | 8 |
-XX:G1MixedGCLiveThresholdPercent=n | 一个分区是否会被放入mix GC的CSet的阈值。对于一个分区来说,它的存活对象率如果超过这个比例,则改分区不会被列入mixed gc的CSet中 | JDK1.6和1.7是65,JDK1.8是85 |
5/8*cpus
,在SPARC等大型机上这个系数是5/16。;ConcGCThreads指的是在并发标记阶段,并发执行标记的线程数,一般设置为ParallelGCThreads的四分之一。