深入理解 java 中的 Soft references & Weak references & Phantom reference

版权声明:心里年龄达到18岁以上的童鞋,转载本文章时请保留出处 https://blog.csdn.net/xlinsist/article/details/57089288

引言

Ethan Nicholas 在他的一篇文章中说:他面试了20多个Java高级工程师,他们每个人都至少有5年的Java从业经验,当他问这些工程师对于Weak References 的理解时,只有其中的2个人知道Weak References 的存在,而这2个人中,只有1人知道如何去使用Weak References,而其他人甚至都不知道Java中有Weak References的存在。

大家可能会想,我接触了Java这么多年,从来都没有使用过Weak References啊,它真的是一个有用的技术吗?对于它有用没用,我相信大家看完这篇文章,理解了它以后,自有判断。如果你从业了3年以上的Java开发,你不知道如何使用Weak References也还可以原谅,有可能你的项目还没有那么复杂。但是如果你甚至都没见过它在哪使用的,我觉得你可能读的源码太少了。我相信大家都知道ThreadLocal 这个类吧,你可以去看一下它的静态内部类ThreadLocalMap, 如果你想见到更多它的应用,我给大家推荐个网站:searchcode,这个网站会根据你输入的代码片段,来搜索几大源码托管平台上使用你输入代码片段的工程,大家可以输入WeakReference试一试。

并不说只有你成为一个Weak References方面的专家,你才是一个优秀的Java开发者,但是,你至少要了解我们在什么样的场景下需要使用它,That’s enough.

由于理解Weak References && soft references 会涉及到JVM的垃圾收集的一些知识,如果你对这方面没有了解,请你参考我的这篇文章:Hotspot虚拟机- 垃圾收集算法和垃圾收集器

Java中的4种reference

在Java中,有4种reference类型,它们从强到弱,依次如下:

  1. Strong reference : 大家平常写代码的引用都是这种类型的引用,它可以防止引用的对象被垃圾回收。
  2. Soft reference : 它引用的对象只有在内存不足时,才会被回收。
  3. Weak reference : 它并不会延长对象的生命周期,即它不能阻止垃圾收集器回收它所引用的对象。
  4. Phantom reference : 它与上面的3种类型有很大的不同,它的get() 方法始终返回null,即通过这个引用,你甚至都不能获取它所引用的对象,如果你看它的源码,它的构造器必须要给定一个ReferenceQueue,当然了,你也可以把它设置为空,但是这样的引用 一点意义都没有。我在下文中会结合Phantom reference的作用来解释为什么会这样。

在这一小节中,我总结了各个引用的作用。如果大家不太明白,没关系,我会在下文中更详细地解释它们各自的用法。

Strong reference

如果大家对垃圾收集机制有所了解,你们就会知道JVM标记一个对象是否为垃圾是根据可达性算法。 我们平常写的代码其实都是Strong reference,被Strong reference所引用的对象它会保持这个对象到GC roots的可达性,以防被JVM标记为垃圾对象,从而被回收。比如下面的代码就是一个Strong reference

String str = new String("hello world");

Soft reference

GC使Java程序员免除管理内存的痛苦,但是这并不意味着我们可以不关心对象的生命同期,如果我们不注意Java对象地生命周期,这很可能会导致Java出现内存泄露。

Object loitering

在我详细解释Soft reference之前,请大家先阅读下面的这段代码,仔细想一想它可能出现什么样的问题?

public class LeakyChecksum {
    private byte[] byteArray;
     
    public synchronized int getFileChecksum(String fileName) {
        int len = getFileSize(fileName);
        if (byteArray == null || byteArray.length < len)
            byteArray = new byte[len];
        readFileContents(fileName, byteArray);
        // calculate checksum and return it
    }
}

对于上面的程序而言,如果我把byteArray字节数组放到getFileChecksum方法中完全没有问题,但是,上面的程序把byteArray字节数组从局部变量提升到实例变量会出现很多问题。比如,由于你需要共享byteArray变量,从而你不得不去考虑线程安全问题,而上面的程序在getFileChecksum方法上加上了synchronized 关键字,这大大降低了程序的可扩展性。

先不去深入讨论上面程序出现的其它问题,让我们来探讨一下它出现的内存泄露问题。上述代码的主要功能就是根据文件的内容去计算它的checksum,如果上述代码的if 条件不成立,它会不断地重用字节数组,而不是重新分配它。除非LeakyChecksum对象被gc,否则这个字节数组始终不会被gc,由于程序到它一直是可达的。而且更糟糕的是,随着程序的不断运行,这个字节数组只会不断增大,不会减小,它的大小始终都和它处理过的最大的文件的大小一致,这样很可能会导致JVM更频繁地GC,降低应用程序地性能。大多数情况下,这个字节数组所占的空间要比它实际要用的空间要大,而多余的空间又不能被回收利用,这导致了内存泄露。

Soft references 解决上面的内存泄露问题

对于只被Soft references所引用的对象,我们称它为softly reachable objects. 只要可得到的内存很充足,softly reachable objects 通常不会被gc. JVM要比我们的程序更加了解内存的使用情况,如果可得到的内存紧张,那么JVM就会频繁地进行垃圾回收,从而释放更多的内存空间,供我们使用。因此,上述程序的字节数组缓存由于一直是可达的,即使在内存很紧张的情况下,它也不会被回收掉,这无疑给垃圾收集器更大的压力,使其更频繁地GC.

那么有没有一种解决方案可以做到这样呢,如果我们的内存很充足,我们就保持这样的缓存在内存中,不被gc; 但是,当我们的内存吃紧时,就把它释放掉。那么大家想一想,谁可以做到这一点呢?答案是JVM,因为它最了解内存的使用情况,我们可以借助它的力量来达到我们的目标,而Soft references 可以帮我们Java 程序员借助JVM的力量。下面,让我们来看看如果用SoftReference 改写上面的代码。

public class CachingChecksum {
    private SoftReference<byte[]> bufferRef;
     
    public synchronized int getFileChecksum(String fileName) {
        int len = getFileSize(fileName);
        byte[] byteArray = bufferRef.get();
        if (byteArray == null || byteArray.length < len) {
            byteArray = new byte[len];
            bufferRef.set(byteArray);
        }
        readFileContents(fileName, byteArray);
        // calculate checksum and return it
    }
}

从上面的代码我们可以看出,一旦走出if 语句,字节数组对象就只被Soft references 所引用,成为了softly reachable objects. 对于垃圾收集器来说,它只会在真正需要内存的时候才会去回收softly reachable objects. 现在,如果我们的内存不算吃紧,这个字节数组buffer会一直保存在内存中。在抛出OutOfMemoryError 之前,垃圾收集器一定会clear掉所有的soft references.

Soft references 与缓存

从上面的例子中,我们看到了如何用soft reference 去缓存1个对象,然后让JVM去决定什么时候应该把对象从缓存中清除。对于严重依赖缓存提升性能的应用而言,用Soft references 做缓存并不合适,我们应该去找一个更全面的缓存框架去做这件事。但是由于它 “cheap and dirty” 的缓存机制, 对于一些小的应用场景,它还是很有吸引力的。

Weak References

由于JVM帮我们管理Java程序的内存,我们总是希望当一个对象不被使用时,它会被立即回收,即一个对象的逻辑生命周期要与它的实际生命周期相一致。但是有些时候,由于写程序人的疏忽,没有注意对象的生命周期,导致对象的实际生命周期要比我们期望它的生命周期要长。这种情况叫做 unintentional object retention. 下面我们来看看由实例变量HashMap 导致的内存泄露问题。

HashMap 导致的内存泄露问题

用Map去关联短暂对象的元数据很容易出现unintentional object retention 问题。比如你想关联一个Socket连接与用户的信息,由于你并不能干涉Socket 对象的实现,向里面加用户数据,因此最常用的做法就是用全局Map 来做这样的事。代码如下:

public class SocketManager {
    private Map<Socket,User> m = new HashMap<Socket,User>();
     
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
    public void removeUser(Socket s) {
        m.remove(s);
    }
}

通常情况下,Socket 对象的生命周期要比整个应用的生命周期要短,同时,它也会比用到它的方法调用要长。上述代码把User 对象的生命周期与Socket 对象绑在一起,因为我们不能准确地知道Socket连接在什么时候被关闭,所以我们不能手动地去把它从Map中移除。而只要SocketManager 对象不死,HashMap 对象就始终是可达的。这样就会出现一个问题,就是即使服务完来自客户端的请求,Socket已经关闭,但是SocketUser 对象一直都不会被gc,它们会一直被保留在内存中。如果这样一直下去,就会导致程序出现内存泄露的错误。

用 WeakHashMap 解决问题

既然上面的代码有内存泄露的问题,我们应该如何解决呢?如果有一种手段可以做到,比如: 当Map中Entry的Key不再被使用了,就会把这个Entry自动移除,这样我们就可以解决上面的问题了。幸运的是,Java团队给我们提供了一个这样的类可以做到这点,它就是WeakHashMap ,我们只要把我们的代码做如下修改就行:

public class SocketManager {
    private Map<Socket,User> m = new WeakHashMap<Socket,User>();
     
    public void setUser(Socket s, User u) {
        m.put(s, u);
    }
    public User getUser(Socket s) {
        return m.get(s);
    }
}

哈哈,是不是很简单,就把HashMap 替换成WeakHashMap 就行了。下面的内容引用自Java官方文档 的说明:

Hash table based implementation of the Map interface, with weak keys. An entry in a WeakHashMap will automatically be removed when its key is no longer in ordinary use. More precisely, the presence of a mapping for a given key will not prevent the key from being discarded by the garbage collector, that is, made finalizable, finalized, and then reclaimed. When a key has been discarded its entry is effectively removed from the map, so this class behaves somewhat differently from other Map implementations.

理解 Weak references

WeakHashMap 为什么会有这么神奇的功能,而我们的HashMap 却没有呢?下面是WeakHashMap 中的部分源码:

    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);
            this.value = value;
            this.hash  = hash;
            this.next  = next;
        }
  }

大家可以看到,它的Entry 继承了WeakReference 类,而我们的HashMap 却没有。再看它的构造函数,它的key被Weak references 所引用,但是它的value并没有。接下来,我来解释一下Weak references的作用。

一个只被Weak references所引用的对象,它被称作weakly reachable object. 而这样的对象不能阻止垃圾收集器对它的回收。 就像上面的源码一样,我们会在构造的时候,用Weak references去引用对象,如果被引用的对象没有被gc,那么可以通过WeakReferenceget() 方法去获取被引用的对象。但是,如果被引用的对象已经被垃圾回收或者有人调用了WeakReference.clear() ,那么get() 方法将始终返回null. 如果你想用get() 方法返回的结果,一个最佳的实践就是你应该做一下非空检查。总之,Weak reference并不会延长对象的生命周期。

现在我们回到上面那个Socket的问题上,当Socket 对象在其它地方被使用时,它不会被回收,而且我们依然可以用WeakHashMapget() 方法去获取它相关联的数据,但是一旦Socket连接被关闭,即我们不再需要这个对象时,WeakHashMap 将不能阻止Socket 对象被回收,因此这完全达到了我们想要的结果。下面,让我们来看看WeakHashMapget() 方法的源码:

public V get(Object key) {
    Object k = maskNull(key); // 如果给定的key是null,则用NULL_KEY
    int h = hash(k); // 根据key算出它的hash值
    Entry<K,V>[] tab = getTable();
    int index = indexFor(h, tab.length); // 找到当前hash值所对应的bucket下标
    Entry<K,V> e = tab[index];
    while (e != null) { // 如果有hash冲突的情况下,会沿着链表找下去
        if (e.hash == h && eq(k, e.get()))
            return e.value;
        e = e.next;
    }
    return null;
}

我已经把上面的代码加了相应地注释,相信大家理解起来会很容易。还有一点值得说的就是:由于给定的key已经被方法参数所引用,因此在get() 方法中,key并不会被垃圾回收。如果你想把WeakHashMap 变成线程安全的,你可以简单地用Collections.synchronizedMap() 把它包装一下就行。

Reference queues

大家现在可以看一看上面构造器的源码,其中的一个参数对象是ReferenceQueue, 那么问题来了,这个东西是干什么用的呢?再具体说它的作用之前,让我们来探讨一下上面Weak references存在的问题。

上面我已经说过了,只有key是被Weak references所引用的,这样就会出现一个问题,只要SocketManager 对象不被gc,那么WeakHashMap 对象就不会被gc,然后除非你手动地调用remove() 方法,不然它里面的Entry 也不会被gc,那么问题来了,即使你的key已经被gc了,但是key对应的value,整个Entry对象依然会被保留在内存中,如果一直这样下去的话,就会导致内存泄漏。

那么,我们如何解决上面出现的问题呢?按照一个正常人的思路来说,我觉得应该去周期性地扫描一下Map,根据get() 方法是否返回null来判断当前Entry是否应该被清除。但是,如果Map中包含了大量的Entry,你这样做会效率很低。现在,Reference queues 大显身手的时候到了。如果在你构造Weak references的时候,你给它关联一个ReferenceQueue 对象,那么当一个Reference 对象被clear的时候,它会被放入给定的队列当中。因此,你只需要从这个队列中获取Reference 对象,然后做相应地清理工作就行了。

WeakHashMap 中有个私有方法expungeStaleEntries,下面让我们来看看它的源码:

private void expungeStaleEntries() {
    for (Object x; (x = queue.poll()) != null; ) { // 遍历引用队列
        synchronized (queue) {
            @SuppressWarnings("unchecked")
                Entry<K,V> e = (Entry<K,V>) x; // Entry对象就是Weak references
            int i = indexFor(e.hash, table.length); // 根据hash值找到相应的bucket下标

            Entry<K,V> prev = table[i];
            Entry<K,V> p = prev;
            while (p != null) { // 在链表中找出给定key对应的Entry,然后清除指向它的引用
                Entry<K,V> next = p.next;
                if (p == e) {
                    if (prev == e)
                        table[i] = next;
                    else
                        prev.next = next;
                    // Must not null out e.next;
                    // stale entries may be in use by a HashIterator
                    e.value = null; // Help GC
                    size--;
                    break;
                }
                prev = p;
                p = next;
            }
        }
    }
}

对于上面的代码我已经给出了相应地注释,主要就是while 循环中的算法你需要好好理解一下,其实也不难,它其实就是从链表中移除一个节点。其实大家可以去看一看WeakHashMap 的源码,它的大部分操作都会调用这个私有方法,比如上面的get 方法中的getTable 方法。

至此,我已经结合WeakHashMap 的源码,把Weak references的知识讲完了,相信有了这个强大的武器,大家可以更自如地控制对象地可达性了。

Phantom References

Phantom References 与上面的几个引用存在很大的不同,至少上面的Reference 对象通过它们的get() 方法可以获取到它们所引用的对象,但是,PhantomReference 的只会返回null. 大家可能会想,既然这个引用连对象都取不到,那要它有什么用呢?如果你去看这个Reference 对象的源码,你会发现只有PhantomReference 类的构造器必须指定一个ReferenceQueue 对象,而这就是重点,当然了,你也可以把它设置为null,但是那样将没有任何意义。因此,Phantom reference 的唯一作用就是它可以监测到对象的死亡,即,当你的对象真正从内存中移除时,指向这个对象的PhantomReference 就会被加入到队列中。 下面是一个有助于你理解PhantomReference 的Demo:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo {
	private final static ReferenceQueue<Object> queue = new ReferenceQueue<>();
	
	public static void main(String[] args) {
		Person p1 = new Person("小明");
		Person p2 = new Person("小花");
		Animal a1 = new Animal(p1, "dog");
		Animal a2 = new Animal(p2, "cat");
		p1 = null;
		p2 = null;
		Runtime.getRuntime().gc();
		waitMoment(2000); // 给gc点时间收集,有时gc收集速度很快,可以不用加这句代码,我只不过是保险起见
		printReferenceQueue(queue);
	}
	
	static class Person {
		private String name;

		public Person(String name) {
			this.name = name;
		}
		
		public String getName() {
			return name;
		}
	}
	
	static class Animal extends PhantomReference<Object>  {
		private String name;
		public Animal(Person referent, String name) {
			super(referent, queue);
			this.name = name;
		}
		public String getName() {
			return name;
		}
	}
	
	private static void waitMoment(long time) {
		try {
			Thread.sleep(time);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	
	private static void printReferenceQueue(ReferenceQueue<Object> rq) {
		int size = 0;
		Object obj;
		while ( ( obj = rq.poll() ) != null ) {
			Animal a = (Animal) obj;
			System.out.println(a.getName());
			size++;
		}
		System.out.println("引用队列大小为: " + size);
	}
}

一旦Person 对象被回收,那么指向它的Animal 对象就会被放进队列中,大家可以把上面的12,13行代码注释掉,看看有什么不同的效果。还有一种场景有可能会用到Phantom reference,比如你已经把一张很大的图片加载到内存当中,只有当你确定这张图片从内存移除之后,我才会加载下一张图片,这样可以防止内存溢出错误。

用Phantom reference 验证不靠谱的finalize 方法

在这一小节中,我让大家来看看Java中的finalize方法有多“不靠谱”。假设我现在有1个重写了finalize 方法的对象obj,它被收集的过程如下:

  1. 假设现在有足够多的垃圾使得JVM进行gc,并标记了obj 是不可达的
  2. 接着,它会把obj 放到finalization队列中,等待执行它的finalize 方法
  3. 如果执行完它的finalize方法,再次gc时才会把这个对象回收掉。如果这个方法一直没有执行完,这个对象就一直不会被回收。

上面我只说了大致的过程,更详细的请参考:When is the finalize() method called in Java?

下面来看我写的一段Demo代码:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo2 {
	private final static ReferenceQueue<Object> queue = new ReferenceQueue<>();
	
	public static void main(String[] args) {
		Person p = new Person();
		PhantomReference<Person> pr = new PhantomReference<>(p, queue);
		p = null; // 使Person对象变的不可达
		
		// 这次gc会把Person对象标记为不可达的,由于它重写了finalize,因此它会被放入到finalization队列
		Runtime.getRuntime().gc();
		waitMoment(2000); // 给gc更多的时间去处理,并且去执行队列中的finalize方法
		Runtime.getRuntime().gc(); // 再次发起gc,收集Person对象
		waitMoment(2000); // 给gc更多的时间去处理
		printReferenceQueue(queue); // 如果Person对象已经被回收,这个队列中应该有值
	}
	
	static class Person {
		@Override
		protected void finalize() throws Throwable {
			System.out.println("finalize method in Person");
		}
	}
	
	private static void waitMoment(long time) {
		try {
			Thread.sleep(time);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
	}
	
	private static void printReferenceQueue(ReferenceQueue<Object> rq) {
		int size = 0;
		Object obj;
		while ( ( obj = rq.poll() ) != null ) {
			System.out.println(obj);
			size++;
		}
		System.out.println("引用队列大小为: " + size);
	}
}

上面的代码我已经加了很详细的注释,这里我们用Phantom reference 去监控Person 对象的存活状态。大家再看看下面的代码:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo2 {
	private final static ReferenceQueue<Object> queue = new ReferenceQueue<>();
	
	public static void main(String[] args) {
		BlockFinalization bf = new BlockFinalization();
		PhantomReference<BlockFinalization> prbf = new PhantomReference<>(bf, queue);
		bf = null; // 使BlockFinalization对象变的不可达
		// 我让BlockFinalization对象中的finalize方法睡了1000秒,这样会导致主线程即使结束,finalize方法也不会执行完
		Runtime.getRuntime().gc();
		
		Person p = new Person();
		PhantomReference<Person> pr = new PhantomReference<>(p, queue);
		p = null; // 使Person对象变的不可达
		
		// 这次会把Person对象放入到finalization队列
		Runtime.getRuntime().gc();
		waitMoment(2000);
		Runtime.getRuntime().gc();
		waitMoment(2000);
		// 如果这2个对象中的finalize方法不被执行完,它们都不会被回收,根据队列输出的值就可以看出来了
		printReferenceQueue(queue);
	}
	
	static class BlockFinalization {
		@Override
		protected void finalize() throws Throwable {
			System.out.println("finalize method in BlockFinalization");
			Thread.sleep(1000000);
		}
	}
}

上面的代码有时会很诡异,在我的Ubuntu系统上,有时会出现先执行Person 中的finalize 方法的可能,这样就会导致Person 会被垃圾回收,如果是BlockFinalization 对象中的方法先被执行,那么2个对象都不会被回收。大家可以把第31行代码注释掉,看看效果。从上面的例子可以看到,finalize方法是非常不靠谱的,它不但有可能会导致对象无法回收,而且还有可能出现在程序的生命周期之内不被执行的可能。

所以建议大家千万不要使用这个方法,如果你非要使用它,我建议你去看Effective Java中的Item 7: Avoid finalizers ,这本书中介绍了关于使用它的2个场景,以及一些技巧。

垃圾收集器如何对待 Reference 对象

如果你去看WeakReferenceSoftReferencePhantomReference的源码,你会发现它们都继承了抽象的 Reference 类。那么现在我们来看看当垃圾收集器遇到 Reference 对象时,是如何对待它们的?

当垃圾收集器在追踪heap,遇到Reference 对象时,它并不会标记Reference 对象所引用的对象。它会把遇到的Reference 对象放到一个队列中,追踪heap过后,它会标识出softly reachable objects。垃圾收集器会基于当前GC回收的内存大小和其它的一些原则,来决定soft references是否需要被clear. 大家可以看一看Reference 类的clear 方法。

如果垃圾收集器决定clear这些soft references ,并且这些soft references有相应地ReferenceQueue ,那么这些被clear 的Reference 对象会被放到ReferenceQueue 队列中。注意:clear Reference 对象并把它放入到队列中是发生在被引用对象的finalization 或 garbage collection 实际发生之前。

如果垃圾收集器并不打算clear这些Reference 对象,那么它们对应地softly reachable objects会被当作GC roots,并用这些GC roots继续追踪heap,使得这些通过soft references可达的对象被标记。

处理完soft references 过后,接下来会找出weakly reachable objects. Weak references 会被直接clear掉,然后放到对应地 ReferenceQueue中。

所有的Reference 类型都会在放入ReferenceQueue 前被clear掉,因此后续的处理你将不可能访问到Reference 类型引用的对象。

由于要特殊地对待Reference 类型,因此在垃圾收集的过程中,无疑会增加额外的开销。如果Reference 对象不被放到对应地ReferenceQueue 中,那么它本身也会被回收的,而且它可能会在它的引用对象回收之前被回收。

参考资料

Plugging memory leaks with soft references

Plugging memory leaks with weak references

Understanding Weak References Blog

What is the difference between a soft reference and a weak reference in Java?

When is the finalize() method called in Java?

Have you ever used Phantom reference in any project?

What is the difference between a soft reference and a weak reference in Java?

没有更多推荐了,返回首页