不标题党地学习G1

2018-07-25 17:12:39 浏览数 (1)

对于大多数人来说,Java的垃圾收集器就是一个黑盒子,这个黑盒子自己在里边愉快的玩耍,而我们却不太知道它内部的事情。

程序员代码写完后,就交给QE来验证功能的正确性,然后运维基础设施负责把代码发布上去。在这样的过程中,你可能会对堆、PermGen或Metaspace或线程设置等等进行一番盘算,皱着眉头思索之后,然后设置下,让这些配置看起来是生效的。

那么问题就来了,当这些默认的配置不再满足需求的时候会发生什么状况?作为一个开发者、测试工程师、性能工程师或架构师,了解垃圾收集器机制无疑是一个宝贵的技能。在接下来的文字中,我们将带你走进G1。

我们主要会涉及以下两个主题:G1的核心目标是什么?以及它实际上是怎么工作的?

最核心的,G1的目标就是通过-XX:MaxGCPauseMillis来实现可预测的暂停时间,同时还保持一贯的应用程序吞吐量。这是核心之核心。

当下和最终目标是能够维持这些目标,并满足目前对高性能,多线程应用程序的需求,并且需要不断扩大堆大小。

G1的内部规则就是暂停时间越短,可达到的吞吐量和总延迟就越高。 暂停时间越长,可达到的吞吐量和总延迟就越低。

所以你对自己应用的垃圾回收调整就得结合对应用程序运行时需求的理解,应用程序的物理特性以及对G1的理解来调整一系列的参数,从而实现满足业务需求的最佳运行状态。

另外要认识到所谓的tuning,也就是调试,是一个不断调试和评估的过程中,最终才会得到一个基线和一些各方面性价比不错的参数配置。

另外也没有一个权威指南或一套神奇的参数,你只有通过评估性能,然后增量的更改,然后再评估,直到达到你的目标。

上面说了有关参数调优的一般的做法。但G1为了让你能够更快的达到上面的目标,它使用了一些截然不同的做法。

首先,就像它的名字一样,叫G1,Garbage First,它会优先清理存活对象数量最少的那些region,并将存活的数据压缩或疏散到新的region里。

另外,它会使用一系列的增量、并行(parallel)和多个阶段组成的生命周期来完成它的“暂停时间”目标。这就使得G1必须在规定的时间内只做必要的一些事情,而不再在乎整个堆的大小。

在上面我们提到了一个新的名词叫:region(区域)。简单的说region就是一个被分配了空间可以保存任何代的对象的一个块,而不需要保持与同一代的其他region的连续性。

在G1中,传统的年轻代和老年代依然存在。年轻代依然是由Eden(首次分配)和Survivor(在清理过程中在eden中存活的对象被复制到此区域)两种空间组成。在Survivor空间中的对象一直存活,直到他们被清理或者足够老的时候被提升到老年代,可以通过XX:MaxTenuringThreshold来指定(默认是15次)。

老年代(Tenured)是由Old空间组成,里边存放的对象大多是从Survivor空间中达到阈值(XX:MaxTenuringThreshold)被提拔上来的对象。

12288 MB / 2048 Regions = 6 MB - 这不是2的平方

12288 MB / 8 MB = 1536 regions - region太少

12288 MB / 4 MB = 3072 regions - 此配置恰好

根据上面的计算,JVM默认将会分配3072个region,然后每个region可以存放4MB对象,如下图所示。

你也可以显式的通过参数来指定region的大小,参数:-XX:G1HeapRegionSize。当通过这种方式设置了region的大小后,region的数量也就被确定,这里特别需要明白的一点就是,region的数量越少,意味着每个region的体积越大,也就意味着G1的灵活度越低,而且region的体积变大后扫描、标记和清理region的时间就会越久。

关键在于,尽管G1是一个分代的垃圾回收器,但它空间的分配和消费都不是连续的。并且随着它对从年轻代到老年代最有效率的比率有了更好的认知后,就开始自由的扩展了。

当对象生产开始时,从空闲列表中分配一个region作为“线程本地分配缓冲区(thread-local allocation buffer)”(TLAB),使用比较和交换方法(CAS)来实现同步。

接下来对象就会被分配到对应的thread-local buffer中,而不需要额外的同步机制了。当这个region被用尽后,就再选一个新的region,然后分配和填充。

就这样一直到累积的Eden region空间被填满,触发了疏散暂停(evacuation pause)为止。(也被称为young collection或young gc或young pause,以及mixed collection或mixed gc或mixed pause)。

Eden空间的累积量就是我们相信可以在设置的暂停时间内可以清理的region数量。

整个堆可以分配给Eden region的比例可以从5%到60%。这个是根据之前的年轻代清理的性能表现在每次年轻代清理之后进行动态调整的。

下面是一个例子,将对象分配到非连续的Eden region中。

代码语言:javascript复制
GC pause (young); #1         
 [Eden: 612.0M(612.0M)->0.0B(532.0M) 
  Survivors: 0.0B->80.0M 
  Heap: 612.0M(12.0G)->611.7M(12.0G)]
GC pause (young); #2          
 [Eden: 532.0M(532.0M)->0.0B(532.0M) 
  Survivors: 80.0M->80.0M 
  Heap: 1143.7M(12.0G)->1143.8M(12.0G)]

从上面的GC暂停日志可以看出,在第一次暂停时,由于Eden在总共612.0M(153个region)中达到612.0M,所以触发了疏散。 目前Eden的空间已全部腾出,为0.0B,并且考虑到随着时间的推移,它还决定将Eden的总分配减少到532.0M(133个region)

在第二次暂停中,你可以看到当达到532.0M的这个新的阈值时就触发了疏散。 因为我们现在已经调整出了最佳的暂停时间,所以Eden就一直保持在532.0M。

当上述年轻代清理发生时,死去的对象将会被清理,剩下活着的对象会被疏散并压缩到幸存(Survivor)空间中。

G1有一个显式的预留空间,可以通过参数G1ReservePercent来设置(默认为10%),这会确保对于Survivor空间在疏散(或者叫晋升)过程中,始终有一定比例的堆可用。

如果没有这个可用空间,堆可能会填充到没有可用region进行疏散的地步。

无法保证这不会发生,但这就是我们为什么要预留的目的! 这一原则确保在每次成功疏散之后,所有以前被用过的(分配)的Eden region都将返回到free list,重新空闲,并且使得任何被疏散的活着的对象都会留在幸存(Survivor)空间中。

下图就是一个标准的年轻代回收的样子:

继续这种模式,对象再次被分配到新请求的Eden region。

当Eden空间被塞满后,另一次的年轻代清理又会发生,根据存活对象的年龄(经过多少次年轻代清理后,依然存活的各种对象。这个多少次就是它的年龄),你将会看到有的对象会被晋升到老年代(Old)的region里。

由于Survivor空间是年轻代的一部分,死去的对象会被清理或者在这些年轻代暂停中被晋升。

下图鲜活的展示了当幸存者(Survivor)空间中的存活的对象被疏散并被晋升到老年代(Old)空间中的新的region时,来自Eden的存活对象被疏散到新的幸存者(Survivor)空间的region。这就是一个年轻代清理时的样子。

被疏散腾出的region,用“删除线”来表示,表示该region已变空,并重新返回到空闲列表(free list)。

G1将继续这样的方式,一直到下面的三件事情其中一种情况发生后:

  1. 到达了配置的“软边界”,nitiatingHeapOccupancyPercent (IHOP)。
  2. 到达了配置的“硬边界”(G1ReservePercent)。
  3. 遇到了一个大对象的分配(这个前面还没提到过,后面我们会讲到)。

IHOP代表了一个时间点(正如在年轻代收集中计算的那样),老年代region中的对象数量占整个堆的45%(默认)。这种活跃比率被不断计算和评估,然后作为每次年轻代清理的一部分。 当这些触发器中的一个被触发时,就会发起一次并发标记的请求。

代码语言:javascript复制
8801.974: [G1Ergonomics (Concurrent Cycles) 
request concurrent cycle initiation, 
reason: occupancy higher than threshold, 
occupancy: 12582912000 bytes, a
llocation request: 0 bytes, 
threshold: 12562779330 bytes (45.00 %),
 source: end of GC]
 
 8804.670: [G1Ergonomics (Concurrent Cycles) 
 initiate concurrent cycle, 
 reason: concurrent cycle initiation requested]
 
 8805.612: [GC concurrent-mark-start]
 
 8820.483: [GC concurrent-mark-end, 14.8711620 secs]

在G1中,并发标记是基于SATB(snapshot-at-the-beginning)的。

ps:并发标记算法还要一种是增量更新。你可以去了解下二者的区别,G1用的是SATB。

这意味着,为了提高效率,如果在初始快照拍摄时存在对象,它只能将对象识别为垃圾。

在并发标记周期中出现的任何新分配的对象被认为是存活的,与它们的真实状态无关。

这一点很重要,因为并发标记完成所需的时间越长,可收集的比例与隐含的存活比率越高。

如果在并发标记期间分配更多对象而不是最终收集对象,则最终会耗尽你的堆。

在并发标记阶段,你将会看到年轻代清理一直在持续,因为这个并不是一个stop-the-world事件。

下图展示了当IHOP阈值到达后触发并发标记,经过年轻代清理后的整个堆的样子。

一旦并发标记周期完成,会立即触发年轻代清理,然后进行第二种“疏散”,叫做mixed collection,即混合清理(或“混合收集”)。

混合清理工作机制几乎和“年轻代清理(young collection)”一模一样,

只是有两个主要不同点。

首先,混合清理也会清理、疏散以及压缩选定的数个老年代region。

其次,混合清理用的疏散触发机制和年轻代清理时用的疏散触发机制不同。

它的目标是尽可能快和频繁的清理。

通过使得被分配的Eden、Survivor region数量足够小从而实现Old region数量足够多。

代码语言:javascript复制
8821.975: [
G1Ergonomics (Mixed GCs) 
start mixed GCs, 
reason: candidate old regions available, 
candidate old regions: 553 regions, 
reclaimable: 6072062616 bytes (21.75 %), 
threshold: 5.00 %
]

上面的日志告诉我们,触发混合清理是因为老年代的region(553)的总数有21.75%的可回收空间。

此值高于我们的5%最低阈值(JDK7u40 默认值为5%, JDK7默认值为10%),因此,混合清理就被触发。

因为不想出多余的力气,所以G1一直采取垃圾优先清理政策。

基于有序列表,根据候选region中的存活对象百分比来选择候选region。

如果老年代region的存活对象数量少于G1MixedGCLiveThresholdPercent定义的百分比(JDK8u40 中的默认值为85%,JDK7中的默认值为65%),就将其添加到列表中。 简而言之,如果老年代region超过65%(JDK7)或85%(JDK8u40 )存活,就不想浪费时间在这个混合阶段中去收集和疏散它。

代码语言:javascript复制
8822.178: [
GC pause (mixed) 8822.178: 
[G1Ergonomics (CSet Construction) start choosing CSet, 
_pending_cards: 74448, 
predicted base time: 170.03 ms, 
remaining time: 829.97 ms, 
target pause time: 1000.00 ms]

相比年轻代清理,混合清理会清理所有的三代。

它通过基于G1MixedGCCountTarget的值(默认为8)的老年代region的增量集合来管理。

意思是,它会用G1MixedGCCountTarget分割候选老年代region的数量,并尝试在每个周期中至少清理多少个region。

每次清理周期结束后,老年代的region活跃度将会重新评估。

如果可回收空间仍然大于G1HeapWastePercent,混合清理(或“混合收集”)将继续。

代码语言:javascript复制
8822.704: [
G1Ergonomics (Mixed GCs) 
continue mixed GCs, 
reason: candidate old regions available, 
candidate old regions: 444 regions, 
reclaimable: 4482864320 bytes (16.06 %), 
threshold: 10.00 %
]

下图展示了一次混合清理。

所有Eden region都被收集并疏散到幸存者(Survivor)region,并根据年龄,清理所有幸存者(Survivor)region,并将足够的最终存活对象提升到新的老年代(Old)region。

与此同时,还会清理老年代region的选定的region们,并将剩余的存活对象压缩到新的Old region。

压缩和疏散的过程可以显著减少碎片并确保维持足够的自由可用region。

这个图展示了在混合清理完成了以后堆的样子。

所有的Eden region被清理干净了,然后存活的对象被转移到了一个新分配的Survivor region里。

现存的Survivor region们被清理并将存活对象提升到了新的Old(老年代)region里。

被清理的Old region们重新回到了free list,然后任何剩下的存活对象被压缩到了新的Old region里。

混合清理(Mixed collections)将会一直持续进行,直到所有的“8个”(G1MixedGCCountTarget的默认值是8,每次清理8个region,前面提到过)都完成或直到可回收百分比不再符合G1HeapWastePercent。

之后,你会看到混合清理周期就完成了,接下来就返回到了标准的年轻代清理(young collection)。

代码语言:javascript复制
8830.249: [G1Ergonomics (Mixed GCs) 

do not continue mixed GCs, 
reason: reclaimable percentage not over threshold,
 candidate old regions: 58 regions, 
 reclaimable: 2789505896 bytes (9.98 %), 
 threshold: 10.00 %]

我们已经把内部的清理过程过了一遍,现在回到前面提到的那个问题,就是大对象问题。

G1认为的“大对象”是指单个对象大小大于单个region 50%的情况。

在这种情况下,对象被认为是巨大的,也就是我们说的大对象,这时候普通的单个的region就无法存放这样的对象了。

比如:

Region 大小: 4096 KB

对象 A大小: 12800 KB

结果: 大对象的分配跨越了4个region(Humongous Allocation across 4 regions)

下图展示了一个12.5MB的对象跨越了4个连续的region,就那个大大的H:

  1. 大对象分配必须分配到一个连续的空间,这会导致严重的碎片问题。
  2. 大对象被直接分配到一个指定的humongous(巨大)的region里,这个humongous是属于老年代的。因为如果把这样的巨型对象放在了年轻代,疏散和复制的成本可能更高。
  3. 即使上面所讨论的那个对象只有12.5 MB,但它也必须占用四个完整region,占用总共16 MB的空间。
  4. 无论是否满足了IHOP设置,大对象分配总是会触发“并发标记阶段”。

少量的大对象也许不会搞出问题,但老是有大对象分配会导致大量的堆碎片以及明显的性能影响。

在JDK8u40之前,只能通过Full GC来清理大对象,因此这会影响到JDK7和早期JDK8用户。

这就是为什么知道“应用程序产生的对象大小”以及“G1为region定义的大小”是如此的关键。

即使在最新的JDK8中,如果你的项目中正在执行大量的大对象分配,也请你尽可能多地评估和调整,好好考虑下这个问题。

代码语言:javascript复制
4948.653: [G1Ergonomics (Concurrent Cycles) 
request concurrent cycle initiation, 
reason: requested by GC cause, 

GC cause: G1 Humongous Allocation]
7677.280: [G1Ergonomics (Concurrent Cycles)
 do not request concurrent cycle initiation, 
 reason: still doing mixed collections, 
 occupancy: 14050918400 bytes, 
 allocation request: 16777232 bytes, 
 threshold: 12562779330 
 
 32234.274: [G1Ergonomics (Concurrent Cycles) 
 request concurrent cycle initiation, 
 reason: occupancy higher than threshold, 
 occupancy: 12566134784 bytes, 
 allocation request: 9968136 bytes, 
 threshold: 12562779330 bytes (45.00 %), 
 
source: concurrent humongous allocation]

最后,不幸的是,G1还必须处理可怕的Full GC。

尽管G1最终试图避免使用Full GC,但仍然是一个残酷的现实,尤其是在不恰当的配置下,极有可能触发Full GC。

鉴于G1针对的是较大的堆的场景,Full GC的影响对于in-flight处理和SLA来说可能是灾难性的。

其中一个主要原因是在JDK10之前,Full GC在G1中仍然是单线程操作。

不过在JDK10以后,G1的Full GC已变成多线程处理。我们在之前的文章也提到过这个问题:五分钟了解Java10针对垃圾收集的改进。

这样至少在Full GC之后没有那么恼火了。

好,继续说Full GC。其中一个比较常见,也是最容易避免的,就是与Metaspace有关的Full GC。如下:

代码语言:javascript复制
[Full GC (Metadata GC Threshold) 
2065630K->2053217K(31574016K), 3.5927870 secs]

要想尽可能的避免Full GC,你还可以升级到JDK8u40 ,这样的话,比如类卸载就不再需要Full GC了。

即使这样,你仍然可能遇到Metaspace的Full GC,但这将与UseCompressedOops和UseCompressedClassesPointers或并发标记所需的时间有关。

第二个往往是不可避免的。

我们作为程序员能做的就是尽可能地去延迟和避免这样的情况,通过不断调整和评估那些创建对象的代码。

接下来再看一个案例(看下面的日志),首先是一个“空间耗尽”事件,然后就是一个 Full GC事件。

该事件说明疏散失败,其中堆不能再扩展,并且没有可用region来容纳疏散过来的对象。

如果你记得,我们先前讨论了由G1ReservePercent定义的“硬边缘”。

此事件表示你将更多的对象转移到to空间(to-space)中,而不是你的预留帐户,并且堆已满,没有其他可用region。

在某些情况下,如果JVM能够解决空间问题,就不会导致Full GC,但它仍然是一个非常昂贵的stop-the-world事件。

代码语言:javascript复制
6229.578: [GC pause (young)
 (to-space exhausted), 0.0406140 secs]6229.691:
 [Full GC 10G->5813M(12G), 15.7221680 secs]

如果你发现这种状况经常发生,你是时候去对你的应用程序做一些tuning了。

第二种情况是在并发标记期间的Full GC。 在这种情况下,我们并不是没有疏散,而是在完成并发标记并触发混合清理之前,堆已经满了。

两个Full GC要么是内存溢出要么创建和晋升对象的速度快过清理对象的速度。

如果Full GC清理是堆的重要部分,则可以认为它与创建对象和晋升对象有关。

如果清理的对象非常少,并且最终遇到OutOfMemoryError,则很可能是因为内存泄漏。

代码语言:javascript复制
57929.136: [GC concurrent-mark-start]
57955.723: [Full GC 10G->5109M(12G), 15.1175910 secs]
57977.841: [GC concurrent-mark-abort]

最后,我希望这篇文章能够阐明G1的设计方式以及如何对垃圾收集进行决策。

如果有机会以后我们会进一步讨论一些G1参数配置等更细节的内容。

本文翻译了Matt Robson关于G1的大部分内容,但随着岁月的流逝,jdk已发布到10了,马上11也在9月就来了。所以对部分内容进行了更新。比如G1的Full GC,在jdk10已变为了多线程。

相关文:

五分钟了解Java10针对垃圾收集的改进

使用G1 GC,降低内存消耗20%

快来了解JDK10中引入的全新JIT编译器:Graal

JDK11中增加了一个常量池类型:CONSTANT_Dynamic

是时候忘掉finalize方法了

吐槽“双亲委派”

JDK10要来了:下一代 Java 有哪些新特性?

0 人点赞