写点什么

JVM 实战—G1 垃圾回收器的原理和调优

作者:EquatorCoco
  • 2024-12-31
    福建
  • 本文字数:11491 字

    阅读完需:约 38 分钟

1.G1 垃圾回收器的工作原理


(1)ParNew + CMS 的组合有哪些痛点


Stop the World 是最大的问题。无论是新生代 GC 还是老年代 GC,都会或多或少产生 STW 现象,这对系统的运行是有一定影响的。

 

所以 JVM 对垃圾回收器的优化,都是朝减少 STW 的目标去做的。在这个基础之上,就诞生了 G1 垃圾回收器。G1 垃圾回收器可以提供比 ParNew + CMS 组合更好的垃圾回收性能。

 

(2)G1 垃圾回收器


G1 垃圾回收器可以同时回收新生代和老年代的对象,不需要两个垃圾回收器配合起来运作,它自己就能搞定所有的垃圾回收。G1 的一大特点就是把 Java 堆内存拆分为多个大小相等的 Region。如下图示:



然后 G1 也会有新生代和老年代,但是只是逻辑上的概念。也就是说,某些 Region 属于新生代,某些 Reigon 属于老年代。如下图示:



G1 的另一特点,就是可以设置每次垃圾回收时的最大停顿时间,以及指定在一个长度为 M 毫秒的时间片段内,垃圾回收时间不超 N 毫秒。

 

比如可指定,希望 G1 在垃圾回收时保证:在 1 小时内由 G1 垃圾回收导致系统停顿时间,不超过 1 分钟。

 

从前面的 JVM 优化思路可知,我们对内存合理分配,优化一些参数,就是为了尽可能减少 YGC 和 FGC,尽量减少 GC 带来的系统停顿影响。

 

现在 G1 则可以直接指定在一个时间段内,垃圾回收导致的系统停顿时间不能超过多久。而 G1 会全权进行负责,保证达到这个目标,这样就相当于我们可以控制垃圾回收对系统性能的影响了。

 

(3)G1 如何实现垃圾回收的停顿时间是可控的


如果 G1 要做到这一点,就必须要追踪每个 Region 里的回收价值。

 

什么是回收价值?即 G1 必须搞清楚每个 Region 里有多少垃圾对象。如果对一个 Region 进行垃圾回收,会耗费多长时间,可回收多少垃圾?

 

如下图示:G1 通过追踪发现,1 个 Region 中的垃圾对象有 10M,回收它们要耗费 1 秒。另外一个 Region 中的垃圾对象有 20M,回收他们需要耗费 200 毫秒。



然后在 GC 时 G1 发现在最近一个时间段内,垃圾回收已导致几百毫秒的系统停顿。现在又要执行一次垃圾回收,那么对这些 Region 进行筛选后,发现必须回收上图中只需 200ms 就能回收 20M 的 Region。如下图示:



所以 G1 的核心设计是:G1 可以让我们设定垃圾回收对系统的影响,G1 会把内存拆分为大量的小 Region,G1 会追踪每个 Region 中可以回收的对象大小和预估时间,G1 在垃圾回收时会尽量把垃圾回收对系统影响控制在指定时间范围内,同时在有限的时间内尽量回收尽可能多的垃圾对象。

 

(4)Region 可能属于新生代也可能属于老年代


在 G1 中,每一个 Region 可能属于新生代,也可能属于老年代。刚开始一个 Region 可能谁都不属于,然后接着就被分配给了新生代。然后这个 Region 会被放入很多属于新生代的对象,接着触发了垃圾回收,需要回收这个 Region。如下图示:



然后下一次这个 Region 可能又被分配给了老年代,用来存放老年代需要长期存活的的对象。如下图示:



所以在 G1 的内存模型中,一个 Region 会属于新生代也会属于老年代。于是就没有所谓新生代给多少内存,老年代给多少内存这一说法。新生代和老年代各自的内存区域是不停变动的,由 G1 自己去控制。

 

(5)总结


这里介绍了 G1 垃圾回收器的设计思想:包括 Region 划分、Region 动态变成新生代或老年代,Region 的按需分配。当触发 G1 垃圾回收时,可以根据设定的预期的系统停顿时间,来选择最少回收时间和最多回收对象的 Region 进行垃圾回收。保证 GC 对系统停顿的影响在可控范围内,同时尽可能回收最多对象。

 

接下来会介绍关于 G1 的更多技术细节,比如:

一.G1 是如何工作的

二.对象什么时候进入新生代的 Region

三.什么时候触发 Region GC

四.什么时候对象进入老年代的 Region

五.什么时候触发老年代的 Region GC

 

2.G1 分代回收原理—性能为何比传统 GC 好

 

(1)G1 垃圾回收器的设计思想


G1 垃圾回收器设计的思想:就是把内存拆分为很多 Region,然后新生代和老年代各自对应一些 Region。回收的时候尽可能挑选停顿时间最短以及回收对象最多的 Region,从而尽量保证达到指定的垃圾回收系统停顿时间。

 

(2)如何设定 G1 对应的内存大小


一.G1 会把内存拆分为很多个 Region 内存区域,每个 Region 大小都一样


如下图示:



二.每个 Region 的大小范围是 1M~32M,而且必须是 2 的倍数


通过-Xms 和-Xmx 参数可以设置整个堆内存的大小,通过-XX:+UseG1GC 参数可以指定使用 G1 垃圾回收器。

 

如果 JVM 启动时发现了指定使用 G1 垃圾回收器,那么默认情况下 G1 会自动用堆大小除以 2048 得出每个 Region 的大小。每个 Region 的大小范围是 1M~32M,且必须是 2 的倍数。如果堆大小是 4G = 4096M,除以 2048,每个 Region 的大小就是 2M。当然也可以通过-XX:G1HeapRegionSize 参数来手动指定 Region 大小。

 

需要注意的是:按照默认值计算,G1 可以管理的最大内存为 2048 * 32M = 64G。假设设置 xms=32G,xmx=128G。由于 Region 的大小最小是 1M,最大是 32M,而且要是 2 的倍数。那么初始化时按 2048 个 Region 计算,得出每个 Region 分区大小为 32M。然后分区个数动态变化范围从 1024 个到 4096 个。



系统刚开始运行时,默认新生代对堆内存的占比是 5%。也就是占据 200M 左右的内存,对应大概是 100 个 Region。这可以通过-XX:G1NewSizePercent 来设置新生代初始占比,但通常维持默认值即可。

 

因为在系统运行中,JVM 会不停地给新生代增加更多的 Region。但新生代占比最多不超 60%,可通过-XX:G1MaxNewSizePercent 设置。而且一旦 Region 进行了垃圾回收,新生代的 Region 数量就会减少。

 

如下图示,系统刚开始运行时有一部分的 Region 是属于新生代的。



(3)新生代 Region 还会分 Eden 区和 Survivor 区


G1 虽然把内存划分为很多的 Region,但还是有新生代、老年代的区分,而且新生代里同样有 Eden 和 Survivor 的划分。

 

所以前面介绍的很多原理在 G1 中都还是适用的。比如参数-XX:SurvivorRatio=8,系统刚开始运行时有 100 个 Region。此时新生代中有 80 个 Region 是 Eden 区,20 个 Region 是两个 Survivor 区。如下图示:



所以在 G1 中还是有 Eden 和 Survivor 的,它们会占据不同数量的 Region。然后随着对象不停地在新生代分配,属于新生代的 Region 会不断增加,Eden 和 Survivor 对应的 Region 也会不断增加。

 

(4)G1 的新生代垃圾回收


既然 G1 的新生代有 Eden 和 Survivor 之分,那么垃圾回收的机制也类似。当不停往新生代 Eden 的 Region 放对象,G1 会不停给新生代加入 Region。直到新生代占据堆大小的最大比例 60%,一旦新生代大小达到了设定的占据堆内存大小的最大比例 60%。比如 2048 个 Region 中有 1200 个 Region 都是属于新生代的了,里面的 Eden 占了 1000 个 Region,每个 Survivor 占了 100 个 Region,而且 Eden 中的 Region 都占满了对象。如下图示:



这时就会触发新生代 GC。G1 就会使用复制算法来进行垃圾回收,进入 Stop the World 状态。然后把 Eden 对应的 Region 中的存活对象放入 S1 对应的 Region 中,接着回收掉 Eden 对应的 Region 中的垃圾对象,如下:



G1 的新生代垃圾回收过程和 ParNew 是有区别的。因为 G1 可以设定 GC 停顿时间,执行 GC 时最多会让系统停顿某个时间。可以通过-XX:MaxGCPauseMills 参数来设定,默认值是 200ms。G1 会追踪每个 Region,然后 GC 时根据回收各 Region 需要多少时间、以及可回收多少对象,来选择回收其中一部分 Region。从而保证 GC 时的停顿时间控制在指定范围内,并尽可能多地去回收对象。

 

(5)对象什么时候进入老年代


在 G1 的内存模型下,新生代和老年代各自都会占据一定的 Region。如果按照默认新生代最多只能占据堆内存 2048 个 Region 的 60%的 Region 来推算,老年代最多可以占据 40%的 Region,大概就是 800 个左右的 Region。

 

那么对象何时候会从新生代进入老年代?和 ParNew 几乎一样,还是以下几个条件:

一.对象在新生代躲过多次 YGC,达到参数-XX:MaxTenuringThreshold 设置的年龄

二.动态年龄判定规则,比如年龄为 1 岁、2 岁、3 岁、4 岁的对象大小总和超过了 Survivor 的 50%,此时 Survivor 区还有 5 岁+的对象,那么 4 岁及以上的对象就会全部进入老年代

三.新生代回收后存活的对象在 Survivor 区的 Region 都放不下了

 

所以经过一段时间的新生代使用和垃圾回收后,会有些对象进入老年代。如下图示:



(6)大对象 Region


一.G1 内存模型下对大对象的分配策略


G1 提供专门的 Region 存放大对象,不让大对象进入老年代的 Region。G1 中大对象的判定规则就是一个大对象超过了一个 Region 大小的 50%。比如按照上面算的,每个 Region 是 2M。那么只要一个大对象超过了 1M,就会被放入大对象专门的 Region 中,而且一个大对象如果太大,可能会横跨多个 Region 来存放。如下图示:



堆内存里哪些 Region 会用来存放大对象?60%的 Region 给新生代,40%的 Region 给老年代,那还有哪些 Region 给大对象?

 

其实在 G1 里,新生代和老年代的 Region 是不停的动态变化的。比如新生代现占 1200 个 Region,但一次 GC 后里面 1000 个 Region 空了。此时这 1000 个 Region 就可以不属于新生代,可用部分 Region 放大对象,所以大对象既不属于新生代也不属于老年代。

 

二.G1 内存模型下对大对象的回收策略


既然大对象既不属于新生代也不属于老年代,那何时会触发垃圾回收?

 

其实在新生代、老年代回收时,会顺带着大对象 Region 一起回收,这其实就是在 G1 内存模型下对大对象的分配和回收策略。

 

(7)总结


这里介绍了 G1 的内存模型和分配规则,包括:

一.每个 Region 多大(1-32M)

二.新生代包含多少 Region(60%)

三.新生代动态增加 Region(初始 5% -> 60%)

四.G1 中仍然存在 Eden 和 Survivor 两个区域

五.什么时候触发新生代的垃圾回收(新生代达到 60%占比且满了)

六.G1 新生代垃圾回收使用的复制算法

七.G1 特有的预设 GC 停顿时间功能

八.对象进入老年代(15 岁 + 动态年龄 + S 区不足)

九.大对象的独立 Region 存放和回收

 

(8)问题


从新生代的垃圾回收来看,G1 相比 ParNew 的优点:

一.停顿时间可以预设

二.大对象不再进入老年代

三.对象进入老年代的情况少很多

四.同样内存大小,Eden 和 Survivor 都大很多

五.ParNew 的 GC 需要停止系统程序,但 G1 的新生代 GC 可以不用停止

 

3.使用 G1 垃圾回收器时应如何设置参数

 

(1)G1 的动态内存管理策略总结


G1 的动态内存管理策略:根据情况动态地把 Region 分配给新生代(Eden+S 区)、老年代和大对象。但是新生代和老年代会有一个各自的最大占比,新生代占比最大 60%,老年代占比最大 40%。然后在新生代的 Eden 满的时候,触发新生代垃圾回收。

 

G1 新生代的垃圾回收还是采用了复制算法。只是会考虑预设 GC 停顿时间,保证垃圾回收的停顿时间不超预设时间。因此会挑选一些回收价值比较高的 Region 来进行垃圾回收。

 

然后 G1 新生代垃圾回收和 ParNew 一样:如果一些对象在新生代熬过一定次数 GC,或触发了动态年龄判定规则,或 GC 后的存活对象在 Survivor 放不下,都会让对象进入老年代中。所以 G1 中的新生代对象还是会因为各种情况而慢慢地进入老年代的。

 

G1 对大对象的处理则与 ParNew 不一样:G1 的大对象会进入单独的大对象 Region,不再进入老年代。

 

(2)何时触发新生代 + 老年代的混合垃圾回收


-XX:InitiatingHeapOccupancyPercent 是 G1 的参数,默认值是 45%。意思是如果老年代占据了堆内存的 45%的 Region 时,就会尝试触发新生代 + 老年代一起回收的混合回收。

 

比如按照默认情况下的堆内存有 2048 个 Region:如果老年代占据了其中 45%的 Region,就会开始触发混合回收。如下图示:



(3)G1 混合垃圾回收的过程


G1:初始标记-并发标记-最终标记-混合回收

CMS:初始标记-并发标记-重新标记-并发清除

 

一.首先进入初始标记阶段


这个阶段需要 STW,标记 GC Roots 直接引用的对象,这个过程速度是很快的。

 

如下图示:首先 STW 停止系统程序的运行。然后对各个线程栈内存中局部变量所代表的 GC Roots,以及方法区中类静态变量所代表的 GC Roots,进行扫描。也就是标记出这些 GC Roots 直接引用的对象。



二.然后会进入并发标记阶段


这个阶段会允许系统程序的运行,同时进行 GC Roots 追踪,从 GC Roots 开始追踪所有的存活对象,如下:


这里对 GC Roots 追踪进行说明,代码如下:


public class Kafka {    public static ReplicaManager replicaManager = new ReplicaManager();}public class ReplicaManager {    public ReplicaFetcher replicaFetcher = new ReplicaFetcher();}
复制代码


可以看到:Kafka 类有一个静态变量是 replicaManager,它就是一个 GC Root 对象。首先在初始标记阶段,仅仅会标记 GC Roots 直接引用的对象。所以会标记 replicaManager 作为 GC Roots 直接关联的对象,也就是表明堆内存中的 ReplicaManager 对象,它肯定是要存活的。

 

然后在并发标记阶段,就会进行 GC Roots 追踪。即会从 replicaManager 直接关联的 ReplicaManager 对象开始往下追踪,ReplicasManager 对象里有一个实例变量 replicaFetcher,此时追踪这个 replicaFetcher 变量可知它引用了 ReplicaFetcher 对象,于是这个 ReplicaFetcher 对象也要被标记为存活对象。

 

这个并发标记阶段还是很耗时的,因为要追踪全部的存活对象。但这个阶段可以跟系统程序并发运行,所以对系统程序影响不太大。而且在并发标记阶段对对象进行的修改,JVM 也会记录起来。比如哪个对象被新建了,哪个对象失去了引用。

 

三.接着会进入最终标记阶段


这个阶段会 STW 禁止系统程序运行,但会根据并发标记时的记录,最终标记出哪些对象存活、哪些对象回收。如下图示:



四.最后进入混合回收阶段


这个阶段首先会进行如下计算:老年代中各 Region 的存活对象数量、存活对象占比,还有执行垃圾回收的预期性能和效率。

 

接着会 Stop The World 停止系统程序,选择部分 Region 进行回收,因为必须让垃圾回收的停顿时间控制在指定的范围内。

 

比如老年代此时有 1000 个 Region 都满了:但是根据预定目标,本次垃圾回收可能只能停顿 200 毫秒。那么通过之前计算得知,可能回收其中 800 个 Region 刚好需要 200ms。于是就只回收那 800 个 Region,把 GC 停顿时间控制在指定范围内。如下图示:



需要注意的是:老年代对堆内存占比达到 45%时,触发的是混合回收。此时垃圾回收不仅会回收老年代,还会回收新生代,还会回收大对象。

 

那么到底会回收这些区域的哪些 Region,那就要看情况了。因为 G1 为了满足设定的 GC 停顿时间要求,会从新生代、老年代、大对象里各自挑选一些 Region,保证在指定的时间范围内(比如 200ms)回收尽可能多的垃圾对象。这也就是所谓的混合回收,如下图示:


(4)G1 垃圾回收器的一些参数


在老年代的 Region 占据堆内存 Region 的 45%之后,会触发混合回收。混合回收也就是 Mixed GC,进行混合回收时会分为如下四个阶段:初始标记 -> 并发标记 -> 最终标记 -> 混合回收。在最后的混合回收阶段,会从新生代和老年代中都回收一些 Region。

 

注意:G1 会执行多次混合回收。即 G1 在最后的混合回收阶段时,会多次停止运行的系统程序。比如先停止系统运行,执行一次混合回收。回收掉一些 Region 后,恢复系统运行。然后再次停止系统运行,接着又执行一次混合回收。回收掉一些 Region,恢复系统运行。

 

一.-XX:G1MixedGCCountTarget 指定混合回收阶段会执行多少次回收

在一次 MixedGC 过程中,最后一个阶段应执行多少次回收,默认 8 次。为什么在最后一个混合回收阶段需要反复回收多次呢?因为停止系统一会儿,回收掉一些 Region,再让系统运行一会儿。然后再次停止系统一会儿,再次回收掉一些 Region。这样可以尽可能让系统的停顿时间不会太长,可以在多次回收的间隙,也运行一下程序。

 

二.-XX:G1HeapWastePercent 指定结束混合回收时空 Region 的比例

G1 在混合回收时,对 Region 的回收都是基于复制算法进行的。首先会把要回收的 Region 里的存活对象放入其他 Region,然后清理原 Region 的对象,这样在回收过程中就会不断空出新的 Region。一旦空出的 Region 达到默认堆内存大小的 5%,那么此时就会结束本次的混合回收。

 

由于 G1 整体(新生代和老年代)是基于复制算法进行 Region 垃圾回收的,所以不会出现内存碎片的问题。G1 不需要像 CMS 那样,在标记清理后再进行内存碎片的整理。

 

三.G1MixedGCLiveThresholdPercent 指定被回收 Region 的存活对象占比

默认值是 85%,意思是要回收的 Region 的存活对象大小占比要小于 85%。如果一个 Region 中,其存活对象都占了该 Region 大小的 85%以上。那么再把 85%大小的存活对象都拷贝到另一个 Region 中的成本就会很高,所以就没必要回收这种 Region 了。

 

(5)回收失败时的 Full GC


在进行 Mixed GC 回收时,新生代和老年代都是基于复制算法进行回收的。也就是 Mixed GC 会把要回收 Region 的存活对象拷贝到其他空闲的 Region。如果在拷贝过程中发现没有空闲的 Region 可存放 Mixed GC 的存活对象了,那么就会触发一次 Mixed GC 失败时的 Full GC。

 

一旦触发 Mixed GC 失败时的 Full GC,就会停止系统程序。然后采用单线程进行标记、清理和压缩整理,清空出一批 Region,这个过程会非常慢。

 

(6)问题


结合 ParNew + CMS 组合的 JVM GC 优化思路:

一.G1 垃圾回收器中值得优化的地方(合理停顿 + 少 MGC + 避免 FGC )

二.什么情况可能会导致 G1 频繁触发 Mixed GC(老年代占 45%触发 MGC)

三.如何减少 MGC 频率(S 区足够大 +提高触发占比 + 不过早结束 MGC)

 

4.如何基于 G1 垃圾回收器优化性能

 

(1)案例背景


一个百万级注册用户的在线教育平台,主要目标用户群体是中小学生。注册用户大概是几百万,日活用户大概是几十万。

 

系统的业务流程也不复杂,普通用户浏览课程详情、下单付费、选课排课等低频行为几乎不用考虑。对于这样一个在线教育平台,其高频行为就是上课。

 

这个平台的使用人群是中小学生,该用户群体周一到周五白天要上学,放学后到八九点才会频繁使用平台,周末也会频繁地使用这个平台。

 

所以在每天晚上两三小时高峰期会有几十万日活用户来该教育平台上课,甚至可认为白天几乎没什么流量,而 99%的流量都集中在晚上两三小时。



(2)系统核心业务流程分析


接着来明确一下,用户在上课时主要高频使用的这个系统的哪些功能。假设用户使用该系统时,核心的业务流程就是游戏互动环节。通过游戏互动让用户感兴趣、愿意学、保持注意力、提升学习效果。



也就是说,这个游戏互动功能,会承载用户高频率、大量的互动点击。比如在完成某任务时要点击很多按钮、频繁的进行互动。然后系统需要接收大量互动请求,并且记录用户的互动过程和互动结果。比如系统需要记录下用户完成了多少任务、做对了几个、做错了几个等。

 

(3)系统的运行压力


现在开始来分析一下这个系统运行时对内存使用产生的压力。核心就是在晚上两三小时高峰期内,每秒钟会有多少请求,每个请求会产生多少对象、占用多少内存,每个请求要处理多长时间。

 

一.首先估算晚上高峰期几十万用户使用系统时每秒会产生多少请求


假设晚上 3 小时高峰期内共有 60 万活跃用户,平均每个用户使用 1 小时。那么每小时会有 20 万活跃用户进行在线学习,这 20 万用户会进行大量互动操作。

 

假设一用户每分钟进行 1 次互动操作,那么一个用户一小时内就会进行 60 次互动操作,所以 20 万用户在 1 小时内会进行 1200 万次互动操作。平均到每秒大概就是 3000 次左右的互动操作,也就是系统每秒要处理 3000 并发请求。根据经验,一般需要部署 5 台 4 核 8G 机器,每台机器每秒处理 600 请求。这个压力可以接受,一般不会导致宕机的问题。

 

二.然后估算每个请求会产生多少个对象


一次互动请求不会有太复杂的对象,主要记录用户的一些互动过程。比如用户每完成一个活动,就给用户累加一些"XX 币","XX 宝石"等。所有一次互动请求大致会创建几个对象,占据几 K 的内存。一个对象大概几十个字段,每个 Long 字段 8 字节,一个对象就几百字节。加上系统其他功能的运行,一次请求假设涉及十几个这样的对象。那么一次请求涉及创建的对象占 5K,一秒 600 请求就会占用 3M 内存。

 

(4)在线教育系统背景总结


在介绍百万用户在线教育平台的 G1 垃圾回收优化案例前,先分析了:系统核心业务、高峰压力、机器部署、每秒请求数、每秒内存压力。

 

接下来会基于每秒内存使用压力,结合 G1 的运行原理,进行如下分析:

 

G1 垃圾回收机制会如何运行,在这个运行过程中可能会产生哪些问题;G1 垃圾回收器在使用时有哪些地方是值得优化的;如何对 G1 的一些参数进行优化来调整垃圾回收性能;我们应该要合理分析系统的内存压力,然后合理优化 JVM 的参数,尽可能降低 JVM GC 的频率,同时降低 JVM GC 导致的系统停顿的时间。

 

(5)G1 垃圾回收器的默认内存布局


系统采用了 5 台 4 核 8G 机器来部署,每台机器每秒会有 600 个请求占用 3M 的内存。假设给每台机器上的 JVM 分配了 4G 的堆内存,并且使用 G1 垃圾回收器。其中新生代默认初始占比为 5%,最大占比为 60%。每个 Java 线程的栈内存为 1M,元数据区域(永久代)的内存为 256M。此时 JVM 参数如下:


 -Xms4096M -Xmx4096M  -Xss1M  -XX:PermSize=256M -XX:MaxPermSize=256M -XX:+UseG1GC
复制代码


-XX:G1NewSizePercent 设置新生代初始占比,采用默认值 5%。

 

-XX:G1MaxNewSizePercent 设置新生代最大占比,采用默认值 60%。

 

此时堆内存为 4G,G1 会除以 2048,计算出每个 Region 的大小为 2M。刚开始新生代只占 5%的 Region,即只有 100 个 Region 共 200M 内存空间。如下图示:



(6)GC 停顿时间如何设置


在 G1 垃圾回收器中有一个至关重要的参数会影响到 GC 的表现,就是-XX:MaxGCPauseMills,默认值是 200 毫秒。这个参数指定每次触发 GC 时导致的系统停顿时间期望不超过 200 毫秒,这个参数可以先保持默认值。

 

(7)到底多长时间会触发新生代 GC


当系统运行起来后,会不停地在新生代的 Eden 区域内分配对象。按照前面的估算,每秒会分配 3M 大小的对象。如下图示:



一.一些问题和假设


问题一:

Eden 区空间不够,就触发新生代 GC,那什么时候 Eden 区会内存不够?

 

问题二:

-XX:G1MaxNewSizePercent 限定了新生代最多占用堆内存 60%的大小。那么难道必须随着系统运行一直给新生代分配更多的 Region,直到新生代占据 60%的 Region 后,无法再分配 Region 才触发新生代 GC?G1 肯定不是这么做的。

 

二.G1 的运行原理


假设在这个系统里,G1 回收 300 个 Region(600M 内存)大概需要 200ms。那么很有可能系统在运行时呈现出如下的效果:系统运行时每秒创建 3M 对象,大概 1 分钟就会塞满 100 个 Region(200M)。如下图示:



此时很可能 G1 会觉得:要是现在就触发一次新生代 GC。那么回收区区 200M 只需要大概几十 ms,最多就让系统停顿几十 ms 而已。这与启动时-XX:MaxGCPauseMills 参数设定的 200ms 停顿时间相差甚远。所以要是现在就触发新生代 GC,那么久可能会导致:回收完成后 1 分钟,再次占满新生代的这 100 个 Region,又要触发新 GC。这样每分钟都要执行一次新生代 GC,过于频繁了,没这个必要。

 

因此 G1 可能就会觉得:还不如给新生代先增加一些 Region。然后让系统继续运行着,在新增加的新生代 Region 中分配对象好了,这样就不用过于频繁的触发新生代 GC。如下图示:



然后系统继续运行,一直到可能 300 个 Region 都占满了。此时通过计算发现回收这 300 个 Region 大概需要 200ms,那么可能这个时候才会触发一次新生代的 GC。

 

由此可见,其实 G1 是很动态灵活的。它会根据设定的 GC 停顿时间给新生代不停分配更多 Region。然后到一定程度,感觉差不多了,才会触发新生代 GC。从而保证新生代 GC 时导致的系统停顿时间在预设范围内,而且也避免了频繁的新生代 GC。

 

需要注意的是:

分配多少 Region 给新生代、多久触发一次新生代 GC、每次耗费多长时间。G1 并不能确定,必须通过工具查看系统实际情况才知道,无法提前预知。

 

G1 的运行原理总结:

G1 会根据预设的 GC 停顿时间,给新生代分配一些 Region。然后到一定程度才触发 GC,并且把 GC 停顿时间控制在预设范围内,尽量避免一次性回收过多 Region 导致 GC 停顿时间超出预期。

 

(8)新生代 GC 如何优化


垃圾回收器是一代比一代先进的,虽然内部实现机制越来越复杂,但是优化却越来越简单。

 

比如对于 G1 而言:


一.首先给整个 JVM 的堆区域足够的内存

比如我们在这里就给了 JVM 超过 5G 的内存,其中堆内存有 4G 的内存。

 

二.接着合理设置-XX:MaxGCPauseMills 参数

如果这个参数设置太小了:

那么说明每次 GC 停顿时间可能特别短。此时 G1 可能在发现几十个 Region 占满时,就要开始触发新生代 GC。从而导致新生代 GC 频率特别频繁。比如如果设置每次停顿 30 毫秒,那么可能会每 30 秒触发一次新生代 GC。

 

如果这个参数设置过大了:

那么 G1 会允许不停地在新生代分配新对象。然后积累很多对象,再一次性回收几百个 Region。此时可能一次 GC 停顿时间就会达到几百毫秒,但 GC 的频率很低。比如每 30 分触发一次新生代 GC,但每次停顿 500 毫秒。

 

所以预期的 GC 停顿时间到底如何设置,需要结合系统压测工具、GC 日志、内存分析工具来考虑,尽量别让系统的 GC 频率太高,同时每次 GC 停顿时间也别太长。

 

(9)Mixed GC 如何优化


一.频繁触发 Mixed GC 的关键


新生代对象进入老年代的几个条件是:YGC 后存活对象太多没法放入 Survivor 区 + 对象年龄太大 + 动态年龄判定规则。

 

Mixed GC 的触发条件是:老年代在堆内存里占比超过 45%。

 

在新生代对象进入老年代的几个条件其中比较关键的就是:新生代 GC 后存活对象太多无法放入 Survivor 区和动态年龄判定规则,因为这两个条件可能让很多对象快速进入老年代。一旦老年代达到占用堆内存 45%的阈值,那么就会频繁触发 Mixed GC。

 

所以 Mixed GC 本身很复杂,很多参数可以优化。但是优化 Mixed GC 的核心不是优化它的参数,而是和前面分析的一样。尽量避免对象过快进入老年代,避免频繁触发 Mixed GC,就能实现优化。

 

二.合理设置-XX:MaxGCPauseMills 避免频繁触发 Mixed GC


由于 G1 和 ParNew + CMS 的组合是不同的,那应该如何来优化参数呢?其实核心的还是-XX:MaxGCPauseMills 这个参数。

 

如果-XX:MaxGCPauseMills 参数设置的值很大,导致系统运行很久,新生代都占用堆内存的 60%时才触发新生代 GC。那么存活下来的对象可能就会很多,导致 Survivor 区放不下那么多对象。于是这些存活下来的对象就会全部进入老年代,或者存活下来的对象比较多,达到 S 区的 50%,触发动态年龄判定规则,那么也会导致下一次新生代 GC 的存活对象快速进入老年代。

 

所以核心还是在于调节-XX:MaxGCPauseMills 这个参数的值。在保证新生代 GC 不太频繁的同时,还得考虑每次 GC 后有多少存活对象。避免存活对象太多快速进入老年代,频繁触发 Mixed GC。

 

5.问题汇总


问题一:


一个广告系统,使用的就是 G1 垃圾回收器。因为堆内存有 30G,传统回收器可能会造成很大的停顿,所以使用了 G1。

 

答:G1 非常适合超大内存的机器。因为内存太大,不用 G1 会导致新生代每次 GC 回收垃圾太多,停顿太长。使用 G1 则可以指定每次 GC 停顿时间,每次回收一部分 Region。

 

问题二:


从 GC 效果上看,G1 最明显的特点就是可以预测 STW 的时间。G1 为了达到这个效果,抛弃传统分代内存,分成各个小内存块 Region。针对这些 Region 计算垃圾回收价值,然后选某些性价比高的进行 GC,以便在预先设定的 GC 时间内完成 GC。所以是不是 G1 可以用在对 STW 特别敏感的业务上?比如实时通信等追求低延迟响应的业务。

 

答:是的。还有就是那种大内存机器,比如 16G,32G 的机器部署的系统。大内存机器如果不用 G1,那么新生代满时对象太多,一次 GC 时间太长。而用了 G1 则可以控制停顿时间,每次只回收部分 Region 即可。


问题三:


G1 按 Region 回收会不会形成新的内存碎片?

 

答:不会。Region 回收时使用的是复制算法,会将存活对象拷贝到其他 Region。然后再对原来的 Region 直接回收掉全部垃圾。

 

问题四:


G1 分那么多 Region,有点像 HDFS 里的小文件,小文件太多会影响性能。但是为什么 G1 的性能会比之前那些更好?

 

答:划分为很多的 Region,回收时按照设定只能停顿系统 20ms。所以就会挑选少量 Region 来回收,这样可以控制垃圾回收的停顿时间。如果按照 ParNew + CMS 组合简单分代划分,必须回收整个新生代。这时每次 GC 回收的内存区域大了,必然要停顿更久时间。

 

问题五:


一个 Spring Boot 应用在 8G 内存开发机上跑,启动需要加载的类特别多。每次 JVM 一启动,新生代就以每秒 10M 的速度增长,光启动就要十分钟。因为发现启动期间就进行了两次 Full GC,半小时执行了十几次 YGC。于是就调整了新老比例为 2 比 1,共分配 4G。之后 FGC 一直为 0, YGC 半小时只有两次,启动时间也降为 1 分钟以内。

 

答:是的,这就是典型的新生代内存不足导致的。系统启动时要创建一堆对象,发现新生代不够。于是频繁 YGC,很多对象进入到老年代。然后老年代又不足,又要对老年代 Full GC。最后就出现十多次 YGC + 几次 Full GC。

 

由于 GC 太多会导致系统启动速度很慢。优化比例后,新生代内存充足,很多对象直接进入新生代不用进老年代。于是最多就是少数 YGC 回收一部分对象,也不会有 FGC。GC 次数减少了,那系统启动速度也就快了。

 

问题六:


G1 垃圾回收器也应该合理分配新生代的占比,保证 S 区足够大。不让存活对象很快进入老年代,不让老年代很快占到 45%。如果老年代不那么快占到 45%,自然就可以减少混合回收。

 

问题七:


一.G1 混合回收在第四个阶段会进行多次混合回收,这个多次混合回收的间隔是由 G1 自己控制的。

二.空闲的 Region 数量达到堆内存 5%就会停止回收,即默认最多进行 8 次混合回收。但可能到了 4 次,发现空闲 Region 达到 5%就不进行混合回收了。

三.Mixed GC 回收失败时 Full GC,应该是采用 Serial Old 回收器。


文章转载自:东阳马生架构

原文链接:https://www.cnblogs.com/mjunz/p/18642777

体验地址:http://www.jnpfsoft.com/?from=infoq

用户头像

EquatorCoco

关注

还未添加个人签名 2023-06-19 加入

还未添加个人简介

评论

发布
暂无评论
JVM实战—G1垃圾回收器的原理和调优_Java_EquatorCoco_InfoQ写作社区