「一文搞懂」G1垃圾回收器

本章内容

基本介绍

G1(Garbage-First)是一个服务器风格的垃圾收集器,针对的是具有大内存的多处理器机。

它试图在实现高吞吐量的同时,以较高的概率满足垃圾收集(GC)暂停时间目标。

Garbage-First(垃圾优先)表示优先处理那些垃圾较多的内存块。即:根据堆中各个区域(Region)的垃圾回收价值在后台维护一个优先级列表,每次在允许的收集时间内优先回收价值最大的区域,从而避免在整个堆中进行全区域垃圾回收。

其中:

G1将对象从堆的一个或多个区域复制到堆的单个区域,并在进程中压缩和释放内存。这种转移在多处理器上并行执行,以减少暂停时间并提高吞吐量。因此,对于每次垃圾收集,G1都会持续地减少碎片。

注意:G1并不是一个实时收集器。它满足设定的暂停时间目标,具有较高的概率,但不是绝对确定的。根据以前收集的数据,G1估计在目标时间内可以收集多少个区域。因此,收集器拥有一个相当精确的区域收集成本模型,并使用该模型来确定在暂停时间目标内收集哪些区域以及收集多少个区域。

G1的第一个重点是为运行需要大堆且GC延迟有限的应用程序的用户提供解决方案。这意味着堆大小约为6GB或更大,并且稳定且可预测的暂停时间低于0.5秒(在Java19中推荐10G或者更大的堆内存)。

G1从整体来看采用标记-整理算法,从局部来看采用复制算法。

JDK9默认使用G1收集器。

应用场景

如果应用程序具有以下一个或多个特性,可以考虑使用G1回收器:

G1与CMS对比

G1被计划作为并发标记扫描收集器(CMS)的长期替代品,它们的主要区别:

内存模型

传统GC内存模型

传统的垃圾回收器把内存分成三类:Eden、Survivor、Old和Permanent(永久代)。 Eden、Survivor属于年轻代,Old属于老年代,且各区域的内存空间是连续的。

如图所示:

G1内存模型

G1是一个既分区也分代的垃圾收集器,它将堆内存划分为多个大小相同的分区(Region),在启动时,JVM设置分区大小,根据堆大小,分区大小可以设置为1MB 32MB(设置参数-XX:G1HeapRegionSize)。分区数量默认不超过2048个(实际可以超过该值,但是不推荐),Eden、Survivor和Permanent是这些分区的逻辑集合,并不相邻。

通过分区的方式使得内存块的粒度更小,可以更加细化的管理内存,每次垃圾回收就不需要对整块Eden或Old区进行回收,只需针对小块的分区进行回收即可,减少停顿时间,同时可以有效的避免内存碎片的问题,因为每次垃圾回收都是清空一整块的Region,避免的内存碎片。这些小块的分区可以高度并行化收集,有效的减少停顿时间。

G1内存分布,如图所示:

其中:

巨型对象(Humongous Region)

巨型对象指的是大小大于等于分区大小一半的对象。当线程为巨型对象分配空间时,不能简单在TLAB进行分配,巨型对象的移动成本很高,而且有可能一个分区不能容纳整个巨型对象。因此,巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。

G1针对巨型对象进行了优化,当发现没有引用指向巨型对象时,巨型对象可直接在年轻代收集周期中被回收。

巨型对象会独占一个、或多个连续分区,其中:

由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整个堆,因此确定巨型对象开始位置的成本非常高,应用程序应避免生成巨型对象。

巨型对象有如下特征:

如果发现由于大对象分配导致频繁的并发回收,需要把大对象变为普通的对象,建议增大Region Size。

本地分配缓冲(Lab)

由于分区的思想,每个线程均可以”认领”某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓冲区(Lab)。

其中:

卡表(Card Table)

一个线程修改分区内部的引用,需要通知RSet修改其中的记录。当引用的对象很多时,赋值器需要对每个引用进行处理,性能开销会很大,因此,G1回收器引入了Card Table来解决这个问题。

一个Card Table将一个分区在逻辑上划分为若干个固定大小(128 512字节)的连续区域,每个区域称之为卡片Card,因此Card是堆内存中的最小可用粒度,分配的对象会占用物理上连续的若干个Card。查找对分区内对象的引用时,便可通过Card进行查找,每次对内存的回收,也是对指定分区的Card进行处理。

每个Card都用一个Byte来记录是否修改过,Card Table就是这些Byte的集合,是一个字节数组,由Card的数组下标来标识每个分区的空间地址。默认情况下,每个Card都未被引用,当一个地址空间被引用时,这个地址空间对应的数组索引的值被标记为0(即:标记为脏被引用),此外RSet也将这个数组下标记录下来。

示例:在一个大小为8GB的堆中,CardTable的长度为16777215 (8GB / 512B);假设-XX:G1HeapRegionSize参数为2MB(即:每个Region 大小为2 MB),则每个Region都会对应4096(2M/512B)个Card。CardTable将占用16MB额外内存空间。

如图所示:

查找一个对象所在的Card的公式为:CardPageIndex = (对象的地址 – 堆开始地址) 512。

一个分区可能有多个线程在并发修改,因此也可能会并发修改RSet。为避免冲突,G1垃圾回收器进一步把RSet划分成了多个HashTable,每个线程都在各自的HashTable中修改。最终,从逻辑上来说,RSet就是这些HashTable的集合。哈希表是实现RSet的一种常见方式,它的好处就是能够去除重复,这意味着,RSet的大小将和修改的指针数量相当,而在不去重的情况下,RSet的数量和写操作的数量相当。

注:HashTable的Key是其他Region的起始地址,Value是一个集合,其中的元素是Card Table的Index。

如图所示:

图中,Region-B中的对象y引用了Region-A中的对象x,x和y分别属于两个不同的Region,y对应的Card为156,在Region-A的RSet中,以Region-B的地址作为key,y对应的Card Index(156)为value记录了这个引用关系,这样就完成了跨Region引用的记录。不过这个CardTable的粒度有点粗,一个Card为512B,在一个Card内可能会存在多个对象。所以在扫描标记时,需要扫描RSet中关联的整个Card,上图的示例中需要把CardTable下标为156的Card都扫描一遍。

G1对内存的使用以Region为单位,而对对象的分配则以Card为单位。

记忆集合(Rset)

记忆集合(Rset:Remember Set)主要用来记录其他Region对当前Region中Card的引用,每个Region都会划出一部分内存来存储RSet。

G1回收时可以设定STW时间(参数-XX:MaxGCPauseMillis),可以通过扫描Region中的Rset来确定该Region内对象的存活情况,进而分析每个Region的回收价值,在设定的STW时间内将回收价值最大的Region放入CSet(收集集合)中进行回收,因此不需要扫描整个堆。

注意:并不是所有的引用都需要记录在Region的RSet中,如果某个Region确定需要扫描,那么源自该Region中的对象无需扫描RSet也可以得到引用关系(即:Region内对象的引用关系无需记录到Rset中)。同时,G1每次都会对年轻代进行整体回收,因此引用源自年轻代的对象也不需要记录在RSet中。最终只有老年代的Region可能会存在RSet记录。

Per Region Table(PRT)

RSet在内部使用Per Region Table(PRT)记录Region的引用情况。由于RSet的记录要占用Region的空间,如果一个Region非常”受欢迎”,那么RSet占用的空间会上升,从而降低Region的可用空间。G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:

由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。

如图所示:

收集集合(CSet)

收集集合(CSet:Collection Set)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。

年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。候选老年代分区的CSet准入条件,可以通过活跃度阈值参数-XX:G1MixedGCLiveThresholdPercent(默认为85%)进行设置,只有Region中对象的活跃度高于85%才会进入CSet,从而拦截那些回收开销巨大的对象;同时,每次混合收集可以包含的候选老年代分区数量,可根据CSet对堆的总大小占比参数-XX:G1OldCSetRegionThresholdPercent(默认为10%)设置数量上限(即:最多能有堆中总分区数的10%能进入CSet)。由此可知,G1的收集都是根据CSet进行操作的,年轻代收集与混合收集没有明显的不同,最大的区别在于两种收集的触发条件。

如图所示:

SATB

SATB (Snapshot At The Beginning,初始快照),是一种将并发标记阶段开始时对象间的引用关系,以逻辑快照的形式进行保存的手段。即:在并发标记时,以当前的引用关系作为基础引用数据,不考虑并发运行时对引用关系的修改,标记时是存活状态就认为是存活状态,同时利用SATB写屏障记录引用变化。

读写屏障

读屏障可以让高速缓存中的数据失效,强制从主内存中加载数据,避免缓存不一样。

写屏障保证了在屏障之前的操作会强制更新到主内存,对其他线程是可见的,这种显示调用防止了指令重排序。

并发标记周期

并发标记周期会为混合收集周期识别垃圾最多的老年代分区。整个周期完成根标记、识别所有(可能)存活对象,并计算每个分区的活跃度,从而确定GC效率等级。

当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,便会触发并发标记周期。整个并发标记周期分为初始标记、根分区扫描、并发标记、重新标记、清除几个阶段。其中,初始标记、重新标记、清除需要STW,而并发标记如果来不及标记存活对象,则可能在并发标记过程中,G1又触发了几次年轻代收集。

初始标记(Initial Mark)

初始标记(与年轻代收集一起活动)负责标记所有能被直接可达的根对象(GC Roots),该阶段需要暂停应用线程(即:STW)。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。初始标记是并发执行,直到所有的分区处理完。

根分区扫描(Root Region Scanning)

在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描,同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。

并发标记(Concurrent Marking)

并发标记阶段和应用线程并发执行,并发标记线程在并发标记阶段启动,由参数-XX:ConcGCThreads(默认GC线程数的1/4,即:-XX:ParallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。该阶段会扫描标记对象的引用字段。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。所有的标记任务必须在堆满前就完成扫描,如果并发标记耗时很长,那么有可能在并发标记过程中,又经历了几次年轻代收集。如果堆满前没有完成标记任务,则会触发担保机制,经历一次长时间的串行Full GC。

重新标记(Remark)

重新标记是最后一个标记阶段。该阶段需要暂停应用线程(即:STW),去处理剩下的SATB日志缓冲区和所有更新,找出所有未被访问的存活对象,同时安全完成存活数据计算。这个阶段也是并行执行的,通过参数-XX:ParallelGCThread可设置GC暂停时可用的GC线程数。同时,引用处理也是重新标记阶段的一部分,所有重度使用引用对象(弱引用、软引用、虚引用、最终引用)的应用都会在引用处理上产生开销。

清除(Cleanup)

清除阶段需要暂停应用线程(即:STW)。

清除阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿STW时间(JVM参数 -XX:MaxGCPauseMillis指定)来制定回收计划,如:老年代此时有1000个Region已满,根据预期停顿时间,本次垃圾回收可能只能停顿200毫秒,通过回收成本计算,如果回收其中800个Region刚好需要200ms,则只会回收其中的800个Region,尽量把GC导致的停顿时间控制在指定的范围内。

清除阶段主要操作:

垃圾收集周期

如图所示:

图中:

GC工作线程数

JVM可以通过参数-XX:ParallelGCThreads进行指定GC工作的线程数量。参数-XX:ParallelGCThreads默认值并不是固定的,而是根据当前的CPU资源进行计算。如果用户没有指定,且CPU小于等于8,则默认与CPU核数相等;若CPU大于8,则默认JVM会经过计算得到一个小于CPU核数的线程数;当然也可以人工指定与CPU核数相等。

年轻代收集(Young Collection)

年轻代收集阶段会对整个年轻代的分区进行回收。

Eden区耗尽时就会触发新生代收集,新生代垃圾收集会对整个新生代(Eden+Survivor)进行回收:

并发标记周期后的年轻代收集

当G1发起并发标记周期之后,并不会马上开始混合收集。G1会先等待下一次年轻代收集,然后在该收集阶段中,确定下次混合收集的CSet(Choose CSet)。

混合收集周期(Mixed Collection Cycle)

混合收集阶段会根据暂停目标,选定所有年轻代中的分区,外加根据全局并发标记(Global Concurrent Marking)统计得出收集收益高的若干老年代分区进行回收。老年代的分区可能不能一次暂停收集中被处理完,G1会发起连续多次的混合收集,称为混合收集周期(Mixed Collection Cycle)。G1会计算每次加入到CSet中的分区数量、混合收集进行次数,并且在上次的年轻代收集、以及接下来的混合收集中,G1会确定下次加入CSet的分区集(Choose CSet),并且确定是否结束混合收集周期。

转移失败担保机制(Full GC)

转移失败(Evacuation Failure)是指当G1无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的Full GC。Full GC会对整堆做标记清除和压缩,最后将只包含纯粹的存活对象。参数-XX:G1ReservePercent(默认10%)可以保留空间,来应对晋升模式下的异常情况,最大占用整堆50%,更大也无意义。

G1在以下场景中会触发Full GC,同时会在日志中记录to-space-exhausted以及Evacuation Failure:

由于G1的应用场合往往堆内存都比较大,所以Full GC的收集代价非常昂贵,应该避免Full GC的发生。

整体流程

G1回收整体流程如下:

G1问题及优化

G1是如何满足目标暂停时间的?

G1的JVM内存模型是在物理上分区(Region)、逻辑上分代。

为什么G1在处理并发标记的过程比CMS效率要高?

原因:原始快照算法是关注引用的删除,增量更新算法关注引用的增加。如果G1使用增量更新算法,变成灰色的黑色对象需要重新遍历,效率低。因此G1在处理并发标记的过程比CMS效率要高,主要是G1采用原始快照算法处理漏标问题。

大对象分配(Humongous Allocation)

原因分析:

出现大对象分配导致的内存耗尽问题,一般是老年代剩余的Region中已经不能够找到一组连续的区域分配给新的巨型对象。

解决方案:

一般在转移阶段(Evacuation Pause)和大对象分配(Humongous Allocation)会比较容易出现空间耗尽(to-space exhausted)或空间溢出(to-space overflow)的GC事件,导致出现转移失败(Evacuation Failure) ,进而引发Full GC从而导致GC的暂停时间超过G1的设置的目标暂停时间。所以要尽量避免出现转移失败(Evacuation Failure)。

Young GC花费时间太长

通常Young GC的耗时与年轻代的大小成正比,具体地说,是需要复制的集合集中的活跃对象的数量。

如果Young GC中CSet的疏散阶段(Evacuate Collection Set phase)需要很长时间,尤其是其中的对象复制-转移,可以通过降低-XX:G1NewSizePercent的值,降低年轻代的最小尺寸,从而降低停顿时间。

还可以使用-XX:G1MaxNewSizePercent降低年轻代的最大占比,从而减少Young GC暂停期间需要处理的对象数量。

Mixed GC耗时太长

G1优缺点

优点:

缺点:

【阅读推荐】

更多精彩内容请移步【南秋同学】个人主页查阅Redis系列合集(持续更新中)。

想了解常用数据结构与算法(如:数组、链表、栈、队列、快速排序、堆与堆排序、二分查找、动态规划、广度优先搜索算法、深度优先搜索算法等)的同学请移步->数据结构与算法系列合集(持续更新中)。

【作者简介】

一枚热爱技术和生活的老贝比,专注于Java领域,关注【南秋同学】带你一起学习成长

展开阅读全文

页面更新:2024-04-16

标签:垃圾   线程   分区   标记   对象   内存   年代   阶段   年轻   时间

1 2 3 4 5

上滑加载更多 ↓
推荐阅读:
友情链接:
更多:

本站资料均由网友自行发布提供,仅用于学习交流。如有版权问题,请与我联系,QQ:4156828  

© CopyRight 2008-2024 All Rights Reserved. Powered By bs178.com 闽ICP备11008920号-3
闽公网安备35020302034844号

Top