Java JVM的引用计数和可达性分析垃圾收集算法

·  阅读 236

「这是我参与11月更文挑战的第3天,活动详情查看:2021最后一次更文挑战」。

介绍了Java中的垃圾分析算法,包括引用计数法和可达性分析算法的原理!

1 垃圾收集概述

在C/C++语言中,没有自动垃圾回收机制,是通过new关键字申请内存资源,通过delete关键字释放内存资源。如果,程序员在某些位置没有写delete进行释放,那么申请的对象将一直占用内存资源,最终可能会导致内存溢出。

为了让程序员更专注于代码的实现,而不用过多的考虑内存释放的问题,所以,在Java语言中,有了自动的垃圾回收机制,也就是我们熟悉的GC。有了垃圾回收机制后,程序员只需要关心内存的申请即可,内存的释放由系统自动识别完成。换句话说,自动的垃圾回收的算法就会变得非常重要了,如果因为算法的不合理,导致内存资源一直没有释放,同样也可能会导致内存溢出的。当然,除了Java语言,C#、Python等语言也都有自动的垃圾回收机制。

不可能再被任何途径使用的对象,便可称之为垃圾,就可以被回收了。常见的垃圾分析算法有两种,一种是引用计数法,另一种是可达性分析算法。

2 引用计数算法

引用计数是最简单直接的一种方式,给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的,那么此对象就可以作为垃圾收集器的目标对象来收集。

  1. 优点:
    1. 简单,直接,不需要暂停整个应用。
  2. 缺点:
    1. 需要编译器的配合,编译器要生成特殊的指令来进行引用计数的操作;
    2. 不能处理循环引用的问题。比如对象 A 中有一个字段指向了对象 B ,而对象 B 中也有一个字段指向了对象 A,而事实上他们俩都不再使用,但计数器的值永远都不可能为 0 ,也就不会被回收,然后就发生了内存泄露。

如下案例:

public class ReferenceCountingGC {
    private Object instance;
    private static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        //objA 中有objB,objB中有objA
        objA.instance = objB;
        objB.instance = objA;
        //虽然objA 和objB 置空,但这是指将他们的引用置空,在堆内存中,这两个对象还是互相持有\依赖的,这就是循环引用。
        objA = null;
        objB = null;
    }

    public static void main(String[] args) {
        testGC();
    }
}
复制代码

3 可达性分析算法

现在,在主流的商用程序语言(Java、C#,甚至包括前面提到的古老的Lisp)的主流实现中,都是称通过可达性分析(Reachability Analysis)来判定对象是否存活的。

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的,即可被回收,又称为GC Roots Tracing算法。如下图所示,对象object5、object6、object7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

在这里插入图片描述

3.1 可以作为GC Roots对象种类

在Java语言中,可作为GC Roots的对象包括下面几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中类静态属性引用的对象。
  3. 方法区中常量引用的对象。
  4. 本地方法栈中JNI(即一般说的Native方法)引用的对象。

3.2 两次标记

为了增加垃圾收集的灵活性。实际上,一个到GC Roots没有任何引用链相连的对象有可能在某一个条件下“ 复活” 自己。对象的状态可以简单分成三类:

  1. 可达的: 从根节点开始, 可以到达这个对象。(实际上可触及对象也分为四种:)
  2. 可复活的: 对象的所有引用都被释放, 但是对象有可能在finalize()函数中复活。
  3. 不可触及的:对象对象没有覆盖finalize()方法或者finalize()函数已经被调用,并且没有复活,那么就会进入不可触及状态,不可触及的对象不可能被复活,因为finalize()函数只会被调用一次。

即使在一次可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

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

关于两次标记的Java代码源码,可以看这个文章:Java中的Finalizer类以及GC二次标记过程中的Java源码解析

案例演示:

/**
 1. 此代码演示了两点:
 2. 1.对象可以在被GC时自我拯救。
 3. 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 4.  5. @author zzm
 */
public class FinalizeEscapeGC {
    private static FinalizeEscapeGC SAVE_HOOK = null;

    private static void isAlive() {
        if (SAVE_HOOK != null) {
            System.out.println("yes,i am still alive:)");
        } else {
            System.out.println("no,i am dead :(");
        }
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize mehtod executed!");
        //持有引用,对象"复活"
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE_HOOK = new FinalizeEscapeGC();
        /*测试对象第一次成功拯救自己*/
        //首先引用置null,该对象没有其他引用.
        SAVE_HOOK = null;
        //然后尝试进行一次gc,此时会执行回收SAVE_HOOK类,但是会执行finalize方法,在finalize中对其进行复活
        System.gc();
        //因为finalize方法由低优先级的线程finalizer调用,优先级很低,所以暂停0.5秒以等待它执行
        Thread.sleep(500);
        //测试是否复活
        isAlive();

        /*下面这段代码与上面的完全相同,但是这次自救却失败了。*/
        SAVE_HOOK = null;
        //然后再次尝试进行一次gc,此时会执行回收SAVE_HOOK类,但是不会执行finalize方法,因为fiinalize已经执行过一次了,第二次不会执行,这次自救却失败了
        System.gc();
        //因为finalize方法由低优先级的线程finalizer调用,优先级很低,所以暂停0.5秒以等待它
        Thread.sleep(500);
        isAlive();
    }
}
复制代码

代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败,这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。

Java中只有构造函数并没有析构函数一说,这里finalize()方法看起来像是实现了析构函数,但这只是Java刚诞生时为了使C/C++程序员更容易接受它所做出的一个妥协。

  1. 由于调用的线程优先级很低,因此调用时间是不确定的,我们也无法主动调用。
  2. 改变了要被回收的对象的引用和生命周期,由于加入到队列中导致应该被回收的对象迟迟不被回收,造成内存泄漏。
  3. 某些流比如 InputStreamReader在关闭时,并不会关闭它的嵌套流,此时只能由finalize释放,但是如果开启的流很多,那么由于finalizer是单线程,可能造成释放速度小于加入队列速度,这时就会有大量的Finalizer堆积, 导致内存的异常。

现在,finalize()能做的所有工作,例如关闭外部资源等,使用try-finally或者其他方式都可以做得更好、更及时,因此finalize()方法不建议被使用。

析构函数(destructor): 与构造函数相反,当对象结束其生命周期,如对象所在的函数已调用完毕时,系统自动执行析构函数。

4 方法区/永久代的垃圾分析

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类。

回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,假如一个字符串“abc”已经进入了常量池中,但是当前系统没有任何String对象引用常量池中的“abc”常量,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个“abc”常量就会被系统清理出常量池。

但是判断一个类是否是“无用的类”却需要同时满足下面3个条件才能行:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被回收。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然会回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制,还可以使用-verbose:class以及-XX:+TraceClassLoading、-XX:+TraceClassUnLoading查看类加载和卸载信息,其中-verbose:class和-XX:+TraceClassLoading可以在Product版的虚拟机中使用,-XX:+TraceClassUnLoading参数需要FastDebug版的虚拟机支持。

在大量使用反射、动态代理, CGLib等Byt式ode框架、动态生成JSP 以及OSG,这类频繁(自定义ClassLoader的场景都需耍虔拟机具备类卸载的功能,以保证永久代不会溢出。

相关文章:

  1. 《深入理解Java虚拟机》
  2. 《Java虚拟机规范》

如有需要交流,或者文章有误,请直接留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!

分类:
后端
标签:
分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改