JVM--Garbage First(G1) 垃圾收集器

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
G1 (Garbage-First)是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足GC停顿时间要求的同时,还具备高吞吐量性能特征。
G1垃圾回收器是在Java7 update 4之后引入的一个新的垃圾回收器在 JDK9 中更被指定为官方GC收集器

一、G1垃圾收集器的开发背景

1.1 CMS 垃圾收集器的缺陷

JVM 团队设计出 G1 收集器的目的就是取代 CMS 收集器因为 CMS 收集器在很多场景下存在诸多问题缺陷暴露无遗具体如下

(1CMS收集器对CPU资源非常敏感。在并发阶段虽然不会导致用户线程停顿但是会占用CPU资源而导致引用程序变慢总吞吐量下降。CMS默认启动的回收线程数是(CPU数量+3) / 4

(2CMS收集器无法处理浮动垃圾由于CMS并发清理阶段用户线程还在运行伴随程序的运行自然会有新的垃圾不断产生这一部分垃圾出现在标记过程之后称为“浮动垃圾”CMS 无法在本次收集中处理它们只好留待下一次GC时将其清理掉。

(3由于垃圾收集阶段会产生“浮动垃圾”因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集需要预留一部分内存空间提供并发收集时的程序运作使用。在默认设置下CMS收集器在老年代使用了68%的空间时就会被激活也可以通过参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比以降低内存回收次数提高性能。要是CMS运行期间预留的内存无法满足程序其他线程需要就会出现“Concurrent Mode Failure”失败这时候虚拟机将启动后备预案临时启用Serial Old收集器来重新进行老年代的垃圾收集这样停顿时间就很长了。所以参数 -XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致 “Concurrent Mode Failure” 失败性能反而降低。

(4CMS是基于“标记-清除”算法实现的收集器会产生大量不连续的内存碎片。当老年代空间碎片太多时如果无法找到一块足够大的连续内存存放对象时将不得不提前触发一次Full GC。为了解决这个问题CMS收集器提供了一个-XX:UseCMSCompactAtFullCollection开关参数用于在Full GC之后增加一个碎片整理过程还可通过-XX:CMSFullGCBeforeCompaction参数设置执行多少次不压缩的Full GC之后跟着来一次碎片整理过程。

1.2 G1 垃圾收集器的特点

G1(Garbage First收集器是 JDK7 提供的一个新收集器在 JDK9 中更被指定为官方GC收集器与CMS收集器相比最突出的改进是

  • 基于 “标记-整理” 算法收集后不会产生内存碎片。

  • 可以非常精确控制停顿时间在不牺牲吞吐量前提下实现低停顿垃圾回收。

二、G1的内存模型

G1是一个分代的增量的并行与并发的标记-复制垃圾回收器。它的设计目标是为了适应现在不断扩大的内存和不断增加的处理器数量进一步降低暂停时间(pause time同时兼顾良好的吞吐量。G1回收器和CMS比起来有以下不同

  1. G1垃圾回收器是compacting的因此其回收得到的空间是连续的。这避免了CMS回收器因为不连续空间所造成的问题。如需要更大的堆空间更多的floating garbage。连续空间意味着G1垃圾回收器可以不必采用空闲链表的内存分配方式而可以直接采用bump-the-pointer的方式;

  1. G1回收器的内存与CMS回收器要求的内存模型有极大的不同。G1将内存划分一个个固定大小的region每个region可以是年轻代、老年代的一个。内存的回收是以region作为基本单位的;

G1还有一个及其重要的特性软实时(soft real-time。所谓的实时垃圾回收是指在要求的时间内完成垃圾回收。“软实时”则是指用户可以指定垃圾回收时间的限时G1会努力在这个时限内完成垃圾回收但是G1并不担保每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标可以让达到90%以上的垃圾回收时间都在这个时限内。

2.1 相关参数

  • -XX:+UseG1GC使用G1垃圾回收算法

  • -XX:G1HeapRegionSize=sizeG1堆区域的划分大小

  • -XX:MaxGCPauseMillis=time默认的最大暂停时间

例子

-XX:+UseG1GC -Xmx32g -XX:MaxGCPauseMillis=200

-XX:+UseG1GC为开启G1垃圾收集器

-Xmx32g 设计堆内存的最大内存为32G

-XX:MaxGCPauseMillis=200设置GC的最大暂停时间为200ms。

如果我们需要调优在内存大小一定的情况下我们只需要修改最大暂停时间即可。

其次G1将新生代老年代的物理空间划分取消了。

这样我们再也不用单独的空间对每个代进行设置了不用担心每个代内存是否足够。

2.2 分区Region

  1. G1将Java堆划分为多个大小相等的独立区域(RegionJVM目标是不超过2048个Region(JVM源码里TARGET_REGION_NUMBER 定义)实际可以超过该值但是不推荐。

一般Region大小等于堆大小除以2048比如堆大小为4096M则Region大小为2M当然也可以用参数“-XX:G1HeapRegionSize”手动指定Region大小但是推荐默认的计算方式。

  1. G1虽然抛弃了新生代和老年代作为整块内存空间的方式但还是保留了年轻代和老年代的概念只是新生代和老年代的内存空间不再是物理隔阂了它们都是(可以不连续Region的集合。

默认年轻代对堆内存的占比是5%如果堆大小为4096M那么年轻代占据200MB左右的内存对应大概是100个Region可以通过“-XX:G1NewSizePercent”设置新生代初始占比在系统运行中JVM会不停的给年轻代增加更多的Region但是最多新生代的占比不会超过60%可以通过“-XX:G1MaxNewSizePercent”调整。年轻代中的Eden和Survivor对应的region也跟之前一样默认8:1:1假设年轻代现在有1000个regioneden区对应800个s0对应100个s1对应100个。

一个Region可能之前是年轻代如果Region进行了垃圾回收之后可能又会变成老年代也就是说Region的区域功能可能会动态变化。

3. G1垃圾收集器对于对象什么时候会转移到老年代跟之前讲过的原则一样唯一不同的是对大对象的处理G1有专门分配大对象的Region叫Humongous区而不是让大对象直接进入老年代的Region中。在G1中大对象的判定规则就是一个大对象超过了一个Region大小的50%比如按照上面算的每个Region是2M只要一个大对象超过了1M就会被放入Humongous区中而且一个大对象如果太大可能会横跨多个Region来存放。

Humongous区专门存放短期巨型对象不用直接进老年代可以节约老年代的空间避免因为老年代空间不够的GC开销。

Full GC的时候除了收集年轻代和老年代之外也会将Humongous区一并回收。

2.3 本地分配缓冲 Local allocation buffer (Lab)

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

其中应用线程可以独占一个本地缓冲区(TLAB)来创建的对象而大部分都会落入Eden区域(巨型对象或分配失败除外)因此TLAB的分区属于Eden空间;而每次垃圾收集时每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象每次回收会将对象复制到Suvivor空间或老年代空间;对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象同样有GC独占的本地缓冲区进行操作该部分称为晋升本地缓冲区(PLAB)。

2.4 (RSet) 记忆集合 Remember Set

在串行和并行收集器中GC通过整堆扫描来确定对象是否处于可达路径中。然而G1为了避免STW式的整堆扫描在每个Region(分区)记录了一个记忆集合(RSet)内部类似一个反向指针记录引用分区内对象的卡片索引。当要回收该分区时通过扫描分区的RSet来确定引用本分区内的对象是否存活进而确定本分区内的对象存活情况。

事实上并非所有的引用都需要记录在RSet中如果一个分区确定需要扫描那么无需RSet也可以无遗漏的得到引用关系。那么引用源自本分区的对象当然不用落入RSet中;同时G1 GC每次都会对年轻代进行整体收集因此引用源自年轻代的对象也不需要在RSet中记录。最后只有老年代的分区可能会有RSet记录这些分区称为拥有RSet分区(an RSet’s owning region)。

2.4.1 Per Region Table(PRT)

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

  • 稀少直接记录引用对象的卡片索引

  • 细粒度记录引用对象的分区索引

  • 粗粒度只记录引用情况每个分区对应一个比特位

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

2.5 Card Table

如果一个线程修改了Region内部的引用就必须要去通知RSet更改其中的记录。需要注意的是如果引用的对象很多赋值器需要对每个引用做处理赋值器开销会很大因此 G1 回收器引入了 Card Table 解决这个问题。

一个 Card Table 将一个 Region 在逻辑上划分为若干个固定大小(介于128到512字节之间的连续区域每个区域称之为卡片 Card因此 Card 是堆内存中的最小可用粒度分配的对象会占用物理上连续的若干个卡片当查找对分区内对象的引用时便可通过卡片 Card 来查找(见RSet)每次对内存的回收也都是对指定分区的卡片进行处理。每个 Card 都用一个 Byte 来记录是否修改过Card Table 就是这些 Byte 的集合是一个字节数组由 Card 的数组下标来标识每个分区的空间地址。默认情况下每个 Card 都未被引用当一个地址空间被引用时这个地址空间对应的数组索引的值被标记为”0″即标记为脏被引用此外 RSet 也将这个数组下标记录下来。

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

图中的RSet并不是一个和Card Table独立的不同的数据结构而是指RSet是一个概念模型。实际上Card Table是RSet的一种实现方式。

2.6 RSet的维护

由于不能整堆扫描又需要计算分区确切的活跃度因此G1需要一个增量式的完全标记并发算法通过维护RSet得到准确的分区引用信息。在G1中RSet的维护主要来源两个方面写栅栏(Write Barrier)并发优化线程(Concurrence Refinement Threads)

2.6.1 写栅栏

我们首先介绍一下栅栏(Barrier)的概念。栅栏是指在原生代码片段中当某些语句被执行时栅栏代码也会被执行。而G1主要在赋值语句中使用写前栅栏(Pre-Write Barrrier)和写后栅栏(Post-Write Barrrier)。事实上写栅栏的指令序列开销非常昂贵应用吞吐量也会根据栅栏复杂度而降低。

写前栅栏 Pre-Write Barrrier

即将执行一段赋值语句时等式左侧对象将修改引用到另一个对象那么等式左侧对象原先引用的对象所在分区将因此丧失一个引用那么JVM就需要在赋值语句生效之前记录丧失引用的对象。JVM并不会立即维护RSet而是通过批量处理在将来RSet更新(见SATB)。

写后栅栏 Post-Write Barrrier

当执行一段赋值语句后等式右侧对象获取了左侧对象的引用那么等式右侧对象所在分区的RSet也应该得到更新。同样为了降低开销写后栅栏发生后RSet也不会立即更新同样只是记录此次更新日志在将来批量处理(见Concurrence Refinement Threads)。

2.6.2 (SATB) 起始快照算法 Snapshot at the beginning

Taiichi Tuasa贡献的增量式完全并发标记算法起始快照算法(SATB)主要针对标记-清除垃圾收集器的并发标记阶段非常适合G1的分区块的堆结构同时解决了CMS的主要烦恼重新标记暂停时间长带来的潜在风险。

SATB会创建一个对象图相当于堆的逻辑快照从而确保并发标记阶段所有的垃圾对象都能通过快照被鉴别出来。当赋值语句发生时应用将会改变了它的对象图那么JVM需要记录被覆盖的对象。因此写前栅栏会在引用变更前将值记录在SATB日志或缓冲区中。每个线程都会独占一个SATB缓冲区初始有256条记录空间。当空间用尽时线程会分配新的SATB缓冲区继续使用而原有的缓冲去则加入全局列表中。最终在并发标记阶段并发标记线程(Concurrent Marking Threads)在标记的同时还会定期检查和处理全局缓冲区列表的记录然后根据标记位图分片的标记位扫描引用字段来更新RSet。此过程又称为并发标记/SATB写前栅栏。

2.6.3 并发优化线程 Concurrence Refinement Threads

G1中使用基于Urs Hölzle的快速写栅栏将栅栏开销缩减到2个额外的指令。栅栏将会更新一个card table type的结构来跟踪代间引用。

当赋值语句发生后写后栅栏会先通过G1的过滤技术判断是否是跨分区的引用更新并将跨分区更新对象的卡片加入缓冲区序列即更新日志缓冲区或脏卡片队列。与SATB类似一旦日志缓冲区用尽则分配一个新的日志缓冲区并将原来的缓冲区加入全局列表中。

并发优化线程(Concurrence Refinement Threads)只专注扫描日志缓冲区记录的卡片来维护更新RSet线程最大数目可通过-XX:G1ConcRefinementThreads(默认等于-XX:ParellelGCThreads)设置。并发优化线程永远是活跃的一旦发现全局列表有记录存在就开始并发处理。如果记录增长很快或者来不及处理那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZoneG1会用分层的方式调度使更多的线程处理全局列表。如果并发优化线程也不能跟上缓冲区数量则Mutator线程(Java应用线程)会挂起应用并被加进来帮助处理直到全部处理完。因此必须避免此类场景出现。

2.7 (CSet) 收集集合 Collect Set

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中CSet所有分区都会被释放内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集还是混合收集工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区而混合收集会通过启发式算法在老年代候选回收分区中筛选出回收收益最高的分区添加到CSet中。

候选老年代分区的CSet准入条件可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置从而拦截那些回收开销巨大的对象;同时每次混合收集可以包含候选老年代分区可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。

由上述可知G1的收集都是根据CSet进行操作的年轻代收集与混合收集没有明显的不同最大的区别在于两种收集的触发条件。

2.7.1 年轻代收集集合 CSet of Young Collection

应用线程不断活动后年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时便会触发一次STW式的年轻代收集。在年轻代收集中Eden分区存活的对象将被拷贝到Survivor分区;原有Survivor分区存活的对象将根据任期阈值(tenuring threshold)分别晋升到PLAB中新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。

同时年轻代收集还负责维护对象的年龄(存活次数)辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。年轻代收集首先先将晋升对象尺寸总和、对象年龄信息维护到年龄表中再根据年龄表、Survivor尺寸、Survivor填充容量-XX:TargetSurvivorRatio(默认50%)、最大任期阈值-XX:MaxTenuringThreshold(默认15)计算出一个恰当的任期阈值凡是超过任期阈值的对象都会被晋升到老年代。

2.7.2 混合收集集合 CSet of Mixed Collection

年轻代收集不断活动后老年代的空间也会被逐渐填充。当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(默认45%)时G1就会启动一次混合垃圾收集周期。为了满足暂停目标G1可能不能一口气将所有的候选分区收集掉因此G1可能会产生连续多次的混合收集与应用线程交替执行每次STW的混合收集与年轻代收集过程相类似。

为了确定包含到年轻代收集集合CSet的老年代分区JVM通过参数混合周期的最大总次数-XX:G1MixedGCCountTarget(默认8)、堆废物百分比-XX:G1HeapWastePercent(默认5%)。通过候选老年代分区总数与混合周期最大总次数确定每次包含到CSet的最小分区数量;根据堆废物百分比当收集达到参数时不再启动新的混合收集。而每次添加到CSet的分区则通过计算得到的GC效率进行安排。

三、G1垃圾收集器收集过程

3.1 Marking bitmaps和TAMS

Marking bitmap是一种数据结构其中的每一个bit代表的是一个可用于分配给对象的起始地址。举例来说

其中addrN代表的是一个对象的起始地址。绿色的块代表的是在该起始地址处的对象是存活对象而其余白色的块则代表了垃圾对象。

G1使用了两个bitmap一个叫做previous bitmap另外一个叫做next bitmap。

  • previous bitmap记录的是上一次的标记阶段完成之后的构造的bitmap;

  • next bitmap则是当前正在标记阶段正在构造的bitmap。

在当前标记阶段结束之后当前标记的next bitmap就变成了下一次标记阶段的previous bitmap。

TAMS(top at mark start)变量是一对用于区分在标记阶段新分配对象的变量分别被称为previous TAMS和next TAMS。在previous TAMS和next TAMS之间的对象则是本次标记阶段时候新分配的对象。如图

白色region代表的是空闲空间绿色region代表是存活对象橙色region代表的在此次标记阶段新分配的对象。注意的是在橙色区域的对象并不能确保它们都事实上是存活的。

3.2 垃圾收集器的算法

  1. fully-young generational mode有时候也会被称为young GC该模式只会回收young region算法是通过调整young region的数量来达到软实时目标的;

  1. partially-young mode也被称为Mixed GC该阶段会回收young region和old region算法通过调整old region的数量来达到软实时目标;

有趣的地方是不论处在何种模式之下yong region都在被回收的范围内。而old region只能期望于Mixed GC。但是如同在CMS垃圾回收器中遇到的困境一样Mixed GC可能来不及回收old region。也就说在需要分配老年代的对象的时候并没有足够的空间。这个时候就只能触发一次full GC。

算法会自动在young GC和mixed GC之间切换并且定期触发Marking cycle phase。HotSpot的G1实现允许指定一个参数InitiatingHeapOccupancyPercent在达到该参数的情况下就会执行marking cycle phase。

算法并不使用在对象头增加字段来标记该对象而是采用bitmap的方式来记录一个对象被标记的情况。这种记录方法的好处就是在使用这些标记信息的时候仅仅需要扫描bitmap而已。G1统计一个region的存活的对象就是依赖于bitmap的标记。

3.3 并发标记周期 Concurrent Marking Cycle

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

当达到IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比默认45%)时便会触发并发标记周期。

整个并发标记周期为

  • 初始标记(Initial Mark)G1收集器扫描所有的根。该过程是和young GC的暂停过程一起的;

  • 根分区扫描(Root Region Scanning)扫描Survivor Regions中指向老年代的被initial mark phase标记的引用及引用的对象这一个过程是并发进行的。但是该过程要在下一个young GC开始之前结束;

  • 并发标记(Concurrent Marking)并发标记阶段标记整个堆的存活对象。该过程可以被young GC所打断。并发阶段产生的新的引用(或者引用的更新会被SATB的write barrier记录下来;

  • 重新标记(Remark)

  • 也叫final marking phase。该阶段只需要扫描SATB(Snapshot At The Beginning)的buffer处理在并发阶段产生的新的存活对象的引用。作为对比CMS的remark需要扫描整个mod union table的标记为dirty的entry以及全部根;

  • 清除(Cleanup)

  • 清理阶段。该阶段会计算每一个region里面存活的对象并把完全没有存活对象的Region直接放到空闲列表中。在该阶段还会重置Remember Set。该阶段在计算Region中存活对象的时候是STW(Stop-the-world)的而在重置Remember Set的时候却是可以并行的;

其中初始标记(随年轻代收集一起活动)、重新标记、清除是STW的而并发标记如果来不及标记存活对象则可能在并发标记过程中G1又触发了几次年轻代收集。

3.3.1 初始标记 Initial Mark

初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象)根是对象图的起点因此初始标记需要将Mutator线程(Java应用线程)暂停掉也就是需要一个STW的时间段。事实上当达到IHOP阈值时G1并不会立即发起并发标记周期而是等待下一次年轻代收集利用年轻代收集的STW时间段完成初始标记这种方式称为借道(Piggybacking)。在初始标记暂停中分区的NTAMS都被设置到分区顶部Top初始标记是并发执行直到所有的分区处理完。

3.3.2 根分区扫描 Root Region Scanning

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

该过程主要是扫描Survivor region中指向老年代的在initial mark标记的引用及其引用的对象。这是一个很奇怪的步骤因为在前面不论是Parallel Collector还是CMS都没有这么一个步骤。

要理解这一点要注意的是算法的两种模式不论是young GC还是mixed GC都需要回收young region。因为实际上RSet是不记录从young region出发的指针例如这部分指针包括young region - young region也包括young-region - old region指针。那么就可能出现一种情况一个老年代的存活对象只被年轻代的对象引用。在一次young GC中这些存活的年轻代的对象会被复制到Survivor Region因此需要扫描这些Survivor region来查找这些指向老年代的对象的引用作为并发标记阶段扫描老年代的根的一部分。

在理解了这一点的基础上那么对于阶段必须在下一次young GC启动前完成的要求也就理解了。因为如果第二次的young GC启动了那么这个过程中survivor region就可能发生变化。这个时候执行root region phase就会产生错误的结果。

3.3.3 并发标记 Concurrent Marking

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

可以说在并发标记阶段因为应用还在运行所以可能会有引用变更包括现有引用指向别的对象或者删除了一个引用或者创建了一个新的对象等。G1采用的是使用SATB的并发标记算法。

在G1中该算法的关键在于如果在并发标记的时候出现了引用修改(不包含新分配内存给对象那么写屏障会把这些引用的原始值捕获下来记录在log buffer中。而后再处理。后续的所有的标记都是从原来的值出发而不是从新的值出发的。

SATB是一个逻辑上存在概念在实际中并没有任何真的实际的数据结构与之对应。叫这个名字是因为一旦进入了concurrent marking阶段那么该在该阶段的运行过程中即便应用修改了引用但是因为SATB的写屏障记录下来了原始的值在遍历整个堆查找存活对象的时候使用的依然是原来的值。这就是在逻辑上保持了一个snapshot at the beginning of concurrent marking phase。

在处理新创建的对象G1采用了不同的方式。G1用了两个TAMS变量了判断新创建的对象。一个叫做previous TAMS一个叫做next TAMS。位于两者之间的对象就是新分配的对象。

并发标记阶段bitmap和TAMS的作用如图

该图的详细解释如下

  1. A是第一次marking cycle的initial marking阶段。next bitmap尚未标记任何存活对象而此时的previous TAMS被初始化为region内存地址起始值next TAMS被初始化为top。top实际上就是一个region未分配区域和已分配区域的分界点;

  1. B是经过concurrent marking阶段之后进入了remark阶段。此时存活对象的扫描已经完成了因此next bitmap构造好了刚好代表的是当下状态中region中的内存使用情况。注意的是此时top已经不再与next TAMS重合了top和next TAMS之间的就是在前面标记阶段之时新分配的对象;

  1. C代表的是clean up阶段。C和B比起来next bitmap变成了previous bitmap而在bitmap中标记为垃圾(也就是白色区域的的对应的region的区域也被染成了浅灰色。这并不是指垃圾对象已经被清扫了仅仅是标记出来了。同时next TAMS和previous TAMS也交换了角色;

  1. D代表的是下一个marking cycle的initial marking阶段该阶段和A类似next TAMS重新被初始化为top的值;

  1. EF就是BC的重复;

3.3.4 重新标记 Remark

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

3.3.5 清除 Clean up

紧挨着重新标记阶段的清除(Clean)阶段也是STW的。Previous/Next标记位图、以及PTAMS/NTAMS都会在清除阶段交换角色。清除阶段主要执行以下操作

  • RSet梳理启发式算法会根据活跃度和RSet尺寸对分区定义不同等级同时RSet数理也有助于发现无用的引用。参数-XX:+PrintAdaptiveSizePolicy可以开启打印启发式算法决策细节;

  • 整理堆分区为混合收集周期识别回收收益高(基于释放空间和暂停目标)的老年代分区集合;

  • 识别所有空闲分区即发现无存活对象的分区。该分区可在清除阶段直接回收无需等待下次收集周期。

3.3 G1 Young GC

当Eden区已满JVM分配对象到Eden区失败时便会触发一次STW式的年轻代收集young GC将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够Eden区的部分数据会直接晋升到年老代空间。最终Eden空间的数据为空GC停止工作应用线程继续执行。

young GC 还负责维护对象的年龄(存活次数)辅助判断老化(tenuring)对象晋升时的去向。young GC 首先将晋升对象尺寸总和、年龄信息维护到年龄表中再根据年龄表、Survivor尺寸、Survivor填充容量 -XX:TargetSurvivorRatio(默认50%)、最大任期阈值 -XX:MaxTenuringThreshold(默认15)计算出一个恰当的任期阈值凡是超过任期阈值的对象都会被晋升到老年代。

这时我们需要考虑一个问题如果仅仅 GC 新生代对象我们如何找到所有的根对象呢? 老年代的所有对象都是根么?那这样扫描下来会耗费大量的时间。于是就需要使用到我们上文介绍到的 RSet 了。RSet 中记录了其他 region 对当前 region 的引用因此在进行Young GC 时扫描根时仅仅需要扫描这一块区域而不需要扫描整个老年代。

3.3.1、young GC的详细回收过程

(1第一阶段根扫描

根是指static变量指向的对象、正在执行的方法调用链上的局部变量等。根引用连同 RSet 记录的外部引用作为扫描存活对象的入口。

(2第二阶段更新RSet

处理 dirty card 队列中的 card更新 RSet此阶段完成后RSet 可以准确的反映老年代对所在的region 分区中对象的引用

(3第三阶段处理RSet

识别被老年代对象指向的 Eden 中的对象这些被指向的Eden中的对象被认为是存活的对象

(4第四阶段对象拷贝

将 Eden 区存活的对象将被拷贝到 to survivor 区;from survivor 区存活的对象则根据存活次数阈值分别晋升到 PLAB、to survivor 区和老年代中;如果 survivor 空间不够Eden区的部分数据会直接晋升到年老代空间。

(5第五阶段处理引用

处理软引用、弱引用、虚引用最终Eden空间的数据为空GC停止工作而目标内存中的对象都是连续存储的、没有碎片所以复制过程可以达到内存整理的效果减少碎片。

3.4 G1 Mixed GC

老年代的堆占有率达到参数(-XX:InitiatingHeapOccupancyPercent)设定的值则触发回收所有的Young和部分Old(根据期望的GC停顿时间确定old区垃圾收集的优先顺序)以及大对象区正常情况G1的垃圾收集是先做MixedGC主要使用复制算法需要把各个region中存活的对象拷贝到别的region里去拷贝过程中如果发现没有足够的空region能够承载拷贝对象就会触发一次Full GC

3.7 Full GC

停止系统程序然后采用单线程进行标记、清理和压缩整理好空闲出来一批Region来供下一次MixedGC使用这个过程是非常耗时的。(Shenandoah优化成多线程收集了)

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使用

4.1 G1收集器参数设置

  • -XX:+UseG1GC:使用G1收集器

  • -XX:ParallelGCThreads:指定GC工作的线程数量

  • -XX:G1HeapRegionSize:指定分区大小(1MB~32MB且必须是2的N次幂)默认将整堆划分为2048个分区

  • -XX:MaxGCPauseMillis:目标暂停时间(默认200ms)

  • -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%值配置整数默认就是百分比)

  • -XX:G1MaxNewSizePercent:新生代内存最大空间

  • -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%)Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%此时就会把年龄n(含)以上的对象都放入老年代

  • -XX:MaxTenuringThreshold:最大年龄阈值(默认15)

  • -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%)则执行新生代和老年代的混合收集(MixedGC)比如我们之前说的堆默认有2048个region如果有接近1000个region都是老年代的region则可能就要触发MixedGC了

  • -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region如果超过这个值存活对象过多回收的的意义不大。

  • -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次)在最后一个筛选回收阶段可以回收一会然后暂停回收恢复系统运行一会再开始回收这样可以让系统不至于单次停顿时间过长。

  • -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值在混合回收的时候对Region回收都是基于复制算法进行的都是把要回收的Region里的存活对象放入其他Region然后这个Region中的垃圾对象全部清理掉这样的话在回收过程就会不断空出来新的Region一旦空闲出来的Region数量达到了堆内存的5%此时就会立即停止混合回收意味着本次混合回收就结束了。

4.2 G1垃圾收集器优化建议

  1. 假设参数 -XX:MaxGCPauseMills 设置的值很大导致系统运行很久年轻代可能都占用了堆内存的60%了此时才触发年轻代gc。

  1. 那么存活下来的对象可能就会很多此时就会导致Survivor区域放不下那么多的对象就会进入老年代中。

  1. 或者是你年轻代gc过后存活下来的对象过多导致进入Survivor区域后触发了动态年龄判定规则达到了Survivor区域的50%也会快速导致一些对象进入老年代中。

  1. 所以这里核心还是在于调节 -XX:MaxGCPauseMills 这个参数的值在保证他的年轻代gc别太频繁的同时还得考虑每次gc过后的存活对象有多少,避免存活对象太多快速进入老年代频繁触发mixed gc.

4.3 G1什么场景适合使用

  1. 50%以上的堆被存活对象占用

  1. 对象分配和晋升的速度变化非常大

  1. 垃圾回收时间特别长超过1秒

  1. 8GB以上的堆内存(建议值)

  1. 停顿时间是500ms以内

4.4 每秒几十万并发的系统如何优化JVM(题外话

Kafka类似的支撑高并发消息系统大家肯定不陌生对于kafka来说每秒处理几万甚至几十万消息时很正常的一般来说部署kafka需要用大内存机器(比如64G)也就是说可以给年轻代分配个三四十G的内存用来支撑高并发处理这里就涉及到一个问题了我们以前常说的对于eden区的young gc是很快的这种情况下它的执行还会很快吗?很显然不可能因为内存太大处理还是要花不少时间的假设三四十G内存回收可能最快也要几秒钟按kafka这个并发量放满三四十G的eden区可能也就一两分钟吧那么意味着整个系统每运行一两分钟就会因为young gc卡顿几秒钟没法处理新消息显然是不行的。那么对于这种情况如何优化了我们可以使用G1收集器设置 -XX:MaxGCPauseMills 为50ms假设50ms能够回收三到四个G内存然后50ms的卡顿其实完全能够接受用户几乎无感知那么整个系统就可以在卡顿几乎无感知的情况下一边处理业务一边收集垃圾。

G1天生就适合这种大内存机器的JVM运行可以比较完美的解决大内存垃圾回收时间过长的问题。

五、总结

G1是一款非常优秀的垃圾收集器不仅适合堆内存大的应用同时也简化了调优的工作。通过主要的参数初始和最大堆空间、以及最大容忍的GC暂停目标就能得到不错的性能;同时我们也看到G1对内存空间的浪费较高但通过首先收集尽可能多的垃圾(Garbage First)的设计原则可以及时发现过期对象从而让内存占用处于合理的水平。

参考资料

[1]详解 JVM Garbage First(G1) 垃圾收集器_coderlius的博客-CSDN博客

[2]G1垃圾回收器详解 - 简书 (jianshu.com)

[3]关于G1 GC底层原理的深度研究 - 知乎 (zhihu.com)

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6