当前位置: 代码迷 >> 综合 >> jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解
  详细解决方案

jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解

热度:50   发布时间:2023-10-14 11:01:31.0

系统性学习请点击jvm学习目录

前言

G1垃圾收集器,英文是Garbage First。G1垃圾收集器问世的原因在于设计者们希望作出一个“停顿时间模型”收集器,也就是能在指定停顿时间内完成垃圾回收。G1的出现替代了CMS垃圾收集器,它是一款面向服务端应用的垃圾收集器,它在jdk7中出场,在jdk9中,被任命为服务端模式下的默认垃圾收集器。如果要来形容下G1,那么它与CMS、serial等垃圾收集器相比,它无疑是更智能,更复杂也是更厉害的垃圾收集器。
废话了这么多,下面就来好好的介绍它吧,由于G1的内容实在太多,所以这里这做一个入门的介绍 ,希望能够在更短的时间让读者们对G1有个更清晰的理解。参考资料中我也会列出一些博客,如果有兴趣深入了解,那么可以点击它们。

G1的堆内存布局

首先要讲的是G1的堆内存布局,这种布局是有别于我们之前讲的分代回收策略中的,但是又有些类似。它遵从了分代理论,但又做了修改。
G1将堆内存划分为了一个个大小相等的区域,这里我们称为Region。每个Region的大小都可以通过jvm的参数来设置,取值范围是1MB~32MB,且一定是2的N次幂。其中每个Region既可以做为新生代中的Eden或者Survivor,也可以作为老年代,它是可以由G1来随时决定的,按照G1收集器的指示来扮演Eden或者Survivor或者老年代。同时这里还引入了一个Humongous区域(为了简便,我们就暂且称作H区域),专门用来存储大对象,H区域的实现实际上就是多个连续的Region组成,当比较大的的对象创建时,很可能一个Region装不下(想起了鲲之大一锅装不下,哈哈哈),此时就给放到H区域里。

话不多说,直接上图,咱们直观点来理解堆内存究竟被G1划分成了什么样子jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解
(HotSpot Virtual Machine Garbage Collection Tuning Guide)
从上图可以有个直观了解。一个个小方块就是一个个Region,而灰色的是未被使用的Region,有浅蓝色背景的区域便是使用中的Region。
在使用中的Region中,有着红色的是Eden,而有着红色+S的则是Survivor,毫无疑问他们是新生代,除了他俩,剩下的都是老年代。
而在老年代中,只有浅蓝色背景,其他啥都没有的就是单纯的老年代,而有个H的就是我们上面讲的H区域,专门用来存储大对象的。(G1大多数的行为都将H区域的Region作为老年代来看待,并且除非大对象死亡,不会移动大对象到别的Region)。

G1收集器的运行过程

看了许多博客,都喜欢长篇大论,从头看到尾还是懵的,对于G1的整个运行过程还没有一个清楚的把握,这里我就直接把大概的运行过程给画下来。
jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解
从上图可以看出,核心关键即使young GC与mixed GC就是如图所示的流程。
young GC呢,就是对新生代的垃圾回收,而Mixed GC是混合垃圾回收,它是对新生代和老年代一起进行回收。
二者都有触发的条件,一旦触发,便进行回收,如果没有触发,则继续正常使用。
下面来分别介绍young GC与mixed GC。

young GC

在jvm正常运行时,创建的新对象会放在Eden的Region里,当Eden中装满了不能再分配了,就会将Eden区域存活的对象给复制到Survivor的Region去,年龄够的对象会被复制到老年代(一般经过一次垃圾回收之后的对象年龄+1,超过一定阈值就可以被判定为老年代,或者是Survivor区域装不下了,也会被判定为是老年代)。

总结一下,young GC阶段,关注的是新生代。主要采用的是类似于标记-复制算法。所以这里实际上涉及到了标记的问题,不过在这里只是介绍个大概。

几个概念

在这里要介绍几个概念,来解决其中的问题。

记忆集:首先是跨代引用问题,何谓跨代引用问题呢,就是我们在进行young GC的可达性分析时,只扫描新生代嘛,未扫描老年代,但实际可能有些新生代对象是只被老年代所引用的,那么此时,你一定要考虑这些引用,不然就会将这些未死亡的对象误判为死亡,那就麻烦了。但是我们又不能把整个老年代对象都扫描一遍,如果这样,那和mixed GC又有什么区别呢?
为了解决这个问题,采用了记忆集的方式(Remember Set)。用记忆集来记录外部指向本region对象的所有引用,每个Region都维护一个记忆集。
通过记忆集,便可以找到谁引用了本Region的对象。
记忆集更像是一个抽象类,是一个抽象概念,实际G1是通过卡表来实现,卡表是它的“实现类”。每个Region被划分成多个卡,当卡对应区域有引用本Region对象,就将其标记为脏卡。
总之,具体来说,G1中的记忆集实际上是一个HashTable,key是Region的起始地址,value是字节数组,而字节数组的下标则代表该Region中的卡们,当该Region中该卡中的对象引用了本Region中的对象,则在字节数组中进行标记,也就是上面说到的标记为脏卡。
如此一来,我们在进行young GC时,考虑到老年代跨代引用新生代时,只需要按照每个Region中的记忆集直接去扫描部分老年代Region的部分卡,需要扫描的区域将降低了很多很多,从减轻了很多的负担。
下面还是上图来示意
jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解

收集集合(Collect Set):无论是young GC还是MixedGC,他们都有各自的收集区域,但由于G1垃圾收集器要满足用户设定的停顿时间任务,所以它不可能所有的Region都进行回收,所以就设置了一个收集集合,只有在收集集合中的Region才会被收集。
young GC阶段,收集集合会容纳所有的新生代的Region。
Mixed阶段,其面向老年代和新生代,会根据算法,在保证完成用户设定的停顿时间任务的前提下,通过根据现有信息,使用启发式算法来计算不同Region的回收效益,将回收效益高的放入收集集合。

大型对象:这里对于大对象的定义,当分配的对象大于等于Region的一半时,就可以认为它是大对象了,同时因为大对象可能占用好几个Region,最后一个Region可能占不满,有剩余空间,这个空间按照GUIDE那篇文章来说是lost的,也就是忽略它,它不会被其他对象使用,也不会被回收。
如果大对象死亡,就将它所占据内存空间释放,如果存活,则不进行任何操作,也不移动它,因为大型对象占据多个Region,移动它的成本太高了。
由于不能移动,GUIDE一文中也提到,这可能会造成内存碎片的问题,这也是没有办法的。

原始快照(Snapshot At The Beginning):这里做一下简单介绍,具体了解请点击jvm学习 并发可达性分析详解
在确定了GC Roots之后,我们继续往下扫描,此时用户线程可能会对可达性分析造成干扰,详情见图。
jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解
这里JVM从0这个GC Roots开始扫描,当扫描了2时,正准备扫描其子节点3时,此时用户线程对引用关系进行了修改,将2-3的引用关系删除,添加了1-3的引用关系,如下图所示(虚线是删除)。
jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解
可以看到此时出问题了,即使是修改了之后,从我们用户的眼光来看,3依然是可达的,但是在jvm角度来看,1是黑的,它和它的子节点不会再被扫描,所以扫描不到3,2是灰的,但是它与3之间没有引用关系。此时,jvm就会错误的认为3是不可达的。
这里采取原始快照来解决该问题。
其做法是:当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,等本次扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,再重新扫描一次。
同样,我们拿上面的例子走一遍,再放一次图
jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解
在引入了原始快照之后,当删除了2-3引用关系时,我们记录下这一引用关系,而添加了1-3引用关系,这不管,随他去,本次扫描结束后,3不可达。然后进行第二次扫描,我们从记录中发现之前删除了2-3引用关系,此时便在记录中扫描,记录中明显有2-3,则从记录中的2,肯定能扫描到记录中的3,那么3便可达。
这里是不是有些迷糊?对的,如果按照原始快照在书上的阐述(感觉书上阐述的不清楚),重新再扫描一次,肯定是扫描不到3的,因为引用关系已经变了,所以重新扫描是扫描的原来的引用关系。也就是我们记录中已经删除的引用关系,因为只有这个才是原始的。
所以这也是原始快照这个名字的由来,(所以它没有起一个什么删除不变的土里土气的名字),无论引用关系删除与否,我都按照我第一眼看到的那样来扫描,那么就不可能出现有对象还活着,但我误以为它死了的误判了
但这会导致一个问题,就是下图这种情况,
jvm学习 Garbage First(G1)垃圾收集器通俗理解 入门讲解
就是加入只删除,不添加,那么3不是确实不可达了嘛,但根据原始快照,3又可达,这不是矛盾吗?确实矛盾,但是不影响,因为你想,活的对象你把它判定死了,这是要出事的,但是死的对象你判定它活着,这顶多多耗你一丢丢内存呗,基本都没有本质上的区别,所以,这不是个问题。
在G1中,实现原始快照主要是通过写屏障来实现的。所谓写屏障,在这里实际上可以理解为,当删除某一个节点的引用关系时,必须执行一串代码,这串代码的目的是将该对象将入到一个队列中(即将删除的引用关系记录下来)。

TAMS指针:在并发标记过程中,如何进行新对象的内存分配呢?
G1有两个TAMS指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象的分配。并发回收时新分配的对象地址都必须在这两个指针之上,G1收集器默认在这个地址上的对象是存活的,不纳入回收范围。

Mixed GC 混合垃圾收集阶段

下面来介绍Mixed GC,Mixed GC 触发条件是当堆内存中老年代的比例达到一定阈值时就会启动(一般是45%),当然这个值我们可以设置(通过参数XX:InitiatingHeapOccupancyPercent)。

在进行混合垃圾收集阶段,此时的Collect Set中包含的是所有的新生代和部分老年代。为什么是部分老年代呢?
这是因为G1的特性,它能够将停顿的时间控制在用户设置的时间内。为了实现这一目标,它不会将所有的垃圾都回收,而是只能的从老年代中挑选出那些回收效率高的区域,也就是回收时间短且可回收的空间大的Region,这样才可能完成停顿时间的任务,同时回收尽可能多的空间。

整个Mixed可以分为两个主要的子阶段:

  1. 并发标记阶段
  2. 最终回收

并发标记阶段:
该阶段细分为大概4个步骤:

  • 初始标记(initial mark):标记从GC Roots开始直接可达的对象。该阶段借用了young GC的暂停,利用young
    GC的STW时间短,完成初始标记,这种方式称为借道。该过程没有STW。
  • 并发标记(concurrent mark):和用户线程一起并发工作,在可达性树上进行扫描,确认对象们的存活状态。
  • 重新标记(remark):将在并发标记中被用户修改引用关系的对象重新扫描,避免出现并发可达性分析的安全问题。这里采用的是上面讲到的原始快照。同时,G1执行全局引用处理和类卸载,在这一阶段,G1根据已有信息,计算各个Region回收的效益的期望。该阶段STW。
  • 清除垃圾(cleanup):该阶段会整理堆分区,将回收效益高的Region放入Collect Set中去,然后识别空闲分区,将无存活对象的Region直接回收。该阶段也是STW的。

对于并发标记阶段不理解的读者可以点击jvm学习 并发可达性分析详解学习

最终回收阶段(Evacuation):

  • 该阶段是STW的,将Collect
    Set中的Region中的存活对象拷贝到其他空的Region中去,然后收回原本的Region所占用的内存空间。

Full GC

在上面的运行过程中我少画了一个Full GC,但也不算是忘了,因为Full GC本来就不是常规操作。当jvm无法在堆内存中给新创建的对象分配内存空间时,就会强制放弃G1,退化为Serial垃圾收集器来进行一次长时间STW且效率极低的Full GC。
在以下场景会触发Full GC:

  • 拷贝存活对象时,无法找到可用的空闲分区
  • 为创建的大型对象分配空间是找不到足够的连续分区

总结

G1垃圾收集器是一个面向与新生代和老年代都可的一个垃圾收集器,它有针对新生代的young GC,也有针对老年代和新生代的Mixed GC。G1收集器将堆内存划分成了多个相等的Region区域,同时,它也遵循了分代回收理论。它可以在用户设置的停顿时间目标下,结合运行的信息,自主计算哪些区域的回收效率高而选择部分空间回收,从而控制停顿时间。G1在考虑停顿时间的同时,也没有落下吞吐量。从整体来看,G1是基于标记-整理算法(对于堆内存),而对于局部(单个Region)来说,G1是基于标记-复制算法的。相较于CMS,G1更可控,造成的内存碎片也更少。在内存空间较大时,G1的表现会更优秀。

参考资料

  • HotSpot Virtual Machine Garbage Collection Tuning Guide
  • 详解 JVM Garbage First(G1) 垃圾收集器
  • G1从入门到放弃(一)
  • 深入理解 Java G1 垃圾收集器
  • 《深入理解jvm》周志明