从内存泄漏谈起

说到 Android 的内存泄漏,就不得不提到 Java 的引用和内存回收机制,不论是面试,还是日常开发,内存泄漏都是 Android 开发者的一个重点,这起源于一个优秀的开发者对自己的规范,一个优秀的产品对用户的负责,我们开发软件时始终都会围绕着两个问题,功能和体验,丰富易用的功能是基础,良好的用户体验提高竞争力,而一个处处存在着内存泄漏的软件必然不是体验良好的,它会不断蚕食分配给程序的空间,直到无可用内存,程序崩溃。

说得这么吓人,手机上的应用却少有因为这个崩溃的,一方面用户使用一个软件大概率是打开,浏览几个页面,然后关闭,尽管有一些内存泄漏,但是往往并不会占满空间,除非发生内存泄漏的对象十分巨大,或者用户打开一个应用很久都不关闭。也就是说,大部分情况下,一些简单的内存泄漏用户是不会感知到的,但是,这显然不符合一个程序员该有的态度。

内存泄漏的原理,需要从垃圾回收开始说,不同于 C/C++ 语言每一个使用 new 关键字申请的内存都需要使用 delete 关键字回收(如果开发者不主动回收这段内存空间就会被一直占用),JVM 有自己的垃圾回收机制,这给用户带来的最大的便利就是,用户无需再为自己申请的内存空间负责,所有的回收工作由 JVM 完成,这对于开发来说是一大幸事,但反而不利于高质量开发。开发门槛降低,开发者不再关注内存空间的利用,全身心投入到功能实现上,这的确会加速开发过程,但是 JVM 的垃圾回收在某些时候并不是那么准确,放任 JVM 完成内存回收的风险就是会导致有些内存无法被回收,纵然 JVM 也在不断更新垃圾回收算法,从引用计数到可达性分析等,但是仍不够精确,有时会出现某个应该被回收的对象没有被回收的情况,这就是内存泄漏,虽然某个对象在开发者角度已经不会被使用了(需要被回收),但是却因为某些原因导致它不能立刻被回收或者不能被回收。每一个应用拥有的内存空间有限,如果废弃的对象持续占用资源,就会导致新的对象无法创建,最终出现 OOM 异常。

而对于垃圾回收机制,当它判断某一个对象是否需要回收时,当前程序中是否还存在这个对象的“引用”是一个重要的判断依据。最简单的判断就是,如果存在引用,就不回收,否则就回收,但如此简单的逻辑必然不能满足 Java 这种功能复杂的跨平台语言,一般来说,Java 中存在四种引用类型,分别是强引用、软引用、弱引用和虚引用,各种声明如下:

// 假设有一个类 Tool ,下为这个类的四种引用声明方式

// 强引用
Tool tool;
// 软引用
SoftReference<Tool> softTool;
// 弱引用
WeakReference<Tool> weakTool;
// 虚引用
PhantomReference<Tool> phantomTool;

强引用就是在开发过程中最常见的一种方式,利用类名声明一个引用,并将对应的对象赋值给它,就完成了一个强引用,其他三种引用通过 Reference 实现,Reference 的三个子类继承它实现了三种不同的引用方式,不过既然它们都继承于 Reference ,就肯定有一定的相似点。

下面说各类引用的功能,以下表略表:

引用类型是否能获取引用对象是否会造成内存泄漏只有当前引用时是否回收
强引用
软引用需要根据情况
弱引用
虚引用

到了解引用实现

四种引用的强度从上至下依次减弱,直到虚引用,弱到都不能获取其引用对象,但是毋庸置疑,每一种引用都有其存在的意义。下面就每一种引用的详细阐述其功能。

强引用

Java 中最常使用的一种引用,也是默认的引用方式,发生 GC 时 JVM 一般无法回收存在强引用的对象,不过在某些情况,比如互相强引用的两个对象,它们如果在可达性分析中被判断出是不可达的,那么它们都会被回收。

软引用

比强引用弱一些的引用,如果某个对象只存在软引用,一般来说,在 GC 发生时,该对象不会被回收,只有在可用内存不足的时候才会被回收,但是对于软引用来说,它不应该被这样一种说法限制住,软引用更像是一种策略式回收,不同的 JVM 可以实现不同的回收策略,如可以实现上述的回收方式,也可以实现成某一个对象在只有一个软引用的时候只能存活一定的时间,超时就会被回收这种,只不过是前者更常用,但并非固化在前者。

软引用的实现类是 SoftReference ,继承自 Reference ,它的实现很简单:

public class SoftReference<T> extends Reference<T> {
    /**
     * Timestamp clock, updated by the garbage collector
     */
    private static long clock;
    /**
     * Timestamp updated by each invocation of the get method.  The VM may use
     * this field when selecting soft references to be cleared, but it is not
     * required to do so.
     */
    private long timestamp;
    /**
     * Creates a new soft reference that refers to the given object.  The new
     * reference is not registered with any queue.
     *
     * @param referent object the new soft reference will refer to
     */
    public SoftReference(T referent) {
        super(referent);
        this.timestamp = clock;
    }
    /**
     * Creates a new soft reference that refers to the given object and is
     * registered with the given queue.
     *
     * @param referent object the new soft reference will refer to
     * @param q the queue with which the reference is to be registered,
     *          or {@code null} if registration is not required
     *
     */
    public SoftReference(T referent, ReferenceQueue<? super T> q) {
        super(referent, q);
        this.timestamp = clock;
    }
    /**
     * Returns this reference object's referent.  If this reference object has
     * been cleared, either by the program or by the garbage collector, then
     * this method returns {@code null}.
     *
     * @return   The object to which this reference refers, or
     *           {@code null} if this reference object has been cleared
     */
    public T get() {
        T o = super.get();
        if (o != null && this.timestamp != clock)
            this.timestamp = clock;
        return o;
    }
}

除了实现两个构造方法和重写了get()方法之外,它还有两个成员变量,静态成员变量 clock 和 成员变量 timestamp ,变量 clock ,根据注释了解到它记录着上一次 GC 发生的时间,由 JVM 更新,所以在这个类中看不到对这个变量的更新操作,另一个变量 timestamp ,在软引用初始化或者是调用get()方法的时候会被更新成 clock 的值,也就是上次 GC 的时间。也就是说,软引用中使用 timestamp 变量保存了引用的上次访问时间,而这个变量是供 JVM 使用的,如当内存不足的时候,JVM 可以回收一部分的软引用对象,至于选择哪些对象回收,就可以根据这个变量,将最不常访问的对象回收掉。

也就是说,JVM 中可以实现一个类似于 LRU 的算法回收软引用对象,在 Android 中有一个专门的类 LruCache ,通过 LinkedHashMap 实现的一个缓存空间,与之类似的软引用,可以看作为将所有的软引用对象都作为缓存数据保存在软引用中,由 JVM 调度完成资源回收,当然这种方式开发者无法自行定义缓存空间,而且它会将所有的软引用都当做缓存,无法分别处理。

弱引用

弱引用比软引用更弱,主要的特点就是,在 GC 发生的时候只有弱引用的对象会被立刻回收,只要它引用的对象没被回收,就可以通过get()方法得到对象,所以,它是避免内存泄漏的最佳帮手。在实现类 WeakReference 中,它仅有两个构造方法的实现。

虚引用 虚引用是最弱的引用,以至于这个引用无法获取引用对象,而只能通过引用队列得知它引用的对象是否被回收。在实现类 PhantomReference 中有两个方法,一是重写了get()方法:

public T get() {
    return null;
}

由此便可以得知它为何无法获取引用对象了,强制返回了 null ,而不是通过 Reference 的get()方法获取引用对象,另一个就是实现了带有引用队列的构造方法。虚引用没有实现不带引用队列的方法,而上述两种引用都是实现了两种方法,原因何在?从引用的功能上来说,Reference 大致有两个功能,一是获取引用对象,而是监听引用对象的回收情况,前者以get()方法为接口,获取引用对象,后者以初始化的时候构造方法中的引用队列为基础,通过判断引用是否在引用队列中判断引用对象是否被回收,而虚引用的功能只有后者。

如果使用不带引用队列的构造方法初始化虚引用,那虚引用就真的成“虚”引用了,所以此处只实现了一个构造方法。

综上四种引用,之后撇开默认的强引用不谈,主要说继承于 Reference 的另外三种引用,它们的实现类中都没有关于回收策略的逻辑,意味着这个逻辑要么存在于 Reference ,要么存在于 JVM 中,而 Reference 是它们的共同父类,自然也不能区分处理,所以一般情况下,是在 JVM 中直接判断引用的类型,并分别处理的。不过一些变量和方法还是存在于 Reference 类中,了解 Java 引用就需要从了解 Reference 的实现开始。

Reference 是一个抽象类,它有两个构造方法,分别是带有引用队列和不带。引用队列上面说到过,如果一个引用对象被回收,引用就会被加入引用队列,实现类为 ReferenceQueue ,它本质上是一个链表,内部保存的并不是整个队列,而是队列的头结点 Reference ,通过 next 变量连接成链表。而在 ReferenceQueue 中主要就是实现了队列的一些操作方法,如enqueue()poll()等,但是这些都是基于头结点完成,总的来说,它只定义了一些操作方法,而不保存数据。一般情况下通过enqueue()入队,通过poll()出队,在入对出队的过程中它使用notify()wait()实现了同步,poll()的调用可能会造成线程阻塞。

在 Reference 中有一些成员变量,分别是 referent 、queue 、next 、discovered ,并且一个 Reference 在 JVM 中被划分为多个状态,不同状态下上述每个变量保存的值是不同的,或者也可以反过来说,Reference 的状态是通过上述四个变量的内容组合而成的。一个引用从创建到回收会有一个完整的生命周期,两种构造方法不同。

关于引用的状态,有两种划分,一种可以划分为 active、pending 和 inactive,另一种可以划分为 registered、enqueued、dequeued 和 unregistered,这两种划分对应着引用的两种功能,“引用”和“监控”,前者主要用于区分引用对象是否被回收,当处于 active 状态时,可以通过get()方法获取引用对象,当处于 pending 和 inactive 时,便不可以通过get()获取对象,另外,pending 意味着理论上对象被回收了,所以无法获取,不过对象可能还需要经过 ReferenceHandle 进行最后的处理,如将引用加入引用队列等,而当处于 inactive 时,一般认为对象已经被回收。另一种划分就是针对“监控”这个功能的,主要围绕着引用队列,registered 状态表示已经与某一个引用队列建立了联系,在之后对象被回收之后可以将引用加入这个队列,enqueued 表示此引用已经被加入引用队列,dequeued 表示该引用被引用队列出队,而 unregistered 则表示没有与该引用关联的引用队列,即在初始化引用的时候没有使用引用队列初始化。

再看 Reference 中提供的方法,大致有clear()enqueue()get()isEnqueued()reachabilityFence()这五个。clear()方法实现很简单,就是将 referent 置为 null ,

public void clear() {
    this.referent = null;
}

这个方法只会由 Java 代码调用,也与引用队列无关。

然后是enqueue()方法,它先将 referent 置为 null ,然后将当前引用加入引用队列,也是只会由 Java 代码调用,相比clear()它多了关于引用队列的处理。get()方法就是直接返回 referent 。isEnqueued()方法用于判断当前引用是否已经在引用队列中。reachabilityFence()比较特殊,它在 Java 9 中加入,是一个静态方法,有一个参数,意为在调用这个方法之前,此方法的参数(某一个对象)不会被回收。

无引用队列

/*
*                           clear/enqueue/GC [3]
*   [active/unregistered]   ------
*          |                      |
*          | GC                   |
*          |                      |--> [inactive/unregistered]
*          v                      |
*   [pending/unregistered]  ------
*                           ReferenceHandler
*/

当不使用引用队列构造引用时,后一个状态始终是 unregistered ,前一个状态最初是 active ,有两个分支,当 GC 发生该引用需要被回收时,会先变为 pending 状态(也有可能直接变为 inactive 状态),等待 ReferenceHandler 进一步处理,最终变为 inactive 状态,或者是主动调用clear()enqueue()方法将其置为 inactive 状态。

有引用队列

/*                            clear
*   [active/registered]     ------->   [inactive/registered]
*          |                                 |
*          |                                 | enqueue [2]
*          | GC              enqueue [2]     |
*          |                -----------------|
*          |                                 |
*          v                                 |
*   [pending/registered]    ---              v
*          |                   | ReferenceHandler
*          | enqueue [2]       |--->   [inactive/enqueued]
*          v                   |             |
*   [pending/enqueued]      ---              |
*          |                                 | poll/remove
*          | poll/remove                     |
*          |                                 |
*          v            ReferenceHandler     v
*   [pending/dequeued]      ------>    [inactive/dequeued]
*
*/

这是使用引用队列时的状态转换图,引用初始化之后,它的状态是active/registered ,之后的经历有三个分支,分别是 GC 发生、调用clear()和调用enqueue(),每一个动作发生之后都会进入对应的状态,不过最终都会到达 inactive/dequeued 的结束状态。

再说每一个成员变量的含义,首先,referent 保存的是引用对象,它在 active 状态下就是相应的对象,而在 pending 和 inactive 状态时,由于此时对象在理论上已经不可用,所以它是 null 。然后是 queue ,一般来说它保存着与该引用关联的引用队列,当处于 registered 状态时,这个变量保存的就是构造方法中传入的应用队列,而在 enqueued 和 dequeued 状态时,则分别使用 ReferenceQueue.ENQUEUE 和 ReferenceQueue.NULL 标识,另外,在 unregistered 状态下也是 ReferenceQueue.NULL 。next ,这个用于维护引用队列的链式结构,上面说到过 ReferenceQueue 本身不存储数据,它只保存引用的头结点,而整个链表就是由这个 next 变量形成的,所以,这个变量只有在该引用在引用队列中时有明确的意义(即 enqueued 状态),其他状态下一般是 null ,或者是在 dequeued 状态下,next 就指向自身。最后是 discovered 变量,它与 next 类似,也用于构成链表,处于 pending 状态下的链表,discovered 变量指向下一个结点。

上面还几次提到过 ReferenceHandler ,它用于处理处于 pending 状态的引用,pending 状态是由 JVM 确定的。ReferenceHandler 本质上是一个线程,它的工作内容如下:

public void run() {
    while (true) {
        processPendingReferences();
    }
}

一个无限循环,不断调用processPendingReferences()方法,从名字就可以看出,这是用于处理处于 pending 状态的引用的。首先通过waitForReferencePending()getAndClearReferencePendingList()两个 native 方法获取 JVM 中确定的 pending 链表,然后遍历链表(通过 discover 变量),处理如下:

while (pendingList != null) {
    Reference<Object> ref = pendingList;
    pendingList = ref.discovered;
    ref.discovered = null;
    if (ref instanceof Cleaner) {
        ((Cleaner)ref).clean();
        // Notify any waiters that progress has been made.
        // This improves latency for nio.Bits waiters, which
        // are the only important ones.
        synchronized (processPendingLock) {
            processPendingLock.notifyAll();
        }
    } else {
        ReferenceQueue<? super Object> q = ref.queue;
        if (q != ReferenceQueue.NULL) q.enqueue(ref);
    }
}

有两个分支,Cleaner 是虚引用的一个子类,它可以实现自己的处理逻辑,也有自己的引用队列,所以直接调用它的clean()方法,而对于其他的引用,则将其加入引用队列。

止于更好的使用

上面从类别、Java 层实现讲了一些关于引用相关的知识,总的来说就是四种引用,而引用的基本目的,就是“引用”,一般情况下对象实例是存在于堆里的,我们要使用这个对象,首先要能够找到它,通过引用找到对象。引用通过某种方法指向对象本身,从代码上看我们直接调用引用的方法或者变量,貌似这些变量就是引用本身的,但是在 Java 中,大部分情况下对象的引用只是一个指针,它指向堆中的某个对象,对引用的操作都会由 JVM 处理成为对某一内存的操作,引用本身的目的,就是让我们能够更直观、方便地使用对象,否则就需要像汇编语言那样,代码具体到操作某一块内存的值,有了引用,就相当于给了某块内存一个名字,之后只只需根据名字操作,而非内存。

四种引用各有自己的用途,强引用最为普遍,能够确保引用对象不被回收,软引用为策略回收,每个 JVM 的实现可能不同,一般情况下会尽可能保证对象不被回收,弱引用不会影响 JVM 原本的垃圾回收过程,一般可以用于协同多个类生命周期不一时防止内存泄漏,而虚引用,只能判断引用对象是否回收,一般常用于一些辅助功能里。

在开发中要根据自己的需要合理使用每一种引用,就软引用来说,虽然它能够延迟回收,但是由于这个策略是 JVM 实现,对于开发者来说,不可控性太大,所以在 Android 中 LruCache 是一种更常用的内存缓存方案。相比于强引用,其他三种引用都属于非常规引用,使用的时候需要很多额外的开销,所以也应该合理取舍,只在需要的地方使用。