概述:

  1. 官网介绍JDK 7 update 4 and later releases. 之后开始有G1收集器,G1是一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能的满足垃圾收集暂停时间的要求。<br /> G1 被设计用来长期取代 CMS 收集器,和 CMS 相同的地方在于,它们都属于并发收集器,在大部分的收集阶段都不需要挂起应用程序。区别在于,1. G1 没有 CMS 的碎片化问题(或者说不那么严重),2. G1在停顿时间上添加了预测机制,同时提供了更加可控的停顿时间。<br /> 如果你的应用使用了较大的堆(如 6GB 及以上)而且还要求有较低的垃圾收集停顿时间(如 0.5 秒),那么 G1 是你绝佳的选择,是时候放弃 CMS 了。<br />[https://www.cnblogs.com/aspirant/p/8663897.html](https://www.cnblogs.com/aspirant/p/8663897.html)<br />[https://www.cnblogs.com/aspirant/p/8663872.html](https://www.cnblogs.com/aspirant/p/8663872.html)

G1 垃圾收集器架构和如何做到可预测的停顿(阿里)

这么做给G1带来了很大的好处,由于把三块内存变成了几百块内存,内存块的粒度变小了,从而可以垃圾回收工作更彻底的并行化。
G1的并行收集做得特别好,我们第一次听到并行收集应该是CMS(Concurrent Mark & Sweep)垃圾回收算法, 但是CMS的并行收集也只是在收集老年代能够起效,而在回收年轻代的时候CMS是要暂停整个应用的(Stop-the-world)。而G1整个收集全程几乎都是并行的,它回收的大致过程是这样的:

  • 在垃圾回收的最开始有一个短暂的时间段(Inital Mark)会停止应用(stop-the-world)
  • 然后应用继续运行,同时G1开始Concurrent Mark
  • 再次停止应用,来一个Final Mark (stop-the-world)
  • 最后根据Garbage First的原则,选择一些内存块进行回收。(stop-the-world)

由于它高度的并行化,因此它在应用停止时间(Stop-the-world)这个指标上比其它的GC算法都要好。
G1的另一个显著特点他能够让用户设置应用的暂停时间,为什么G1能做到这一点呢?也许你已经注意到了,G1回收的第4步,它是“选择一些内存块”,而不是整代内存来回收,这是G1跟其它GC非常不同的一点,其它GC每次回收都会回收整个Generation的内存(Eden, Old), 而回收内存所需的时间就取决于内存的大小,以及实际垃圾的多少,所以垃圾回收时间是不可控的;而G1每次并不会回收整代内存,到底回收多少内存就看用户配置的暂停时间,配置的时间短就少回收点,配置的时间长就多回收点,伸缩自如。 (阿里面试)
由于内存被分成了很多小块,又带来了另外好处,由于内存块比较小,进行内存压缩整理的代价都比较小,相比其它GC算法,可以有效的规避内存碎片的问题。
说了G1的这么多好处,也该说说G1的坏处了,如果应用的内存非常吃紧,对内存进行部分回收根本不够,始终要进行整个Heap的回收,那么G1要做的工作量就一点也不会比其它垃圾回收器少,而且因为本身算法复杂了一点,可能比其它回收器还要差。因此G1比较适合内存稍大一点的应用(一般来说至少4G以上),小内存的应用还是用传统的垃圾回收器比如CMS比较合适。

G1通过在垃圾回收领域应用并行化的策略,把几块大内存块的回收问题,变成了几百块小内存的回收问题,使得回收算法可以高度并行化,同时也因为分成很多小块,使得垃圾回收的单位变成了小块内存,而不是整代内存,使得用户可能对回收时间进行配置,垃圾回收变得可以预期了。
分而治之、化整为零这些朴素的架构思想往往是很多牛叉技术产品背后的思想根源啊。

G1总览:

  1. G1 没有新生代和老年代的概念,而是将堆划分为一块块独立Region。当要进行垃圾收集时,首先估计每个 Region 中垃圾的数量,每次都从垃圾回收价值最大的 Region 开始回收,因此可以获得最大的回收效率。从整体上看, G1 是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间上看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。<br /> G1主要改变是Eden,SurvivorTenured等内存区域不再是连续的,而成为一个个大小一样的region。每个region1M32M不等。G1 中每个块也会充当 EdenSurvivorOld 三种角色,但是它们不是固定的,这使得内存使用更加地灵活。<br />![WX20191214-164845@2x.png](https://cdn.nlark.com/yuque/0/2019/png/416592/1576313339707-04cfac43-de6a-455f-ba44-d3eb12c1d36f.png#align=left&display=inline&height=430&margin=%5Bobject%20Object%5D&name=WX20191214-164845%402x.png&originHeight=430&originWidth=1408&size=201407&status=done&style=none&width=1408)<br /> Hopspot虚拟机堆结构 G1堆内存配置

G1底层原理:

Region区域化垃圾收集器

每个 Region 都有一个 Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,进行可达性分析时,只要在 GC Roots 中再加上 Remembered Set 即可防止对整个堆内存进行遍历。所以不需要扫描整个堆内存才能完整地进行一次可达性分析

回收步骤

针对Eden区进行收集,Eden区耗尽后会被触发,主要是小区域收集+形成连续的内存块,避免内存碎片。

  • Eden区的数据移动到Survivor区,假如出现了Survivor区 空间不够,Eden区数据会部分晋升到Old区
  • Survivor区的数据移动到新的Survivor区,部分数据晋升到Old区
  • 最后Eden区收拾干净了,GC结束,用户的应用程序继续执行。

09E2D600-62BD-42E7-A002-8DDD42DB4F1B.png
开始回收前

B1628C55-BD42-4C14-BA94-6730E159C243.png

  1. 回收完成

回收4步过程

G1 收集器主要包括了以下 4 种操作:

  • 1、年轻代收集
  • 2、并发收集,和应用线程同时执行
  • 3、混合式垃圾收集
  • 4、必要时的 Full GC

年轻代收集:**年轻代中的垃圾收集流程(Young GC)如下图所示:
WX20191214-213206@2x.png

我们可以看到,年轻代收集概念上和之前介绍的其他分代收集器大差不差的,但是它的年轻代会动态调整。

Old GC / 并发标记周期:
Old GC 的流程(含 Young GC 阶段),其实把 Old GC 理解为并发周期是比较合理的,不要单纯地认为是清理老年代的区块,因为这一步和年轻代收集也是相关的。下面我们介绍主要流程:

  • 初始标记:Stop The World,它伴随着一次普通的 Young GC 发生,然后对 Survivor 区(root region)进行标记,因为该区可能存在对老年代的引用。

    因为 Young GC 是需要 stop-the-world 的,所以并发周期直接重用这个阶段,虽然会增加 CPU 开销,但是停顿时间只是增加了一小部分。

  • 扫描根引用区:扫描 Survivor 到老年代的引用,该阶段必须在下一次 Young GC 发生前结束。

    这个阶段不能发生年轻代收集,如果中途 Eden 区真的满了,也要等待这个阶段结束才能进行 Young GC。

  • 并发标记:进行GC Roots Tracing的过程,寻找整个堆的存活对象,该阶段可以被 Young GC 中断。与用户线程并发执行。此过程进行可达性分析,速度很慢。

这个阶段是并发执行的,中间可以发生多次 Young GC,Young GC 会中断标记过程

  • 重新标记:stop-the-world,完成最后的存活对象标记。使用了比 CMS 收集器更加高效的 snapshot-at-the-beginning (SATB) 算法。修正并发标记期间,因程序运行导致标记发生变化的那一部分对象,使用多条标记线程并发执行。

Oracel 的资料显示,这个阶段会回收完全空闲的区块

  • 清理:清理阶段真正回收的内存很少。

到此,G1 的一个并发周期就算结束了,其实就是主要完成了垃圾定位的工作,定位出了哪些分区是垃圾最多的。

834972C7-4C38-8497-AFFB022387B8.png

混合垃圾回收周期:
并发周期结束后是混合垃圾回收周期,不仅进行年轻代垃圾收集,而且回收之前标记出来的老年代的垃圾最多的部分区块。
混合垃圾回收周期会持续进行,直到几乎所有的被标记出来的分区(垃圾占比大的分区)都得到回收,然后恢复到常规的年轻代垃圾收集,最终再次启动并发周期。
Full GC:
下面我们来介绍特殊情况,那就是会导致 Full GC 的情况,也是我们需要极力避免的:

  1. concurrent mode failure:并发模式失败,CMS 收集器也有同样的概念。G1 并发标记期间,如果在标记结束前,老年代被填满,G1 会放弃标记。

    这个时候说明

    • 堆需要增加了
    • 或者需要调整并发周期,如增加并发标记的线程数量,让并发标记尽快结束
    • 或者就是更早地进行并发周期,默认是整堆内存的 45% 被占用就开始进行并发周期。
  1. 晋升失败:并发周期结束后,是混合垃圾回收周期,伴随着年轻代垃圾收集,进行清理老年代空间,如果这个时候清理的速度小于消耗的速度,导致老年代不够用,那么会发生晋升失败。

    说明混合垃圾回收需要更迅速完成垃圾收集,也就是说在混合回收阶段,每次年轻代的收集应该处理更多的老年代已标记区块。

  1. 疏散失败:年轻代垃圾收集的时候,如果 Survivor 和 Old 区没有足够的空间容纳所有的存活对象。这种情况肯定是非常致命的,因为基本上已经没有多少空间可以用了,这个时候会触发 Full GC 也是很合理的。

最简单的就是增加堆大小

  1. 大对象分配失败,我们应该尽可能地不创建大对象,尤其是大于一个区块大小的那种对象。

G1 参数配置:

G1 调优的目标是尽量避免出现 Full GC,其实就是给老年代足够的空间,或相对更多的空间
G1 调优的方向:

  • 增加堆大小,或调整老年代和年轻代的比例
  • 增加并发周期的线程数量,其实就是为了加快并发周期快点结束
  • 让并发周期尽早开始,这个是通过设置堆使用占比来调整的(默认 45%)
  • 在混合垃圾回收周期中回收更多的老年代区块

    G1 的很重要的目标是达到可控的停顿时间,所以很多的行为都以这个目标为出发点开展的。通过设置 -XX:MaxGCPauseMillis=N 来指定停顿时间(单位 ms,默认 200ms),如果没有达到这个目标,G1 会通过各种方式来补救:调整年轻代和老年代的比例,调整堆大小,调整晋升的年龄阈值,调整混合垃圾回收周期中处理的老年代的区块数量等等。
    当然了,调整每个参数满足了一个条件的同时往往也会引入另一个问题,比如为了降低停顿时间,可以减小年轻代的大小,可是这样的话就会增加年轻代垃圾收集的频率。如果我们减少混合垃圾回收周期处理的老年代区块数量,虽然可以更容易满足停顿时间要求,可是这样就会增加 Full GC 的风险等等。

  • -XX:+UseG1GC

  • -XX:G1HeapRegionSize=n 设置G1区域的大小。值是2的幂,范围是1MB到32MB。目标是根据最小的 Java 堆大小划分出约 2048 个区域。目前的游戏项目22G内存,所以这个值设定成16。也就是一个内存区域16M,22G大概分成了1408个区域。
  • -XX:MaxGCPauseMillis=200 为所需的最长暂停时间设置目标值。默认值是 200 毫秒。这个数值是一个软目标,也就是说JVM会尽一切能力满足这个暂停要求,但是不能保证每次暂停一定在这个要求之内。根据测试发现,如果我们将这个值设定成50毫秒或者更低的话,JVM为了达到这个要求会将年轻代内存空间设定的非常小,从而导致youngGC的频率大大增高。所以我们并不设定这个参数。
  • -XX:InitiatingHeapOccupancyPercent=45 设置触发标记周期的 Java 堆占用率阈值。默认占用率是整个 Java 堆的 45%。就是说当使用内存占到堆总大小的45%的时候,G1将开始并发标记阶段。为混合GC做准备,这个数值在测试的时候我想让混合GC晚一些处理所以设定成了70%,经过观察发现如果这个数值设定过大会导致JVM无法启动并发标记,直接进行FullGC处理。G1的FullGC是单线程,一个22G的对GC完成需要8S的时间,所以这个值在调优的时候写的45%
  • -Xmn 官方文档说使用G1回收器的时候不要指定年轻代大小,使用MaxGCPauseMillis参数让JVM自行决定大小,之前也说过了如果MaxGCPauseMillis时间过小的话会带来younggc频率高,所以这个参数在调优的时候设定成4g
  • -XX:ReservedCodeCacheSize=256m 这个参数特别容易忽略,这个参数JDK8默认是48M,含义是当JIT运行过程中将JAVA代码进行底层代码编译,让程序从解释运行模式变成性能更高的编译运行模式,这个cache就是用来保存编译后代码的内存,之前出现了程序压测30小时之后CPU100%的问题,经过排查就是因为这个cache满了造成优化被关闭。Jvm日志里面会有输出:CodeCache is full. Compiler has been disabled。
  • -XX:ConcGCThreads=n 并发GC使用的线程数

增加这个值可以让并发标记更快完成,如果没有指定这个值,JVM 会通过以下公式计算得到: ConcGCThreads=(ParallelGCThreads + 2) / 4^3

  • -XX:G1ReservePercent=n 设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险,默认值是 10%。增加或减少百分比时,请确保对总的 Java 堆调整相同的量。Java HotSpot VM build 23 中没有此设置。
  • -XX:NewRatio=n 老年代/年轻代,默认值 2,即 1/3 的年轻代,2/3 的老年代

不要设置年轻代为固定大小,否则:

  • G1 不再需要满足我们的停顿时间目标
  • 不能再按需扩容或缩容年轻代大小

总结:

G1与其他垃圾收集器对比

其他垃圾收集器:

  1. 年轻代和老年代是各自独立且连续的内存块
  2. 年轻代收集使用eden+s0+s1进行复制算法
  3. 年老代收集必须扫描整个老年代区域
  4. 都是以尽可能少而快速的执行GC为设计原则

G1垃圾收集器:

  1. G1能充分利用多CPU等硬件优势,缩短STW
  2. G1整体上采用标记整理算法,局部通过复制算法,不会产生内存碎片。
  3. 宏观上G1中不在区分年轻代和年老代,把内存划分为多个独立的子区域。
  4. G1虽然整体内存混合在一起,但在小范围内还保留了新生代和老年代,G1只有逻辑上的分代概念,或者说每个分区都可能随着G1的运行在不用代之间前后切换。
  5. G1最大的好处是化整为零,避免全内存扫描,只需要按照区域来进行扫描即可。
  1. 并发标记结束后,G1 也就知道了哪些区块是最适合被回收的,那些完全空闲的区块会在这这个阶段被回收。如果这个阶段释放了足够的内存出来,其实也就可以认为结束了一次 GC。<br /> 如果并发标记结束了,那么下次 GC 的时候,还是会先回收年轻代,如果从年轻代中得到了足够的内存,那么结束;过了几次后,年轻代垃圾收集不能满足需要了,那么就需要利用之前并发标记的结果,选择一些活跃度最低的老年代区块进行回收。直到最后,老年代会进入下一个并发周期。<br /> 什么时候会启动并发标记周期呢?这个是通过参数控制的,此参数默认值是 45,也就是说当堆空间使用了 45% 后,G1 就会进入并发标记周期。

参考资料

Oracle 官方出品
Understanding G1 GC Logs
https://plumbr.io/handbook/garbage-collection-algorithms-implementations#g1
Java Hotspot G1 GC的一些关键技术链接
G1 垃圾收集器介绍
《Java 性能权威指南》