JVM G1垃圾收集器

448 阅读12分钟

了解G1

设计原则:就是简单可行的性能调优

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

参数说明:

  • -XX:+UseG1GC 开启G1垃圾收集器
  • -Xmx32g 设计堆内存的最大内存为32G
  • -XX:MaxGCPauseMillis=200设置GC的最大暂停时间为200ms

如果我们需要调优,在内存大小一定的情况下,我们只需要修改最大暂停时间即可。

G1取消了以前的新生代、老年代的物理空间划分:G1算法将堆(Heap)划分为若干个区域(Region),它仍然属于分代收集器。不过,这些区域的一部分包含新生代,新生代的收集依然采用暂停所有应用线程的方法,将存活对象拷贝到老年代或者Survivor空间。老年代也分成很多区域,G1收集器通过将对象从一个区域复制到另一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1完成了堆的压缩(至少是部分堆的压缩),这样就不会有CMS内存碎片问题存在了。

HotSpot JVM把年轻代分为了三部分:Eden区和Survivor区(分别叫from和to),默认比例为8:1.一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。

因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代(Old)中。

Humongous:巨型对象。如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。这些巨型对象,默认直接会分配在年老代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

G1对象分配策略

栈上分配

该对象只会被本线程使用,那么就将该对象在栈上分配

TLAB

线程本地分配缓冲区(Thread Local Allocation Buffer):对象在栈上分配不成功,就会使用TLAB来分配

共享Eden区分配

对TLAB空间中无法分配的对象,JVM会尝试在共享Eden空间中进行分配

Humongous区分配

如果一个对象占用的空间超过了分区容量50%以上,G1收集器就认为这是一个巨型对象。G1划分了一个Humongous区,它用来专门存放巨型对象。如果一个H区装不下一个巨型对象,那么G1会寻找连续的H分区来存储。为了能找到连续的H区,有时候不得不启动Full GC。

Young GC

发生GC操作时会Stop the world(STW),暂停所有工作线程。

主要对Eden区进行GC,它在Eden空间耗尽时会触发。在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。

RSet(RememberedSet)

跟踪指向某个heap区内的对象引用

在CMS中,也有RSet的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种point-out,在进行Young GC时,扫描根时,仅仅需要扫描这一块区域,而不需要扫描整个老年代。

但在G1中,并没有使用point-out,这是由于一个分区太小,分区数量太多,如果是用point-out的话,会造成大量的扫描浪费,有些根本不需要GC的分区引用也扫描了。

于是G1中使用point-in来解决。point-in的意思是哪些分区引用了当前分区中的对象。这样,仅仅将这些对象当做根来扫描就避免了无效的扫描。由于新生代有多个,那么我们需要在新生代之间记录引用吗?这是不必要的,原因在于每次GC时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。

卡表(Card Table)

解决赋值器开销

一个Card Table将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。

卡通常较小,介于128到512字节之间。CardTable通常为字节数组,由Card的索引(即数组下标)来标识每个分区的空间地址。

默认情况下,每个卡都未引用。当一个地址空间有引用时,这个地址空间对应的数组索引的值被标记为"0",即标记为脏引用,此外RSet也将这个数组下标记录下来。

一般情况下,这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。

Mixed GC

Stop The World(STW)

Mix GC不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的老年代分区。

在进行Mix GC之前,会先进行global concurrent marking(全局并发标记)。

被称作“混合式”是因为他们不仅仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。

全局并发标记(global concurrent marking)

初始标记(initial mark,STW)

在此阶段,G1 GC对根进行标记。该阶段与常规的STW年轻代垃圾回收密切相关。

根区域扫描(root region scan)

G1 GC 在初始标记的存活区扫描对老年代的引用,并标记被引用的对象。

该阶段与应用程序(非STW)同时运行,并且只有完成该阶段后,才能开始下一次的STW年轻代垃圾回收。

并发标记(Concurrent Marking)

G1 GC 在整个堆中查找可访问的(存活的)对象。

该阶段与应用程序同时运行,可以被STW年轻代垃圾回收中断。

最终标记(Remark,STW)

该阶段是STW回收,帮助完成标记周期。

G1 GC 清空 SATB 缓冲区,跟踪未被访问的存活对象,并执行引用处理。

清除垃圾(Cleanup,STW)

在这个最后阶段,G1 GC执行统计和 RSet 净化的 STW 操作。

在统计期间,G1 GC 会识别完全空闲的区域和可供进行混合垃圾回收的区域。 清理阶段在将空白区域重置并返回到空闲列表时为部分并发。

拷贝存活对象(evacuation)

Evacuation阶段是全暂停的。该阶段把一部分Region里的活对象拷贝到另一部分Region中,从而实现垃圾的回收清理。Evacuation阶段从第一阶段选出来的Region中筛选出任意多个Region作为垃圾收集的目标,这些要收集的Region叫CSet,通过RSet实现。

筛选出CSet之后,G1将并行的将这些Region里的存活对象拷贝到其他Region中,这点类似于ParalledScavenge的拷贝过程,整个过程是完全暂停的。关于停顿时间的控制,就是通过选择CSet的数量来达到控制时间长短的目标。

三色标记算法(并发标记)

三色标记算法的三种状态:

  • 黑色:根对象,或者该对象与他的子对象都被扫描
  • 灰色:对象本身被扫描,但还没扫描完该对象中的子对象
  • 白色: 未被扫描的对象,扫描完成所有对象之后的不可达对象

问题解决方式

1、在插入的时候记录对象

CMS增量更新(Incremental update):在CMS采用的是增量更新,只要在写屏障(write barrier)里发现有一个白对象的引用被赋值到一个黑对象的字段里,那就把这个对象变成灰色。即插入的时候记录下来。

2、在删除的时候记录对象

在G1中,使用的是SATB(snapshot-at-the-beginning)的方式,删除的时候记录所有对象。

  • 在开始标记的时候生成一个快照标记存活对象
  • 在并发标记的时候所有被改变的对象入队(在write barrier里把所有旧的引用所指向的对象都变成非白的)
  • 可能存在游离的垃圾,将在下次被收集

Full GC 触发条件

在某些情况下,G1触发了Full GC,这时G1会退化使用Serial收集器来完成垃圾的清理工作,它仅仅使用单线程来完成GC工作,GC暂停时间将达到秒级别的。整个应用处于假死状态,不能处理任何请求。

并发模式失败

G1启动标记周期,但在Mix GC之前,老年代就被填满,这时候G1会放弃标记周期。 这种情形下,需要增加堆大小,或者调整周期(例如增加线程数-XX:ConcGCThreads等)。

  • 增加堆大小
  • 调整周期(例如增加线程数-XX:ConcGCThreads等)

晋升失败或疏散失败

G1在进行GC的时候没有足够的内存供存活对象或晋升对象使用,由此触发了Full GC。 可以在日志中看到(to-space exhausted)或者(to-space overflow)

  • 增加-XX:G1ReservePercent选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。
  • 减少-XX:InitiatingHeapOccupancyPercent 提前启动标记周期
  • 增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目

巨型对象分配失败

当巨型对象找不到合适的空间进行分配时,就会启动Full GC,来释放空间。 这种情况下,应该避免分配大量的巨型对象,增加内存或者增大-XX:G1HeapRegionSize,使巨型对象不再是巨型对象。

  • 增加内存
  • 增大-XX:G1HeapRegionSize

调优实践

-XX:MaxGCPauseMillis=200

允许的GC最大的暂停时间。G1尽量确保每次GC暂停的时间都在设置的MaxGCPauseMillis范围内。 那G1是如何做到最大暂停时间的呢?这涉及到另一个概念,CSet(collection set)。 它的意思是在一次垃圾收集器中被收集的区域集合。

Young GC:选定所有新生代里的region。通过控制新生代的region个数来控制young GC的开销。

Mixed GC:选定所有新生代里的region,外加根据global concurrent marking统计得出收集收益高的若干老年代region。在用户指定的开销目标范围内尽可能选择收益高的老年代region。

在理解了这些后,我们再设置最大暂停时间就好办了。首先,我们能容忍的最大暂停时间是有一个限度的,我们需要在这个限度范围内设置。但是应该设置的值是多少呢?我们需要在吞吐量跟MaxGCPauseMillis之间做一个平衡。如果MaxGCPauseMillis设置的过小,那么GC就会频繁,吞吐量就会下降。如果MaxGCPauseMillis设置的过大,应用程序暂停时间就会变长。G1的默认暂停时间是200毫秒,我们可以从这里入手,调整合适的时间。

-XX:G1HeapRegionSize=n

设置的 G1 区域的大小。值是 2 的幂,范围是 1 MB 到 32 MB 之间。目标是根据最小的 Java 堆大小划分出约 2048 个区域。

-XX:ParallelGCThreads=n

设置 STW 工作线程数的值。将 n 的值设置为逻辑处理器的数量。n 的值与逻辑处理器的数量相同,最多为 8。

如果逻辑处理器不止八个,则将 n 的值设置为逻辑处理器数的 5/8 左右。 这适用于大多数情况,除非是较大的 SPARC 系统,其中 n 的值可以是逻辑处理器数的 5/16 左右。

-XX:ConcGCThreads=n

设置并行标记的线程数。将 n 设置为并行垃圾回收线程数 (ParallelGCThreads) 的 1/4 左右。

-XX:InitiatingHeapOccupancyPercent=45

设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。

避免使用-Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。

固定年轻代的大小会覆盖暂停时间目标。