JVM系列 2-垃圾收集器(上)

1 概述

        在上篇介绍了JVM的内存管理(jvm内存管理),其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配了多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这个区域内不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然会被回收。

        而Java堆和方法区两个区域则有很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

2 对象已死?

        在堆里面存放着Java中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事就要是确定这些对象之后哪些还“存活”,哪些已经“死去”(死去指的是不可能再被任何途径使用的对象)。

2.1 引用计数算法

        引用计数算法:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效后,计数器值减1;任何时刻计数器为0的对象就是不可能再被使用的。可观的说,引用计数算法虽然占用一些额外的内存空间进行计数,但原理简单,判定效率也很高,在大多数情况下是一个不错的算法。但是在Java领域,至少主流的Java虚拟机并没有选用这种方式来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,例如单纯的引用计数就很难解决对象之间相互循环引用的问题。

        示例:请看下面代码中testGC()方法:

public class ReferenceCountingGC {
    public Object instance = null;
    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;
        objA = null;
        objB = null;
        //假设在这行发生gc,objA和objB是否能被回收
        System.gc();
    }

    public static void main(String[] args) {
        testGC();
    }
}

        对象objA和objB都有字段instance,赋值objA.instance = objB即bjB.instance = objA;

除此之外,这两个对象再无任何引用,实际上两个对象已经不可能再被访问,但是它们因为互相引用对方,导致它们的引用计数都不为0,引用计数算法就无法回收它们。

2.2 可达性分析算法

        这个算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots之间没有任何引用链,或者用图论的话来说就是从GC Roots到这个对象不可达,则证明此对象是不可能再被使用的。

        如下图,对象object5、object6、object7虽然互相关联,但是它们到GC Roots是不可达的,因此它们将被判定为回收的对象。

        

        在Java技术体系里,固定可作为GC Roots的对象包括以下几种:

        1、在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈种使用到的参数、局部变量、临时变量等。

        2、在方法区中类静态属性引用的对象,譬如Java类的引用类型变量。

        3、在方法区中常量引用的对象,譬如字符串常量池里的引用。

        4、在本地方法栈中JNI(通常所说的native方法)引用的对象。

        5、Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器。

        6、所有被同步锁持有的对象。

        7、反射Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

        除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”加入,共同构成完成GC Roots集合。如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域时虚拟机自己的实现细节,更不是孤立封闭的,所有某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。

2.3 对象引用

        无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判断对象是否存活都和"引用"离不开关系。在JDK 1.2以后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱。

        1、强引用是最传统的“引用”的定义,是值在程序代码之中普遍存在的引用赋值,即类似"Object obj = new Object()"这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

        2、软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出前,会把这些对象列进回收范围之中进行第二个回收,如果这次回收还没有足够的内存,才会抛出内存溢出。在JDK 1.2版之后,提供了SoftReference类来实现软引用。

        3、弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一下,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2版之后,提供了WeakReference类来实现弱引用。

        4、虚引用是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对齐生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK1.2版以后,提供了PhantomReference类来实现虚引用。

2.4 生存还是死亡?

·        即使在可达性分析算法中判定为不可达对象,也不是“非死不可”的,这时候他们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为"没有必要执行"。

        如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为

F- Queue的队列之中,并在稍后由一条虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize()方法。这里说的“执行”是指虚拟机会触发这个方法开始运行,当并不承诺一定会等待它运行结束。这样做的原因是,如果某个对象的finalize()方法执行缓慢,或者更极端的发生了死循环,将可能导致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃。finalize()方法是对象逃脱死亡的最后一次机会,稍后收集器将对F-Queue中对象进行第二次小规模标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那么在第二次标记时,它将被移除“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

示例:看到一个对象的finalize()被执行,但是它仍然可以存活。

/**
 * 此代码演示两点:
 * 1、对象可以被GC时自我拯救
 * 2、这种自救的机会只有一次,因为一个对象的finalize()方法最多只能被系统自动调用一次
 */
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 Throwable{
        SAVE_HOOK = new FinalizeEscapeGC();
        //对象第一次拯救自己
        SAVE_HOOK = null;
        System.gc();
        //因为finalize()方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if(SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no , i am dead");
        }
        //下面这段代码与上面完全相同,但是这次却自救失败了
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(500);
        if(SAVE_HOOK != null){
            SAVE_HOOK.isAlive();
        }else{
            System.out.println("no , i am dead");
        }
    }
}

   运行结果:

     

        从上面代码可以看到,SAVE_HOOK对象的finalize()方法确实被垃圾收集器触发过,并且在被收集前成功逃脱了。另一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败。这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。还有一点需要特别说明,建议大家尽量避免使用这个方法。原因是运行代价高昂,不确定性大,无法保证各个对象的调用顺序,如今已被官方明确声明为不推荐使用的语法。

2.5 回收方法区

        方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量。且虚拟机中也没有其他地方引用这个字面量。如果这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

3 垃圾收集算法

        从如何判定对象消亡的角度出发,垃圾收集算法可以分为“引用计数式垃圾收集”和“追踪式垃圾收集”两类,也被称为“直接垃圾收集”和“间接垃圾收集”。由于引用计数式垃圾收集在主流的Java虚拟机中从未涉及,所以下面只介绍追踪式垃圾收集。

3.1 分代收集理论

        当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,建立在两个分代假说之上:

        1、弱分代假说:绝大多数对象都是朝生夕灭的。

        2、强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

        这两个分代假说共同奠定了多长常用的垃圾收集器的一致设计元素:收集器应该将Java堆分为不同的区域,然后将回收对象依据其年龄分配到不同的区域之中存储。显而易见,如果一个区域中大多数对象都是朝生夕灭的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低的代价回收到大量的空间;如果剩下的都是难以消亡的对象,那么把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存空间有效利用。

        在Java堆划分出不同的区域后,垃圾收集器才可以每次回收其中某一个或某些部分的区域——因而才有了“Minor GC”、“Major GC”、“Full GC”这样的回收类型的划分;也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法——因此发展出了“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等针对性的垃圾收集算法。

        把分代收集理论具体放在现在的商用Java虚拟机中,一般至少会把Java堆分为新生代(Young Generation)和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都发现大批对象死去,而每次回收后存活的少量对象,将逐步晋升到老年代中存放。

        假如要现在进行一个只局限于新生代区域内的收集(Minor GC),但新生代中的对象有可能被老年代所引用,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,在额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样。遍历整个老年代所有对象的方案虽然理论上可以行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要堆分代收集理论添加第三条经验法则:跨代引用假说,跨代引用相对于同代引用仅占极少数。

        这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或同时灭亡的。例如,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随着被消除了。

        依据这条假说,我们就不应再为少量的跨代引用去扫描整个老年代,也不必浪费内存专门记录每一个对象是否存在以及存在哪些跨代引用,只需要在新生代上建立一个全局的数据结构(记忆集),这个结构把老年代分为若干个小块,标识出老年代的哪一块内存会存在跨代引用。后面发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

垃圾收集名词解释:

1 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,又分为:

        1)新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。

        2)老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。

2 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.2 标记-清除算法

        该算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收掉所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。主要有两个缺点:第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低;第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后再程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前出发另一次垃圾收集动作。标记-清除算法的执行过程如下图:

3.3 标记-复制算法

        将可用内存按容量划分为大小想等你的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块里面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开下,但是对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要推动堆项指针,按顺序分配即可。这样实现简单,运行高效。确定是对内存空间造成大量的浪费。执行过程下如图:

 

        现在商用Java虚拟机大多都都优先采用了这种收集算法回收新生代,IBM增专门研究对新生代“朝生夕灭”的特点做了量化解释——新生代中的对象有98%熬不过第一轮回收。因此实际上并不需要1:1的比例来划分新生代的内存空间。

        再次基础上,又提出一种更优化的半区复制分代策略,现在成为“appel式回收”。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。具体做法是把新生代分成一块较大的Eden空间和两个较小的Survivor空间,每次分配内存使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例式8:1,即每次新生代中可用内存空间为真个新生代容量的90%,只有一个Survivor空间,即10%的新生代会被浪费到。当然在特殊情况下,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多数是老年代)进行分配担保。如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入到老年代,这对虚拟机来说就是安全的。

3.4 标记-整理算法

        标记-复制算法在对象存活率较高时,就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费额外的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都是100%存活的极端情况。所以在老年代一般不能直接选用这种算法。

        针对老年代的存亡特征,提出“标记-整理”算法,其中标记过程与“标记-清除”一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存,“标记-整理”算法示意图如下:

        标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,后者是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险:

        如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼的权衡其弊端了,这样的停顿被最初的虚拟机设计者称为“stop the world”。

        但如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散与堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分散列表”来解决内存分配问题。内存的访问是用户程序最频繁的操作,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

        基于以上两点,是否移动对象都存在弊端,移动则内存回收时更加复杂,不移动则内存分配时更加复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,设置可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。

4 HotSpot算法细节实现

        上文从理论原理上介绍了常见的对象存活判定算法和垃圾收集算法,Java虚拟机实现这些算法时,必须堆算法的执行效率有严格的考量,才能保证虚拟机高效运行。

4.1 根节点枚举

        我们以可达性分析算法从GC Roots集合找引用链这个操作作为介绍虚拟机高效实现的第一个例子。固定可作为GC Roots的节点主要在全局性的引用(例如常量、类静态属性)与执行上下文(例如栈帧中的本地变量表)中,尽管目标明确,但查找过程要做到高效并非一件容易的事情,现在Java应用越做越庞大,光是方法区的大小就常有数百上千MB,里面的类、常量等更是越来越多,若要逐个检查以这里为起源的应用肯定会消耗不少时间。

        到目前为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,因此毫无疑问根节点枚举与之前体积的整理内存碎片一样会面临相似的“stop the world”的困扰。现在可达性分析算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但根节点枚举始终还是必须在一个能保证一致性的快照中才得以进行——这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,若这点不能满足,分析结果准确性也无法保证。这是导致垃圾收集过程必须停顿所有用于线程的其中一个重要原因。

        目前主流Java虚拟机使用的都是准确式垃圾收集,所以当用户线程停顿下来之后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机是有办法直接得到哪些地方存放着对象的引用。在HotSpot的解决方案里,是使用一组称为OopMap的数据结果来达到这个目的的。一旦类加载动作完成的时候,HopSpot就会把对象里哪个偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置是引用。这样收集器在扫描时就可以直接得知这些信息了,并不需要真正的一个不漏的从方法区等GC Roots开始查找了。

4.2 安全点

        在OopMap的协助下,HopSpot可以快速准确地完成GC Roots枚举,但一个很现实的问题:可能导致引用关系变化,或者说导致OopMap内容变化的指令非常多,如果为每一条指令都生成对应的OopMap,那将会需要大量的额外存储空间,这样垃圾收集伴随而来的空间成本就会变得十分高昂。

        实际上HotSpot也的确没有为每条指令都生成OopMap,只是在“特定的位置”记录了这些信息,这些位置被称为安全点。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。因此,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上都是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

        对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程(不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断和主动式中断,抢先式中断不需要线程的执行代码主动配合,如果发现用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现抢先式中断来暂停线程相应GC事件。

        而主动式中断的思想时当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点事重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

        由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程序。

4.3 安全区域

        使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于sleep状态或者blocked状态,这时候线程无法相应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待县城重新被激活分配处理器时间。对于这种情况,就必须引入安全区域来解决。

        安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。也可以把安全区域看作被扩展拉伸了的安全点。

        当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必管这些已经声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举,如果完成了,那线程就当做没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

4.4 记忆集与卡表

        前面说到分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了记忆集的数据结构,用来避免把整个老年代加进GC Roots扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有设计部分区域收集行为的垃圾收集器,都会面临相同的问题,因此有必要进一步理清记忆集的原理和实现方式。

        记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果不考虑效率和成本,最简单的视线可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗矿的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择的记录精度:

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

2、对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。

3、卡精度:每个记录精确到一块内存区域,该区域内有对象还有跨代指针。

        其中,第三种“卡精度”所指的是用一种称为“卡表”的方式去实现记忆集,这也是目前最常用的一种记忆集实现方式。前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。关于卡表与记忆集的关系,可以按照Java中hashmap与map的关系类比理解。

        卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的。一个卡页的内存通常包含不止一个对象,只要卡页内有一个或更多对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏,没有则标识为0.在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

4.5 写屏障

        目前已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

        卡表元素何时变脏的答案时明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

        在HotSpot虚拟机里是通过写屏障技术维护卡表状态的。写屏障可以看作是虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值钱的部分的写屏障叫做写前屏障,在赋值后的叫做写后屏障。HotSpot虚拟机的许多收集器中都使用到写屏障。

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

        除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”问题。伪共享时处理并发底层细节时一种经常需要考虑的问题,目前CPU的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

        假设CPU的缓存行大小是64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32kb(64*512字节),也就是说如果不同线程更新的对象正好处于这32kb的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方法是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记时才将其标记为变脏。在JDK 7以后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启后会增加一个额外判断的开销,是否打开要根据应用实际运行情况来进行测试权衡。

4.6 并发的可达性分析

        前面提到了当前主流编程语言的垃圾收集器基本上都是依靠可达性分析算法来判定对象是否存活的,可达性分析算法理论上要求过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。在根节点枚举这个步骤中,由于GC Roots相比起整个Java堆中全部的对象毕竟还算是极少数的,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经是非常短暂且相对固定(不随着堆容量而增长)了。可从GC Roots再继续往下遍历对象图,这一步骤的停顿时间就必定会与Java堆容量直接成正比:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长。

        要知道包含“标记”阶段是所有追踪式垃圾收集算法的共同特征,如果这个阶段随着堆变大而等比例增加停顿时间,其影响就会波及几乎所有的垃圾收集器,同理可知,如果能够减少这部分停顿时间的话,那收集也将会是系统性的。

        想解决或降低用户线程的停顿,就要先搞清楚为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?为了解释这个问题,引入三色标记作为工作来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:

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

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

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

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

geminigoth

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

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

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

打赏作者

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

抵扣说明:

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

余额充值