程序员

Java 引用对象

2018-10-16  本文已影响129人  wean_a23e

这篇文章整理自一位外国大神的英文博客,我在保存文章的结构下,增加了一些自己的见解,并做了一个文章的脑图。原文链接为 http://www.kdgregory.com/index.php?page=java.refobj

脑图如下


基础

Java堆和对象生命周期

在 Java 中,随着函数的调用,局部变量和函数会被压入栈帧,而 new 操作符生成出来的实际对象保存在堆中,当然,如果这时堆中没有合适足够的空间生成新对象,在报出 OutOfMemoryError之前,就会尝试进行一次垃圾收集来获取空间。

垃圾收集

Java 语言给我们提供了 new 操作符来在堆中分配一块内存,但是却没有给我们提供一个delete操作符来释放这些空间,如果仅仅是这样,那么我们的堆内存空间很快就会占满,程序久无法继续执行了。

幸运的是 Java 给我们提供了垃圾收集器。在我们 new 一个对象时,如果堆内存空间不足,调用 new 操作符的线程就会被挂起,等待垃圾收集器扫描一遍堆内存并释放空间,如果收集后仍然没有足够的空间,就会报出 OOM 了。

标记-清除算法

标记清除算法可以概括为:所有不可达的对象都是垃圾,并且可以被收集清除。

标记清除算法有如下步骤:

步骤一:标记

垃圾收集器从根引用开始,遍历对象关系图并把遍历过的对象标记为可达对象。

步骤二:清除

所有在步骤一中没有被标记到的对象,如果有定义 finalizer,则会被加入到 finalization queue 中执行 finalize 方法,否则会被清除。

步骤三:压缩(可选)

有些垃圾收集器会有第三个步骤:压缩整理堆内存,即把第二步执行结束后,零碎的堆内存重新对齐,整理出大片的连续堆内存空间。

比如,在1.6 和 1.7 server 模式下的 Hotspot JVM,就会将年轻代的空间压缩整理,但是不会压缩整理老年代的空间。

Finalizer

虽然 Java 替我们提供了垃圾收集机制去释放堆内存,但是内存不是我们唯一要清理的资源。比如,FileOutputStream 在不可达之后,被垃圾收集器收集之前,应该释放它关联的文件和系统的连接、把缓冲区的数据刷入文件等。为此,Java 为我们提供了 Finalizer 机制,我们只要实现 finalize() 方法即可。

虽然 Finalizer 看起来简单好用,但是我们不应该依赖它,因为如果垃圾收集一直不执行,那么它将一直不被调用。如果有过多的 Finalizer 拖住了内存空间的清理,那么也可能导致不能及时释放出足够的空间而出现 OOM。

Java 对象生命周期(没有 Reference 的情况下)

创建---->构造---->使用---->不可达---->Finalizer

引用关系

Reference 对象

三个生命周期中的新状态

从 JDK 1.2 起,Java 提供了三个新的状态在 Java 生命周期中:分别是软可达(softly-reachable)、弱可达(weakly-reachable)、虚幻可达(phantom-reachable)。这些状态都是应用在对象满足垃圾收集状态时,换句话说,就是这个对象已经没有任何强引用了。

这里需要注意的两个点是:

引用关系和被引用对象

Reference 引用关系是我们程序和具体对象之间的一个中间层,其中被引用的对象是在 Reference 构造时指定的,并且不可修改。下面是一个例子:

SoftReference<List<Foo>> ref = new SoftRerence<>(new LinkedList<Foo>);

List<Foo> list = ref.get();
if (list != null){
    list.add(foo);
}else {
    // somthing else
}

其中要注意的点是:

  1. 每次使用对象前必须确认对象是否已经被清理(null)
  2. 必须先拿到对象的强引用再使用对象。不然直接使用 ref.get().add(foo),如果这时在执行到 ref.get() 时触发了一次垃圾收集,将会报 NPE。
  3. 比如给这个 Reference 指定一个强引用。如果这个引用关系被垃圾收集清理了。那我们讲这么多都没用了……

软引用

在 JDK 文档中讲了,软引用关系适合用于内存敏感的缓存:每个被缓存的对象通过一个 SoftReference 连接,然后 JVM 会在不需要这部分被引用对象的空间时,不去清理它,在内存空间不足时再清理。也因此,对于正在使用的缓存对象,我们应该加上一个强引用指向被引用对象,防止它被清理。当然,如果要使用的对象已经被清理了,我们就刷新一下缓存再加它进去即可。

需要注意的是,不建议缓存很小的对象,应该缓存大文件、大对象、层层嵌套的对象图的根对象之类的。因为,如果缓存小文件,那么需要清理很多很多对象才能释放出看起来有起色的内存空间,并且这个引用关系也会占用很多空间。

使用软引用来触发循环的终止

这时软引用的一种典型的用途,可以在循环继续运行时会触发 OOM 的情况下,终止循环,避免 OOM 的出现。

来看下面一段代码:

public List<Object> getBigObjectListByIdList(List<String> ids){
    List<Object> list = new LinkedList<>();
    for (String id : ids){
        list.add(getBigObjectFromDisk(id));
    }
    return list;
}

显然如果这时内存空间不足,经过垃圾收集后仍然不够的话。程序将会发出 OOM 然后崩溃。如果这时我们给 list 对象套上一层软引用,并判断 list 对象的状态是否为 null 来决定是否终止循环。那么当内存不足时,list 将会清理,循环将终止,OOM 就可以被避免,程序的鲁棒性就能得到增强。当然,依旧提供代码示例:

public List<Object> getBigObjectListByIdList(List<String> ids){
    SoftReference<List<Object>> ref =  new SoftReference<>(new LinkedList<>());
    for (String id : ids){
        List<Object> list = ref.get();
        if (list == null) 
            return null;
        else
            list.add(getBigObjectFromDisk(id));
        list = null;
    }
    return list;
}

需要注意的是,我在循环末尾把 list 显式声明为 null,因为这里避免了一种特殊情况,虽然我们在循环结束时失去 list 这个对象,但是垃圾收集器可能还没发现它已经是不可达状态,因为 list 的引用还存在 JVM 的栈中,是处于一种不明显、不易被察觉的强引用状态。

软引用不是银弹

虽然软引用可以帮我们避免很多内存溢出的情况,但是却不能避免所有情况。问题在于:当我们实际使用一个软引用来连接对象时,比如上面的 getBigObjectListByIdList(List<String> ids) 函数,当我们要添加一行新数据到结果里,我们必须先拿到被引用对象 list 的强引用。在我们拿到 list 强引用的这段时间,我们就处在 OOM 的风险中。

这样看来,使用软引用作为循环的终止,只是最小化了我们触发 OOM 的风险,并没有完全解决了 OOM 的问题。

弱引用

弱引用,如同它的名字一样,在 gc 时它不会做任何反抗,只要被引用对象没有存在强引用关系,即使保留了弱引用关系,仍会被清理。

弱引用关系,存在肯定不会一无是处啦。它也有适合的应用场景:

连接那些没有天生存在关联的对象

比如 ObjectOutputStream 使用了一个 WeakClassKey 来保存最近输出的对象的 ObjectStreamClass。避免反复对同一个Class创建ObjectStreamClass对象。

从被序列化的对象的角度来看,它跟 ObjectOutputStream 没有天生的关联,从 ObjectOutputStream 的角度来看,它跟被序列化的对象的 ObjectStreamClass 只是存在使用时要用到的关系,也不是天然有关联的。

假设我们写了一个程序,这个程序直接强引用 ObjectStreamClass 作为 socket 中发送消息的协议,那么这里就存在一个问题:每个消息一瞬间就发送完了,但是消息对象的 ObjectStreamClass 仍然存在内存中一直占有这部分资源,那么这部分内存就废了,慢慢程序的内存也会被耗尽。(除非我们显式释放掉这部分内存)

这样看来,弱引用提供了这样一种方式去维持对象的引用关系:当对象正在使用被引用对象时,就显式持有一个被引用对象的强引用,当使用完被引用对象后,就释放掉强引用关系,只留下弱引用关系。这个弱引用关系会维持住跟被引用对象的连接,以期待下次程序再次调用到被引用对象时,将其取出,或者直到被引用对象被垃圾收集器清理。

通过一个调度 map 来减少重复数据

这个功能跟 String.intern() 极其相似,假设我们手动实现一个 String.intern() 方法,就可以通过一个 WeakHashMap 和 WeakReference 配合实现:

private Map<String,WeakReference<String>> _map
    = new WeakHashMap<String,WeakReference<String>>();

public synchronized String intern(String str)
{
    WeakReference<String> ref = _map.get(str);
    String s2 = (ref != null) ? ref.get() : null;
    if (s2 != null)
        return s2;

    _map.put(str, new WeakReference(str));
    return str;
}

当存在大量的相同的 String 对象时,这个做法就可以节省大量的内存,使它们都引用到同一个 String 对象的地址;当一个 String 不再被使用时,就可以被垃圾收集器自由清理掉,不再占用空间。推广到其他对象,也可以用这种方法来减少重复对象。这其实也是一种缓存。

引用队列 Reference Quences

当我们在创建一个引用关系时,把这个引用关系关联到一个队列,并且这个引用在对象被清理时被入队。当我们们要寻找哪个对象被清理掉时,就来队列中寻找。那这就是引用队列的作用了。

下面提供一个使用 Reference Queue 的例子

public static void main(String[] argv) throws Exception
{
    Set<WeakReference<byte[]>> refs = new HashSet<WeakReference<byte[]>>();
    ReferenceQueue<byte[]> queue = new ReferenceQueue<byte[]>();
    
    for (int ii = 0 ; ii < 1000 ; ii++)
    {
        WeakReference<byte[]> ref = new WeakReference<byte[]>(new byte[1000000], queue);
        System.err.println(ii + ": created " + ref);
        refs.add(ref);
        
        Reference<? extends byte[]> r2;
        while ((r2 = queue.poll()) != null)
        {
            System.err.println("cleared " + r2);
            refs.remove(r2);
        }
    }
}

通过这个例子我们可以看出引用队列的两个要点:

  1. 一旦入队了,那么这个对象就已经被清理了,回不来了。
  2. 引用关系知道引用队列的存在,引用队列不知道引用关系的存在。所以我们必须持有一个引用关系的强引用。同时我们又要在做完我们对入队对象的操作后,清理掉这个引用关系的强引用,否则,这就会触发内存泄漏了。

虚幻引用

虚幻引用不同于软引用和弱引用的是,不能通过虚幻引用关系获得被引用对象(它的 get() 方法始终返回 null)。所以,虚幻引用唯一的作用应该就是告诉程序被引用对象被垃圾回收了(通过 ReferenceQueue)。

虽然虚幻引用表面上看起来没什么用,但是它可以在资源回收方面做得比 Finalizer 好一些。(但并没有完全解决 Finalizer 的问题)

Finalizer 存在的问题

如果我们一直没有耗尽可用的内存,那么垃圾回收可能会一直不被执行,finalize() 也就会一直不被调用。

虽然有办法在程序退出之前通知 JVM 调用 Finalizer,但这个方法不太可靠,而且还可能和其他 JVM 退出时的 hook 冲突。

在垃圾回收时,如果一个对象即将被清理,但是它实现了 FInalizer,那它就暂时不会被立刻清理,而是加入到另个独立于垃圾收集的线程去执行 Finalizer。假如我们所有对象都实现了 Finalizer,那那么垃圾收集将没有任何成果,OOM 也将出现。

要多说一点的是,不建议使用 finalizer 去释放资源,但也不建议使用虚幻引用去清理资源。最好还是手动在 try/catch/finally 或 try-resources 去释放资源。

关于虚幻引用不得不知道的知识

虚幻引用允许程序去清理那些不再被使用的对象,因此程序可以借此清理已经不在内存中的资源。不像 finalizers,我们使用虚幻引用来清理对象时,对象已经不再内存了。

虚幻引用还有一点跟 Finalizer 不一样的是,清理是在程序调用的时候进行的,而不是在垃圾收集的时候触发的。我们可以根据我们需要,开一个或者多个线程来清理对象。一个可选的方式就是,我们通过一个对象工厂来生产我们需要的资源,然后工厂在生产一个新的资源出来之前,先进行一次清理,把已经被垃圾收集的资源做一次清理。

理解虚幻引用最关键的点就是:我们不能通过这个引用关系 reference 去访问对象: get() 一直返回 null,即使是这个被引用的对象是强可达的。这也意味着我们这个虚幻引用不能帮我们拿到被引用对象,我们也无法通过虚幻引用知道对象是否被清理。所以我们必须自己另外对被引用对象做一个强引用保存起来,并用一个引用队列 ReferenceQueue 来标记那些已经被垃圾收集的对象。

下图是虚幻引用典型的使用方式,看不懂的可以配合后面的虚幻引用实现连接池的例子来理解。

使用虚幻引用实现一个连接池

数据库连接是应用中最宝贵的资源之一:它需要花一定的时间来建立连接,并且数据库服务器会严格限制并发连接的数量。也因此,程序员们应该非常谨慎地使用数据库连接。但还是有时会有为了查询打开连接,然后忘记手动清理或者忘记在 finally 块中清理。

比起在应用中直接使用数据库连接,大多数应用还是会选择使用数据库连接池来管理连接:这个连接池会维持一定地数据库连接,并且在程序需要使用到数据库连接的使用从提供可用的连接。可靠的连接池会提供几种功能来防止连接泄漏,包括超时(连接查询太长时间),还有从垃圾回收中恢复可用的连接。

后面这个功能,就可以用虚幻引用来实现了。为了达到目的,连接池提供的连接 Connection 必须在真实的数据库连接上做一层包装。这样做的好处是,被包装的连接对象可以被系统垃圾回收,但是底层真实的数据库连接仍会保留下来继续被后续使用。这样看来,数据库连接池通过虚幻引用来关联包装的连接,并且在虚幻引用进入引用队列时,回收真实的连接到连接池中。

这个池还有一个点要关注,那就是 PooledConnection 类,代码在下面。如同上面说的,这是一个包装过的类,它将请求委派给真正的连接。其中,我用了动态代理来实现这个类。每个 Java 版本的 JDBC 接口都在改进,也因此,如果是根据某个 JDK 写出来的代码,那么前一个版本的 JDK 或者后面版本的 JDK 都可能跑不动下面的连接池代码。这里使用了动态代理就解决了这个问题,而且也使得代码简洁了一些。

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
    }

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

    private boolean isClosed() throws SQLException
    {
        return (_cxt == null) || (_cxt.isClosed());
    }
}

要关注的最重要的地方是,PooledConnection 关联了底层的数据库连接还有我们的连接池。后一个点用来让程序关闭包装的连接:我们通知连接池我们已经用完了连接,然后连接池就可以回收底层真正的连接来重用。

还要提及一下 getConnection() 方法,它检查了一种特殊情况:程序是否尝试使用一个已经关闭的连接。如果没有这个检查,然后直接使用一个已经重新分配给其他地方使用的连接,那么会造成相当恶劣的结果。总结起来就是, close() 显示地关闭包装的连接,getConnection() 检查连接是否被关闭的特殊情况,然后动态代理委派请求给真实的底层连接。

接下来看看连接池的代码

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>();

我们构建完底层可用的连接后将它存储在 _pool,然后使用一个引用队列来标记那些已经被关闭的包装连接。最后,我们使用两个 Map 来构成底层连接和包装连接的虚幻引用的双向 Map,用来释放已经用完的连接。

如同我们上面说的,真实的底层数据库连接会被包装起来,这里我们用了 wrapConnection() 方法来做这件事,在这个方法里我们还创建了虚幻引用,并做了连接-引用双向映射。

private synchronized Connection wrapConnection(Connection cxt)
{
    Connection wrapped = PooledConnection.newInstance(this, cxt);
    PhantomReference<Connection> ref = new PhantomReference<Connection>(wrapped, _refQueue);
    _cxt2Ref.put(cxt, ref);
    _ref2Cxt.put(ref, cxt);
    System.err.println("Acquired connection " + cxt );
    return wrapped;
}

wrapConnection 相反的是 releaseConnection(),这个方法有两种处理情况:一种是连接被显式关闭释放。

synchronized void releaseConnection(Connection cxt)
{
    Object ref = _cxt2Ref.remove(cxt);
    _ref2Cxt.remove(ref);
    _pool.offer(cxt);
    System.err.println("Released connection " + cxt);
}

另一种是连接没有被手动释放,而是被垃圾回收后,我们通过相应的虚幻引用,来解放底层连接。

private synchronized void releaseConnection(Reference<?> ref)
{
    Connection cxt = _ref2Cxt.remove(ref);
    if (cxt != null)
        releaseConnection(cxt);
}

另外,有一种边缘情况我们要考虑的是:如果我们程序并发调用了 getConncetion()close() 会怎么样?这也是为什么我在上面的 releaseConnection() 中添加了一个 synchronized 关键字,接下来我们再改造下 getConnection() 方法,加上 synchronized,就避免了这种边缘情况。

public Connection getConnection() throws SQLException
{
    while (true)
    {
        synchronized (this) 
        {
            if (_pool.size() > 0)
                return wrapConnection(_pool.remove());
        }    

        tryWaitingForGarbageCollector();
    }
}

可以想到,理想的情况是我们每次请求 getConnection() 都会返回一个可用的连接,但是我们必须考虑没有现成的可用连接的情况,这里我们就用了 tryWaitingForGarbageCollector() 方法来检查有没有废弃的连接没有被显式清理掉,并解放底层的连接。

private void tryWaitingForGarbageCollector()
{
    try
    {
        Reference<?> ref = _refQueue.remove(100);
        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
    }
}

相关代码我已经整理到了 github 上:https://github.com/wean2016/ConnectionPool

虚幻引用存在的问题

如同 Finalizer,虚幻引用也存在如果垃圾回收一直不执行,那么它相关的代码就一直不会运行的问题。如果在上面的例子中,我们初始化了 5 个连接,并且一直向连接池申请连接,那么可用连接很快就会耗尽,垃圾回收不会执行,我们将一直陷入等待。

解决这个问题最简单的方法是,在 tryWaitingForGarbageCollector 手动调用 System.gc()。这个解决方案也同样适用于 Finalizer。

但这不意味着我们可以只关注 Finalizer 而忽视虚幻引用。实际上,如果这个连接池用 Finalizer 来处理,我们需要关闭连接池的话,在 Fianlizer 中我们要显式手动关闭连接池和相关连接,代码相当长。而使用虚幻引用来做这件事,那就很简洁了,只要关联一下虚幻引用就可以在合适的时候清理掉了。

一个最后的思考:有时候我们也许只是需要更大的内存

有时候引用对象确实是我们管理内存相当有用的工具,但是它们并不是万能的。如果我们要维持一个超大的对象连接图,但是我们只有极少内存,那么我们再怎么秀,也秀不起来是吧。

上一篇 下一篇

猜你喜欢

热点阅读