搬运工聊Java:SoftReference, WeakReference, PhantomReference及其应用举例

·  阅读 1718

前言

看来看去,借用《深入理解Java虚拟机》第三版第三章3.2 “对象已死?” 这一小节可以很自然的引出本文的主题,我猜你肯定不想去翻书,所以本搬运工先大致的复述一下书里的内容。(前言接下来部分大量参考《深入理解Java虚拟机》一书。)

对象的可达性分析算法

我们都知道堆的大小是有限的,为了内存资源得到有效利用,Java虚拟机需要适时地对堆内存进行清理,清理的目标便是那些被定义为已经“死去”的对象。怎么判定对象已经“死去”?书中给出了两种算法——

  • 引用计数算法
  • 可达性分析算法

我们重点来看下第二种,当前主流的商用程序语言(Java、C#,或上溯到古老的Lisp)的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的。它的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,即已经“死去”的对象,可被回收。(至于在Java体系里哪些对象可以被定性为GC Roots,感兴趣的话推荐看书,貌似也是一道常见的面试题来着)

为什么需要更丰富的引用类型

无论是使用引用计数算法还是可达性分析算法来判断对象是否存活,都和“引用”离不开关系。在JDK1.2之前,Java对引用的定义是这样的:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。在这种定义下,对象只会有两种状态:“被引用”和“未被引用”。但在实际的应用中,对象的状态往往并不是非此即彼的,譬如对于某类特殊的对象,我们是希望在系统内存充足的时候其能够存活,而在系统内存紧张的时候其能够得到清理以释放内存空间(虽然被引用但仍然能被回收)——很多系统的缓存功能都符合这样的应用场景。

于是乎,在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference,也有叫幻象引用的),这四种引用强度依次逐渐减弱。进而一个对象的状态也不再是简单的“被引用”和“未被引用”,而是扩充了三种新的可达性状态:软可达(softly reachable)、弱可达(weakly reachable)和虚可达(phantom reachable)。

对象的生命周期

在JDK1.2之前,一个对象的生命周期可以用下图简单表示,(图来自www.kdgregory.com/index.php?p… 这篇文章墙裂推荐,后续也会提到,我们暂时先放着) 而在JDK1.2之后,一个对象的状态定义被扩充了,其生命周期也有所改变,(图来自极客时间杨晓峰老师的专栏《Java核心技术面试精讲》第4讲) 细心的读者应该会发现上图出现了不少双向箭头,杨晓峰老师在专栏里是这样解释的:所有引用类型都是抽象类 java.lang.ref.Reference 的子类,其有一个get方法,除了虚引用(因为 get 永远返回 null),如果对象还没有被销毁,都可以通过该方法获取原有对象。这意味着,利用软引用和弱引用,我们可以将访问到的对象,重新指向强引用,也就是人为的改变了对象的可达性状态。所以,对于软引用、弱引用之类,垃圾收集器可能会存在二次确认的问题,以保证处于弱引用状态的对象,没有改变为强引用。

Java的引用类型

概念的东西虽然很虚,但还是要过一遍的。

强引用

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

软引用

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

弱引用

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

虚引用

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

引用队列

谈到各种引用的编程,就必然要提到引用队列。我们在创建各种引用并关联到相应对象时,可以选择是否需要关联引用队列,JVM 会在特定时机将引用 enqueue 到队列里,我们可以从队列里获取引用。尤其是幻象引用,get 方法只返回 null,如果再不指定引用队列,基本就没有意义了。其具体用法会在下面的示例代码中再进行说明,先不着急哈。

不同引用类型的应用场景

前面绕了一大圈其实都是在讲清楚Java引用类型是什么的问题,这些概念性的知识看起来很虚,但还是不得不进行介绍,因为这些概念串起来正好帮助我们理清Java引用类型这一块知识的脉络,所以还是希望你能把前面的内容完整过一遍,其实内容也不多嘛。

接下来,我们来弄清楚Java引用类型应该怎么用,以及它们为什么这样用。接下来的内容大量翻译自www.kdgregory.com/index.php?p… 一文,文章的作者是个从上世纪80年代活跃至今的上古程序员,我跟我的朋友们给他起了个昵称“程序员爷爷”,我是更推荐大家直接去阅读这篇文章的,虽然文章本身也是比较上年纪了,但是文中“程序员爷爷”对原理和应用讲的非常清晰。当然如果你实在不想看英文,那就看下面我给你专门翻译的精yan华ge版吧。

软引用的应用:断路器(Circuit Breaker)

利用软引用可以给程序的内存分配提供一种“断路器”机制,这可以帮助程序在很大程度上避免OutOfMemoryError.举个栗子,下面这段JDBC代码,逻辑是查询DB的多行数据,仔细观察这段代码,可能会有什么问题:往比较极端的情况想,如果查询到的数据有一百万行,但你的系统的可用内存资源已经不足以装得下这一百万行数据,此时程序肯定就抛错误了。

public static List<List<Object>> processResults(ResultSet rslt) throws SQLException {
    try {
        List<List<Object>> results = new LinkedList<List<Object>>();
        ResultSetMetaData meta = rslt.getMetaData();
        int colCount = meta.getColumnCount();

        while (rslt.next()) { // 发生内存分配时机 1
            List<Object> row = new ArrayList<Object>(colCount);
            for (int ii = 1 ; ii <= colCount ; ii++)
                row.add(rslt.getObject(ii)); // 发生内存分配时机 2

            results.add(row);
        }

        return results;
    } finally {
        closeQuietly(rslt);
    }
}
复制代码

这个时候软引用的价值就体现出来了:如果在查询数据期间JVM已经耗尽了内存,那么被软引用指向的对象的内存将得到释放,同时在业务线程上我们可以抛出自定义异常以便我们进行程序的后续处理。它是怎么做到的呢?请看代码:

// 用一个软引用指向最终要返回的查询结果集,即对应上面代码的results
SoftReference<List<List<Object>>> ref
        = new SoftReference<List<List<Object>>>(new LinkedList<List<Object>>());
        
// ...省略了与上面代码重复的逻辑

while (rslt.next())
{
    rowCount++;
    // store the row data

    List<List<Object>> results = ref.get(); // 在迭代过程中,仅当你需要的时候再去拿到对最终要返回的查询结果集的强引用
    if (results == null) // 为null说明软引用指向的对象被GC标记清理,可以选择抛出自定义异常,业务线程可以根据自定义异常类型执行相应处理,比如限制查询条数等
        throw new TooManyResultsException(rowCount);
    else
        results.add(row);

    results = null;
}
复制代码

这里有两处细节可以留意一下:

一个是用了LinkedList去接结果数据而不是ArrayList,这是因为返回数据量很多时ArrayList扩容过程中可能造成较多的冗余内存分配;

另外一个是在每一步迭代的结尾,都会将results重新置为null。这是因为虽然每一步迭代结束results变量已经跑出了它的作用域(原意是“go out of scope”,我实在想不到更好的翻译),但是JVM没有任何理由需要去清理掉这个位于栈内存中的局部变量引用,所以如果没有手动将results赋值为null,那么在接下去的迭代过程中将出现一个并不希望存在的潜在强引用。

弱引用的应用:ThreadLocal的ThreadLocalMap实现

关于弱引用的应用,“程序员爷爷”在文章中是举了两个栗子:一种应用是关联两个没有任何内在关联关系的对象,另一种是借助构建一种规范化映射(canonicalizing map)来减少副本的产生。初看你肯定不知道这是啥,但我也不打算详细介绍这两个东西了,感兴趣的话推荐你看下“程序员爷爷”的文章。我们来看另外一个我们耳熟闻详的例子,ThreadLocal。

因为本文并不是专门介绍ThreadLocal的(后面有机会单开一篇也做个搬运总结==),所以我就直接放ThreadLocal的内部实现图了,如下所示(图来自掘金用户“卡巴拉的树”童鞋的文章 juejin.cn/post/684490… 反正写的比我好,推荐推荐) 相信眼尖的人已经注意到上图中Entry的key与ThreadLocal的连线是一条虚线,没错,这条虚线就是弱引用,让我们来翻下ThreadLocal的源码,我截取几处有助于理解上图的放下面了,

// ThreadLocal.java
static class ThreadLocalMap {

        /**
         * The entries in this hash map extend WeakReference, using
         * its main ref field as the key (which is always a
         * ThreadLocal object).  Note that null keys (i.e. entry.get()
         * == null) mean that the key is no longer referenced, so the
         * entry can be expunged from table.  Such entries are referred to
         * as "stale entries" in the code that follows.
         */
        static class Entry extends WeakReference<ThreadLocal<?>> {
            /** The value associated with this ThreadLocal. */
            Object value;

            Entry(ThreadLocal<?> k, Object v) {
                super(k);
                value = v;
            }
        }

        /**
         * The table, resized as necessary.
         * table.length MUST always be a power of two.
         */
        private Entry[] table;
        
        ......
}
复制代码
// Thread.java
......
/* ThreadLocal values pertaining to this thread. This map is maintained
* by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;
......
复制代码

那么ThreadLocalMap.Entry的key(即ThreadLocal对象)为什么要使用弱引用来指向呢?我们不妨考虑这样一种情况,我们在使用ThreadLocal的过程中肯定会产生对堆中ThreadLocal对象的引用,比如上图中stack中有一个对heap中ThreadLocal对象的引用ThreadLocal Ref,假如我们在使用完ThredLocal之后手动将ThreadLocal Ref置为null并且后续不会再使用到该ThreadLocal对象,那么ThreadLocal Ref到堆中ThreadLocal的连线(这是一条强引用)将被清除,如果这个时候Entry的key指向ThreadLocal对象的也是一条强引用,那么该ThreadLocal对象将不会被GC回收,这就产生了 memory leak。站在框架设计者的角度来看,自然是希望可以避免这种由用户可能执行的操作导致的memory leak问题,所以就将Entry到ThreadLocal的引用弄成弱引用,这样就解决了问题。

不过值得注意的是,ThreadLocalMap.Entry到ThreadLocal的这条弱引用也带来了另外的memory leak问题,感兴趣的同学推荐看看“卡巴拉的树”童鞋的文章。

虚引用的应用:实现一个数据库连接池

终于轮到最后一种引用类型了,“程序员爷爷”举的栗子是利用虚引用来编写一个数据库连接池,其应该具备的一个优点应该是能够有效的避免连接资源泄露,同时能够对连接资源进行回收。让我们来看看“程序员爷爷”是怎么实现的:

// 这个类可以不用怎么看,我只是为了展示代码的完整性所以给了出来。
// 不过有一点值得注意,用户使用该连接池时业务线程拿到的连接对象正是这个PooledConnection对象,而不是真正的Connection对象
public class PooledConnection implements InvocationHandler {

    private ConnectionPool _pool;
    private Connection _cxt;

    public PooledConnection(ConnectionPool pool, Connection cxt) {
        _pool = pool;
        _cxt = cxt;
    }

    private Connection getConnection() {
        try {
            if ((_cxt == null) || _cxt.isClosed()) {
                throw new RuntimeException("Connection is closed");
            }
        } catch (SQLException ex) {
            throw new RuntimeException("unable to determine if underlying connection is open", ex);
        }

        return _cxt;
    }

    public static Connection newInstance(ConnectionPool pool, Connection cxt)
    {
        return (Connection) Proxy.newProxyInstance(
                PooledConnection.class.getClassLoader(),
                new Class[] { Connection.class },
                new PooledConnection(pool, cxt));
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        // if calling close() or isClosed(), invoke our implementation
        // otherwise, invoke the passed method on the delegate
        return null;
    }

    private void close() throws SQLException {
        if (_cxt != null) {
            _pool.releaseConnection(_cxt);
            _cxt = null;
        }
    }

    private boolean isClosed() throws SQLException {
        return (_cxt == null) || (_cxt.isClosed());
    }
}
复制代码
// 重点看下这个类的实现,关注两个 releaseConnection() 方法
public class ConnectionPool {

    private Queue<Connection> _pool = new LinkedList<Connection>();

	// 创建一个引用队列
    private ReferenceQueue<Object> _refQueue = new ReferenceQueue<Object>();

    private IdentityHashMap<Object,Connection> _ref2Cxt = new IdentityHashMap<Object,Connection>();
    private IdentityHashMap<Connection,Object> _cxt2Ref = new IdentityHashMap<Connection,Object>();

    public Connection getConnection() throws SQLException {
        while (true) {
            // 拿不到池中连接的话就一直自旋
            synchronized (this) {
                if (_pool.size() > 0) {
                    return wrapConnection(_pool.remove());
                }
            }
            // 顾名思义,等待GC清理资源后执行相应操作
            tryWaitingForGarbageCollector();
        }
    }

    private void tryWaitingForGarbageCollector() {
        try {
            Reference<?> ref = _refQueue.remove(100);
            // 如果引用队列中能够拿到引用,证明连接对象被GC回收,此时应该对连接池执行相应的清理逻辑
            if (ref != null) {
                releaseConnection(ref);
            }
        } catch (InterruptedException ignored) {
            // we have to catch this exception, but it provides no information here
            // a production-quality pool might use it as part of an orderly shutdown
        }
    }
	
    private synchronized Connection wrapConnection(Connection cxt) {
        Connection wrapped = PooledConnection.newInstance(this, cxt);
        // 由于虚引用的get()方法返回永远是null,所以虚引用通常需要搭配引用队列一起使用,这里就将虚引用与一个引用队列绑定在一起
        PhantomReference<Connection> ref = new PhantomReference<Connection>(wrapped, _refQueue);
        _cxt2Ref.put(cxt, ref);
        _ref2Cxt.put(ref, cxt);
        System.err.println("Acquired connection " + cxt );
        return wrapped;
    }

    // 这个是使用者主动释放连接时调用的方法
    synchronized void releaseConnection(Connection cxt) {
        Object ref = _cxt2Ref.remove(cxt);
        _ref2Cxt.remove(ref);
        _pool.offer(cxt);
        System.err.println("Released connection " + cxt);
    }

    // 这个是当使用者忘记主动释放连接时连接池本身被动的释放连接的方法,应该结合getConnection()和tryWaitingForGarbageCollector()一起看
    private synchronized void releaseConnection(Reference<?> ref) {
        Connection cxt = _ref2Cxt.remove(ref);
        if (cxt != null) {
            releaseConnection(cxt);
        }
    }

}
复制代码

代码大致上的逻辑我在注释中都给出来了,相信应该多多少少能帮到你理解“程序员爷爷”的连接池为什么这样子设计,其实本质上就是围绕着虚引用的一个特性:你不能通过它访问对象,但是它提供了一种确保对象被 finalize 以后,做某些事情的机制。 实践中应用虚引用的例子其实还有不少,例如apache.commons.io.FileCleaningTracker类和JDK提供的Cleaner机制(常被用于清理堆外内存)等,感兴趣的同学可以google一下,本搬运工就不再介绍了,毕竟这篇文章已经蛮长的了🤣

后记

有关Java引用类型的知识搬运工作就到这里为止了,感谢有人能坚持看到这里😂给一下搬运过程中参考到的资源,觉得有帮助的话给个赞呗~

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