引言

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​​​对象(​​obj​​​和​​softRef​​​中的​​referent​​,可以结合下图)。

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

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

上图中还有一个​​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<Integer,byte[]> 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​​​自动清理数据,
就尽量不要在代码的其他地方强引用​​​WeakHashMap​​​的​​key​​​,否则这些​​key​​就不会被回收。

虚引用

虚引用是所有引用中最弱的一个。它是​​finalize()​​方法的一个更加灵活的代替版本。

= 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​​语法及时释放不要的资源。