写点什么

深入解析 G1 垃圾回收器

作者:码农BookSea
  • 2023-08-29
    浙江
  • 本文字数:4170 字

    阅读完需:约 14 分钟

深入解析G1垃圾回收器

本文已收录至 GitHub,推荐阅读 👉 Java随想录

微信公众号:Java 随想录


上篇文章我们聊了 CMS,这篇就来好好唠唠 G1。


CMS 和 G1 可以说是一对欢喜冤家,面试问你 CMS,总喜欢把 G1 拿进来进行比较。


G1 在 JDK7 中加入 JVM,在 JDK9 中成为了默认的垃圾收集器,如果在 JDK8 中使用 G1,我们可以使用参数 -XX:+UseG1GC 来开启。


G1 和 CMS 相比有哪些优缺点?G1 为什么能够建立可停顿的时间模型?


别着急,本篇文章告诉你答案。


G1,全名叫:Garbage First。是垃圾收集器技术发展历史上的里程碑式的成果,开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。


这句话啥意思?


在 G1 之前的垃圾回收器,如 Parallel Scavenge、Parallel Old、CMS 等,主要针对 Java 堆内存中的特定部分(新生代或老年代)进行操作。然而,G1 将 Java 堆划分为多个「小区域」,并根据每个区域中垃圾对象的数量和大小来优先进行垃圾回收。


称之为「基于 Region 的内存布局」。


另外设计者们设计 G1 的时候希望 G1 能够建立起「停顿时间模型」,停顿时间模型的意思是能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒这样的目标。


所以我们能够总结出 G1 身上的两个标签:


  • 基于 Region 的内存布局

  • 停顿时间模型


先来说说基于内存布局是怎么个事儿。


基于 Region 的堆内存布局

G1 的基于 Region 的堆内存布局,这是能够建立起「停顿时间模型」的关键。


G1 逻辑上分代,但是物理上不分代。


G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域,每一个区域称之为「Region」。



每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理。


好比角色扮演,不同的角色拿着不同的剧本


G1 可以通过参数控制新生代内存的大小:-XX:G1NewSizePercent(默认等于 5),-XX:G1MaxNewSizePercent(默认等于 60)。


也就是说新生代大小默认占整个堆内存的 5% ~ 60%


G1 收集器将整个 Java 堆划分成约「2048 个大小相同的独立 Region 块」,每个 Region 的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。


可以简单推算一下,G1 能管理的最大内存大约 32MB * 2048 = 64G 左右。


Region 中还有一类特殊的「Humongous 区域」,专门用来存储大对象,可以简单理解为对应着老年代。


G1 认为只要大小超过了一个 Region 容量一半的对象(即超过 1.5 个 region)即可判定为大对象。


而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中。


G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看待。


分配大对象的时候,因为占用空间太大,可能会过早发生 GC 停顿。G1 在每次分配大对象的时候都会去检查当前堆内存占用是否超过初始堆占用阈值 IHOP(The Initiating Heap Occupancy Percent),缺省情况是 Java 堆内存的 45%。当老年代的空间超过 45%,G1 会启动一次混合周期。

可预测的停顿时间模型

基于 Region 的停顿时间模型是 G1 能够建立可预测的停顿时间模型的前提。


G1 将 Region 作为单次回收的最小单元,即每次收集到的内存空间都是 Region 大小的整数倍,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。


G1 收集器会去跟踪各个 Region 里面的垃圾堆积的「价值」大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表。


每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默认值是 200 毫秒),优先处理回收价值收益最大的那些 Region,这也就是「Garbage First」名字的由来。


这种使用 Region 划分内存空间,以及具有优先级的区域回收方式,保证了 G1 收集器在有限的时间内获取尽可能高的收集效率。


所以说 G1 实现可预测的停顿时间模型的关键就是Region布局优先级队列。看起来好像 G1 的实现也不复杂,但是其实有许多细节是需要考虑的。


跨 Region 引用对象

首先第一个问题:G1 将 Java 堆分成多个独立 Region 后,Region 里面存在的跨 Region 引用对象如何解决?


本质上还是我们之前提过的「跨代引用」问题,解决方案的思路我们已经知道,使用「记忆集」。


G1 的记忆集在存储结构的本质上是一种「哈希表」,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号。


使用记忆集固然没啥毛病,但是麻烦的是,G1 的堆内存是以 Region 为基本回收单位的,所以它的每个 Region 都维护有自己的记忆集,这些记忆集会记录下别的 Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。


由于 Region 数量较多,每个 Region 都维护有自己的记忆集,光是存储记忆集这块就要占用相当一部分内存,G1 比其他圾收集器有着更高的内存占用负担。根据经验,G1 至少要耗费大约相当于 Java 堆容量 10%至 20%的额外内存来维持收集器工作。


这可以说是 G1 的缺陷之一。


除了跨代引用外,对象引用关系改变,如何解决?

对象引用关系改变

解决的办法我们之前在讲「三色标记算法」的时候提过,G1 使用「原始快照」来解决这一问题。


垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建。


G1 为每一个 Region 设计了两个名为「TAMS(Top at Mark Start)」的指针。


把 Region 中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1 收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。


与 CMS 中的「Concurrent Mode Failure」失败会导致 Full GC 类似,如果内存回收的速度赶不上内存分配的速度,G1 收集器也要被迫冻结用户线程执行,导致 Full GC 而产生长时间 Stop The World。


G1 可以通过-XX:MaxGCPauseMillis参数设置垃圾收集的最大停顿时间的 JVM 参数,单位为毫秒。


在垃圾收集过程中,G1 收集器会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。


然后通过这些信息预测现在开始回收的话,由哪些 Region 组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。


G1 收集器会根据这个设定值进行自我调整以尽量达到这个暂停时间目标。例如,如果设定了-XX:MaxGCPauseMillis=200,那么 JVM 会尽力保证大部分(但并非全部)的 GC 暂停时间不会超过 200 毫秒。

运作过程

  1. 初始标记(Initial Marking):仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。

  2. 并发标记(Concurrent Marking):从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还要重新处理 SATB 记录下的在并发时有引用变动的对象

  3. 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。

  4. 筛选回收(Live Data Counting and Evacuation):负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。


从上述阶段的描述可以看出,G1 收集器除了并发标记外,其余阶段也是要完全暂停用户线程的。


G1 在逻辑上仍然采用了分代的思想,从整体来看是基于「标记-整理」算法实现的收集器,但从局部(两个 Region 之间)上看又是基于「标记-复制」算法实现。



这时候有些点子王可能会想,如果我把-XX:MaxGCPauseMillis,调的非常小,那是不是就回收的更快了?



G1 默认的停顿目标为两百毫秒,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。


应用运行时间一长,最终占满堆引发 Full GC 反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的。

CMS VS G1


相比 CMS,G1 的优点有很多,较为明显的优点就是 G1 不会产生垃圾碎片


But,G1 相对于 CMS 仍然不是占全方位、压倒性优势的,至少 G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。


就内存占用来说,虽然 G1 和 CMS 都使用卡表来处理跨代指针,但 G1 的每个 Region 都必须有一份卡表,这导致 G1 的记忆集可能会占整个堆容量的 20%乃至更多的内存空间,相比起来 CMS 的卡表就相当简单,全局只有一份。


在执行负载的角度上,譬如它们都使用到写屏障,CMS 用写后屏障来更新维护卡表;而 G1 除了使用写后屏障来进行同样的卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。


相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免 CMS 那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。


由于 G1 对写屏障的复杂操作要比 CMS 消耗更多的运算资源,所以 CMS 的写屏障实现是直接的同步操作,而 G1 就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理。


目前在小内存应用上 CMS 的表现大概率仍然要会优于 G1,而在大内存应用上 G1 则大多能发挥其优势,这个优劣势的 Java 堆容量平衡点通常在 6GB 至 8GB 之间。


文章的最后放一组 G1 的常用参数:



最后吐槽一句,JVM 真的很难,垃圾收集器的内部原理实在太复杂,如果要深究需要长时间的积累。


当然我们不是 JVM 的专业人员,不需要学的那么深入,这篇文章讲到的内容能基本应付面试和工作场景了。


那本篇文章到这结束啦,我们下篇再见👋🏻。



感谢阅读,如果本篇文章有任何错误和建议,欢迎给我留言指正。


老铁们,关注我的微信公众号「Java 随想录」,专注分享 Java 技术干货,文章持续更新,可以关注公众号第一时间阅读。


一起交流学习,期待与你共同进步!

发布于: 2023-08-29阅读数: 57
用户头像

码农BookSea

关注

Java开发工程师 2021-12-26 加入

Java开发菜鸟工程师,写博客的初衷是为了沉淀我所学习,累积我所见闻,分享我所体验。希望和更多的人交流学习。

评论

发布
暂无评论
深入解析G1垃圾回收器_Java_码农BookSea_InfoQ写作社区