JVM 三色标记法与读写屏障

Posted 运维开发故事

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM 三色标记法与读写屏障相关的知识,希望对你有一定的参考价值。

微信公众号:运维开发故事,作者:老郑

三色标记法

GC 垃圾回收器其主要的目的是为了实现内存的回收,在这个过程中主要的两个步骤就是:内存标记,内存回收。

三色标记法简介

三色标记法,主要是为了高效的标记可被回收的内存块。

三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。

  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

三色标记过程

标记过程:

  1. 在 GC 并发开始的时候,所有的对象均为白色;

  2. 在将所有的 GC Roots 直接应用的对象标记为灰色集合;

  3. 如果判断灰色集合中的对象不存在子引用,则将其放入黑色集合,若存在子引用对象,则将其所有的子引用对象存放到灰色集合,当前对象放入灰色集合

  4. 按照此步骤 3 ,依此类推,直至灰色集合中所有的对象变黑后,本轮标记完成,并且在白色集合内的对象称为不可达对象,即垃圾对象。

  5. 标记结束后,为白色的对象为 GC Roots 不可达,可以进行垃圾回收。

误标

什么是误标?当下面两个条件同时满足,会产生误标:

  1. 赋值器插入了一条或者多条黑色对象到白色对象的引用

  2. 赋值器删除了全部从灰色对象到白色对象的直接引用或者间接引用

误标的解决方案

要解决误标的问题,只需要破坏这两个条件中的任意一种即可,分别有两种解决方案:增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning, STAB)

增量更新

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。

原始快照 (STAB)

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

漏标和多标

对于错标其实细分出来会有两种情况,分别是:漏标和多标

多标-浮动垃圾

如果标记执行到 E 此刻执行了 object.E = null

在这个时候, E/F/G 理论上是可以被回收的。但是由于 E 已经变为了灰色了,那么它就会继续执行下去。最终的结果就是不会将他们标记为垃圾对象,在本轮标记中存活。在本轮应该被回收的垃圾没有被回收,这部分被称为“浮动垃圾”。浮动垃圾并不会影响程序的正确性,这些“垃圾”只有在下次垃圾回收触发的时候被清理。还有在,标记过程中产生的新对象,默认被标记为黑色,但是可能在标记过程中变为“垃圾”。这也算是浮动垃圾的一部分。

漏标-读写屏障

写屏障(Store Barrier)

给某个对象的成员变量赋值时,其底层代码大概长这样:

/**
 * @param field 某个对象的成员属性
 * @param new_value 新值,如:null
 */
void oop_field_store(oop* field, oop new_value) 
    *fieild = new_value // 赋值操作


所谓写屏障,其实就是在赋值操作前后,加入一些处理的逻辑(类似 AOP 的方式)

void oop_field_store(oop* field, oop new_value) 
    pre_write_barrier(field); // 写屏障-写前屏障
    *fieild = new_value // 赋值操作 
    pre_write_barrier(field); // 写屏障-写后屏障


写屏障 + SATB

当对象E的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将E原来成员变量的引用对象G记录下来:

void pre_write_barrier(oop* field) 
    oop old_value = *field; // 获取旧值
    remark_set.add(old_value); // 记录 原来的引用对象



【当原来成员变量的引用发生变化之前,记录下原来的引用对象】 这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB),当某个时刻 的GC Roots确定后,当时的对象图就已经确定了。比如 当时 D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots。

SATB破坏了条件一:【灰色对象 断开了 白色对象的引用】,从而保证了不会漏标。

一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断:

void pre_write_barrier(oop* field) 
  // 处于GC并发标记阶段 且 该对象没有被标记(访问)过
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field))  
      oop old_value = *field; // 获取旧值
      remark_set.add(old_value); // 记录  原来的引用对象
  


写屏障 + 增量更新

当对象D的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将D新的成员变量引用对象G记录下来:

void post_write_barrier(oop* field, oop new_value)   
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) 
      remark_set.add(new_value); // 记录新引用的对象
  


当有新引用插入进来时,记录下新的引用对象】 这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。

增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标。

读屏障(Load Barrier)

oop oop_field_load(oop* field) 
    pre_load_barrier(field); // 读屏障-读取前操作
    return *field;


读屏障直接针对第一步 var objF = object.fieldG;,

void pre_load_barrier(oop* field, oop old_value)   
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) 
      oop old_value = *field;
      remark_set.add(old_value); // 记录读取到的对象
  


这种做法是保守的,但也是安全的。因为条件二中【黑色对象 重新引用了 该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。

三色标记法与垃圾回收器

增量更新:CMS

原始快照(STAB):G1,Shenandoah

参考文档

  • https://www.jianshu.com/p/12544c0ad5c1

  • https://hllvm-group.iteye.com/group/topic/44381

  • https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html

  • https://tech.meituan.com/2016/09/23/g1.html

  • 《深入理解 JVM 虚拟机-第三版》周志明

带颜色的JVM:三色标记详解


 

 

点击上方关注 “终端研发部

设为“星标”,和你一起掌握更多数据库知识

三色标记法是一种垃圾回收法,它可以让JVM不发生或仅短时间发生STW(Stop The World),从而达到清除JVM内存垃圾的目的。JVM中的CMS、G1垃圾回收器所使用垃圾回收算法即为三色标记法。

# 三色标记算法思想

三色标记法将对象的颜色分为了黑、灰、白,三种颜色。

白色:该对象没有被标记过。(对象垃圾)

灰色:该对象已经被标记过了,但该对象下的属性没有全被标记完。(GC需要从此对象中去寻找垃圾)

黑色:该对象已经被标记过了,且该对象下的属性也全部都被标记过了。(程序所需要的对象)


# 算法流程

从我们main方法的根对象(JVM中称为GC Root)开始沿着他们的对象向下查找,用黑灰白的规则,标记出所有跟GC Root相连接的对象,扫描一遍结束后,一般需要进行一次短暂的STW(Stop The World),再次进行扫描,此时因为黑色对象的属性都也已经被标记过了,所以只需找出灰色对象并顺着继续往下标记(且因为大部分的标记工作已经在第一次并发的时候发生了,所以灰色对象数量会很少,标记时间也会短很多), 此时程序继续执行,GC线程扫描所有的内存,找出扫描之后依旧被标记为白色的对象(垃圾),清除。

具体流程:

  1. 首先创建三个集合:白、灰、黑。

  2. 将所有对象放入白色集合中。

  3. 然后从根节点开始遍历所有对象(注意这里并不递归遍历),把遍历到的对象从白色集合放入灰色集合。

  4. 之后遍历灰色集合,将灰色对象引用的对象从白色集合放入灰色集合,之后将此灰色对象放入黑色集合

  5. 重复 4 直到灰色中无任何对象

  6. 通过write-barrier检测对象有变化,重复以上操作

  7. 收集所有白色对象(垃圾)


# 三色标记存在问题

1.浮动垃圾:并发标记的过程中,若一个已经被标记成黑色或者灰色的对象,突然变成了垃圾,由于不会再对黑色标记过的对象重新扫描,所以不会被发现,那么这个对象不是白色的但是不会被清除,重新标记也不能从GC Root中去找到,所以成为了浮动垃圾,浮动垃圾对系统的影响不大,留给下一次GC进行处理即可。

2.对象漏标问题(需要的对象被回收):并发标记的过程中,一个业务线程将一个未被扫描过的白色对象断开引用成为垃圾(删除引用),同时黑色对象引用了该对象(增加引用)(这两部可以不分先后顺序);因为黑色对象的含义为其属性都已经被标记过了,重新标记也不会从黑色对象中去找,导致该对象被程序所需要,却又要被GC回收,此问题会导致系统出现问题,而CMS与G1,两种回收器在使用三色标记法时,都采取了一些措施来应对这些问题,CMS对增加引用环节进行处理(Increment Update),G1则对删除引用环节进行处理(SATB)。


# 解决办法

在JVM虚拟机中有两种常见垃圾回收器使用了该算法:CMS(Concurrent Mark Sweep)、G1(Garbage First) ,为了解决三色标记法对对象漏标问题各自有各自的法:


# CMS回顾

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网网站或者基于浏览器的B/S系统的服务端上,这类应用通常都会较为关注服务的响应速度,希望系统停顿时间尽可能短,以给用户带来良好的交互体验。CMS收集器就非常符合这类应用的需求(但是实际由于某些问题,很少有使用CMS作为主要垃圾回收器的)。

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

1)初始标记(CMS initial mark) 

2)并发标记(CMS concurrent mark) 

3)重新标记(CMS remark) 

4)并发清除(CMS concurrent sweep)

其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GCRoots能直接关联到的对象,速度很快;

并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;

重新标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短;

最后是并发清除阶段,清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。由于在整个过程中耗时最长的并发标记和并发清除阶段中,垃圾收集器线程都可以与用户线程一起工作,所以从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。


# CMS解决办法:增量更新

在应对漏标问题时,CMS使用了增量更新(Increment Update)方法来做:

在一个未被标记的对象(白色对象)被重新引用后,引用它的对象若为黑色则要变成灰色,在下次二次标记时让GC线程继续标记它的属性对象。

但是就算时这样,其仍然是存在漏标的问题:

  • 在一个灰色对象正在被一个GC线程回收时,当它已经被标记过的属性指向了一个白色对象(垃圾)

  • 而这个对象的属性对象本身还未全部标记结束,则为灰色不变

  • 而这个GC线程在标记完最后一个属性后,认为已经将所有的属性标记结束了,将这个灰色对象标记为黑色,被重新引用的白色对象,无法被标记


# CMS另两个致命缺陷

1.CMS采用了Mark-Sweep算法,最后会产生许多内存碎片,当到一定数量时,CMS无法清理这些碎片了,CMS会让Serial Old垃圾处理器来清理这些垃圾碎片,而Serial Old垃圾处理器是单线程操作进行清理垃圾的,效率很低。

所以使用CMS就会出现一种情况,硬件升级了,却越来越卡顿,其原因就是因为进行Serial Old GC时,效率过低。

  • 解决方案:使用Mark-Sweep-Compact算法,减少垃圾碎片

  • 调优参数(配套使用):

-XX:+UseCMSCompactAtFullCollection  开启CMS的压缩
-XX:CMSFullGCsBeforeCompaction 默认为0,指经过多少次CMS FullGC才进行压缩

2.当JVM认为内存不够,再使用CMS进行并发清理内存可能会发生OOM的问题,而不得不进行Serial Old GC,Serial Old是单线程垃圾回收,效率低

  • 解决方案:降低触发CMS GC的阈值,让浮动垃圾不那么容易占满老年代

  • 调优参数:

-XX:CMSInitiatingOccupancyFraction 92% 可以降低这个值,让老年代占用率达到该值就进行CMS GC

# G1回顾

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

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于那些超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行看待,如图所示


# G1前置知识

Card Table(多种垃圾回收器均具备)

  • 由于在进行YoungGC时,我们在进行对一个对象是否被引用的过程,需要扫描整个Old区,所以JVM设计了CardTable,将Old区分为一个一个Card,一个Card有多个对象;如果一个Card中的对象有引用指向Young区,则将其标记为Dirty Card,下次需要进行YoungGC时,只需要去扫描Dirty Card即可。

  • Card Table 在底层数据结构以 Bit Map实现。

RSet(Remembered Set)

是辅助GC过程的一种结构,典型的空间换时间工具,和Card Table有些类似。

后面说到的CSet(Collection Set)也是辅助GC的,它记录了GC要收集的Region集合,集合里的Region可以是任意年代的。

在GC的时候,对于old->young和old->old的跨代对象引用,只要扫描对应的CSet中的RSet即可。逻辑上说每个Region都有一个RSet,RSet记录了其他Region中的对象引用本Region中对象的关系,属于points-into结构(谁引用了我的对象)。

而Card Table则是一种points-out(我引用了谁的对象)的结构,每个Card 覆盖一定范围的Heap(一般为512Bytes)。G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。每个Region中都有一个RSet,记录其他Region到本Region的引用信息;使得垃圾回收器不需要扫描整个堆找到谁引用当前分区中的对象,只需要扫描RSet即可。微信搜索终端研发部获取2021年面试大全

CSet(Collection Set)

一组可被回收的分区Region的集合, 是多个对象的集合内存区域。

新生代与老年代的比例

5% - 60%,一般不使用手工指定,因为这是G1预测停顿时间的基准,这地方简要说明一下,G1可以指定一个预期的停顿时间,然后G1会根据你设定的时间来动态调整年轻代的比例,例如时间长,就将年轻代比例调小,让YGC尽早行。

# G1解决办法:SATB

SATB(Snapshot At The Beginning), 在应对漏标问题时,G1使用了SATB方法来做,具体流程:

  1. 在开始标记的时候生成一个快照图标记存活对象

  2. 在一个引用断开后,要将此引用推到GC的堆栈里,保证白色对象(垃圾)还能被GC线程扫描到(在**write barrier(写屏障)**里把所有旧的引用所指向的对象都变成非白的)。

  3. 配合Rset,去扫描哪些Region引用到当前的白色对象,若没有引用到当前对象,则回收


# SATB详细流程

  1. SATB是维持并发GC的一种手段。G1并发的基础就是SATB。SATB可以理解成在GC开始之前对堆内存里的对象做一次快照,此时活的对像就认为是活的,从而开成一个对象图。

  2. 在GC收集的时候,新生代的对象也认为是活的对象,除此之外其他不可达的对象都认为是垃圾对象。

  3. 如何找到在GC过程中分配的对象呢?每个region记录着两个top-at-mark-start(TAMS)指针,分别为prevTAMS和nextTAMS。在TAMS以上的对象就是新分配的,因而被视为隐式marked。

  4. 通过这种方式我们就找到了在GC过程中新分配的对象,并把这些对象认为是活的对象。

  5. 解决了对象在GC过程中分配的问题,那么在GC过程中引用发生变化的问题怎么解决呢?

  6. G1给出的解决办法是通过Write Barrier。Write Barrier就是对引用字段进行赋值做了额外处理。通过Write Barrier就可以了解到哪些引用对象发生了什么样的变化。

  7. mark的过程就是遍历heap标记live object的过程,采用的是三色标记算法,这三种颜色为white(表示还未访问到)、gray(访问到但是它用到的引用还没有完全扫描)、back(访问到而且其用到的引用已经完全扫描完)。

  8. 整个三色标记算法就是从GC roots出发遍历heap,针对可达对象先标记white为gray,然后再标记gray为black;遍历完成之后所有可达对象都是balck的,所有white都是可以回收的。

  9. SATB仅仅对于在marking开始阶段进行“snapshot”(marked all reachable at mark start),但是concurrent的时候并发修改可能造成对象漏标记。

  10. 对black新引用了一个white对象,然后又从gray对象中删除了对该white对象的引用,这样会造成了该white对象漏标记。

  11. 对black新引用了一个white对象,然后从gray对象删了一个引用该white对象的white对象,这样也会造成了该white对象漏标记。

  12. 对black新引用了一个刚new出来的white对象,没有其他gray对象引用该white对象,这样也会造成了该white对象漏标记。


# SATB效率高于增量更新的原因?

因为SATB在重新标记环节只需要去重新扫描那些被推到堆栈中的引用,并配合Rset来判断当前对象是否被引用来进行回收;微信搜索终端研发部获取2021年面试大全

并且在最后G1并不会选择回收所有垃圾对象,而是根据Region的垃圾多少来判断与预估回收价值(指回收的垃圾与回收的STW时间的一个预估值),将一个或者多个Region放到CSet中,最后将这些Region中的存活对象压缩并复制到新的Region中,清空原来的Region。

# G1会不会进行Full GC?

会,当内存满了的时候就会进行Full GC;且JDK10之前的Full GC,为单线程的,所以使用G1需要避免Full GC的产生。

解决方案:

  • 加大内存;

  • 提高CPU性能,加快GC回收速度,而对象增加速度赶不上回收速度,则Full GC可以避免;

  • 降低进行Mixed GC触发的阈值,让Mixed GC提早发生(默认45%)

今日好文推荐

GitHub上非常实用的40个开源JAVA项目

XShell收费太贵?快试试开源的NuShell,好用!

GET 和 POST请求的本质区别是什么?看完觉得自己太无知了...

MyBatis批量插入数据你还在用foreach?你们的服务器没崩?

点个在看少个 bug 👇

以上是关于JVM 三色标记法与读写屏障的主要内容,如果未能解决你的问题,请参考以下文章

JVM三色标记法

带颜色的JVM:三色标记详解

JVM垃圾回收的“三色标记算法”实现,内容太干

浅谈JVM GC三色标记算法

你对JVM三色标记的理解嘛?

Go内存分配和逃逸分析三色标记法