转载

Effective Java学习笔记(六)对象引用

本文对应原书条目7,这个条目的标题是“消除过期的对象引用”,书中作者只给出了几条实用建议,但是缺乏原理性知识的讲解,所以我想先针对这块前置知识分享下自己的学习心得,主要是Java语言中的四种引用和引用队列,再就着书中的几条建议说说自己的看法。

Java 引用相关知识

Java语言存在四种引用,分别是强引用,软引用,弱引用和虚引用。它们各自有着不同的特点和用途,下面逐一进行介绍。

强引用(StrongReference)

强引用是最常见的一种引用,不需要显式地进行创建。我们在new一个对象给一个变量的时候,其实就完成了一个强引用的创建。

Object obj = new Object();

如果一个对象拥有强引用,那么它就是 强可达 (strongly reachable)的,任何时候垃圾回收器都不会回收这个对象。事实上,JVM宁愿抛出 OutOfMemoryError 也不会回收这个对象。如果要让它被回收掉,要么显式地把变量(引用)置为null,要么等到超出对象的生命周期范围的时候。怎么理解后者呢?比如在一个方法里new了一个对象,那么在这个方法执行结束后,对象的引用就不存在了,此时这个对象就会被回收。

软引用(SoftReference)

软引用是一个和内存紧密关联的引用。如果对象只有一个软引用,那么当内存空间足够时,JVM不会回收它,只有当内存吃紧的时候才会回收。以下是创建软引用的一个示例:

// 先创建一个强引用
Object obj = new Object(); 
// 再创建一个软引用
SoftReference<Object> softRef = new SoftReference<>(obj);
// 之后可以把强引用删掉
obj = null;
// 可以通过get方法来获取软引用指向的对象
Object obj2 = softRef.get();
// 上面的操作,如果对象已经被回收了,就会返回null,否则返回这个对象,这时对象就多了obj2这个强引用了

弱引用(WeakReference)

弱引用指向的对象也是能够被回收的,只是它的回收策略更严格。如果一个对象只有一个弱引用,那么不管现在内存空间是否足够,这个对象都会被回收掉。以下是创建弱引用的示例:

// 先创建一个强引用
Object obj = new Object();
// 再创建一个弱引用
WeakReference<Object> weakRef = new WeakReference<>(obj);
// 把强引用删掉后下次gc就会把弱引用指向的对象回收了
obj = null;

虚引用(PhantomReference)

虚引用是最弱的一种引用,拿到这个引用没有任何实际用途。它只是用来“跟踪对象被垃圾回收器回收的活动”。 [2] 虚引用必须得和引用队列一起使用。

引用队列(ReferenceQueue)

引用队列是一个存储除强引用外其他三种引用的队列。引用队列就像是死刑宣判区,在这个队列里的引用所指向的对象,要么即将被回收,要么已经被回收了。因为JVM在执行垃圾回收的时候,可能不是马上执行的,但可以马上把相关的引用放进引用队列里。(这段是我自己的理解,如果不对请指正)我们通过遍历这个队列,就可以知道哪些对象是肯定要被回收的了,这样我们就可以及时止步,不去用这些对象了,或者重新赋予它一个强引用。引用队列可以这么用:

Object obj = new Object();
ReferenceQueue<Object> refQueue = new ReferenceQueue<>();
SoftReference<Object> softRef = new SoftReference<>(obj, refQueue);

软引用、弱引用、虚引用的构造方法里都可以塞一个ReferenceQueue进去,在对象被回收的时候,这个引用就会被加入队列中去。

软引用vs弱引用

这四种引用里,强引用不必多说,是我们平常最多用的。虚引用更像是个高级特性,我们一般也不会用到。而软引用和弱引用夹在它们中间,我们有时可能会用到,比如自己实现本地缓存的时候。两者都是指向一些程序运行时非必需的对象的,但是相应的gc策略又不同。那么它们各自适用于哪种场景呢?参考资料[3]给了我一个答案:软引用通常可用于服务端的本地缓存,因为服务端对内存的要求并不苛刻,而且访问量比较大,缓存里的对象过期时间比较长,不应该被频繁回收。而弱引用则可用于移动客户端的缓存或者事件监听器,因为移动设备内存比较小,对gc很敏感,很多资源不用的时候就需要及时回收掉。

消除过期引用的实用建议

有了上面关于Java语言引用的的基本知识,我们可以来看看书上这个条目所想讨论的内容了。其实主要就是我们该如何处理由 过期引用 (obsolete reference)引起的内存泄露问题。 [1]

1. 自己管理内存

书中给出了一个自己手写Stack类的例子,像我们写这种类的时候,一定要警惕,因为涉及到内存空间的管理。我们声明了一个数组,管JVM要了一块内存,这块内存里有两部分内容,活动区域和非活动区域。活动区域就是我们栈内的空间,随着每次入栈和出栈都会动态改变,而非活动区域就是栈以外的部分(总空间-活动空间),这个区域里的对象其实都是没有用的,应该要被回收掉的,不然就会占用堆的空间了。所以我们需要在出栈的时候把数组中的这个引用显式地置为null。

不过Joshua Bloch也提醒我们,“清空对象引用应该是一种例外,而不是一种规范行为”。 [1] 只有涉及对内存空间的管理时才需要去考虑清空对象引用。

2. 缓存

上面这种情况还好,因为我们只要了堆上一块固定的区域,如果我们用了HashMap这种会自动扩容的类,那问题就严重了,随着时间的推移,Map占的内存会越来越大,而其中的过期对象也会越来越多,最后就会导致内存泄露。这种情况通常会出现在我们自建本地缓存的时候。这个时候我们就要用软引用或弱引用来实现缓存了。软引用缓存需要我们自己去实现,而弱引用可以直接使用 WeakHashMap 。WeakHashMap中的Entry直接继承了WeakReference,所以当外部没有对其某项值的引用时,这个项值就会被回收掉了。

此外我们也可使用LinkedHashMap里的 removeEldestEntry 方法,在每次put的时候,指定一个removeEldestEntry的策略,满足的时候就把最早的项值删去。比如我们可以参考Java自带的ExpiringCache中的使用:

private int MAX_ENTRIES = 200;

ExpiringCache(long millisUntilExpiration) {
    this.millisUntilExpiration = millisUntilExpiration;
    map = new LinkedHashMap<String,Entry>() {
        protected boolean removeEldestEntry(Map.Entry<String,Entry> eldest) {
            return size() > MAX_ENTRIES;
        }
    };
}

ExpiringCache中removeEldestEntry的策略是当map的size超过最大值时触发的。而每次put操作的最后都会进行removeEldestEntry。

3. 监听器和其他回调

回调方法这块我比较陌生,应该也是客户端用得比较多。这块内容如果不准确欢迎大家在评论区指正~我理解客户端涉及很多事件监听器的注册,这些事件监听器不应该一直存在堆里,所以也应该用一个WeakHashMap这样的工具类进行统一的维护,如果外部没有事件源对其产生依赖了,就需要及时地被回收掉。

总结

虽然Java是一个自带垃圾回收功能的高级语言,这让程序员省了很多心,不用和内存打太多交道。但其实我们平时遇到的很多事故也和内存泄漏息息相关。知己知彼才能百战不殆。我们在写代码的时候也要注意好(1)这个类是否需要程序员自己去管理内存;(2)是否使用了正确的内存回收策略;(3)是否及时清除了过期引用。

声明

本文仅用于学习交流,请勿用于商业用途。转载请注明出处,如果涉及任何版权问题,请及时与我联系,谢谢!

参考资料

  1. 《Effective Java(第3版)》
  2. 【转】JAVA四种引用(强引用,弱引用,软引用,虚引用) https://www.cnblogs.com/fengb...
  3. Java——软引用、弱引用应用 https://www.jianshu.com/p/8bf...
  4. Java的弱引用—WeakHashMap https://blog.csdn.net/zjq_131...
  5. Java中弱引用和软引用的区别以及虚引用和强引用介绍 https://www.jb51.net/article/...
  6. Java 如何有效地避免OOM:善于利用软引用和弱引用 https://www.cnblogs.com/dolph...
  7. java监听器实现与原理 https://www.cnblogs.com/again...
原文  https://segmentfault.com/a/1190000022542189
正文到此结束
Loading...