一文弄懂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<MyObject> softQueue = new ReferenceQueue<>();//创建引用队列,如果对象被回收则进入引用队列
SoftReference<MyObject> softRef = new SoftReference<>(obj,softQueue);//创建软引用
new CheckRefQueueThread(softQueue).start();
obj = null;//删除强引用

第1行构造了一个强引用对象,第3行构造了一个软引用对象。
也就是说,现在有两个引用指向了MyObject对象(objsoftRef中的referent,可以结合下图)。

注意软引用对象软引用的对象之间的区别,软引用对象指该软引用本身,而软引用的对象指的是软引用中referent对象,也就是该软引用持有的对象,在本例中就是MyObject对象实例。下文中的弱引用的对象以及虚引用的对象都是这个意思。

一文弄懂Java中的四种引用类型_第1张图片

上图中还有一个ReferenceQueue,这个后文会分析。

此时,MyObject实例还不具备被回收的条件(因为还有强引用指向它)。

第5行释放了该强引用,此时,该对象不存在强引用,但存在软引用(称为软可达(softly reachable ))。

此时,我们还是能使用MyObject对象,通过softRef.get()

一个被软引用持有的对象不会被JVM很快回收,只有当堆快要溢出时(内存不足时),才会回收软引用的对象。也就是说,只要有足够的内存,软引用的对象就能在内存中存活相当长的一段时间,该对象还可以继续被程序使用。 软引用一般用来实现内存敏感的缓存。

下面给出完整的例子:

private static void testSoftReference() {
    MyObject obj = new MyObject();//强引用
    ReferenceQueue<MyObject> softQueue = new ReferenceQueue<>();//创建引用队列,如果对象被回收则进入引用队列

    SoftReference<MyObject> 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<MyObject> queue;
    private CheckRefQueueThread(ReferenceQueue<MyObject> 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对象实例,说明,它已经被回收,此时该软引用会加入到注册的引用队列(通过构造函数注册)。

在新线程中将该软引用取出,同时调用get方法验证此时却是无法获取MyObject实例了。

弱引用

构造和测试弱引用的代码如下:

MyObject obj = new MyObject();
ReferenceQueue<MyObject> weakQueue = new ReferenceQueue<>();
WeakReference<MyObject> 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<K,V> extends WeakReference<Object> implements Map.Entry<K,V> {
    V value;
    final int hash;
    Entry<K,V> next;

    /**
     * Creates new entry.
     */
    Entry(Object key, V value,
          ReferenceQueue<Object> queue,
          int hash, Entry<K,V> next) {
        super(key, queue);//以key为referent构造了key的弱引用
        this.value = 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<K,V> e = (Entry<K,V>) x;
            int i = indexFor(e.hash, table.length);//找到这个项的索引

            Entry<K,V> prev = table[i];
            Entry<K,V> p = prev;
            while (p != null) {
                Entry<K,V> 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 = null; // Help GC  ,key已经不新鲜了,value也没什么用了
                    size--;
                    break;
                }
                prev = p;
                p = next;
            }
        }
    }
}

简单了解下WeakHashMap的工作原理后,可以知道,如果存放WeakHashMap中的
key都存在强引用,那么WeakHashMap就会退化为HashMap

Map<Integer, byte[]> map;

map = new WeakHashMap<>();//new HashMap<>();
List<Integer> 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<MyObject> phantomQueue = new ReferenceQueue<>();
 PhantomReference<MyObject> 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语法及时释放不要的资源。

你可能感兴趣的:(java)