一文弄懂Java中的四种引用类型

引言 Java虚拟机会为我们管理内存,当内存不足时,通过垃圾回收算法来释放不可达的内存。作为Java程序员我们似乎不需要关注这些。
但是在工作中我们可能会遇到内存充足的情况下,也会出现OutOfMemoryError

笔者就多次遇到过这种情况,有一次是加载内容过多引起的,通过Xmx2g,在分配了2G内存的情况下出现了内存溢出的问题,最后定位到了是压测的时候压测导出Excel接口没有分页导致一次性读取整张表的数据到内存,同时查询数据量过多时间过长,上一个还没导出成功下一个导出请求又来了。
解决方案是控制一次导出的数据量。在此期间执行过Full GC,但是并不会回收加载到内存中的待导出数据,因为它们都是强引用。
本文的切入点是引用类型,下面开始进入主题。Java中有四种引用类型:强引用(Strong Reference),软引用(Soft Reference),弱引用(Weak Reference)和虚引用(Phantom Reference)。
强度由强到弱。
软引用和弱引用哪个更强呢?是不是容易混淆。可以通过单词来记忆,Strong的反义词是Weak,Strong很强的话,Weak就很弱了,而Soft就介于它们之间。
强引用 我们通常遇到的引用类型都是强引用,比如通过new关键词实例化的对象:
Object obj = new Object();

如果没有出现obj = null,那么在它的作用域范围内,GC是不会进行回收的。当然可能你知道这个对象已经没用了,但是GC不知道。
因此很多书籍推荐显示的执行obj = null将该引用置空,这样它原先的那块内存就是不可达的了。
软引用 为了演示软引用,我们定义一个类,复写了它的finalize方法,打印一些日志,以使我们知悉。
public class MyObject { @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("MyObject's finalize called."); }@Override public String toString() { return "MyObject@" + Integer.toHexString(hashCode()); } }

构造软引用代码如下:
MyObject obj = new MyObject(); //强引用 ReferenceQueue softQueue = new ReferenceQueue<>(); //创建引用队列,如果对象被回收则进入引用队列 SoftReference softRef = new SoftReference<>(obj,softQueue); //创建软引用 new CheckRefQueueThread(softQueue).start(); obj = null; //删除强引用

第1行构造了一个强引用对象,第3行构造了一个软引用对象。
也就是说,现在有两个引用指向了MyObject对象(objsoftRef中的referent,可以结合下图)。
注意软引用对象和软引用的对象之间的区别,软引用对象指该软引用本身,而软引用的对象指的是软引用中referent对象,也就是该软引用持有的对象,在本例中就是MyObject对象实例。下文中的弱引用的对象以及虚引用的对象都是这个意思。
一文弄懂Java中的四种引用类型
文章图片

上图中还有一个ReferenceQueue,这个后文会分析。
此时,MyObject实例还不具备被回收的条件(因为还有强引用指向它)。
第5行释放了该强引用,此时,该对象不存在强引用,但存在软引用(称为软可达(softly reachable ))。
此时,我们还是能使用MyObject对象,通过softRef.get()
一个被软引用持有的对象不会被JVM很快回收,只有当堆快要溢出时(内存不足时),才会回收软引用的对象。也就是说,只要有足够的内存,软引用的对象就能在内存中存活相当长的一段时间,该对象还可以继续被程序使用。 软引用一般用来实现内存敏感的缓存。
下面给出完整的例子:
private static void testSoftReference() { MyObject obj = new MyObject(); //强引用 ReferenceQueue softQueue = new ReferenceQueue<>(); //创建引用队列,如果对象被回收则进入引用队列SoftReference softRef = new SoftReference<>(obj,softQueue); //创建软引用 new CheckRefQueueThread(softQueue).start(); obj = null; //删除强引用System.gc(); System.out.println("After GC:soft Get=" + softRef.get()); System.out.println("分配大块内存"); //分配大块内存,强迫GC byte[] b = new byte[4 *1024 * 804]; //防止出现OOM,这个值需要微调一下 System.out.println("After new byte[]:Soft Get=" + softRef.get()); }

上面代码中的CheckRefQueueThread:
private static class CheckRefQueueThread extends Thread { private ReferenceQueue queue; private CheckRefQueueThread(ReferenceQueue queue) { this.queue = queue; }@Override public void run() { Reference ref = null; try { ref = queue.remove(); } catch (InterruptedException e) { e.printStackTrace(); } if (ref != null) { System.out.println("Object for " + ref.getClass().getSimpleName() + " is " + ref.get()); } } }

执行前,先限定堆大小为5M: Xmx5M,执行结果为:
After GC:soft Get=MyObject@723279cf 分配大块内存 MyObject's finalize called. Object for SoftReference is null After new byte[]:Soft Get=null

(不同的电脑执行结果可能不同,需要微调byte[] b = new byte[4 *1024 * 804]; 里面的数值,过大的话会出现OOM)
我们来分析一下上面的执行结果,首先构造出MyObject对象,然后构造该对象的软引用softRef,并注册到引用队列,
MyObject强引用对象被回收时,软引用会被加入到引用队列。
设置obj=null来删除强引用,此时MyObject对象变成软可达,然后显式调用GC,通过软引用的get()(此时我们只能通过软引用的该方法来访问它了,之前的obj已经置为null了)方法,还是可以取得MyObject对象实例的强引用,发现对象并未被回收。这说明GC在内存充足的情况下,并不会回收软可达对象。
然后请求一块大的堆空间,使得内存不足,从而迫使新一轮的GC。在这次GC后,软引用的get()方法也无法返回MyObject对象实例,说明,它已经被回收,此时该软引用会加入到注册的引用队列(通过构造函数注册)。
【一文弄懂Java中的四种引用类型】在新线程中将该软引用取出,同时调用get方法验证此时却是无法获取MyObject实例了。
弱引用 构造和测试弱引用的代码如下:
MyObject obj = new MyObject(); ReferenceQueue weakQueue = new ReferenceQueue<>(); WeakReference weakRef = new WeakReference<>(obj,weakQueue); new CheckRefQueueThread(weakQueue).start(); obj = null; System.out.println("Before GC:Weak Get=" + weakRef.get()); System.gc(); System.out.println("After GC:Weak Get=" + weakRef.get());

可以看出,和软引用的构造方法类似,只是名称不同。
第5行删除强引用,此时不存在强引用和软引用,存在弱引用指向它,称为弱可达(weakly reachable )。
在系统GC时,只要发现该对象仅是弱可达的,不管内存是否充足,都会对对象进行回收。但是,由于GC线程优先级较低,可能不会那么快的发现,可能也会存在较长时间。
上面代码的执行结果如下:
Before GC:Weak Get=MyObject@723279cf MyObject's finalize called. After GC:Weak Get=null Object for WeakReference is null

从结果来看,在GC前,可以通过weakRef.get()取得对应的强引用。但是只要进行垃圾回收,并且发现弱引用的对象(这里是MyObject)是弱可达,便会立即被回收,并且weakRef会加入到引用队列中。
此时,再次通过get方法获取对象会失败。
弱引用的典型应用就是WeakHashMap,下面我们一起简单探讨下它的使用方法及原理。
WeakHashMap 它使用弱引用作为内部数据的存储方案。
Map map; map = new WeakHashMap<>(); //new HashMap<>(); for (int i = 0; i < 10000; i++) { Integer ii = new Integer(i); //注意这个ii,它每次循环都指向一个Integer对象,下次循环后之前指向的对象就没有强引用指向它了,类似于ii = null; 而在作用域之外,也是类似的 map.put(ii,new byte[i]); }

使用-Xmx5M限定最大可用堆后,执行WeakHashMap的代码正常运行,使用HashMap的代码抛出java.lang.OutOfMemoryError: Java heap space
WeakHashMap是如何工作的呢,在其源码中对Entry的定义如下:
/** * 继承了WeakReference, 使用referent封装了key */ private static class Entry extends WeakReference implements Map.Entry { V value; final int hash; Entry next; /** * Creates new entry. */ Entry(Object key, V value, ReferenceQueue queue, int hash, Entry next) { super(key, queue); //以key为referent构造了key的弱引用 this.value = https://www.it610.com/article/value; this.hash= hash; this.next= next; } ... }
在上面的代码中似乎看不到K key了,其实它变成了WeakReference中的referent了,理解这一点很重要。
WeakHashMap的各项操作中,如get()put()函数,都直接或间接调用
expungeStaleEntries()方法(方法名意味清理不新鲜的项),以清理持有弱引用的key的表项。
private void expungeStaleEntries() { //queue中都是弱可达对象的弱引用,表示这些对象可以移除了 //注意此处使用的是poll()方法,不会阻塞,如果队列中有值直接返回队顶元素;否则返回null //若是remove()方法,在队列中无值的情况下会阻塞 for (Object x; (x = queue.poll()) != null; ) { synchronized (queue) { @SuppressWarnings("unchecked") Entry e = (Entry) x; int i = indexFor(e.hash, table.length); //找到这个项的索引Entry prev = table[i]; Entry p = prev; while (p != null) { Entry next = p.next; if (p == e) {//找到了 if (prev == e)//说明是链表头节点 table[i] = next; else prev.next = next; //让pre指向它的next,就没有引用指向它了 // Must not null out e.next; // stale entries may be in use by a HashIterator e.value = https://www.it610.com/article/null; // Help GC,key已经不新鲜了,value也没什么用了 size--; break; } prev = p; p = next; } } } }

简单了解下WeakHashMap的工作原理后,可以知道,如果存放WeakHashMap中的
key都存在强引用,那么WeakHashMap就会退化为HashMap
Map map; map = new WeakHashMap<>(); //new HashMap<>(); List list = Lists.newArrayList(); for (int i = 0; i < 10000; i++) { Integer ii = new Integer(i); list.add(ii); //强引用key map.put(ii, new byte[i]); }

上面的代码也会抛出内存不足异常。如果希望在系统中通过WeakHashMap自动清理数据,
就尽量不要在代码的其他地方强引用WeakHashMapkey,否则这些key就不会被回收。
虚引用 虚引用是所有引用中最弱的一个。它是finalize()方法的一个更加灵活的代替版本。
MyObject obj = new MyObject(); ReferenceQueue phantomQueue = new ReferenceQueue<>(); PhantomReference phantomRef = new PhantomReference<>(obj,phantomQueue); System.out.println("Phantom Get: " + phantomRef.get()); new CheckRefQueueThread(phantomQueue).start(); obj = null; Thread.sleep(1000); int i = 1; while (true) { System.out.println("第" + i++ + "次gc"); System.gc(); Thread.sleep(1000); }

本例中需要修改下检查引用线程的代码,在run()方法体最后加入:
ref.clear(); //虚引用需要手动调用clear方法 System.exit(0);

软引用和弱引用其实可以在构造时将引用队列置为null,但是虚引用不同,没有注册引用队列的虚引用是没有意义的。
如果一个对象只存在虚引用指向它,那它就是虚可达(phantom reachable)。如果垃圾收集器发现虚引用的对象是虚可达的,
那么该虚引用对象会被加到引用队列。
上例中执行结果如下:
Phantom Get: null 第1次gc MyObject's finalize called. 第2次gc Object for PhantomReference is null

在虚引用中调用get()方法总是返回null,一个对象只有虚引用指向它,几乎和没有引用指向它是一样的。
在第一次GC时,系统找到了垃圾对象,并调用其finalize()方法回收内存,但是没有立即加入回收队列。第二次GC时,该对象真正被GC清除,此时,加入虚引用队列。
当JVM真正回收MyObject时,将虚引用放入引用队列。一旦从虚引用队列中取得该虚引用,表明虚引用的对象已经被回收。此时可以做一些清理工作,清理啥呢,清理GC无法清理的资源,比如文件句柄。
比如,在FileInputStream中重写了finalize方法:
protected void finalize() throws IOException { if ((fd != null) &&(fd != FileDescriptor.in)) { close(); } }

最后会调用close()方法,所以如果你忘记调用的话,在GC回收FileInputStream对象时会很贴心的帮你关闭文件句柄,
但是如果一直没有GC的话,那么应该关闭的文件就会一直被打开,浪费系统资源。
虽然虚引用可以用于进行清理工作,但是一般情况下还是建议直接使用try-with-resource语法及时释放不要的资源。

    推荐阅读