JVM高级特性与实践(二):对象存活判定算法(引用) 与 回收

关于垃圾回收器GC(Garbage Collection),多数人意味它是Java语言的伴生产物。事实上,GC的历史远比Java悠远,于1960年诞生在MIT的Lisp是第一门真正使用内存动态分配和垃圾收集技术的语言。当Lisp尚在胚胎时期,开发人员就在思考GC需要完成的3件事情:

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

目前GC早已解决了以上问题,内存的动态分配与内存回收机制已经相当成熟,一切似乎“自动化”起来。而开发人员仍旧需要了解GC和内存分配等底层知识,因为在排查各种内存溢出、内存泄漏问题、垃圾收集成为系统达到更高并发量的瓶颈时,开发人员需要对这些“自动化”技术实施必要的监控和调节。

在上一篇博文中介绍了Java内存运行时区域的各个部分,其中

  • 程序计数器虚拟机栈本地方法栈 3个区域随着线程而生,也随线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配的内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多考虑回收问题,因为方法结束或线程结束时,内存自然跟随着回收了。

  • Java堆方法区则不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾回收器关注的是这部分内存,后续讨论的“内存”分配回收也是指这一块,尤其需要注意。

JVM高级特性与实践(一):Java内存区域与内存溢出异常


一. 对象是否已死

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

1. 引用计数算法(Reference Counting)

(1)算法含义

很多教科书判断对象是否存活的算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器加1;当引用失效时,计数器减1;任何时刻计数器为0的对象就是不可能再被使用的。

(2)算法效率分析

我相信大部分人对这个算法并不陌生,客观地说,引用计数算法的实现简单,判定效率也很高,大部分情况下是一个不错的算法。例如微软公司的COM(Component Object Model)技术、使用ActionScript3的FlashPlayer、Python语言都是用了该算法进行内存管理。但是Java虚拟机中并没有引用该算法来管理内存,最主要的原因是它很难解决对象之间互相循环引用的问题。

(3)举例证明

举个例子来证明,以下代码中的 testGC() 方法:对象objA 和对象objB都有字段instance,赋值令objA.instance = objB; 、objB.instance = objA;除此之外,这两个对象再无引用,实际上这两个对象不可能再被访问,但是他们互相引用着对方,导致它们引用计数不为0,所以引用计数器无法通知GC收集器回收它们。

【引用计数器的缺陷】

/**
 * testGC()方法执行后,objA和objB会不会被GC呢? 
 */
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();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29

运行结果:

[Full GC (System)  [Tenured: 0k->210K(10240K),  0.0149142 secs] 4603K->210K(19456K), 
[Perm : 2999K->2999K(21248K)], 0.0150007 secs]  [Times: user=0.01 sys=0.00, real=0.02 secs]
  • 1
  • 2
  • 1
  • 2

结果分析:

从运行结果可发现,GC日志中包含“4603K->210K”,意味着虚拟机并没有因为这两个对象互相引用就不回收它们,这也从侧面说明虚拟机并不是通过引用计数算法来判断对象是否存活的。



2 . 可达性分析算法(Reachability Analysis)

(1)算法含义

在主流的商用程序语言(JavaC#、甚至是最古老的Lisp)的实现中,都是通过可达性分析来判定对象是否存活的。此算法的基本思路是:通过一系列的称为“GC Roots”的对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是GC Roots 到这个对象不可达)时,则证明此对象时不可用的。

(2)图解说明

如下图举例所示,对象object5、object6、object7虽然互相有关联,但是它们到GCRoots是不可达的,所以它们将会被判定为可回收对象。

这里写图片描述

(3)Java中可作为GCRoots的对象

在Java中,可作为GCRoots的对象包括以下几种:

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


3. 再谈引用(强、软、弱、虚引用)

(1)“引用”旧概念

无论时通过计数算法判断对象的引用数量,还是通过可达性分析算法判断对象的引用链是否可达,判定对象是否存活都与“引用”有关。在JDK1.2以前,Java中的引用定义很传统:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。

这种定义很纯粹但过于狭隘,一个对象中在这种定义下只有被引用或者没有被引用两种状态,缺少另外一类对象的描述:当内存空间足够时,能保留在内存中;如果内存空间在进行垃圾收集后还是非常紧张,则可以抛弃这些对象。很多系统的缓存功能都符合这样的应用场景。

(2)“引用”新概念

在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)4种,引用强度依次逐渐减弱。4种引用解释如下:

  • 强引用:就是指在程序中普遍存在的,类似Obejct obj = new Object() 这种引用。只要强引用还存在,垃圾收集器永远不会回收掉被引用的对象。

  • 软引用:用来描述一些还有用但并非必需的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围进行第二次回收。如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK1.2之后,提供了SoftReference类来实现软引用。

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

  • 虚引用:也称为幽灵引用或幻影引用(好炫的称号hhh),它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。



4 . 生存还是死亡

to be or not to be, this is a question.

(1)宣判对象“死亡”的过程

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

  • 第一次标记:如果对象在进行可达性分析后发现没有与 GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finaliza() 方法。
  • 第二次标记:当对象没有覆盖finaliza() 方法,或者finaliza() 方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

(2)finaliza()方法 —– 对象逃脱“死亡”的最后机会

如果这个对象被判定为有必要执行finaliza() 方法,那么此对象将会放置在一个叫做 F-Queue 的队列中,并在稍后由一个虚拟机自动建立的、低优先级的Finalizer线程去执行它。这里所谓的“执行”是指虚拟机会触发此方法,但并不承诺会等待它运行结束,原因是:如果一个对象在finaliza() 方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能导致F-Queue 队列中的其它对象永久处于等待,甚至导致整个内存回收系统崩溃。

finaliza() 方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue 队列中的对象进行第二次小规模的标记。如果对象想在finaliza() 方法中成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,例如把自己(this关键字)赋值给某个类变量或者对象的成员变量,这样在第二次标记时它将被移出“即将回收”的集合;如果对象这时候还没有逃脱,基本上它就被回收了。

(3)实例证明对象的自救

通过以下代码展示一个对象的finaliza()被执行,但是它仍然可以存活的例子:

【一次对象自我拯救的演示】
/**
 * 此代码演示了两点: 
 * 1.对象可以在被GC时自我拯救。 
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次
 * @author zzm
 */
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 mehtod executed!");
        FinalizeEscapeGC.SAVE_HOOK = this;
    }

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

        //对象第一次成功拯救自己
        SAVE_HOOK = null;
        System.gc();
        // 因为Finalizer方法优先级很低,暂停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();
        // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
        Thread.sleep(500);
        if (SAVE_HOOK != null) {
            SAVE_HOOK.isAlive();
        } else {
            System.out.println("no, i am dead :(");
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48

运行结果:

finalize method executed!
yes, i am still alive :)
no, i am dead :(
  • 1
  • 2
  • 3
  • 1
  • 2
  • 3

结果分析:

由以上结果可知,SAVE_HOOK 对象的finalize() 方法确实被GC收集器触发过,并且在收集前成功逃脱了。

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

(4)有关finaliza()方法的建议

需要特别说的是,上面关于对象死亡时finalize() 方法的描述具有悲情色彩,作者并不建议开发人员使用这种方法拯救对象。应当尽量避免使用它,因为它不是C/C++中的析构函数,而是Java刚诞生时为了使C/C++程序员更容易接受它所做的一个妥协。它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序。

有些教材中描述它适合做“关闭外部资源”之类的工作,这完全是对此方法用途的一种自我安慰。finalize() 能做的工作,使用try-finally 或者其它方法都更适合、及时,所以作者建议大家可以忘掉此方法存在。



5 . 回收方法区

(1)垃圾收集

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

方法区的垃圾收集主要回收两部分:废弃常量无用类。

(2)“废弃常量”的回收

回收废弃常量与回收Java堆中的对象非常类似。以常量池中字面量的回收为例,例如一个字符串“abc”已经进入常量池,但是无任何String对象引用常量池的此常量,也无其它引用此字面量,此时发送内存回收,“abc”常量会被系统清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也是如此。

(3)“无用类”回收的条件

判定一个常量是否是“废弃常量”比较简单,而判定一个类是否是“无用类”的条件较为苛刻,需同时满足以下3个条件:

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

虚拟机可以满足以上3个条件的无用类进行回收,这里仅说“可以”,并非如同“对象”不使用了就必然回收。

(4)注意

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



这一章有关内容学习下来真是收货颇丰,对象存活判定的两种算法、引用的概念和方法区回收的判定,特别是两种算法,面试中经常涉及,读者需注意理解学习。

若有错误,欢迎指教~

posted @ 2017-07-15 16:30  babyyage  阅读(248)  评论(0编辑  收藏  举报