1. 内存泄漏基础:什么是内存泄漏、GC原理与可达性分析、强/软/弱/虚引用区别

1.1 到底什么是内存泄漏?

先说说我的理解。内存泄漏,说白了就是「该回收的对象,没被回收」。

你想想看,Android系统给每个App分配的内存是有限的。正常情况下,一个对象用完了,GC会把它清理掉,腾出空间。但有些对象明明已经没用了,却因为被某些地方「强引用」着,GC没法动它。

我遇到过最典型的场景——Activity泄漏。用户打开一个页面,旋转屏幕,Activity重建了。但旧的Activity被某个静态变量或者匿名内部类持有,一直活在内存里。用户多旋转几次屏幕,内存就蹭蹭往上涨,最后OOM崩溃。

核心定义:内存泄漏 = 无用对象 + 仍然可达 + GC无法回收

内存泄漏的危害是累积的。一次泄漏可能就几十KB,但日积月累,App就会越来越卡,甚至闪退。我见过一个电商App,用户浏览20个商品详情页后必崩,查下来就是每个页面泄漏了约2MB的Bitmap数据。

1.2 GC原理与可达性分析

聊GC之前,先问一个问题:GC怎么知道哪些对象该回收?

早期JVM用引用计数法。每个对象记一个数字,被引用就+1,引用失效就-1。听起来挺合理对吧?但有个致命问题——循环引用。A引用B,B引用A,两个对象都没人用了,但计数都不为0,GC就傻眼了。

所以现在的Java/Android虚拟机,用的都是可达性分析算法

这个算法的思路很简单:从一组「GC Roots」出发,沿着引用链往下走,能走到的对象就是「活的」,走不到的就是「死的」,可以回收。

哪些东西可以作为GC Roots?我列一下:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI引用的对象
  • 活跃线程(Thread对象)

举个例子。你写了个单例:

public class AppManager {
    private static AppManager instance;
    private Context context;

    private AppManager(Context ctx) {
        this.context = ctx;
    }

    public static AppManager getInstance(Context ctx) {
        if (instance == null) {
            instance = new AppManager(ctx);
        }
        return instance;
    }
}

这里instance是静态变量,属于GC Roots。它持有了传入的Context。如果你传入的是Activity的Context,那这个Activity就永远被AppManager「拴住」了,GC没法回收它。这就是典型的静态引用导致的泄漏。

我的经验:排查内存泄漏时,我习惯先用Android Studio的Memory Profiler抓一个Heap Dump,然后用MAT或者Android Studio自带的分析器看「GC Root路径」。找到那条路径,你就找到了泄漏的根源。

1.3 强/软/弱/虚引用,到底怎么用?

Java里引用的强度分四种。我按强度从高到低给你捋一遍。

引用类型 回收时机 典型用途
强引用 永不回收(除非不可达) new出来的对象,日常使用
软引用 内存不足时回收 缓存实现,如图片缓存
弱引用 下次GC时立即回收 Handler、内部类避免泄漏
虚引用 任何时候都可能回收 对象回收跟踪,用于NIO DirectBuffer

强引用

就是最常见的 Object obj = new Object()。只要强引用还在,GC绝对不会碰它。这也是内存泄漏的罪魁祸首——你忘了把引用置null,或者被其他对象持有了。

软引用

软引用在内存充足时不会回收,但快要OOM时,GC会把它干掉。我早期做图片缓存时用过SoftReference,后来发现不太靠谱——因为GC回收软引用的时机不确定,容易导致图片突然被清掉,引发卡顿。现在更推荐用LRU Cache。

// 软引用示例
SoftReference<Bitmap> softRef = new SoftReference<>(bitmap);
Bitmap cachedBitmap = softRef.get(); // 可能为null

弱引用

弱引用就厉害了。只要GC跑一轮,不管内存够不够,弱引用指向的对象都会被回收。这个特性特别适合用来解决Handler泄漏问题。

我曾经踩过一个坑:在Activity里写了个匿名Handler:

Handler handler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
        // 更新UI
    }
};

这个Handler持有外部Activity的引用。如果消息队列里有延迟消息,Activity退出了也回收不了。解决方案就是用弱引用:

static class MyHandler extends Handler {
    WeakReference<Activity> weakRef;

    MyHandler(Activity activity) {
        weakRef = new WeakReference<>(activity);
    }

    @Override
    public void handleMessage(Message msg) {
        Activity activity = weakRef.get();
        if (activity != null) {
            // 更新UI
        }
    }
}

注意:用了弱引用不代表万事大吉。你每次用之前都要判空,因为对象随时可能被回收。我见过有人用了WeakReference但不判空,结果拿到null直接崩溃——这比内存泄漏还严重。

虚引用

虚引用是最弱的。你甚至没法通过它拿到对象实例。它的唯一作用就是:当对象被回收时,会收到一个通知。这个在NIO的DirectBuffer里用得比较多,用来跟踪堆外内存的回收。

ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);
// 当obj被回收时,phantomRef会进入queue

1.4 避坑指南

最后分享几个我踩过的坑:

  • 静态集合类:我曾经把一个对象放到static的ArrayList里,忘了移除。结果这个对象和它引用的所有东西都泄漏了。现在我的习惯是,能用局部变量就别用静态变量。
  • 内部类:非静态内部类默认持有外部类的引用。写回调、Runnable时,要么用静态内部类+弱引用,要么在onDestroy里把引用置null。
  • 注册监听器:注册了BroadcastReceiver、EventBus、观察者模式,一定要在合适的时机反注册。我见过有人只注册不反注册,泄漏得悄无声息。

一个小技巧:如果你不确定某个对象会不会泄漏,可以在onDestroy里打一行日志,打印一下引用情况。或者用LeakCanary,它会自动帮你检测Activity和Fragment的泄漏,省心很多。

嗯,内存泄漏的基础就这些。说白了就是一句话:搞清楚对象的生命周期,别让不该活着的对象赖着不走。下一章我会讲具体的排查工具和实战案例,到时候咱们再细聊。