JVM之路(三(1))垃圾收集器与内存分配策略

一、前言

顾名思义,这章节内容讲的就是 GC、GC算法、内存分配策略(针对GC的);具体一点就是:首先梳理了GC中的两大类,具有确定性的 Program Counter、VM Stack、Native Method Stack,以及具有不确定性的 Heap、Method Area,而堆内存(对象实例)的回收更是我们的重中之重(本章内容基本围绕堆内存回收展开,顺带提了一下方法区);接着介绍了常见的回收算法并明确了 JVM 中更常用的是“可达性分析算法”,由此作为引申,介绍了 GC 时内存自救、GCRoots 如何高效获取、GCRoots 引用链遍历时会碰到的问题及解决方案,这些都是偏向理论性的,所以不容易在逐个讲解时融会贯通,这里我也会尝试去梳理出一个脉络图(本章的主要内容);
在这里插入图片描述

程序计数器、虚拟机栈、本地方法栈:这三个区域是线程私有的,与线程生命周期也相同;程序计数器的大小是固定的,栈帧的大小在类结构确定下来时也基本确定的(编译器可知),所以他们的内存分配都带有确定性,同时也不用考虑内存如何回收,因为方法、线程结束后,内存就可以自然随之回收;
Java堆、方法区:一个接口多个实现类的内存大小可能不一致,一个方法不同条件所需内存也可能不一致,这些更多的是在运行期间才能知道究竟要创建那些对象、创建多少对象,即内存的分配和回收是动态的

二、对象存活分析

堆中对象回收,直白的说就是判断对象是否还能被使用,不能被使用的对象(对象已死)就需要回收,这里就是优先介绍对象状态分析。

2.1 引用计数算法

概念:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的;
缺点:很难解决对象之间相互循环引用的问题,下面就通过实验来验证在 java 中出现的这种相互引用问题是否会被 GC 回收,不会则表示采用的是此算法;

public class ReferenceCountingGC {
    public Object instance = null;

    //这里只是一个单纯的数字,后续字节数组中,每一个元素为8bit,
    //2 * _1M 就是 2*1024*1024*8bit(字节数组每个元素大小),也就是2M
    private static final int _1MB = 1024*1024;

    //这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
    private byte[] bigSize = new byte[2*_1MB];

    public static void testGC(){
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        
        objA.instance = objB;
        objB.instance = objA;
        //两个对象有2*2M的方法区常量池占用,及对象实例占用,
        //下方回收了4603K-210K接近4MBd的空间,就是代表着这两个对象的常量池已经被回收

        System.gc();
    }
}

public class ReferenceCountingGCTest {
    public static void main(String[] args) {
        ReferenceCountingGC.testGC();
    }
}

在这里插入图片描述
上方是书中截图,下方则是我实际实验的日志,简单分析一下:
书中 Tenured 表示老年代,Perm 表示永久代(方法区),那么中间的 4603K->210K 接近4MB 已被回收的空间就是实验对象所处地方,也就是年轻代;
实验中 Full GC中 PSYongGen 代表年轻代,ParOldGen 代表老年代,Metaspace 表示元空间(方法区),而这里年轻代回收了 4888K 的空间,同样表明我们的对象是在年轻代回收的。
由此可以得出:JVM 并没有采用引用计数算法

[GC (System.gc()) [PSYoungGen: 11960K->4888K(152576K)] 11960K->4896K(500736K), 0.0033440 secs] [Times: user=0.11 sys=0.08, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 4888K->0K(152576K)] [ParOldGen: 8K->4707K(348160K)] 4896K->4707K(500736K), [Metaspace: 3253K->3253K(1056768K)], 0.0063137 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
 PSYoungGen      total 152576K, used 3932K [0x0000000716780000, 0x0000000721180000, 0x00000007c0000000)
  eden space 131072K, 3% used [0x0000000716780000,0x0000000716b57230,0x000000071e780000)
  from space 21504K, 0% used [0x000000071e780000,0x000000071e780000,0x000000071fc80000)
  to   space 21504K, 0% used [0x000000071fc80000,0x000000071fc80000,0x0000000721180000)
 ParOldGen       total 348160K, used 4707K [0x00000005c3600000, 0x00000005d8a00000, 0x0000000716780000)
  object space 348160K, 1% used [0x00000005c3600000,0x00000005c3a98df8,0x00000005d8a00000)
 Metaspace       used 3273K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 354K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

2.2 可达性分析法

这里是能看到默认采用的是 G1 GC;

     bool UseG1GC                                  = true                                      {product} {ergonomic}
     bool UseGCOverheadLimit                       = true                                      {product} {default}
     bool UseGCTaskAffinity                        = false                                     {product} {default}
     bool UseMaximumCompactionOnSystemGC           = true                                      {product} {default}
     bool UseParallelGC                            = false                                     {product} {default}
     bool UseParallelOldGC                         = false                                     {product} {default}
     bool UseSerialGC                              = false                                     {product} {default}
     bool UseShenandoahGC                          = false                                     {product} {default}

2.2 可达性分析算法

2.2.1 算法详解

概念:通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
在这里插入图片描述
在 Java 技术体系中,可以用来做 GCRoots 的对象包括以下几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等(个人理解指的是局部变量表中存的 对象引用);
  • 方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量;
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器;
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
  • 其他对象“临时性”地加入,比如分代回收中的局部回收,涉及到其他代内存区中的对象引用也会被临时加入到 GCRoots 中。

GCRoots 涉及到的范围看起来还是相当广的,这里不建议司机硬背,因为我的体会是随着了解的逐步加深,对于上述的 GCRoots 点的理解会愈加透彻,记住有这么个东西就行;

2.2.2 对象自救

要真正宣告一个对象死亡,至少要经历两次标记过程,首先判断是否执行 finalize() ,只有执行才可能自救,否则会直接回收,具体如下:

对象在进行可达性分析后发现没有与 GC Roots 相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行 finalize() 方法。假如对象没有覆盖 finalize() 方法,或者 finalize() 方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
如果这个对象被判定为确有必要执行 finalize() 方法,那么该对象将会被放置在一个名为 F-Queue 的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的 Finalizer 线程去执行它们的 finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的 finalize() 方法执行缓慢,或者更极端地发生了死循环,将很可能导致 F-Queue 队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize() 方法是对象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果对象要在 finalize() 中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了

实验代码如下:

public class FinalizeEscapeGC {
    public static FinalizeEscapeGC SAVE_HOOK = null;

    public void isAlive() {
        System.out.println("yes, i am still alive");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize method executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws InterruptedException {
        SAVE_HOOK = new FinalizeEscapeGC();

        SAVE_HOOK = null;
        //第一次触发,系统自动调用finalize(),会在重写的方法中拯救对象
        System.gc();
        System.out.println("1");
        Thread.sleep(500);
        System.out.println("2");
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead1");
        }

        SAVE_HOOK = null;
        System.gc();
        System.out.println("3");
        Thread.sleep(500);
        System.out.println("4");
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead2");
        }
    }
}
# 打印日志:
1
finalize method executed!
2
yes, i am still alive
3
4
no, i am dead2

Process finished with exit code 0

从上方的实验,我们至少可以总结出以下几个结论:

  1. 如果对象指向null,那么就会在 gc 时回收,除非被“拯救”;
  2. 上述子类重写了 finalize 方法,但是可以看到其中还是优先执行父类的 finalize 方法,然后再将当前对象 SAVE_HOOK 赋值给当前类的类变量 SAVE_HOOK,所以并不是说 finalize 方法一执行就会回收;
  3. 因为所有对象的 finalize 方法只会被系统自动调用一次(finalize是继承自Object的),所以才出现第二次没有成功;同时 SAVE_HOOK 被指定为null,finalize方法为非静态方法,也无法手动触发;

2.3 引用的分类

上述的对象分析算法,离不开“引用”的概念,下方引用的区分,是用来帮助判断对象是否应该被回收(引用是否有效的)
在 JDK 1.2 之后,将引用分为了一下四种:

  1. 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
  2. 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
  3. 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
  4. 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。

2.4 回收方法区

了解:确实有未实现或未能完整实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载),同时方法区的回收率一般远低于堆内存(如年轻代)的回收率(70%-90%)。

方法区的垃圾回收主要有两个部分:废弃的常量不再使用的类(型)
常量回收:与堆中对象回收类似,假如一个字符串“abc”曾经进入常量池中,但当前系统有没有任何一个字符串值为“abc”,即已经没有任何字符串对象引用常量池中的“java”常量,虚拟机中也未用到,那么在发生内存回收时,GC判断如果有必要就会将其清理出常量池;常量池中其他类(接口)、方法、字段的符耀引用也与此类似;

类回收:判断一个类是否不在被使用相对来说就比较苛刻了,首先同时满足下列的三个条件:

  1. 该类所有的实例都已经被回收,也就是堆中不存在该类及其任何派生子类的实例;
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI、JSP的重加载,否则通常很难达成(是不是可以理解为:除了这两处,其他情况下基本很少对类进行回收);
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法。

而满足这三个条件仅仅是被允许回收,是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc 参数进行控制;
还可以使用-verbose:class以及-XX:+TraceClass-Loading-XX:+TraceClassUnLoading 查看类加载和卸载信息,其中 -verbose:class 和 -XX:+TraceClassLoading 可以在 Product 版的虚拟机中使用,-XX:+TraceClassUnLoading 参数需要FastDebug版的虚拟机支持。

三、垃圾收集算法

垃圾收集算法可划分为两种 — 引用计数式垃圾收集(Reference Counting GC )和 追踪式垃圾收集(Tracing GC),也被称为 “直接垃圾回收” 和 “间接垃圾回收”,其实这两种算法对应的就是上述对象存活状态分析中的 “引用计数算法” 和 “可达性分析算法”,JVM 中未涉及引用计数算法,所以后面讲解的为 Tracing GC。
首先是介绍主流的分代收集理论,然后是介绍不同的分代区域采取的 GC 算法。

3.1 分代收集理论

分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在 2+1 个分代假说之上:

  1. 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的;
  2. 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡;
  3. 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。

前两个分代假说奠定了常用 GC 的设计原则:收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(即对象熬过垃圾收集过程的次数)分配到不同的区域中存储;对于一个大都由朝生夕灭对象组成的区域,只用关注如何保留少量存活的对象,对于存储难以消亡对象的区域,就可以以较低的频率来回收,这样就兼顾了垃圾收集的时间开销和内存空间利用率。

在 Java 堆中划分出不同区域后,GC 才可以每次只回收某一个或某一部分区域,由此有了 Minor GC、Major GC、Full GC 这样的回收类型划分,针对不同的区域安排与存储对象存亡特征相匹配的 GC 算法;设计者一般至少会把 Java 堆划分为新生代(Young Generation/Nursery)和老年代(Old Generation/Tenured)两个区域,新生代中每次垃圾收集都会有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

针对对象之间存在跨代引用的,有了第三条分代假说,其给我们的支撑在于:不必为了少量的跨代引用去扫描整个老年代,也不必专门存储每一个对象是否存在跨代引用,实际上:新生代上有一个全局的数据结构(记忆集,Remember Set),将老年代划分为若干小快儿(卡表页),标识其是否存在跨代引用,如此会带来由引用关系该表增加的维护开销,但相对扫描整个老年代来说仍然是划算的。

分代名词同意定义:

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,又分为:
    1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集;
    2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集;目前只有 CMS 收集器会有单独收集老年代的行为;
    3. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集;目前只有 G1 收集器会有这种行为;
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

分代收集理论也有其缺陷,目前也有收集器展现出了全区域收集设计思想(不太了解是哪一版开始出现尝试的)。

3.2 标记-清除算法(Mark-Sweep)

概念:算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,垃圾对象标记判定算法就是前面说的可达性分析算法。
后续的收集算法大都以标记清除算法为基础,对其缺点进行改进而得到。
缺点:执行效率不稳定,线性增长;内存空间碎片化,如果较大对象无法找到足够内存进行分配,可能触发另一次的垃圾回收。
在这里插入图片描述

3.3 标记-复制算法(Semispace Copying)

概念:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
优点:无内存碎片化,存活对象少时效率高;
缺点:存活对象多时会产生大量的内存复制开销,空间浪费大;
在这里插入图片描述
通过上述优缺点,很容易联想到这种算法适用于新生代,但是通常会对其内存利用率低的缺点做优化,如 HotSpot VM 中的Serial、ParNew 等新生代收集器,也就是“Appel式回收”:

Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor 空间,每次分配内存只使用Eden和其中一块 Survivor。发生垃圾搜集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空新生代采用 Appel 式回收来进行内存划分和回收间。HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是8∶1。
但任何人都没有办法百分百保证每次回收都只有不多于10%的对象存活,因此 Appel式回收还有一个充当罕见情况的“逃生门”的安全设计,当 Survivor 空间不足以容纳一次 Minor GC 之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(Handle Promotion)。

3.4 标记-整理算法(Mark-Compact)

概念:标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存;标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动式的;
优点:不会产生内存碎片;
缺点:存活对象多时,移动则是一种极为附中的操作;移动时必须全程暂停用户程序(Stop The World -> 最新的 ZGC 和 Shenandoah 收集器使用读屏障(Read Barrier)技术实现了整理过程与用户线程的并发执行);
在这里插入图片描述

3.5 总结

其实整个程序吞吐量的实质,是由赋值器(Mutator,也就是使用垃圾收集的用户程序)与收集器的效率总和,所以 HotSpot VM 里关注吞吐量的 Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的 CMS 收集器,则是给予标记-清除算法的。
当然还有一种 “和稀泥” 的解决方式,可以不在内存分配和访问上增加太大额外负担,就是让 VM 平时多数时间采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。前面提到的基于标记-清除算法的 CMS 收集器面临空间碎片过多时采用的就是这种处理办法。

四、HotSpot 的算法细节实现

二、三节介绍了对象存活判定算法(可达性分析算法)和垃圾收集算法(分代理论及其对应回收算法),这一节主要是对二者中涉及到的 如何枚举 GCRoots、标记对象做解析,分析其中可能遇到的问题及处理办法;

4.1 根节点(GCRoots)枚举

固定可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情。
根节点枚举必须在一个能保障一致性的快照中才能进行(暂停用户线程),以此确保准确性。
目前主流 JVM 使用的是准确式垃圾收集,当用户停顿下来后,其实并不需要全局检查,而是通过一组称为 OopMap(Ordinary Object Pointer Map) 的数据结构,来直接获取哪些地方存放着对象引用;一旦类加载动作完成,HotSpot 就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中也会在特定位置记录下栈里和寄存器里那些位置是引用。

下面代码清单是 HotSpot 虚拟机客户端模式下生成的一段 String::hashCode() 方法的本地代码,可以看到在 0x026eb7a9 处的 call 令有 OopMap 记录,它指明了 EBX 寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用,有效范围为从call指令开始直到 0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即 hlt 指令为止。
在这里插入图片描述

4.2 安全点(Safe Ponit)

会造成对象引用关系变化的因素太多了,所以 OopMap 当然不能动态变化造成大量额外负担,同时根节点枚举是需要用户线程中断的,这时就引入了安全点;
概念:在 GC 发生前用户线程会在预先选定的安全点暂停,然后再进行 OopMap 的更新。

安全点选取规则:安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

那么垃圾收集发生时如何让线程(todo:不包括JNI调用的线程)跑到安全点并中断呢,这里有两种方案可供选择:

  • 抢先式中断(Preemptive Suspension):不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。
  • 主动式中断(Voluntary Suspension):当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象(即保证线程到安全点中断,然后确保内存空间条件满足)。

HotSpot 就是采用的主动式中断,采用内存保护陷阱的方式,将轮询操作精简到只有一条汇编指令的程度:
在这里插入图片描述test 指令就是HotSpot生成的轮询指令,当需要暂停用户线程时,虚拟机把 0x160100 的内存页设置为不可读,那线程执行到 test 指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。

4.3 安全区域(Safe Region)

安全点解决了正在运行的线程如何开始更新 OopMap 的问题,但线程状态可不是只有 running,还有处于 Sleep、Blocked 等不执行状态,这时就无法通过主动式中断(内存保护陷阱)来轮训进入中断,VM 也不可能因为部分线程未激活就一直处于等待,这时就引入了安全区域;
**概念:**安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
涉及安全区域的 GC 执行过程:
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

todo:关于安全区域的 GC 过程有一些思考,目前只是猜测,希望后续学习的东西多了能解答这些疑问:
如果第一次收集GCRoots(OopMap)时,线程执行的代码不在安全区域,第二次收集GCRoots时,线程进入了安全区域;那么,在第二次收集时不去管那些已声明进入安全区域的线程,会不会出现在第二次收集时,新引入多对象引用而未被纳入GCRoots导致错误回收的情况?
猜测:1. 安全区域执行代码时,引用关系不会发生变化,所以要么线程判断执行某一段代码不会发生引用关系变化而进入,要么线程进入 Sleep、Blocked 状态之前就已经进入安全区域并标识自己。

4.4 记忆集与卡表

概念:记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构;新生代中的记忆集,将老年代分区并标记存在引用的地方,这样就不必为了少量跨代引用而将整个老年代加入 GC Roots 扫描范围;
在介绍分代收集理论的时候,就提到过这个新生代中的记忆集,其实不止新生代、老年代之间才存在跨代引用问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器如 G1、ZGC 和 Shenandoah 收集器都会面临相同的问题。


下方的伪代码其实能看出来:采用的是 Object 数组,数组中每个元素是一个 Set 集合,用来存储对象代间引用大小;
在这里插入图片描述
其实如果真是实现方式如上方代码那样,对每个引用对象都予以记录,那么带来的空间、维护成本是相当高的,其实收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节;设计者实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

这三种精度中,卡精度就是 HotSpot VM 选择的,被称为卡表(Card Table)的方式去实现记忆集(记忆集是一种抽象的数据结构,只定义了记忆集的意图,而卡表则是记忆集的具体实现,定义了记忆集的记录精度、与堆内存的映射关系等);


在这里插入图片描述
卡表最简单形式可以只是一个字节数组(计算机都是最小按字节寻址,相比更小的 bit 反而更快,不用多消耗几条shift+mask指令),HotSpot VM 也确实是这样做的,上方代码就是 HotSpot 默认的卡表标记逻辑:字节数组 CARD_TABLE[] 每一元素都对应标识的内存区域中一块儿特定大小的内存块儿,这个内存块儿被称为 “卡页”(Card Page);一般来说卡页以 2 的 N 次幂的字节数,上方代码终究是 2^9 也就是 512Byte(地址右移9位,相当于用地址除以512);
在这里插入图片描述
卡页其实就是老年代中实际存储对象的内存,而卡表就是标记表,若卡表某一元素对应的卡页中存在跨代指针,那么对应的卡表元素的值就会被标识为 1,称这个元素变脏了(Dirty),没有则标识为 0;所以最终发生垃圾收集时,只要筛选出标识 Drity 的区域,加入 GC Roots 中一并扫描即可。

4.5 写屏障(Write Barrier)

4.4 中介绍了卡表与卡页,那么应该在何时、如何将卡表变脏呢?

何时:卡表变脏的时间其实很明确,就是在其他分代区域(新生代)中对象引用了本区域(老年代)对象时,卡表就应该 Dirty,变脏时间原则上应该是发生在引用类型字段复制的那一刻;

如何:如果是解释执行字节码,VM 负责执行每条字节码指令,还有充分的介入空间;如果在编译执行的场景,编译后直接是机器指令交流了,就必须在机器层面找到一个介入手段,把维护卡表的动作放到每一个赋值操作之中。

概念:写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面;在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier),应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新;
在这里插入图片描述


更新操作,不可避免的要考虑到并发问题,这里同样要考虑“伪共享问题”:
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,伪共享问题的意思是如果多个线程对同一缓存行进行操作,就会在无意中引起性能问题;在这里缓存行所对应的64个卡表页(32KB),如果在并发时,多个线程都对同一个卡表页标记为脏,就会引起伪共享问题。
解决:不采用无条件的写屏障,而是先检查卡表标记,只有该卡表未被标记过时才讲起标记为变脏(todo:会不会并发检查?):
在这里插入图片描述
在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

4.6 并发的可达性分析

GCRoots 相较于整个 Java堆中对象来说,数量相对来说是极少的,并且有 OopMap 等优化的加持,所以在发生 GC 时,GCRoots 的枚举停顿相对短暂且固定;但是 GCRoots 引用链遍历却是随着堆容量增大而增大的,遍历+标记的过程如果都暂停用户线程,那显然并不合理,所以才要去考虑标记过程与用户线程并发的情况,前面提到要保证在一致性的快照上才能进行对象图的遍历,那并发是否能保证呢(如果原本消亡的对象错标记为存活还能接受,要是反之则肯定会导致程序错误)?如果不能又是通过什么方法来解决的?

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

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
    在这里插入图片描述
    当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

    所以解决方案便是破坏两个条件中的任意一个即可:
    增量更新(Incremental Update):要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
    原始快照(Snapshot At The Beginning,SATB):要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描
    一次。可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索(引用保留)。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在 HotSpot 虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1、Shenandoah 则是用原始快照来实现。这样看来可达性分析算法可以通过并发来提升效率。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

mitays

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值