JVM之对象收集器

starlin 281 2018-09-01

垃圾收集器在回收java对象时,是如何判断该对象需要回收的了,即怎么样判断那些对象实例已经“死去”(即不可能在被使用的对象),那些对象还是“存活”着?
带这这个疑问,今天一起来看看Java的垃圾收集器是如何来进行回收的,接下来就是今天要介绍的可达性分析算法

可达性分析算法

在主流的程序语言中,都是通过可达性分析算法来判断对象是否存活的,这个算法的基本思路是通过一系列的称为“GC Root”的对象作为起点,从这些节点开始像下搜索,搜索所走过的路径称为引用链,当一个对象到GC Root没有任何引用链相连时,则证明此对象时不可用的,如下图所示:


可达性分析算法示意图

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

  • 所有被同步锁持有的对象,比如被 synchronize 持有的对象
  • 虚拟机栈中的引用对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象

引用计数算法

上面说的了可达性分析算法,顺便提一下判断另一个对象是否死亡的另外一种算法,即引用计数算法,它属于垃圾收集器最早的实现方法,它是指创建对象时关联一个与之相对应的计数器,当此对象被使用时加 1,相反销毁时 -1。当此计数器为 0 时,则表示此对象未使用,可以被垃圾收集器回收。

引用计数算法的优缺点很明显,其优点是垃圾回收比较及时,实时性比较高,只要对象计数器为 0,则可以直接进行回收操作;
而缺点是无法解决循环引用的问题,如下代码:

class CustomOne {
    private CustomTwo two;
    public CustomTwo getCustomTwo() {
        return two;
    }
    public void setCustomTwo(CustomTwo two) {
        this.two = two;
    }
}
class CustomTwo {
    private CustomOne one;
    public CustomOne getCustomOne() {
        return one;
    }
    public void setCustomOne(CustomOne one) {
        this.one = one;
    }
}
public class RefCountingTest {
    public static void main(String[] args) {
        CustomOne one = new CustomOne();
        CustomTwo two = new CustomTwo();
        one.setCustomTwo(two);
        two.setCustomOne(one);
        one = null;
        two = null;
    }
}

即使 one 和 two 都为 null,但因为循环引用的问题,两个对象都不能被垃圾收集器所回收

垃圾回收的算法

常见的垃圾回收算法有一下几个:

  • 标记-清除算法
  • 标记-复制算法
  • 标记-整理算法

标记-清除(Mark-Sweep)算法属于最早的垃圾回收算法,它是由标记阶段和清除阶段构成的。
标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。而标记的判断方法就是前面讲的引用计数算法和可达性分析算法。
标记-清除算法的执行流程如下图所示:
![标记-清除](https://raw.githubusercontent.com/smartlin/pic/main/_posts/jvm/jvm%E4%B9%8B%E5%88%A4%E6%96%AD%E5%AF%B9%E8%B1%A1%E6%98%AF%E5%90%A6%E5%AD%98%E6%B4%BB.md/%E6%A0%87%E8%AE%B0-%E6%B8%85%E9%99%A4%E7%AE%97%E6%B3%95.jpg =888x)

从上图可以看出,标记-清除算法有一个最大的问题就是会产生内存空间的碎片化问题,
也就是说标记-清除算法执行完成之后会产生大量的不连续内存,这样当程序需要分配一个大对象时,因为没有足够的连续内存而导致需要提前触发一次垃圾回收动作。

标记-复制算法是标记-清除算法的一个升级,使用它可以有效地解决内存碎片化的问题。它是指将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。这样就不会产生内存碎片的问题了,其执行流程如下图所示:
![标记-复制算法](https://raw.githubusercontent.com/smartlin/pic/main/_posts/jvm/jvm%E4%B9%8B%E5%88%A4%E6%96%AD%E5%AF%B9%E8%B1%A1%E6%98%AF%E5%90%A6%E5%AD%98%E6%B4%BB.md/%E6%A0%87%E8%AE%B0-%E5%A4%8D%E5%88%B6%E7%AE%97%E6%B3%95.jpg =888x)

标记-复制的算法虽然可以解决内存碎片的问题,但同时也带来了新的问题。因为需要将内存分为大小相同的两块内存,那么内存的实际可用量其实只有原来的一半,这样此算法导致了内存的可用率大幅降低了。

标记-整理算法的诞生晚于标记-清除算法和标记-复制算法,它也是由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,标记-整理算法的后一个阶段不是直接对内存进行清除,而是把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除,执行流程图如下图所示:
![标记-整理算法](https://raw.githubusercontent.com/smartlin/pic/main/_posts/jvm/jvm%E4%B9%8B%E5%88%A4%E6%96%AD%E5%AF%B9%E8%B1%A1%E6%98%AF%E5%90%A6%E5%AD%98%E6%B4%BB.md/%E6%A0%87%E8%AE%B0-%E6%95%B4%E7%90%86%E7%AE%97%E6%B3%95-1.jpg =888x)

引用

Java中的引用可以分为4种:强引用、软引用、弱引用、虚引用,这4种引用强度依次逐渐减弱。

  1. 强引用是指程序代码之中普遍存在的,只要强引用还存在,垃圾收集器永远不会回收被引用的对象
    特点:我们平常典型编码Object obj = new Object()中的obj就是强引用。通过关键字new创建的对象所关联的引用就是强引用。 当JVM内存空间不足,JVM宁愿抛出OutOfMemoryError运行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。

  2. 软引用是指一类还有用但非必须得对象,对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常,在JDK中提供了SoftReference类来实现软引用
    特点:软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。只有当 JVM 认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。后续,我们可以调用ReferenceQueue的poll()方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个null,否则该方法返回队列中前面的一个Reference对象。
    应用场景:软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

  3. 弱引用也是用来描述非必须对象的,但是它的强度比软引用要弱一些,被弱引用关联的对象只能活到下一次垃圾收集发生之前,当垃圾收集器开始工作时,无法内存是否足够,都会被回收掉,JDK提供了WeakReference类来实现弱引用
    特点:弱引用通过WeakReference类实现。 弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

应用场景:弱应用同样可用于内存敏感的缓存

  1. 虚引用也称为幽灵引用或幻影引用,它是最弱的一种引用关系,一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用唯一的目的就是在被垃圾收集器回收时收到一个系统通知,JDK提供了PhantomReference类来实现虚引用
    特点:虚引用也叫幻象引用,通过PhantomReference类来实现。无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
    ReferenceQueue queue = new ReferenceQueue ();
    PhantomReference pr = new PhantomReference (object, queue);
    程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取一些程序行动。

应用场景:可用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。

这里有一个不恰当的比喻,方便记忆:

强引用就像大老婆,关系很稳固。 
软引用就像二老婆,随时有失宠的可能,但也有扶正的可能。 
弱引用就像情人,关系不稳定,可能跟别人跑了。 
需引用(幻像引用)就是梦中情人,只在梦里出现过。

生存or死亡

上面提到了通过可达性分析算法,某个对象不可达,那个这个对象就会被回收掉,其实这个时候仅仅是处于“缓刑”阶段,并为真正的被处死,该对象还有一次“上诉”的机会,即还能完成一次自我救赎
要真正宣告一个对象死亡,至少要经历两次标记过程,如果一个对象没有与GC Roots相连接的引用链,那它将会进行第一次标记并筛选,筛选的条件就是此对象是否有必要执行finalize()方法
当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过了,虚拟机将这两种情况都视为“没有必要执行”

如果这个对象有必要执行finalize()方法,那么这个对象会暂时放置在一个叫“F-Queue”的队列之中。finalize()方法时对象逃脱死亡的最后一次机会,收到GC将对F-Queue中的对象进行第二次标记,如果对象要在finalize()中成功解救自己,主要重新与引用链上的任何一个对象建立关联即可,那么在进行第二次标记的时候就会被移出“即将回收”集合,如果这个时候还未逃离,那基本它就是真的回收了

下面通过代码演示一个对象自我救赎的过程:

public class FinalizeEscapeGC {
    private 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 menthod executed");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

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

        // 对象第一次拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为finalize方法优先级很低,所有暂停以等待
        Thread.sleep(1000);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead");
        }

        // 下面的代码与上面完全相同,但是这次却拯救失败了
        SAVE_HOOK = null;
        System.gc();
        Thread.sleep(1000);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no,i am dead");
        }
    }
}

运行结果:

finalize menthod executed
yes,i am still alive
no,i am dead

从上面的结果可以看出,SAVE_HOOK对象的finalize()方法确实被GC过,并且在收集前成功逃脱过一次
另外值得注意的是,程序中有有一段代码是完全重复的,执行结果是一次成功,一次失败,这也验证了之前所说的finalize()方法只能被虚拟机自动执行一次,对象在面临下一次回收,finalize()方法是不会在执行的,因此第二段代码自救失败

回收方法区

很多人认为方法区是没有垃圾收集的,Java虚拟机规范中确实说过可以不要求虚拟机在方法区实现垃圾收集,而且在方法区实行垃圾收集的性价比一般比较低。在堆中,尤其是在新生代中,常规应用进行一次垃圾回收一般可以回收70%--90%的空间,而永久代的垃圾收集效率远低于此

永久代的垃圾回收主要是收集废弃常量和无用的类,常量的回收很简单,而要判定一个类是否是无用的类的条件苛刻的多,需要同时满足以下3点才能算是“无用的类”:

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

虚拟就可以对满足上面3个条件的类进行回收,这里仅仅说的是“可以”,而并不是和对象一样,不使用了就必然回收。是否对类进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGi这类频繁定义ClassLoader的场景都需要虚拟机具备类卸载功能,以保证永久代不会溢出

参考

深入理解Java虚拟机


# JVM