引子最近遇到很多朋友过来咨询G1调优的问题,我自己去年有专门学过一次G1,但是当时只是看了个皮毛,因此自己也有不少问题。总体来讲,对于G1我有几个疑惑,希望能够在这篇文章中得到解决。
一、基础知识1. 初衷在G1提出之前,经典的垃圾收集器主要有三种类型:串行收集器、并行收集器和并发标记清除收集器,这三种收集器分别可以是满足Java应用三种不同的需求:内存占用及并发开销最小化、应用吞吐量最大化和应用GC暂停时间最小化,但是,上述三种垃圾收集器都有几个共同的问题:(1)所有针对老年代的操作必须扫描整个老年代空间;(2)新生代和老年代是独立的连续的内存块,必须先决定年轻代和老年代在虚拟地址空间的位置。 2. 设计目标G1是一种服务端应用使用的垃圾收集器,目标是用在多核、大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间,同时还能保持较高的吞吐量。 3. 使用场景G1适用于以下几种应用:
二、G1的重要概念1. 分区(Region)G1采取了不同的策略来解决并行、串行和CMS收集器的碎片、暂停时间不可控制等问题——G1将整个堆分成相同大小的分区(Region),如下图所示。 每个分区都可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。 新生代其实并不是适用于这种算法的,依然是在新生代满了的时候,对整个新生代进行回收——整个新生代中的对象,要么被回收、要么晋升,至于新生代也采取分区机制的原因,则是因为这样跟老年代的策略统一,方便调整代的大小。 G1还是一种带压缩的收集器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另一个可用分区,这个拷贝的过程就实现了局部的压缩。每个分区的大小从1M到32M不等,但是都是2的冥次方。 2. 收集集合(CSet)一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小。 3. 已记忆集合(RSet)RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可。 如下图所示,Region1和Region3中的对象都引用了Region2中的对象,因此在Region2的RSet中记录了这两个引用。
4. Snapshot-At-The-Beginning(SATB)SATB是维持并发GC的正确性的一个手段,G1GC的并发理论基础就是SATB,SATB是由Taiichi Yuasa为增量式标记清除垃圾收集器设计的一个标记算法。Yuasa的SATAB的标记优化主要针对标记-清除垃圾收集器的并发标记阶段。按照R大的说法:CMS的incremental update设计使得它在remark阶段必须重新扫描所有线程栈和整个young gen作为root;G1的SATB设计在remark阶段则只需要扫描剩下的satb_mark_queue。 SATB算法创建了一个对象图,它是堆的一个逻辑“快照”。标记数据结构包括了两个位图:previous位图和next位图。previous位图保存了最近一次完成的标记信息,并发标记周期会创建并更新next位图,随着时间的推移,previous位图会越来越过时,最终在并发标记周期结束的时候,next位图会将previous位图覆盖掉。 在并发周期开始之前,NTAMS字段被设置到每个分区当前的顶部,并发周期启动后分配的对象会被放在TAMS之前(图里下边的部分),同时被明确定义为隐式存活对象,而TAMS之后(图里上边的部分)的对象则需要被明确地标记。 并发标记过程中的堆分区 位于堆分区的Bottom和PTAMS之间的对象都会被标记并记录在previous位图中; 位于堆分区的Top和PATMS之间的对象均为隐式存活对象,同时也记录在previous位图中; 在重新标记阶段的最后,所有NTAMS之前的对象都会被标记 在并发标记阶段分配的对象会被分配到NTAMS之后的空间,它们会作为隐式存活对象被记录在next位图中。一次并发标记周期完成后,这个next位图会覆盖previous位图,然后将next位图清空。 SATB是一个快照标记算法,在并发标记进行的过程中,垃圾收集器(Collecotr)和应用程序(Mutator)都在活动,如果一个对象还没被mark到,这时候Mutator就修改了它的引用,那么这时候拿到的快照就是不完整的了,如何解决这个问题呢? G1 GC使用了SATB write barrier来解决这个问题——在并发标记过程中,将该对象的旧的引用记录在一个SATB日志对列或缓冲区中。去翻G1的代码,却发现实际代码如下——只该对象入队列,并没有将整个修改过程放在写屏障之间完成。 // hotspot/src/share/vm/gc_implementation/g1/g1SATBCardTableModRefBS.hpp enqueue的真正代码在 void G1SATBCardTableModRefBS::enqueue(oop pre_val) { stab_mark_queue.enqueue方法首先尝试将以前的值记录在一个缓冲区中,如果这个缓冲区已经满了,就会将当期这个SATB缓冲区“退休”并放入全局列表中,然后再给线程分配一个新的SATB缓冲区。并发标记线程会定期检查和处理那些“被填满”的缓冲区。 三、G1的过程1. 四个操作G1收集器的收集活动主要有四种操作:
第一、新生代垃圾收集的图例如下:
G1设计了一个标记阈值,它描述的是总体Java堆大小的百分比,默认值是45,这个值可以通过命令 在上图中有几个情况需要注意: 1、新生代的空间占用情况发生了变化——在并发收集周期中,至少有一次(很可能是多次)新生代垃圾收集; 2、注意到一些分区被标记为X,这些分区属于老年代,它们就是标记周期找出的包含最多垃圾的分区(注意:它们内部仍然保留着数据); 3、老年代的空间占用在标记周期结束后变得更多,这是因为在标记周期期间,新生代的垃圾收集会晋升对象到老年代,而且标记周期中并不会是否老年代的任何对象。 第二、G1的并发标记周期包括多个阶段: 1、初始标记(initial-mark),在这个阶段,应用会经历STW,通常初始标记阶段会跟一次新生代收集一起进行,换句话说——既然这两个阶段都需要暂停应用,G1 GC就重用了新生代收集来完成初始标记的工作。 在新生代垃圾收集中进行初始标记的工作,会让停顿时间稍微长一点,并且会增加CPU的开销。初始标记做的工作是设置两个TAMS变量(NTAMS和PTAMS)的值,所有在TAMS之上的对象在这个并发周期内会被识别为隐式存活对象; 2、根分区扫描(root-region-scan),这个过程不需要暂停应用,在初始标记或新生代收集中被拷贝到survivor分区的对象,都需要被看做是根,这个阶段G1开始扫描survivor分区,所有被survivor分区所引用的对象都会被扫描到并将被标记。 survivor分区就是根分区,正因为这个,该阶段不能发生新生代收集,如果扫描根分区时,新生代的空间恰好用尽,新生代垃圾收集必须等待根分区扫描结束才能完成。如果在日志中发现根分区扫描和新生代收集的日志交替出现,就说明当前应用需要调优。 3、并发标记阶段(concurrent-mark),并发标记阶段是多线程的,我们可以通过 记录在标记的时候发生的引用改变,SATB的思路是在开始的时候设置一个快照,然后假定这个快照不改变,根据这个快照去进行trace,这时候如果某个对象的引用发生变化,就需要通过pre-write barrier logs将该对象的旧的值记录在一个SATB缓冲区中,如果这个缓冲区满了,就把它加到一个全局的列表中——G1会有并发标记的线程定期去处理这个全局列表。 4、重新标记阶段(remarking),重新标记阶段是最后一个标记阶段,需要暂停整个应用,G1垃圾收集器会处理掉剩下的SATB日志缓冲区和所有更新的引用,同时G1垃圾收集器还会找出所有未被标记的存活对象。这个阶段还会负责引用处理等工作。 5、清理阶段(cleanup),清理阶段真正回收的内存很小,截止到这个阶段,G1垃圾收集器主要是标记处哪些老年代分区可以回收,将老年代按照它们的存活度(liveness)从小到大排列。 这个过程还会做几个事情:识别出所有空闲的分区、RSet梳理、将不用的类从metaspace中卸载、回收巨型对象等等。识别出每个分区里存活的对象有个好处是在遇到一个完全空闲的分区时,它的RSet可以立即被清理,同时这个分区可以立刻被回收并释放到空闲队列中,而不需要再放入CSet等待混合收集阶段回收;梳理RSet有助于发现无用的引用。 第三、混合收集只会回收一部分老年代分区,下图是第一次混合收集前后的堆情况对比。 混合收集会执行多次,一直运行到(几乎)所有标记点老年代分区都被回收,在这之后就会恢复到常规的新生代垃圾收集周期。当整个堆的使用率超过指定的百分比时,G1 GC会启动新一轮的并发标记周期。在混合收集周期中,对于要回收的分区,会将该分区中存活的数据拷贝到另一个分区,这也是为什么G1收集器最终出现碎片化的频率比CMS收集器小得多的原因——以这种方式回收对象,实际上伴随着针对当前分区的压缩。 2. 两个模式G1收集器的模式主要有两种:
在R大的帖子中,给出了一个假象的G1垃圾收集运行过程,如下图所示,在结合上一小节的细节,就可以将G1 GC的正常过程理解清楚了。 3. 巨型对象的管理巨型对象:在G1中,如果一个对象的大小超过分区大小的一半,该对象就被定义为巨型对象(Humongous Object)。巨型对象时直接分配到老年代分区,如果一个对象的大小超过一个分区的大小,那么会直接在老年代分配两个连续的分区来存放该巨型对象。巨型分区一定是连续的,分配之后也不会被移动——没啥益处。 由于巨型对象的存在,G1的堆中的分区就分成了三种类型:新生代分区、老年代分区和巨型分区,如下图所示: 如果一个巨型对象跨越两个分区,开始的那个分区被称为“开始巨型”,后面的分区被称为“连续巨型”,这样最后一个分区的一部分空间是被浪费掉的,如果有很多巨型对象都刚好比分区大小多一点,就会造成很多空间的浪费,从而导致堆的碎片化。如果你发现有很多由于巨型对象分配引起的连续的并发周期,并且堆已经碎片化(明明空间够,但是触发了FULL GC),可以考虑调整 关于巨型对象的回收:在JDK8u40之前,巨型对象的回收只能在并发收集周期的清除阶段或FULL GC过程中过程中被回收,在JDK8u40(包括这个版本)之后,一旦没有任何其他对象引用巨型对象,那么巨型对象也可以在年轻代收集中被回收。 4. G1执行过程中的异常情况并发标记周期开始后的FULL GCG1启动了标记周期,但是在并发标记完成之前,就发生了Full GC,日志常常如下所示: 51.408: [GC concurrent-mark-start] GC concurrent-mark-start开始之后就发生了FULL GC,这说明针对老年代分区的回收速度比较慢,或者说对象过快得从新生代晋升到老年代,或者说是有很多大对象直接在老年代分配。针对上述原因,我们可能需要做的调整有:调大整个堆的大小、更快得触发并发回收周期、让更多的回收线程参与到垃圾收集的动作中。 混合收集模式中的FULL GC在GC日志中观察到,在一次混合收集之后跟着一条FULL GC,这意味着混合收集的速度太慢,在老年代释放出足够多的分区之前,应用程序就来请求比当前剩余可分配空间大的内存。针对这种情况我们可以做的调整:增加每次混合收集收集掉的老年代分区个数;增加并发标记的线程数;提高混合收集发生的频率。 疏散失败(转移失败)在新生代垃圾收集快结束时,找不到可用的分区接收存活下来的对象,常见如下的日志: 60.238: [GC pause (young) (to-space overflow), 0.41546900 secs] 这意味着整个堆的碎片化已经非常严重了,我们可以从以下几个方面调整:(1)增加整个堆的大小——通过增加 巨型对象分配失败如果在GC日志中看到莫名其妙的FULL GC日志,又对应不到上述讲过的几种情况,那么就可以怀疑是巨型对象分配导致的,这里我们可以考虑使用 四、G1的调优G1的调优目标主要是在避免FULL GC和疏散失败的前提下,尽量实现较短的停顿时间和较高的吞吐量。关于G1 GC的调优,需要记住以下几点: 1、不要自己显式设置新生代的大小(用 2、由于G1收集器自身已经有一套预测和调整机制了,因此我们首先的选择是相信它,即调整 3、针对混合垃圾收集的调优。如果调整这期望的最大暂停时间这个参数还是无法解决问题,即在日志中仍然可以看到FULL GC的现象,那么就需要自己手动做一些调整,可以做的调整包括: 1)调整G1垃圾收集的后台线程数,通过设置 2)调整G1垃圾收集器并发周期的频率,如果让G1更早得启动垃圾收集,也可以帮助G1赢得这场比赛,那么可以通过设置 这个值需要谨慎设置:如果这个参数设置得太高,会导致FULL GC出现得频繁;如果这个值设置得过小,又会导致G1频繁得进行并发收集,白白浪费CPU资源。通过GC日志可以通过一个点来判断GC是否正常——在一轮并发周期结束后,需要确保堆剩下的空间小于InitiatingHeapOccupancyPercent的值。 3)调整G1垃圾收集器的混合收集的工作量,即在一次混合垃圾收集中尽量多处理一些分区,可以从另外一方面提高混合垃圾收集的频率。在一次混合收集中可以回收多少分区,取决于三个因素: (1)有多少个分区被认定为垃圾分区, (2)G1在一个并发周期中,最多经历几次混合收集周期,这个可以通过 (3)期望的GC停顿的最大值,由 五、G1的最佳实践1. 关键参数项
2. 最佳实践不要设置年轻代的大小通过
响应时间度量不要根据平均响应时间(ART)来设置 3. G1 GC的参数选项
常见问题1、Young GC、Mixed GC和Full GC的区别? 2、ParallelGCThreads和ConcGCThreads的区别? 3、write barrier在GC中的作用?如何理解G1 GC中write barrier的作用? write barrier在GC中的作用有点复杂,我们这里以trace GC算法为例讲下:trace GC有些算法是并发的,例如CMS和G1,即用户线程和垃圾收集线程可以同时运行,即mutator一边跑,collector一边收集。这里有一个限制是:黑色的对象不应该指向任何白色的对象。如果mutator视图让一个黑色的对象指向一个白色的对象,这个限制就会被打破,然后GC就会失败。 针对这个问题有两种解决思路: (1)通过添加read barriers阻止mutator看到白色的对象; (2)通过write barrier阻止mutator修改一个黑色的对象,让它指向一个白色的对象。write barrier的解决方法就是讲黑色的对象放到写write barrier后面。如果真得发生了white-on-black这种写需求,一般也有多种修正方法:增量得将白色的对象变灰,将黑色的对象重新置灰等等。 我理解,增量的变灰就是CMS和G1里并发标记的过程,将黑色的对象重新变灰就是利用卡表或SATB的缓冲区将黑色的对象重新置灰的过程,当然会在重新标记中将所有灰色的对象处理掉。关于G1中write barrier的作用,可以参考R大的这个帖子里提到的: 4、G1里在并发标记的时候,如果有对象的引用修改,要将旧的值写到一个缓冲区中,这个动作前后会有一个write barrier,这段可否细说下? 答:这块涉及到SATB标记算法的原理,SATB是指start at the beginning,即在并发收集周期的第一个阶段(初始标记)是STW的,会给所有的分区做个快照,后面的扫描都是按照这个快照进行;在并发标记周期的第二个阶段,并发标记,这是收集线程和应用线程同时进行的,这时候应用线程就可能修改了某些引用的值,导致上面那个快照不是完整的,因此G1就想了个办法,我把在这个期间对对象引用的修改都记录动作都记录下来,有点像mysql的操作日志。 5、GC算法中的三色标记算法怎么理解? PS:这个问题来自参考资料17,我将原文也贴在下面:
参考资料
|
|
来自: 昵称60672321 > 《jvm》