JVM性能调优实践—G1垃圾收集器全视角解析

共 22944字,需浏览 46分钟

 ·

2021-01-05 12:54

点击上方蓝色字体,选择“设为星标

回复”资源“获取更多资源

大数据技术与架构
点击右侧关注,大数据开发领域最强公众号!

大数据真好玩
点击右侧关注,大数据真好玩!


简介

本文将总结一下GC的种类,然后侧重总结下G1(Garbage-First)垃圾收集器的分代,结合open-jdk源码分析下重要算法如SATB,重要存储结构如CSet、RSet、TLAB、PLAB、Card Table等。最后会再梳理下G1 GC的YoungGC,MixedGC收集过程。

GC的分类

GC的主要回收区域就是年轻代(young gen)、老年代(tenured gen)、持久区(perm gen),在jdk8之后,perm gen消失,被替换成了元空间(Metaspace),元空间会在普通的堆区进行分配。垃圾收集为了提高效率,采用分代收集的方式,对于不同特点的回收区域使用不同的垃圾收集器。系统正常运行情况young是比较频繁的,full gc会触发整个heap的扫描和回收。在G1垃圾收集器中,最好的优化状态就是通过不断调整分区空间,避免进行full gc,可以大幅提高吞吐量。下面会详细介绍。

串行垃圾回收器

JDK 1.3之前的垃圾回收器,单线程回收,并且会有stop theworld(下文会简称STW),也即GC时,暂停所有用户线程。其运行方式是单线程的,适合Client模式的应用,适合单CPU环境。串行的垃圾收集器有两种,Serial以及SerialOld,一般会搭配使用。新生代使用Serial采取复制算法,老年代使用Serial Old采取标记整理算法。Client应用或者命令行程序可以,通过-XX:+UseSerialGC可以开启上述回收模式。

  • Serial:用于新生代垃圾收集,复制算法

  • SerialOld:用于老年代垃圾收集,标记整理算法

并行垃圾回收器

整体来说,并行垃圾回收相对于串行,是通过多线程运行垃圾收集的。也会stop-the-world。适合Server模式以及多CPU环境。一般会和jdk1.5之后出现的CMS搭配使用。并行的垃圾回收器有以下几种:

  • ParNew:Serial收集器的多线程版本,默认开启的收集线程数和cpu数量一样,运行数量可以通过修改ParallelGCThreads设定。用于新生代收集,复制算法。使用-XX:+UseParNewGC,和Serial Old收集器组合进行内存回收。

  • Parallel Scavenge: 关注吞吐量,吞吐量优先,吞吐量=代码运行时间/(代码运行时间+垃圾收集时间),也就是高效率利用cpu时间,尽快完成程序的运算任务 可以设置最大停顿时间MaxGCPauseMillis以及,吞吐量大小GCTimeRatio。如果设置了-XX:+UseAdaptiveSizePolicy参数,则随着GC,会动态调整新生代的大小,Eden,Survivor比例等,以提供最合适的停顿时间或者最大的吞吐量。用于新生代收集,复制算法。通过-XX:+UseParallelGC参数,Server模式下默认提供了其和SerialOld进行搭配的分代收集方式。

  • Parllel Old:Parallel Scavenge的老年代版本。JDK 1.6开始提供的。在此之前Parallel Scavenge的地位也很尴尬,而有了Parllel Old之后,通过-XX:+UseParallelOldGC参数使用Parallel Scavenge + Parallel Old器组合进行内存回收。

并发标记扫描垃圾回收器(CMS)

CMS(Concurrent Mark Sweep)基于“标记—清除”算法,用于老年代,所以其关注点在于减少“pause time”也即因垃圾回收导致的stop the world时间。对于重视服务的响应速度的应用可以使用CMS。因为CMS是“并发”运行的,也即垃圾收集线程可以和用户线程同时运行。缺点就是会产生内存碎片。CMS的回收分为几个阶段:

  1. 初始标记:标记一下GC Roots能直接关联到的对象,会“Stop The World”

  2. 并发标记:GC Roots Tracing,可以和用户线程并发执行。

  3. 重新标记:标记期间产生的对象存活的再次判断,修正对这些对象的标记,执行时间相对并发标记短,会“Stop The World”。

  4. 并发清除:清除对象,可以和用户线程并发执行。

CMS最主要解决了pause time,但是会占用CPU资源,牺牲吞吐量。CMS默认启动的回收线程数是(CPU数量+3)/ 4,当CPU < 4个时,会影响用户线程的执行。另外一个缺点就是内存碎片的问题了,碎片会给大对象的内存分配造成麻烦,如果老年代的可用的连续空间也无法分配时,会触发full gc。并且full gc时如果发生young gc会被young gc打断,执行完young gc之后再继续执行full gc。

-XX:UseConcMarkSweepGC参数可以开启CMS,年轻代使用ParNew,老年代使用CMS,同时Serial Old收集器将作为CMS收集器出现Concurrent Mode Failure失败后的后备收集器使用。

G1垃圾收集器

G1(Garbage-First)是在JDK 7u4版本之后发布的垃圾收集器,并在jdk9中成为默认垃圾收集器。通过“-XX:+UseG1GC”启动参数即可指定使用G1 GC。从整体来说,G1也是利用多CPU来缩短stop the world时间,并且是高效的并发垃圾收集器。但是G1不再像上文所述的垃圾收集器,需要分代配合不同的垃圾收集器,因为G1中的垃圾收集区域是“分区”(Region)的。G1的分代收集和以上垃圾收集器不同的就是除了有年轻代的ygc,全堆扫描的fullgc外,还有包含所有年轻代以及部分老年代Region的MixedGC。G1的优势还有可以通过调整参数,指定垃圾收集的最大允许pause time。下面会详细阐述下G1分区以及分代的概念,以及G1 GC的几种收集过程的分类。

G1分区的概念

在G1之前的垃圾收集器,将堆区主要划分了Eden区,Old区,Survivor区。其中对于Eden,Survivor对回收过程来说叫做“年轻代垃圾收集”。并且年轻代和老年代都分别是连续的内存空间。

G1将堆分成了若干Region,以下和”分区”代表同一概念。Region的大小可以通过G1HeapRegionSize参数进行设置,其必须是2的幂,范围允许为1Mb到32Mb。JVM的会基于堆内存的初始值和最大值的平均数计算分区的尺寸,平均的堆尺寸会分出约2000个Region。分区大小一旦设置,则启动之后不会再变化。如下图简单画了下G1分区模型。

  1. Eden regions(年轻代-Eden区)

  2. Survivor regions(年轻代-Survivor区)

  3. Old regions(老年代)

  4. Humongous regions(巨型对象区域)

  5. Free resgions(未分配区域,也会叫做可用分区)-上图中空白的区域

关于分区有几个重要的概念:

  • G1还是采用分代回收,但是不同的分代之间内存不一定是连续的,不同分代的Region的占用数也不一定是固定的(不建议通过相关选项显式设置年轻代大小。会覆盖暂停时间目标。)。年轻代的Eden,Survivor数量会随着每一次GC发生相应的改变。

  • 分区是不固定属于哪个分代的,所以比如一次ygc过后,原来的Eden的分区就会变成空闲的可用分区,随后也可能被用作分配巨型对象,成为H区等。

  • G1中的巨型对象是指,占用了Region容量的50%以上的一个对象。Humongous区,就专门用来存储巨型对象。如果一个H区装不下一个巨型对象,则会通过连续的若干H分区来存储。因为巨型对象的转移会影响GC效率,所以并发标记阶段发现巨型对象不再存活时,会将其直接回收。ygc也会在某些情况下对巨型对象进行回收。通过上图可以看出,分区可以有效利用内存空间,因为收集整体是使用“标记-整理”,Region之间基于“复制”算法,GC后会将存活对象复制到可用分区(未分配的分区),所以不会产生空间碎片。

  • G1类似CMS,也会在比如一次fullgc中基于堆尺寸的计算重新调整(增加)堆的空间。但是相较于执行fullgc,G1 GC会在无法分配对象或者巨型对象无法获得连续分区来分配空间时,优先尝试扩展堆空间来获得更多的可用分区。原则上就是G1会计算执行GC的时间,并且极力减少花在GC上的时间(包括ygc,mixgc),如果可能,会通过不断扩展堆空间来满足对象分配、转移的需要。

  • 因为G1提供了“可预测的暂停时间”,也是基于G1的启发式算法,所以G1会估算年轻代需要多少分区,以及还有多少分区要被回收。ygc触发的契机就是在Eden分区数量达到上限时。一次ygc会回收所有的Eden和survivor区。其中存活的对象会被转移到另一个新的survivor区或者old区,如果转移的目标分区满了,会再将可用区标记成S或者O区。

G1 中的重要数据结构、算法

在提及G1的垃圾收集过程时,需要理解几个G1的重要的分区内部的详细数据结构、以及核心算法。

TLAB(Thread Local Allocation Buffer)本地线程缓冲区

G1 GC会默认会启用Tlab优化。其作用就是在并发情况下,基于CAS的独享线程(mutator threads)可以优先将对象分配在一块内存区域(属于Java堆的Eden中),只是因为是Java线程独享的内存区,没有锁竞争,所以分配速度更快,每个Tlab都是一个线程独享的。如果待分配的对象被判断是巨型对象,则不使用TLAB。下面把TLAB分配对象内存的open jdk部分源码附上,有助理解。


HeapWord* G1CollectedHeap::allocate_new_tlab(size_t min_size,
size_t requested_size,
size_t* actual_size) {
assert_heap_not_locked_and_not_at_safepoint();
assert(!is_humongous(requested_size), "we do not allow humongous TLABs");

return attempt_allocation(min_size, requested_size, actual_size);
}


inline HeapWord* G1CollectedHeap::attempt_allocation(size_t min_word_size,
size_t desired_word_size,
size_t* actual_word_size) {
assert_heap_not_locked_and_not_at_safepoint();
// 排除巨型对象
assert(!is_humongous(desired_word_size), "attempt_allocation() should not "
"be called for humongous allocation requests");

// 在当前的region分配
HeapWord* result = _allocator->attempt_allocation(min_word_size, desired_word_size, actual_word_size);

// 可用空间不够,申请新的region分配
if (result == NULL) {
*actual_word_size = desired_word_size;
// 可能存在多线程申请,所以通过加锁的方式申请,如果young区没有超出阀值,则会获取新的region
result = attempt_allocation_slow(desired_word_size);
}

// 判断没有因gc导致堆locked
assert_heap_not_locked();
if (result != NULL) {
assert(*actual_word_size != 0, "Actual size must have been set here");
// 脏化年轻代的card(卡片)数据
dirty_young_block(result, *actual_word_size);
} else {
*actual_word_size = 0;
}

return result;
}

PLAB(Promotion Local Allocation Buffer) 晋升本地分配缓冲区

在ygc中,对象会将全部Eden区存货的对象转移(复制)到S区分区。也会存在S区对象晋升(Promotion)到老年代。这个决定晋升的阀值可以通过MaxTenuringThreshold设定。晋升的过程,无论是晋升到S还是O区,都是在GC线程的PLAB中进行。每个GC线程都有一个PLAB。

Collection Sets(CSets)待收集集合

GC中待回收的region的集合。CSet中可能存放着各个分代的Region。CSet中的存活对象会在gc中被移动(复制)。GC后CSet中的region会成为可用分区。

Card Table 卡表

将Java堆划分为相等大小的一个个区域,这个小的区域(一般size在128-512字节)被当做Card,而Card Table维护着所有的Card。Card Table的结构是一个字节数组,Card Table用单字节的信息映射着一个Card。当Card中存储了对象时,称为这个Card被脏化了(dirty card)。对于一些热点Card会存放到Hot card cache。同Card Table一样,Hot card cache也是全局的结构。

Remembered Sets(RSets)已记忆集合

已记忆集合在每个分区中都存在,并且每个分区只有一个RSet。其中存储着其他分区中的对象对本分区对象的引用,是一种points-in结构。ygc的时候,只要扫描RSet中的其他old区对象对于本young区的引用,不需要扫描所有old区。mixed gc时,扫描Old区的RSet中,其他old区对于本old分区的引用,一样不用扫描所有的old区。提高了GC效率。因为每次GC都会扫描所有young区对象,所以RSet只有在扫描old引用young,old引用old时会被使用。

为了防止RSet溢出,对于一些比较“Hot”的RSet会通过存储粒度级别来控制。RSet有三种粒度,对于“Hot”的RSet在存储时,根据细粒度的存储阀值,可能会采取粗粒度。

这三种粒度的RSet都是通过PerRegionTable来维护内部数据的。可以查看其部分源码如下:


class PerRegionTable: public CHeapObj {
friend class OtherRegionsTable;
friend class HeapRegionRemSetIterator;

HeapRegion* _hr; // 来自其他分区的引用
CHeapBitMap _bm; // card索引存放的位图
jint _occupied; // 已占用的容量

// next pointer for free/allocated 'all' list
PerRegionTable* _next;

// prev pointer for the allocated 'all' list
PerRegionTable* _prev;

// next pointer in collision list
PerRegionTable * _collision_list_next;

// Global free list of PRTs
static PerRegionTable* volatile _free_list;

下面是三种粒度级别,以及对应的简要数据结构:细粒度(fine),其PerRegionTable存储了所有对于本Resgion的引用的卡片的索引,其卡片索引都存储在CHeapBitMap结构里。伪代码类似:hash_map 。

Snapshot-At-The-Beginning(SATB)

SATB是在G1 GC在并发标记阶段使用的增量式的标记算法。并发标记是并发多线程的,但并发线程在同一时刻只扫描一个分区。在解释SATB前先要了解三色标记法。三色标记法是将对象的存活状态用三种颜色标记,从黑色到灰色逐层标记:

  • 黑:该对象被标记了,并且其引用的对象也都被标记完成。

  • 灰:对象被标记了,但其引用的对象还没有被标记完。

  • 白:对象还没有被标记,标记阶段结束后,会被回收。

在CMS GC中,并发标记阶段使用的是Incremental update批量更新算法,在增加引用时的写屏障中触发新的对象引用的标记(三色标记法)。G1的并发标记算法,使用的是SATB。在GC开始时先创建一个对象快照,STAB可以在并发标记时标记所有快照中当时的存活对象。标记过程中新分配的对象也会被标记为存活对象,不会被回收。STAB核心的两个结构就是两个BitMap。如下:

 // from G1ConcurrentMark-可以认为Bitmap的内部存储着对象地址(reference 是8byte,所以Bitmap存储着一个个64bit结构)
G1CMBitMap* _prev_mark_bitmap; // 全局的bitmap,存储PTAMS偏移位置,也即当前标记的对象的地址(初始值是对应上次已经标记完成的地址)
G1CMBitMap* _next_mark_bitmap; // 全局的bitmap,存储NTAMS偏移位置。标记过程不断移动,标记完成后会和prev_map 互换。

bitmap分别存储着每个分区中,并发标记过程里的两个重要的变量:PTAMS(pre-top-at-mark-start,代表着分区上一次完成标记的位置) 以及NTAMS(next-top-at-mark-start,随着标记的进行会不断移动,一开始在top位置)。SATB通过控制两个变量的移动来进行标记。为了直观了解标记过程,如下图所示:

A:初始标记,因为要扫描所有Root Trace可达的对象,会有STW的暂停时间,会将扫描分区的NTAMS值设置为分区的顶部(Top)。B:最终标记,因为并发导致会有新分配的对象,因为并发标记过程中对象会被分配到NTAMS~TOP中间的区域。这些对象会被定义为”隐式对象“。因为NTAMS有很多值了,所以 next_mark_bitmap也会开始存储NTAMS标记的对象的地址。C:清除阶段:next_mark_bitmap和 prev_mark_bitmap会进行Swap。PTAMS和NTAMS也会互换值。清除所有Bottom~PTAMS的对象。对于”隐式对象“会在下次垃圾收集过程进行回收(如图F过程)。这也是SATB存在弊端,会一定程度产生未能在本次标记中识别的浮动垃圾。

另,以上过程省略了根分区扫描和并发标记。上图是包含了两次标记过程,主要是为了展示B-E过程中,并发情况新对象的分配。

G1 GC的分类和过程 JDK10 之前的G1中的GC只有YoungGC,MixedGC。FullGC处理会交给单线程的Serial Old垃圾收集器。

YoungGC年轻代收集 在分配一般对象(非巨型对象)时,当所有eden region使用达到最大阀值并且无法申请足够内存时,会触发一次YoungGC。每次younggc会回收所有Eden以及Survivor区,并且将存活对象复制到Old区以及另一部分的Survivor区。到Old区的标准就是在PLAB中得到的计算结果。因为YoungGC会进行根扫描,所以会stop the world。

YoungGC的回收过程如下:

1.根扫描,跟CMS类似,Stop the world,扫描GC Roots对象。

2.处理Dirty card,更新RSet.

3.扫描RSet,扫描RSet中所有old区对扫描到的young区或者survivor去的引用。

4.拷贝扫描出的存活的对象到survivor2/old区

5.处理引用队列,软引用,弱引用,虚引用(下一篇优化中会再讲一下这三种引用对gc的影响)

MixGC混合收集

MixedGC是G1 GC特有的,跟Full GC不同的是Mixed GC只回收部分老年代的Region。哪些old region能够放到CSet里面,有很多参数可以控制。比如G1HeapWastePercent参数,在一次younggc之后,可以允许的堆垃圾百占比,超过这个值就会触发mixedGC。G1MixedGCLiveThresholdPercent参数控制的,old代分区中的存活对象比,达到阀值时,这个old分区会被放入CSet。源码可以看下gc/g1/collectionSetChooser。

MixedGC一般会发生在一次YoungGC后面,为了提高效率,MixedGC会复用YoungGC的全局的根扫描结果,因为这个Stop the world过程是必须的,整体上来说缩短了暂停时间。

MixGC的回收过程可以理解为YoungGC后附加的全局concurrent marking,全局的并发标记主要用来处理old区(包含H区)的存活对象标记,过程如下:

  1. 初始标记(InitingMark)。标记GC Roots,会STW,一般会复用YoungGC的暂停时间。如前文所述,初始标记会设置好所有分区的NTAMS值。

  2. 根分区扫描(RootRegionScan)。这个阶段GC的线程可以和应用线程并发运行。其主要扫描初始标记以及之前YoungGC对象转移到的Survivor分区,并标记Survivor区中引用的对象。所以此阶段的Survivor分区也叫根分区(RootRegion)。部分源码如下:

// 当有需要扫描的的S分区时,该Task会被开启,扫描后会执行scan_finished,notify其他GC活动,如youngGC  
class G1CMRootRegionScanTask : public AbstractGangTask {
G1ConcurrentMark* _cm;
public:
G1CMRootRegionScanTask(G1ConcurrentMark* cm) :
AbstractGangTask("G1 Root Region Scan"), _cm(cm) { }

void work(uint worker_id) {
assert(Thread::current()->is_ConcurrentGC_thread(),
"this should only be done by a conc GC thread");

G1CMRootRegions* root_regions = _cm->root_regions(); // _root_regions 初始化为待扫描的Survivor分区。
HeapRegion* hr = root_regions->claim_next();
while (hr != NULL) { // 循环分别处理所有待扫描的S分区
_cm->scan_root_region(hr, worker_id); //方法如下
hr = root_regions->claim_next();
}
}
};


// 扫描Survivor区 (HeapRegion* hr)
void G1ConcurrentMark::scan_root_region(HeapRegion* hr, uint worker_id) {

assert(hr->next_top_at_mark_start() == hr->bottom(), "invariant");
G1RootRegionScanClosure cl(_g1h, this, worker_id);

const uintx interval = PrefetchScanIntervalInBytes;
HeapWord* curr = hr->bottom(); // 扫描分区的bottom
const HeapWord* end = hr->top(); // 扫描分区的top
while (curr < end) { // 扫描所有bottom到top的分区的对象
Prefetch::read(curr, interval);
oop obj = oop(curr);
int size = obj->oop_iterate_size(&cl);
assert(size == obj->size(), "sanity");
curr += size;
}
}

3.并发标记(ConcurrentMark)。会并发标记所有非完全空闲的分区的存活对象,也即使用了SATB算法,标记各个分区。

4.最终标记(Remark)。主要处理SATB缓冲区,以及并发标记阶段未标记到的漏网之鱼(存活对象),会STW,可以参考上文的SATB处理。

5.清除阶段(Clean UP)。上述SATB也提到了,会进行bitmap的swap,以及PTAMS,NTAMS互换。整理堆分区,调整相应的RSet(比如如果其中记录的Card中的对象都被回收,则这个卡片的也会从RSet中移除),如果识别到了完全空的分区,则会清理这个分区的RSet。这个过程会STW。

清除阶段之后,还会对存活对象进行转移(复制算法),转移到其他可用分区,所以当前的分区就变成了新的可用分区。复制转移主要是为了解决分区内的碎片问题。

FullGC

G1在对象复制/转移失败或者没法分配足够内存(比如巨型对象没有足够的连续分区分配)时,会触发FullGC。FullGC使用的是stop the world的单线程的Serial Old模式,所以一旦触发FullGC则会STW应用线程,并且执行效率很慢。JDK 8版本的G1是不提供Full gc的处理的。对于G1 GC的优化,很大的目标就是没有FullGC。

上文内容都是基于JDK 8的版本的,在jdk10版本的G1 GC会有很多优化。Full CG方面,将提供并发标记的Full GC方案:Parallelize Mark-Sweep-Compact。Card Table的扫描也会得到加速。RSet也优化了,目前的RSet会存储在所有的分区里,新版本的RSet只需要在CSet中,并且是在Remark到Clean阶段之间并发构建RSet。这项优化会增加整个并发标记的周期,但是缩减了很多RSet的占用空间。另外,对于PauseTime会有更精准的处理,在MixedGC的对象拷贝阶段,提供了可放弃拷贝的(Abortable)选项。MixedGC会计算下一个Region的对象拷贝,如果可能会超过预期的pause time,则会放弃这次拷贝。

了解了G1垃圾收集器的运行机制之后,就可以针对一些GC相关参数来调整内存分配以及运行策略。下述的调优主要针对G1垃圾收集器进行介绍,以及会分析一下G1 GC的日志格式。

G1 GC日志分析

在执行具体的调优任务前,需要结合GC日志以及应用本身的特点。打印详细GClog,需要添加如下启动参数:

-XX:+UseG1GC -XX:+PrintGCDetails -XX:+PrintGCDateStamps

本文使用的Java version:

java version "1.8.0_60"
Java(TM) SE Runtime Environment (build 1.8.0_60-b27)
Java HotSpot(TM) 64-Bit Server VM (build 25.60-b23, mixed mode)

下面截取gc.log 中的一次YoungGC和一次MixedGC。

2018-05-26T19:51:45.808-0800: 127.031: [GC pause (G1 Evacuation Pause) (young), 0.0063650 secs]
[Parallel Time: 5.5 ms, GC Workers: 4]
[GC Worker Start (ms): Min: 127030.7, Avg: 127030.7, Max: 127030.7, Diff: 0.0]
[Ext Root Scanning (ms): Min: 1.1, Avg: 1.3, Max: 1.5, Diff: 0.4, Sum: 5.3]
[Update RS (ms): Min: 1.3, Avg: 1.4, Max: 1.4, Diff: 0.1, Sum: 5.4]
[Processed Buffers: Min: 3, Avg: 12.5, Max: 24, Diff: 21, Sum: 50]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.1, Avg: 0.3, Max: 0.7, Diff: 0.5, Sum: 1.3]
[Object Copy (ms): Min: 2.2, Avg: 2.4, Max: 2.5, Diff: 0.4, Sum: 9.5]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Termination Attempts: Min: 1, Avg: 1.8, Max: 3, Diff: 2, Sum: 7]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 5.4, Avg: 5.4, Max: 5.4, Diff: 0.1, Sum: 21.6]
[GC Worker End (ms): Min: 127036.1, Avg: 127036.1, Max: 127036.1, Diff: 0.0]
[Code Root Fixup: 0.1 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 0.6 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.3 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 39.0M(39.0M)->0.0B(2048.0K) Survivors: 3072.0K->4096.0K Heap: 111.4M(128.0M)->72.9M(128.0M)]
[Times: user=0.02 sys=0.00, real=0.01 secs]

2018-05-26T19:57:20.534-0800: 461.748: [GC pause (G1 Evacuation Pause) (mixed), 0.0685311 secs]
[Parallel Time: 67.2 ms, GC Workers: 4]
[GC Worker Start (ms): Min: 461748.1, Avg: 461748.1, Max: 461748.1, Diff: 0.0]
[Ext Root Scanning (ms): Min: 0.8, Avg: 2.6, Max: 7.5, Diff: 6.6, Sum: 10.5]
[Update RS (ms): Min: 0.0, Avg: 0.3, Max: 0.7, Diff: 0.7, Sum: 1.4]
[Processed Buffers: Min: 0, Avg: 9.2, Max: 35, Diff: 35, Sum: 37]
[Scan RS (ms): Min: 29.7, Avg: 34.3, Max: 36.1, Diff: 6.5, Sum: 137.1]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.5, Max: 0.8, Diff: 0.8, Sum: 2.0]
[Object Copy (ms): Min: 28.8, Avg: 29.3, Max: 29.8, Diff: 1.0, Sum: 117.1]
[Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.3]
[Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 4]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[GC Worker Total (ms): Min: 67.1, Avg: 67.1, Max: 67.1, Diff: 0.0, Sum: 268.5]
[GC Worker End (ms): Min: 461815.2, Avg: 461815.2, Max: 461815.2, Diff: 0.0]
[Code Root Fixup: 0.3 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]
[Other: 1.0 ms]
[Choose CSet: 0.4 ms]
[Ref Proc: 0.2 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.2 ms]
[Eden: 5120.0K(5120.0K)->0.0B(57.0M) Survivors: 1024.0K->1024.0K Heap: 64.3M(128.0M)->55.8M(128.0M)]
[Times: user=0.07 sys=0.11, real=0.07 secs]

两个收集过程的日志格式相似。先以young gc为例,分析一下日志信息。

GC并行任务

并行任务部分主要包含Parallel Time这一行以及其下面的详细任务信息。

[Parallel Time: , GC Workers: ] [Parallel Time: 5.9 ms, GC Workers: 4] 这一行标记着并行阶段的汇总信息。总共花费时间以及GC的工作线程数。后续的这两行,start-end是时间戳信息。Diff是偏移平均时间的值。Diff越小越好,说明每个工作线程的速度都很均匀,如果Diff值偏大,就要看下面具体哪一项活动产生的波动。Avg代表平均时间值。如果Avg跟Min,Max偏差不大是比较正常的,否则也要详细分析具体的偏差值大的任务。

  • [GC Worker Start (ms): Min: 127030.7, Avg: 127030.7, Max: 127030.7, Diff: 0.0]

  • [GC Worker End (ms): Min: 127036.1, Avg: 127036.1, Max: 127036.1, Diff: 0.0]

下一段显示的是详细的并行阶段的GC活动。

      [Ext Root Scanning (ms): Min: 1.1, Avg: 1.3, Max: 1.5, Diff: 0.4, Sum: 5.3]
[Update RS (ms): Min: 1.3, Avg: 1.4, Max: 1.4, Diff: 0.1, Sum: 5.4]
[Processed Buffers: Min: 3, Avg: 12.5, Max: 24, Diff: 21, Sum: 50]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.1, Avg: 0.3, Max: 0.7, Diff: 0.5, Sum: 1.3]
[Object Copy (ms): Min: 2.2, Avg: 2.4, Max: 2.5, Diff: 0.4, Sum: 9.5]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Termination Attempts: Min: 1, Avg: 1.8, Max: 3, Diff: 2, Sum: 7]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 5.4, Avg: 5.4, Max: 5.4, Diff: 0.1, Sum: 21.6]

Ext Root Scanning

外部根区扫描。外部根是堆外区。JNI引用,JVM系统目录,Classloaders等。后面跟着具体的时间信息。

RSet的处理

log中RS指的是RSet。

  1. UpdateRS:更新RSet的时间信息。-XX:MaxGCPauseMillis参数是限制G1的暂停之间,一般RSet更新的时间小于10%的目标暂停时间是比较可取的。如果花费在RSetUpdate的时间过长,可以修改其占用总暂停时间的百分比-XX:G1RSetUpdatingPauseTimePercent。这个参数的默认值是10。

  2. Processed Buffers:已处理缓冲区。这个阶段处理的是在优化线程中处理dirty card分区扫描时记录的日志缓冲区。

  3. Scan RS:关于RSet的粒度,如果RSet中的Bitmap是粗粒度的,那么就会增加RSet扫描的时间。如下所示的扫描时间,说明还没有粗化的RSet。

  4. Code Root Scanning:代码跟的扫描。只有在分区的RSet有强代码根时会检查CSet的对内引用,例如常量池。

   [Update RS (ms): Min: 1.3, Avg: 1.4, Max: 1.4, Diff: 0.1, Sum: 5.4]
[Processed Buffers: Min: 3, Avg: 12.5, Max: 24, Diff: 21, Sum: 50]

[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.1, Avg: 0.3, Max: 0.7, Diff: 0.5, Sum: 1.3]

如果观察到RS的处理时间较长,可以使用-XX:+G1SummarizeRSetStats参数,在GC结束后打印RSet的详细信息。一般在debug环境排查用。还有一个辅助参数G1SummarizeRSetStatsPeriod=0用来控制第几次GC后统计一次RSet信息。

Object Copy

Object Copy:该任务主要是对CSet中存活对象进行转移(复制)。对象拷贝的时间一般占用暂停时间的主要部分。如果拷贝时间和”预测暂停时间“有相差很大,也可以调整年轻代尺寸大小。

  [Object Copy (ms): Min: 2.2, Avg: 2.4, Max: 2.5, Diff: 0.4, Sum: 9.5]

Termination

这里的终止主要是终止工作线程。Work线程在工作终止前会检查其他工作线程的任务,如果其他work线程有没完成的任务,会抢活。如果终止时间较长,额能是某个work线程在某项任务执行时间过长。

GC Worker Other

花在GC之外的工作线程的时间,比如因为JVM的某个活动,导致GC线程被停掉。这部分消耗的时间不是真正花在GC上,只是作为log的一部分记录。

GC Worker Total

并行阶段的GC汇总,包含了GC以及GC Worker Other的总时间。

GC 串行活动

一下是串行的GC活动。包括代码根的更新和扫描。Clear的时候还要清理RSet相应去除的Card Table信息。G1 GC在扫描Card信息时会有一个标记记录,防止重复扫描同一个Card。

   [Code Root Fixup: 0.1 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.1 ms]

GC Other活动

剩余的部分就是其他GC活动了。主要包含:选择CSet、引用处理和排队、卡片重新脏化、回收空闲巨型分区以及在收集之后释放CSet。

   [Other: 0.6 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.3 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
  1. Choose CSet:选择CSet,因为年轻代的所有分区都会被收集,所以CSet不需要选择,消耗时间都是0ms。Choose CSet任务一般都是在mixed gc的过程中触发。

  2. Ref Proc、Enq: 引用处理主要针对弱引用,软引用,徐引用,final,JNI引用。将这些引用排列到相应的reference队列中。

  3. Redirty Cards:重新脏化卡片。排队引用可能会更新RSet,所以需要对关联的Card重新脏化(Redirty Cards)。

  4. Humongous Register、Reclaim 主要是对巨型对象回收的信息,youngGC阶段会对RSet中有引用的短命的巨型对象进行回收,巨型对象会直接回收而不需要进行转移(转移代价巨大,也没必要)。

  5. Free CSet: 释放CSet,其中也会清理CSet中的RSet。

垃圾收集结果统计

如下对比了一次youngGC和一次mixedGC的垃圾收集结果:

young: [Eden: 39.0M(39.0M)->0.0B(2048.0K) Survivors: 3072.0K->4096.0K Heap: 111.4M(128.0M)->72.9M(128.0M)]
mixed: [Eden: 5120.0K(5120.0K)->0.0B(57.0M) Survivors: 1024.0K->1024.0K Heap: 64.3M(128.0M)->55.8M(128.0M)]
  1. Eden: 39.0M(39.0M)->0.0B(2048.0K):Eden分区GC前39M,GC后是0。括号里面的分别是GC前后Eden分区的总大小。可以看到在一次GC后,Eden的空间做了调整。G1 GC的暂停时间是可预测的,所以YoungGC之后,会根据pause time的目标重新计算需要的Eden分区数,进行动态调整。

  2. Survivors: 3072.0K->4096.0K。Survivors空间的变化,空间增长了,说明有存活对象E区晋升到S区。

  3. Heap: 111.4M(128.0M)->72.9M(128.0M)。整个堆区的GC前后空间数据,G1 GC会动态调整堆区,但这次回收中没有改变堆区的容量。

G1 GC相关参数

G1 GC是垃圾收集优先的垃圾收集器,同时有着”可预期的暂停时间“,垃圾收集过程是分代的,但堆空间是基于分区进行分配。所以整体的空间利用率,时间效率都有更大的提升。G1的YoungGC和MixedGC以及并发标记阶段都有很多机制可以控制触发时机,一般情况是不建议过度更改官方建议参数。但默认参数不一定适用于所有应用,调优前需要有明确的目标,或者问题处理思路。

以下先整理下G1垃圾收集器可以调整的重要参数:

  • -XX:+UseG1GC:启用 G1 (Garbage First) 垃圾收集器

  • -XX:MaxGCPauseMillis:设置允许的最大GC停顿时间(GC pause time),这只是一个期望值,实际可能会超出,可以和年轻代大小调整一起并用来实现。默认是200ms。

  • -XX:G1HeapRegionSize:每个分区的大小,默认值是会根据整个堆区的大小计算出来,范围是1M~32M,取值是2的幂,计算的倾向是尽量有2048个分区数。比如如果是2G的heap,那region=1M。16Gheap,region=8M。

  • -XX:MaxTenuringThreshold=n:晋升到老年代的“年龄”阀值,默认值为 15。

  • -XX:InitiatingHeapOccupancyPercent:一般会简写IHOP,默认是45%,这个占比跟并发周期的启动相关,当空间占比达到这个值时,会启动并发周期。如果经常出现FullGC,可以调低该值,尽早的回收可以减少FullGC的触发,但如果过低,则并发阶段会更加频繁,降低应用的吞吐。

  • -XX:G1NewSizePercent:年轻代最小的堆空间占比,默认是5%。

  • -XX:G1MaxNewSizePercent:年轻代最大的堆空间占比,默认是60%。

  • -XX:ConcGCThreads:并发执行的线程数,默认值接近整个应用线程数的1/4。

  • -XX:-XX:G1HeapWastePercent:允许的浪费堆空间的占比,默认是5%。如果并发标记可回收的空间小于5%,则不会触发MixedGC。

  • -XX:G1MixedGCCountTarget:一次全局并发标记之后,后续最多执行的MixedGC次数。默认值是8.

G1 GC调优建议

年轻代调优

因为G1 GC是启发式算法,会动态调整年轻代的空间大小。目标也就是为了达到接近预期的暂停时间。年轻代调优中比较重要的就是对暂停时间的处理。一般都是根据MaxGCPauseMillis以及年轻代占比G1NewSizePercent、G1MaxNewSizePercent,结合应用的特点和GC数据进行接近期望pause time的调整。为了能观察到详细的暂停时间信息,可以添加调试的启动参数 -XX:+PrintAdaptiveSizePolicy。下面摘取一段youngGC gc log的输出:

 26.139: [GC pause (G1 Evacuation Pause) (young) 26.139: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 3484, predicted base time: 5.51 ms, remaining time: 194.49 ms, target pause time: 200.00 ms]
26.139: [G1Ergonomics (CSet Construction) add young regions to CSet, eden: 54 regions, survivors: 9 regions, predicted young region time: 5.98 ms]
26.139: [G1Ergonomics (CSet Construction) finish choosing CSet, eden: 54 regions, survivors: 9 regions, old: 0 regions, predicted pause time: 11.49 ms, target pause time: 200.00 ms]
, 0.0163685 secs]

target也即目标是200ms,实际的pause time是16ms。远远小于目标暂停时间。并且再CSet中的分区数是“eden: 54 regions, survivors: 9 regions”,可以适当增加CSet中的年轻代分区,也可以适当缩短暂停时间,让实际值和期望值不断接近。

并发标记和MixGC 调优

InitiatingHeapOccupancyPercent就是触发并发标记的一个决定阀值。当Java堆空间占用到45%便开启并发周期。并发标记的初始标记阶段伴随着一次YoungGC的暂停。会看到如下log记录:

2018-05-26T19:50:57.256-0800: 78.480: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0076560 secs]

IHOP如果阀值设置过高,可能会遇到转移失败的风险,比如对象进行转移时空间不足。如果阀值设置过低,就会使标记周期运行过于频繁,并且有可能混合收集期回收不到空间。IHOP值如果设置合理,但是在并发周期时间过长时,可以尝试增加并发线程数,调高ConcGCThreads。

引用处理

G1 GC对于虚引用、弱引用、软引用的处理会比一般对象多一些收集任务。如果在引用处理占用了很长时间,需要更进一步排查。在并发标记的Remark阶段会记录引用的处理,日志信息如下:

如下:

 [GC remark 2018-05-26T19:50:57.386-0800: 78.610: [Finalize Marking, 0.0002675 secs] 2018-05-26T19:50:57.386-0800: 78.611: [GC ref-proc, 0.0001091 secs] 2018-05-26T19:50:57.386-0800: 78.611: [Unloading, 0.0204521 secs], 0.0212793 secs]

可以通过-XX:+PrintReferenceGC打印更详细的引用计数信息:

 [SoftReference, 0 refs, 0.0000482 secs]2018-06-03T20:52:03.887-0800: 18.033: [WeakReference, 116 refs, 0.0000321 secs]2018-06-03T20:52:03.887-0800: 18.033: [FinalReference, 1073 refs, 0.0009571 secs]2018-06-03T20:52:03.888-0800: 18.034: [PhantomReference, 0 refs, 1 refs, 0.0000211 secs]2018-06-03T20:52:03.888-0800: 18.034: [JNI Weak Reference, 0.0000192 secs], 0.0084976 secs]

一般在Ref Proc时间超过GC暂停时间的10%时就要关注。Ref Proc的信息打印在每次垃圾收集的Other信息模块:

 [Other: 0.6 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 0.4 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.1 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]

如果SoftReference过多,会有频繁的老年代收集。-XX:SoftRefLRUPolicyMSPerMB参数,可以指定每兆堆空闲空间的软引用的存活时间,默认值是1000,也就是1秒。可以调低这个参数来触发更早的回收软引用。如果调高的话会有更多的存活数据,可能在GC后堆占用空间比会增加。对于软引用,还是建议尽量少用,会增加存活数据量,增加GC的处理时间。

总结

本文简单介绍了一下G1 GC的调优参数以及G1 GC的日志内容。在具体调优过程,可以增加一些调优的参数,如-XX:+G1SummarizeRSetStats、-XX:+PrintReferenceGC、-XX:+PrintAdaptiveSizePolicy等。每次调参后还要密切关注GC log,最好能模拟生产环境进行全链路的测试。没有一个参数的调整是可以普适任何应用的,如果没有GC问题,就不需要为了优化而优化。但是对于GC的知识储备和更新,是每个应用开发工程师必备的知识模块。



助力秋招-独孤九剑荡剑式 | Java语言&基础面试题


JVM架构体系与GC命令小总结


Hive性能调优 | 并行执行/严格模式/JVM重用/推测执行


版权声明:

本文为大数据技术与架构整理,原作者独家授权。未经原作者允许转载追究侵权责任。
微信公众号|import_bigdata

编辑 《大数据技术与架构》


欢迎点赞+收藏+转发朋友圈素质三连


文章不错?点个【在看】吧! ?

浏览 85
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报