zoukankan      html  css  js  c++  java
  • 并发可达性分析:三色标记

    原文:三色标记法与读写屏障 - 简书 (jianshu.com)

    《深入理解JVM》3.4.6

    1. 概述

    可达性算法

    引用计数法、可达性分析算法。

    引用计数法:实现简单、效率高,难以解决循环引用问题

    本文仅是垃圾收集算法的标记过程

    2. 三色标记法

    2.1 基本算法

    要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象:

     

    我们把遍历对象图过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:

    • 白色:尚未访问过。
    • 黑色:本对象已访问过,而且本对象 引用到 的其他对象 也全部访问过了。
    • 灰色:本对象已访问过,但是本对象 引用到 的其他对象 尚未全部访问完。全部访问后,会转换为黑色。

    假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:

    1. 初始时,所有对象都在 【白色集合】中;
    2. 将GC Roots 直接引用到的对象 挪到 【灰色集合】中;
    3. 从灰色集合中获取对象:
      3.1. 将本对象 引用到的 其他对象 全部挪到 【灰色集合】中;
      3.2. 将本对象 挪到 【黑色集合】里面。
    4. 重复步骤3,直至【灰色集合】为空时结束。
    5. 结束后,仍在【白色集合】的对象即为GC Roots 不可达,可以进行回收。
    注:如果标记结束后对象仍为白色,意味着已经“找不到”该对象在哪了,不可能会再被重新引用。
    当Stop The World (以下简称 STW)时,对象间的引用 是不会发生变化的,可以轻松完成标记。
    而当需要支持并发标记时,即标记期间应用线程还在继续跑,对象间的引用可能发生变化多标漏标的情况就有可能发生。

    2.3 浮动垃圾

    假设已经遍历到E(变为灰色了),此时应用执行了 objD.fieldE = null :

     

    此刻之后,对象E/F/G是“应该”被回收的。然而因为E已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮GC不会回收这部分内存

    这部分本应该回收 但是 没有回收到的内存,被称之为“浮动垃圾”。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

    另外,针对并发标记开始后的新对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

    2.4 漏标-读写屏障

    假设GC线程已经遍历到E(变为灰色了),此时应用线程先执行了
    var G = objE.fieldG; 
    objE.fieldG = null;  // 灰色E 断开引用 白色G 
    objD.fieldG = G;  // 黑色D 引用 白色G

     

    此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。
    最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。


    此时切回GC线程继续跑,因为E已经没有对G的引用了,所以不会将G放到灰色集合;尽管因为D重新引用了G,但因为D已经是黑色了,不会再重新做遍历处理。
    最终导致的结果是:G会一直停留在白色集合中,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。


    不难分析,漏标只有同时满足以下两个条件时才会发生:
    条件一:灰色对象 断开了 白色对象的引用(直接或间接的引用);即灰色对象 原来成员变量的引用 发生了变化。
    条件二:黑色对象 重新引用了 该白色对象;即黑色对象 成员变量增加了 新的引用。

    代码的角度看:

    var G = objE.fieldG; // 1.读
    objE.fieldG = null;  // 2.写
    objD.fieldG = G;     // 3.写
    1. 读取 对象E的成员变量fieldG的引用值,即对象G;
    2. 对象E 往其成员变量fieldG,写入 null值。
    3. 对象D 往其成员变量fieldG,写入 对象G ;

    我们只要在上面这三步中的任意一步中做一些“手脚”,将对象G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),该集合的对象 遍历即可(重新标记)。

    我们要解决并发扫描时的漏标问题,只需破坏这两个条件的任意一个即可。由此分别
    产生了两种解决方案:增量更新(Incremental Update)原始快照(Snapshot At The Beginning, SATB)。

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

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

    重新标记通常是需要STW的,因为应用程序一直在跑的话,该集合可能会一直增加新的对象,导致永远都跑不完。当然,并发标记期间也可以将该集合中的大部分先跑了,从而缩短重新标记STW的时间,这个是优化问题了。

    2.3.1 写屏障

    注意这里的读写屏障和CPU的/volatile 的读写屏障语义不同。

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

    /**
    * @param field 某对象的成员变量,如 D.fieldG
    * @param new_value 新值,如 null
    */
    void oop_field_store(oop* field, oop new_value) { 
        *field = new_value; // 赋值操作
    } 

    所谓的写屏障,其实就是指在赋值操作前后,加入一些处理(可以参考AOP的概念):

    void oop_field_store(oop* field, oop new_value) {  
        pre_write_barrier(field); // 写屏障-写前操作
        *field = new_value; 
        post_write_barrier(field, value);  // 写屏障-写后操作
    }

    (1) 写屏障 + 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); // 记录  原来的引用对象
      }
    }

    (2) 写屏障 + 增量更新

    当对象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)。

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

    2.3.2 读屏障(Load Barrier)

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

    读屏障是直接针对第一步:var G = objE.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); // 记录读取到的对象
      }
    }

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

    2.4 三色标记法与现代垃圾回收器

    现代追踪式(可达性分析)的垃圾回收器几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同:比如白色/黑色集合一般都不会出现(但是有其他体现颜色的地方)、灰色集合可以通过栈/队列/缓存日志等方式进行实现、遍历方式可以是广度/深度遍历等等。

    对于读写屏障,以Java HotSpot VM为例,其并发标记时对漏标的处理方案如下:

    • CMS:写屏障 + 增量更新
    • G1:写屏障 + SATB
    • ZGC:读屏障

    工程实现中,读写屏障还有其他功能,比如写屏障可以用于记录跨代/区引用的变化,读屏障可以用于支持移动对象的并发执行等。功能之外,还有性能的考虑,所以对于选择哪种,每款垃圾回收器都有自己的想法。

    值得注意的是,CMS中使用的增量更新,在重新标记阶段,除了需要遍历 写屏障的记录,还需要重新扫描遍历GC Roots(当然标记过的无需再遍历了),这是由于CMS对于astore_x等指令不添加写屏障的原因,具体可参考这里

    《深入理解JVM》3.4.6:CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现

    参考资料




  • 相关阅读:
    Redis源代码分析(十三)--- redis-benchmark性能測试
    kvm中运行kvm
    umount.nfs device busy day virsh extend diskSpace, attachDisk
    ultravnc
    openNebula dubug
    maintenance ShellScripts
    virsh VMI deploy data serial xml
    cloud computing platform,virtual authentication encryption
    基于C 的libvirt 接口调用
    storage theory
  • 原文地址:https://www.cnblogs.com/chenxingyang/p/15579084.html
Copyright © 2011-2022 走看看