深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)

垃圾收集器G1

    • 前言
    • 概述
      • 停顿时间模型
      • 内存布局
        • 传统内存布局过时了
        • G1实现的几个关键细节问题
          • 铺垫知识:跨代引用
          • 铺垫知识:记忆集,卡表,卡页
          • 铺垫知识:写屏障
          • 插眼往下看
      • G1内存模型
        • 分区Region
        • 卡片Card
        • 堆Heap
      • 分代模型
        • 分代垃圾收集
        • 本地分配缓冲Local Allocation Buffer(Lab)
      • 分区模型
        • 巨型对象Humongous Region
        • 已记忆集合Remembered Set(RSet)
          • RSet的维护
        • 收集集合(CSet)
          • 年轻代收集集合 CSet of Young Collection
          • 混合收集集合 CSet of Mixed Collection
          • 并发标记算法(三色标记法)
            • 多标和漏标问题

前言

我们之前已经了解过了基本的G1内容,现在我们来详细了解一下G1的细节与实现原理。
学习的时候查了很多博客和书,查博客的时候发现很多都是自顶而下的去讲解,对于一些结构内容和前因后果并没有明确指名,导致我看了半天不知道为什么要学这个,为什么突然出来这个东西,所以写G1的时候我挺苦恼的,本身就属于我只是理解的内容,现在要掰开笔记和总结去写出来,比较考验我的表达和整体理解,不过自信还是有的,如果看完这篇我都能学个差不多,相信你肯定也是可以的。
本篇文章文字叙述较多,有的地方不好理解,如果你有哪些地方觉得我总结的不好,欢迎评论区或私信指正。
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第1张图片

概述

G1是一个分代的,增量的,并行与并发的标记-复制垃圾回收器。
它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。
它的设计目标是为了适应不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间,同时兼顾良好的吞吐量。
G1作为主要面向服务端应用的垃圾收集器,HotSpot开发团队赋予的期望是在为未来可以替换掉JDK5中发布的CMS收集器。

停顿时间模型

那么作为CMS收集器的替代者和继承人,设计者希望做出一款能够简历起“停顿时间模型”的收集器。
停顿时间模型:能够支持指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。

那么我们该如何实现这个目标呢?
首先就是思想上的转变:
G1出现之前所有的收集器,垃圾收集目标范围要么是整个新生代(Minor GC),要么是整个老年代(Major GC),再要么是整个Java堆(Full GC),而G1跳出了这个牢笼,它面向堆内存的任何部分来组成回收集(Collection Set,一般称为CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1的Mixed GC 模式。
所以,G1开创的基于Region的堆内存布局是它能够实现这个目标的关键。

内存布局

传统内存布局过时了

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden,Survivor空间或者是老年代空间。
收集器能够对扮演不同角色的Region采用不同的策略去处理,所以无论是新创建的对象还是已经存活了一段时间,熬过多次收集的旧对象都能获取良好的收集效果。

虽然G1依旧保留新生代和老年代的概念,但是新生代和老年代不再是固定的了,他们都是一系列区域(不需要连续)的动态集合
G1收集器之所以能建立可预测的停顿时间模型,是因为它将Region作为单次回收的最小单元,即每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集。

更具体的思路:G1去跟踪各个Region里面垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region,也就是“Garbage First”名字的由来。

G1实现的几个关键细节问题

G1将堆内存“化整为零”的解题思路,看起来不难理解,但实现细节用将近10年从倒腾出来可以商用的G1,我们来看一看里面的一些细节问题,引出下面我们要详细了解的G1结构。

  • 将Java堆分为多个独立的Region后,Region里面存在的跨Region引用对象如何解决?
铺垫知识:跨代引用

我们在上一篇文章里了解到了分代收集理论,很容易发现分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显困难:对象不是孤立的,对象之间存在跨代引用

举个栗子:如果现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全可能被老年代所引用的,为了找出区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来一样。这个理论是可行的,但是会为内存回收带来很大的性能负担。

所以我们就看到了之前的第三条假说:存在相互引用关系的两个对象,是倾向于同时生存或同时消亡的,且跨代引用相对于同代引用仅占极少数

举个栗子:
某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升老年代,跨代引用也随机被消除了。

依据这点,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需要在新生代上建立一个全局的数据结构(记忆集,Remembered Set),这个结构把老年代划分为若干个小块,标识出老年代哪一块会存在跨代引用。

当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots的扫描。

这个方法需要在对象改变引用关系(如将自己或者某个属性复制)时维护记录数据的正确性,会增加一些运行时开销,但是比起收集时扫描整个老年代来说仍然划算。

铺垫知识:记忆集,卡表,卡页

分代收集理论提到为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围。
事实上,并不只是新生代,老年代之间才会有跨代引用问题,所有涉及部分区域收集行为的垃圾收集器,都会面临相关的问题,所以详细聊一聊记忆集是有必要的。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
假设我们不计效率和成本,那么最简单的实现就是用非收集区域中所有含跨代引用的对象数组来时间这个数据结构。

Class RememberedSet {
    Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都想当高昂。
对于垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。
RSet在内部使用Per Region Table(PRT)记录分区的引用情况,如果一个分区非常“受欢迎”,那么RSet占用会上升,从而降低分区的可用空间,G1应对这个问题采用了改变RSet的密度的方式。
我们可以选择更粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择的记录精度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域有对象含有跨代指针。
    从上到下逐渐粗犷。
    由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆扫描才能找出所有引用,因此扫描速度也是最慢的。

其中,卡精度所指的是用一种称为“卡表(Card Table)”方式去实现记忆集
一些资料直接把它与记忆集混为一谈,这里我们要明确:我们前面说到的记忆集是一种“抽象”的数据结构,只是定义了记忆集的行为意图,但是没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度,与堆内存的映射关系等。不妨按照HashMap与Map之间的关系来类比理解。
卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机也确实是这样做的,以下是HotSpot默认卡表标记逻辑的代码实现:

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE的每一页元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称为“卡页”(Card Page)。
一般来说卡页大小都是2的N次幂的字节数,上面代码是2的9次幂,即512字节,
如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如下图所示:
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第2张图片
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第3张图片

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或多个)对象的字段存在着跨代指针,那就将对应的卡表的数组元素的值标识为1,称这个元素变脏(Dirty),没有则标识为0。
这样,在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

看到这里,求知好学的你一定在想,卡表元素如何维护呢?它们何时变脏?谁使它们变脏?
所以要引出下面写屏障的内容

铺垫知识:写屏障

已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等,我们接下来一起一一分析。
卡表元素何时变脏?
有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏。
变脏的时间点原则上应该发生在引用类型字段赋值的那一刻

那如何变脏呢,换句话说,如何在对象赋值的那一刻去更新维护卡表呢?
假如是解释执行的字节码,那么就相对好处理,因为JVM负责每条字节码指令的执行,我们会有充分的介入空间;
问题是,在编译执行的场景中(经过了编译后的代码已经是纯粹的机器指令流了),就必须找到一个机器码层面的手段,把维护卡表的动作放到每一个赋值操作中。
所以,为了解决这个问题,HotSpot虚拟机通过写屏障(Write Barrier)技术来维护卡表的状态。注意这里的写屏障要和并发乱序执行问题中的“内存屏障”区分开来,避免混淆。

写屏障可以看作JVM层面对“引用类型字段赋值”这个动作的AOP切面,这引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,换句话说就是赋值前后都在写屏障的覆盖范畴内。
那么就很容易理解:
赋值前的部分的写屏障——写前屏障(Pre-Write-Barrier)
赋值后的部分的写屏障——写后屏障(Post-Write-Barrier)。
在G1收集器出现之前,其他收集器都只用到了写后屏障。
举个栗子(更新卡表状态的简化逻辑代码):

void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}

或者用个图来表示一下:
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第4张图片

所以,应用写屏障后,JVM就会为所有的赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论操作的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低很多的。

注意,卡表在高并发场景下还免礼着“伪共享”(False Sharing)问题。
伪共享是处理并发底层细节时一种经常需要考虑的问题,线代中央处理器的缓存系统是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一缓存行,就会彼此影响(写回,无效化或者同步)而导致性能降低,这就是伪共享问题。
举个栗子:
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡标记,只有当该卡元素未被标记过时才将其标记变脏,即将卡表更新的逻辑变为以下代码所示:

if (CARD_TABLE [this address >> 9] != 0)
    CARD_TABLE [this address >> 9] = 0;

JDK7之后,HotSpot虚拟机增加了一个新参数:-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。
开启会增加一次额外判断的开销,但是能避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来测试权衡。

插眼往下看

聊到这里了,想要解决我们之前提出的那个问题还差点东西,之前我们了解了:跨代引用,记忆集,卡表,卡页,写屏障,现在理解分区模型就不难了。
这里我们先跳出问题,去看内存模型的内容(估计看的人觉得有点跳跃,这是我权衡之下不得已这样设计,为的就是好懂,只是有点跳跃而已。)

G1内存模型

分区Region

G1采用了分区(Region)的思路,将整个堆空间分成若干个大小相等的内存区域,每次分配对象空间将逐段地使用内存。
因此,在堆的使用上,G1并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可;
每个分区也不会确定地为某个代服务,可以按需在年轻代和老年代之间切换,默认将整堆划分为2048个分区。
分区示意图:

卡片Card

在每个分区(Region)内部又被分为了若干个512Byte大小的卡片,标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第5张图片

堆Heap

G1同样可以通过-Xms/-Xmx来指定堆空间大小。
当发生年轻代收集或混合收集时,通过计算GC与应用的耗费时间比,自动调整堆空间大小,如果GC频率太高,则通过增加堆尺寸,来减少GC频率,相应地GC占用的时间也随之降低;
当空间不足时,G1会首先尝试增加堆空间,如果扩容失败,则发起担保的Full GC。Full GC后,堆尺寸计算结果也会调整堆空间。

分代模型

分代垃圾收集

分代垃圾收集可以将关注点集中在最近被分配的对象上而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。
虽然分区使得内存分配不再要求紧凑的内存空间,但G1仍然使用了分代的思想。
G1将内存在逻辑上划分为年轻代和老年代,其中年轻代又划分为Eden空间和Survivor空间,但年轻代的空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。

本地分配缓冲Local Allocation Buffer(Lab)

由于分区的思想,每个线程均可以“认领”某个分区用于线程本地的内存分配,而不需要顾及分区是否连续。因此,每个应用线程和GC线程都会独立的使用分区,进而减少同步时间,提升GC效率,这个分区称为本地分配缓存区(Lab)。
其中,应用线程可以独占一个本地缓冲区(TLAB)来创建对象,而大部分都会落入Eden区域(巨型对象或分配失败除外),因此TLAB的分区属于Eden空间;
每次垃圾收集时,每个GC线程同样可以独占一个本地缓冲区(GCLAB)用来转移对象,每次回收会将对象复制到Suvivor空间或老年代空间;
对于从Eden/Survivor空间晋升(Promotion)到Survivor/老年代空间的对象,同样有GC独占的本地缓冲区进行操作,该部分成为晋升本地缓冲区(PLAB)。

深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第6张图片

分区模型

G1对内存的使用是以分区(Region)为单位,但是对对象的分配则是以卡片(Card)为单位。
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第7张图片

巨型对象Humongous Region

一个大小达到甚至超过分区一半大小的对象成为巨型对象。
当线程为巨型对象分配空间时,不能简单在TLAB进行分配,因为巨型对象的移动成本很高,而且有可能一个分区不能容纳巨型对象。因此巨型对象会直接在老年代分配,所占用的连续空间称为巨型分区(Humongous Region)。
G1内部做了一个优化,一旦发现没有引用指向巨型对象,则可直接在年轻代收集周期中被回收。

巨型对象会独占一个或多个连续分区,其中第一个分区被标记开始巨型(StartsHumongous),相邻连续分区被标记为连续分区(ContinuesHumongous)。
由于无法享受Lab带来的优化,并且确定一片连续的内存空间需要扫描整堆,因此确定巨型对象开始位置的成本非常高,如果可以,应用程序应该避免生成巨型对象。

已记忆集合Remembered Set(RSet)

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

RSet的维护

由于不能扫描,有需要计算分区确切的活跃度,因此,G1需要一个增量式的完全标记并发算法,通过维护RSet,得到准确的分区引用信息。在G1中,RSet的维护主要来自于两个方面:

  • 写栅栏
  • 并发优化线程
    写栅栏不多说了,前面应该蛮详细的了。
    这里聊一聊并发优化线程的事情。
    当赋值语句发生后,写后栅栏会先通过G1的过滤技术判断是否跨分区的引用更新,并将跨分区更新对象的卡片加入缓冲区序列,即更新日志缓冲区或脏卡片队列。与SATB(初始快照,后面会详细聊,这里留个印象就行)类似,一旦日志缓冲区用尽,则分配一个新的日志缓冲区,并将原来的缓冲区加入全局列表中。
    并发优化线程(Concurrence Refinement Threads),只专注于扫描日志缓冲区记录的卡片来维护更新RSet,线程最大数目可通过-XX:G1ConcRefinementThreads(默认等于-XX:ParellelGCThreads)来设置。
    并发优化线程是永远活跃的,一旦发现全局列表有路局存在,就开始并发处理。
    如果记录增长很快或者来不及处理,那么通过阈值-X:G1ConcRefinementGreenZone/-XX:G1ConcRefinementYellowZone/-XX:G1ConcRefinementRedZone,G1会用分层的方式调度,使更多的线程处理全局列表。
    如果并发优化线程也不能跟上缓冲区数量,则Java应用线程会挂起应用并被加起来帮助处理,知道全部处理完。
    因此必须避免此类场景出现。

综上对于RSet而言:
事实上,并非所有的引用都需要记录在RSet中,如果一个分区确实是需要扫描的,那么无需RSet也可以无遗漏的得到引用关系,那么引用源自本分区的对象,没必要落入RSet中。
同时,G1 GC 每次都会对年轻代进行整体收集,因此引用源自年轻代的对象,也不需要在RSet中记录,最后只有老年代的分区可能会有RSet记录,这些分区称为拥有RSet分区。

收集集合(CSet)

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

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

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

年轻代收集集合 CSet of Young Collection

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

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

混合收集集合 CSet of Mixed Collection

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

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

并发标记算法(三色标记法)

上篇文章中我们了解到了可达性算法,我们介绍了最简单的实现方案是:从GC Roots节点开始,使用【标记-清除】算法去实现。
这种实现方案分为两个阶段:标记阶段,清除阶段。
在标记阶段:它从GC Roots节点开始扫描整个引用链,找到所有可达的对象。
在清除阶段:扫描整个引用链的不可达对象,然后将垃圾对象清除掉。
整个算法实现如下所示:
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第8张图片
但是这种方式有一个很大的缺点,整个过程必需【Stop The World】。这就导致了整个应用程序必需停止,不能做任何改变,非常不友好,CMS回收器出现之前的所有回收器,都是用这种方式实现的,因此GC停顿时间都挺长的。

为了解决上面【标记-清除】算法的问题,出现了【三色标记算法】!

并发类回收器的并发标记阶段,GC线程和应用线程是并发进行的。所以一个对象被标记后,应用线程可能篡改对象的引用关系,从而造成对象的漏标,误标(误标没什么关系,漏标后果致命,原因后面细聊),为了解决并发标记过程中出现漏标的情况,我们在并发标记时使用三色标记法。
CMS和G1在并发标记时使用的是同一算法:三色标记法,采用黑,灰,白三种颜色。黑色表示从GC Roots开始,已经扫描过它全部引用的对象,灰色指的是扫描过对象本身,还没有完全扫描过它全部引用的对象,白色指的是还没有扫描过的对象。
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第9张图片
有个动图,可以看一看:
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第10张图片
四个过程的静态图:
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第11张图片

  • 初始状态:
    GC开始前所有对象都是白色,GC一开始所有跟都能够直达的对象被压到栈中。
    这个阶段需要【Stop the World】。
  • 并发标记:
    从灰色节点开始,去扫描整个引用链,
    没有子节点的话,将本节点变为黑色。
    有子节点的话,则当前节点变为黑色,子节点变为灰色。
    这个阶段不需要【Stop the World】。
  • 重新标记阶段:
    指的是去校正并发标记阶段的错误,直至灰色对象没有其他子节点引用时结束。
    这个阶段需要【Stop the World】。
  • 并发清除阶段:
    指的是将已经确定为垃圾的对象清除掉。
    这个阶段不需要【Stop the World】。

对比一下【四阶段拆分】和【一段式】实现方式,我们可以看出:通过将最耗时的引用链扫描剥离出来作为并发标记阶段,将其用户线程并发执行,从而极大地降低了GC停顿时间
但是,GC线程与用户线程并发执行,会带来新的问题:对象引用关系可能会发生变化,有可能会发生我们上面提到的多标和漏标问题

多标和漏标问题

多标
多标问题:
指的是原本应该回收的对象,被多余地标记为黑色存活对象,从而导致该垃圾对象没有被回收。
出现的原因:
在并发标记阶段,有可能之前已经被标记为存活的对象,其引用被删除,从而变成了不可达对象。

举个栗子:
假设我们现在遍历到了节点E,此时应用执行了objD.fieldE=null
那么此刻之后,对象E,F,G应该是被回收的。
但是因为节点E已经是灰色的,那么E,F,G节点都会被标记为存活的黑色状态。
深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第12张图片
多表问题会导致内存产生浮动垃圾,但是好在可以在下次GC 的时候回收掉这些浮动垃圾,因此问题不算很严重。
漏标
漏标问题:
原本应该被标记为存活的对象,被遗漏标记为黑色(该变黑色却被遗漏了),从而导致该对象为白色垃圾对象被错误回收。
出现原因:
只有同时满足以下两个条件才会发生漏标。

  • 一个或者多个黑色对象重新引用了白色对象;级黑色对象成员变量增加了新的引用。
  • 灰色对象断开了白色对象的引用(直接或者间接的引用);即灰色对象原来成员变量的引用发生了变化。

举个栗子:
假设GC线程已经遍历到了E对象(变为灰色了),此时应用线程先执行了:

Object G = objE.fieldG;
objE.fieldG = null;  // 灰色E 断开引用 白色G 
objD.fieldG = G;  // 黑色D 引用 白色G

深度学习与总结JVM专辑(三):垃圾回收器—G1(图文+代码)_第13张图片
漏标问题就非常严重了,会导致存活对象被回收,严重影响程序功能。

那么我们垃圾回收器如何解决这个问题呢?
增加一个【重新标记】阶段。无论是在CMS还是G1回收器,它们都在并发标记阶段之后,新增了一个【重新标记】阶段来校正【并发标记】阶段出现的问题。
只不过对于两个不同的回收器来说,解决的原理不同。
我们在这只聊G1的解决方案,CMS的我们后面聊到它的专辑会说的。
具体解决原理:
我们之前也知道了漏标问题发生需要满足两个条件:

  • 一个或者多个黑色对象重新引用了白色对象;级黑色对象成员变量增加了新的引用。
  • 灰色对象断开了白色对象的引用(直接或者间接的引用);即灰色对象原来成员变量的引用发生了变化。
    只有当上面两个条件都满足时,才会发生漏标问题,换句话说,只要我们破坏了其中任意一个条件,这个白色对象就不会被漏标。
    那么就产生了两种解决方式:增量更新,原始快照。
    原始快照方案
    G1采用的是原始快照方案,破坏第二个条件【灰色对象断开了白色对象的引用(直接或者间接的引用);即灰色对象原来成员变量的引用发生了变化。】
    既然灰色对象在扫描完成(针对自身)后删除了对白色对象的引用,那么我们能否在灰色对象取消引用之前,先将灰色对象引用的白色对象记录下来。
    随后在【重新标记】阶段再以白色对象为根,对它的引用进行扫描,从而避免了漏标的问题。
    通过这种方式,原本漏标的对象就会被重新扫描变成灰色,从而变为存活状态。
    缺点在于:产生浮动垃圾。因为当用户线程取消引用时,有可能是真的取消引用,对应的对象是真的要回收掉的。我们通过这种方式,就会把本该回收的对象又复活了,从而导致出现浮动垃圾。但是相对于本该存活的对象被回收,这个还是可以接受的,毕竟下次GC就可以回收了。

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

你可能感兴趣的:(JVM,jvm,java,算法)