垃圾收集器之:G1收集器

G1垃圾收集器是一种工作在堆内不同分区上的并发收集器。分区既可以归属于老年代,也可以归属新生代,同一个代的分区不需要保持连续。为老年代设计分区的初衷是我们发现并发后台线程在回收老年代中没有引用的对象时,有的分区垃圾对象的数量很多,另一些分区垃圾对象相对较少。

虽然分区的垃圾收集工作实际还是要暂停应用线程,不过由于G1收集器专注于垃圾最多的分区,最终的效果是花费较少的时间就能回收这些分区的垃圾。这种只专注于垃圾最多的分区的方式就是G1垃圾收集器的名称由来,即首先收集垃圾最多的分区。

这一算法并不适用新生代的分区,新生代进行垃圾回收时,整个新生代空间要么被回收,要么被晋升。那么新生代也采用分区的原因是因为:采用预定义的分区能够便于代的大小调整。

G1收集器的收集活动包括4种操作:

  • 新生代垃圾收集;
  • 后台收集,并发周期;
  • 混合式垃圾收集;
  • 以及必要时的Full GC。

1、新生代垃圾收集

先看G1对新生代收集的前后对比,图中的每个小方块都代表一个G1的分区。分区中的黑色的区域代表数据,每个分区中的子母代表该区域属于哪个代(E代表Eden,O代表老年代,S代表Survivor)。空的分区不属于任何一个代;需要的时候G1收集器会强制指定这些空间的分区用于任何需要的代。

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

PS:在java 8中,持久代也移动到了普通的堆内存空间中,改为元空间。

二、对象分配策略

说起大对象的分配,我们不得不谈谈对象的分配策略。它分为3个阶段:

  1. TLAB(Thread Local Allocation Buffer)线程本地分配缓冲区
  2. Eden区中分配
  3. Humongous区分配

TLAB为线程本地分配缓冲区,它的目的为了使对象尽可能快的分配出来。如果对象在一个共享的空间中分配,我们需要采用一些同步机制来管理这些空间内的空闲空间指针。在Eden空间中,每一个线程都有一个固定的分区用于分配对象,即一个TLAB。分配对象时,线程之间不再需要进行任何的同步。

对TLAB空间中无法分配的对象,JVM会尝试在Eden空间中进行分配。如果Eden空间无法容纳该对象,就只能在老年代中进行分配空间。

最后,G1提供了两种GC模式,Young GC和Mixed GC,两种都是Stop The World(STW)的。下面我们将分别介绍一下这2种模式。

三、G1提供了两种GC模式

3.1、G1 Young GC

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

这时,我们需要考虑一个问题,如果仅仅GC 新生代对象,我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是,G1引进了RSet的概念。它的全称是Remembered Set,作用是跟踪指向某个heap区内的对象引用。

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

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

需要注意的是,如果引用的对象很多,赋值器需要对每个引用做处理,赋值器开销会很大,为了解决赋值器开销这个问题,在G1 中又引入了另外一个概念,卡表(Card Table)。一个Card Table将一个分区在逻辑上划分为固定大小的连续区域,每个区域称之为卡。卡通常较小,介于128到512字节之间。Card Table通常为字节数组,由Card的索引(即数组下标)来标识每个分区的空间地址。默认情况下,每个卡都未被引用。当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为”0″,即标记为脏被引用,此外RSet也将这个数组下标记录下来。一般情况下,这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。

Young GC 阶段:

  • 阶段1:根扫描
    静态和本地对象被扫描
  • 阶段2:更新RS
    处理dirty card队列更新RS
  • 阶段3:处理RS
    检测从年轻代指向年老代的对象
  • 阶段4:对象拷贝
    拷贝存活的对象到survivor/old区域
  • 阶段5:处理引用队列
    软引用,弱引用,虚引用处理

3.2、G1 Mix GC

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

它的GC步骤分2步:

  1. 全局并发标记(global concurrent marking)
  2. 拷贝存活对象(evacuation)

在进行Mix GC之前,会先进行global concurrent marking(全局并发标记)。 global concurrent marking的执行过程是怎样的呢?

在G1 GC中,它主要是为Mixed GC提供标记服务的,并不是一次GC过程的一个必须环节。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 会识别完全空闲的区域和可供进行混合垃圾回收的区域。清理阶段在将空白区域重置并返回到空闲列表时为部分并发。

四、三色标记算法

提到并发标记,我们不得不了解并发标记的三色标记算法。它是描述追踪式回收器的一种有用的方法,利用它可以推演回收器的正确性。 首先,我们将对象分成三种类型的。

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

当GC开始扫描对象时,按照如下图步骤进行对象的扫描:

根对象被置为黑色,子对象被置为灰色。

 

继续由灰色遍历,将已扫描了子对象的对象置为黑色。

遍历了所有可达的对象后,所有可达的对象都变成了黑色。不可达的对象即为白色,需要被清理。

这看起来很美好,但是如果在标记过程中,应用程序也在运行,那么对象的指针就有可能改变。这样的话,我们就会遇到一个问题:对象丢失问题

我们看下面一种情况,当垃圾收集器扫描到下面情况时:

这时候应用程序执行了以下操作:

A.c=C
B.c=null

这样,对象的状态图变成如下情形:

这时候垃圾收集器再标记扫描的时候就会下图成这样:

很显然,此时C是白色,被认为是垃圾需要清理掉,显然这是不合理的。那么我们如何保证应用程序在运行的时候,GC标记的对象不丢失呢?有如下2中可行的方式:

  1. 在插入的时候记录对象
  2. 在删除的时候记录对象

刚好这对应CMS和G1的2种不同实现方式:

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

在G1中,使用的是STAB(snapshot-at-the-beginning)的方式,删除的时候记录所有的对象,它有3个步骤:

1,在开始标记的时候生成一个快照图标记存活对象

2,在并发标记的时候所有被改变的对象入队(在write barrier里把所有旧的引用所指向的对象都变成非白的)

3,可能存在游离的垃圾,将在下次被收集

这样,G1到现在可以知道哪些老的分区可回收垃圾最多。 当全局并发标记完成后,在某个时刻,就开始了Mix GC。这些垃圾回收被称作“混合式”是因为他们不仅仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。混合式垃圾收集如下图:

混合式GC也是采用的复制的清理策略,当GC完成后,会重新释放空间。

至此,混合式GC告一段落了。下一小节我们讲进入调优实践。

五、调优实践

五、调优实践

5.1、4种情况会触发这类的Full GC

G1收集器同CMS收集器一样,在某些情况下,G1触发了Full GC,这时G1会退化使用Serial收集器来完成垃圾的清理工作,它仅仅使用单线程来完成GC工作,GC暂停时间将达到秒级别的。整个应用处于假死状态,不能处理任何请求,我们的程序当然不希望看到这些。有的时候你会在垃圾回收日志中观察到Full GC,这些日志是一个信号,表明我们需要进一步调优(方式很多,甚至很可能要分配更多的堆空间)才能提升应用程序的性能。主要有4种情况会触发这类的Full GC,如下:

1、并发模式失效

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

GC日志如下的示例:

解决办法:发生这种失败意味着堆的大小应该增加了,或者G1收集器的后台处理应该更早开始,或者需要调整周期,让它运行得更快(如,增加后台处理的线程数)。

2、晋升失败

(to-space exhausted或者to-space overflow)

G1收集器完成了标记阶段,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就会被耗尽。(G1在进行GC的时候没有足够的内存供存活对象或晋升对象使用),由此触发了Full GC。

下面日志中(可以在日志中看到(to-space exhausted)或者(to-space overflow)),反应的现象是混合式GC之后紧接着一次Full GC。

这种失败通常意味着混合式收集需要更迅速的完成垃圾收集:每次新生代垃圾收集需要处理更多老年代的分区。

解决这种问题的方式是:

  1. 增加 -XX:G1ReservePercent 选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。
  2. 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
  3. 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。
3、疏散失败

(to-space exhausted或者to-space overflow)

进行新生代垃圾收集是,Survivor空间和老年代中没有足够的空间容纳所有的幸存对象。这种情形在GC日志中通常是:

这条日志表明堆已经几乎完全用尽或者碎片化了。G1收集器会尝试修复这一失败,但可以预期,结果会更加恶化:G1收集器会转而使用Full GC。

解决这种问题的方式是:

  1. 增加 -XX:G1ReservePercent 选项的值(并相应增加总的堆大小),为“目标空间”增加预留内存量。
  2. 通过减少 -XX:InitiatingHeapOccupancyPercent 提前启动标记周期。
  3. 也可以通过增加 -XX:ConcGCThreads 选项的值来增加并行标记线程的数目。
4、巨型对象分配失败

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

5.2、G1垃圾收集器调优

1、G1垃圾收集器调优的主要目标是避免发生并发模式失败或者疏散失败,一旦发生这些失败就会导致Full GC。避免Full GC的技巧也适用于频繁发生的新生代垃圾收集,这些垃圾收集需要等待扫描根分区完成才能进行。

2、其次,调优可以是过程中的停顿时间最小化。

下面列出能够避免发生Full GC的方法:

  • 通过增加总的堆空间大小伙子调整老年代、新生代之间的比例来增加老年代空间的大小。
  • 增加后台线程的数码(假设我们有足够的CPU资源运行这些线程)。
  • 以更高的频率进行G1的后台垃圾收集活动。
  • 在混合式垃圾收集周期中完成更多的垃圾收集工作。

使用G1垃圾收集器时,XX:MaxGCPauseMillis标志有一个默认值:200毫秒(和throughput收集器有所不同)。如果G1收集器发生时空停顿(stop-the-world)的时长超过该值,G1收集器就会尝试各种方式进行弥补--如调整新生代与老年代的比率,调整堆大小,更早地启动后台处理,改变晋升阈值,或者是在混合式垃圾收集周期中处理更多或者更少的老年代分区。

通常的取舍就是发生在这里:如果减少参数值,为了达到停顿时间的目标,新生代的大小会相应减少,不过新生代垃圾收集的频率会更加频繁。除此之外,为了达到停顿时间的目标,混合式GC收集老年代分区数也会减少,而这会增大并发模式失败发生的机会。

 

1、调整G1垃圾收集的后台线程数

为了让G1赢得这场垃圾收集的比赛,可以尝试增加后台标记线程数码(假如有足够多的空闲CPU)。

调整方法:(与CMS类似),对于应用线程暂停运行的周期,可以使用ParallelGCThreads标志设置运行的线程数;对于并发阶段可以使用ConcGCThreads标志设置运行线程数(注意此处的ConcGCThreads默认值不同CMS)。

2、调整G1垃圾收集器的运行频率

如果G1更早的启动垃圾收集,也能赢得比赛。G1周期通常在堆的占用达到某个比率(通过参数:XX:InitiatingHeapOccupancyPercent=45设定),跟CMS不太一样,这个参数值依据的是整个堆的使用情况而不是老年代的。

3、调整G1收集器的混合式垃圾收集周期

并发周期之后,老年代的标记分区回收完成之前,G1收集器无法启动新的并发周期。因此,让G1更早启动标记周期的另一个方法是在混合式垃圾回收周期中尽量处理更多分区(如此一来最终的混合式GC周期就变少了)。

混合式垃圾收集处理工作量取决3个因素:

A、有多少分区被发现大部分是垃圾对象。如果分区的垃圾占用达到35%,这个分区就被标记为可以进行垃圾回收;(-XX:G1MixedGCLiveThresholdPercent=65)

B、G1回收分区时最大混合式GC周期数,可以通过参数-XX:G1MixedGCCountTarget=8

5.3、常见调优参数

-XX:MaxGCPauseMillis=N,(默认200毫秒,与throughput收集器有所不同)

前面介绍过使用GC的最基本的参数:

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

前面2个参数都好理解,后面这个MaxGCPauseMillis参数该怎么配置呢?这个参数从字面的意思上看,就是允许的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毫秒,我们可以从这里入手,调整合适的时间。

5.4、其他调优参数

-XX:G1HeapRegionSize=n

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

-XX:ParallelGCThreads=n(调整G1垃圾收集的后台线程数

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

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

-XX:ConcGCThreads=n(调整G1垃圾收集的后台线程数)

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

-XX:InitiatingHeapOccupancyPercent=45(调整G1垃圾收集运行频率)

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

该值设置太高:会陷入Full GC泥潭之中,因为并发阶段没有足够的时间在剩下的堆空间被填满之前完成垃圾收集。

如果该值设置太小:应用程序又会以超过实际需要的节奏进行大量的后台处理。

避免使用以下参数:

避免使用 -Xmn 选项或 -XX:NewRatio 等其他相关选项显式设置年轻代大小。固定年轻代的大小会覆盖暂停时间目标。

-XX:G1MixedGCLiveThresholdPercent=65

 

为混合垃圾回收周期中要包括的旧区域设置占用率阈值。默认占用率为 65%。这是一个实验性的标志。有关示例,请参见“如何解锁实验性虚拟机标志”。此设置取代了 -XX:G1OldCSetRegionLiveThresholdPercent 设置。Java HotSpot VM build 23 中没有此设置。

-XX:G1MixedGCCountTarget=8

设置标记周期完成后,对存活数据上限为 G1MixedGCLIveThresholdPercent 的旧区域执行混合垃圾回收的目标次数。默认值是 8 次混合垃圾回收。混合回收的目标是要控制在此目标次数以内。Java HotSpot VM build 23 中没有此设置。

-XX:G1OldCSetRegionThresholdPercent=10

设置混合垃圾回收期间要回收的最大旧区域数。默认值是 Java 堆的 10%。Java HotSpot VM build 23 中没有此设置。

-XX:G1ReservePercent=10

设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险。默认值是 10%。增加或减少百分比时,请确保对总的 Java 堆调整相同的量。Java HotSpot VM build 23 中没有此设置。

 

 

 

-XX:G1HeapWastePercent=10

设置您愿意浪费的堆百分比。如果可回收百分比小于堆废物百分比,Java HotSpot VM 不会启动混合垃圾回收周期。默认值是 10%。Java HotSpot VM build 23 中没有此设置。

 

posted on 2016-11-25 18:05  duanxz  阅读(7016)  评论(0编辑  收藏  举报