这篇文章整理自一位外国大神的英文博客,我在保存文章的结构下,增加了一些自己的见解,并做了一个文章的脑图。原文链接为 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)。这些状态都是应用在对象满足垃圾收集状态时,换句话说,就是这个对象已经没有任何强引用了。
-
软可达: 当对象被一个
SoftReference
关联,并且没有强引用时,这个对象就进入了软可达状态。在这个状态下,垃圾收集器会尝试不去收集这个对象,直到如果不收集这个对象就会引发 OOM 才会尝试收集它。 -
弱可达: 当对象被一个
WeakReference
关联,并且没有强或者软引用时,这个对象就进入了弱可达状态。在这个状态下,垃圾收集器可以自由收集这个对象而不受约束。不过实际上,只有在 full collect 时会收集清除弱可达对象,小收集是不会清除的。 -
虚幻可达: 当对象被一个
PhantomReference
关联时,并且对象已经被垃圾收集器盯上且 finalize() 方法已经执行时,这个对象就进入了虚幻可达状态。换句话说,已经没有任何方式可以挽回这个对象了~
这里需要注意的两个点是:
- 对象可以跳过其中某些生命周期。比如没有软引用,只有弱引用。那么对象可以直接从强可达进入弱可达状态。
- 只有极少对象需要用到这些引用关系。
引用关系和被引用对象
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
}
其中要注意的点是:
- 每次使用对象前必须确认对象是否已经被清理(null)
- 必须先拿到对象的强引用再使用对象。不然直接使用
ref.get().add(foo)
,如果这时在执行到 ref.get() 时触发了一次垃圾收集,将会报 NPE。 - 比如给这个 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 时它不会做任何反抗,只要被引用对象没有存在强引用关系,即使保留了弱引用关系,仍会被清理。
弱引用关系,存在肯定不会一无是处啦。它也有适合的应用场景:
- 连接那些没有天生存在关联的对象
- 通过一个调度 map,来减少重复数据。(缓存)
连接那些没有天生存在关联的对象
比如 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);
}
}
}
通过这个例子我们可以看出引用队列的两个要点:
- 一旦入队了,那么这个对象就已经被清理了,回不来了。
- 引用关系知道引用队列的存在,引用队列不知道引用关系的存在。所以我们必须持有一个引用关系的强引用。同时我们又要在做完我们对入队对象的操作后,清理掉这个引用关系的强引用,否则,这就会触发内存泄漏了。
虚幻引用
虚幻引用不同于软引用和弱引用的是,不能通过虚幻引用关系获得被引用对象(它的 get() 方法始终返回 null)。所以,虚幻引用唯一的作用应该就是告诉程序被引用对象被垃圾回收了(通过 ReferenceQueue)。
虽然虚幻引用表面上看起来没什么用,但是它可以在资源回收方面做得比 Finalizer 好一些。(但并没有完全解决 Finalizer 的问题)
Finalizer 存在的问题
- finalize() 方法可能会一直没有被调用
如果我们一直没有耗尽可用的内存,那么垃圾回收可能会一直不被执行,finalize() 也就会一直不被调用。
虽然有办法在程序退出之前通知 JVM 调用 Finalizer,但这个方法不太可靠,而且还可能和其他 JVM 退出时的 hook 冲突。
- Finalizer 机制制造了另一个强引用
在垃圾回收时,如果一个对象即将被清理,但是它实现了 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 中我们要显式手动关闭连接池和相关连接,代码相当长。而使用虚幻引用来做这件事,那就很简洁了,只要关联一下虚幻引用就可以在合适的时候清理掉了。
一个最后的思考:有时候我们也许只是需要更大的内存
有时候引用对象确实是我们管理内存相当有用的工具,但是它们并不是万能的。如果我们要维持一个超大的对象连接图,但是我们只有极少内存,那么我们再怎么秀,也秀不起来是吧。