4、内存优化(上):Java内存模型、GC机制、内存泄漏常见场景

大家好,我是你们的老朋友。今天咱们聊聊内存优化,这个话题我做了这么多年Android,每次面试必问,每次项目必踩坑。说实在的,内存问题就像慢性病,平时不疼不痒,一旦爆发就是ANR、OOM,直接让用户卸载你的App。

这一章我们先啃上半部分:Java内存模型、GC机制,还有那些年我们踩过的内存泄漏坑。嗯,准备好了吗?

4.1 Java内存模型:别再说“堆和栈”就完了

很多人一聊内存模型,张口就是“堆存对象,栈存引用”。这话没错,但太粗糙了。我习惯把Java内存模型拆成三个核心区域来理解:

区域 存储内容 生命周期 是否共享
堆(Heap) 对象实例、数组 GC管理,存活时间不定 线程共享
栈(Stack) 局部变量、方法调用帧 方法结束即销毁 线程私有
方法区(Method Area) 类信息、常量、静态变量 类卸载时释放 线程共享

这里有个细节我特别想强调:静态变量是存在方法区的,不是堆里。我在项目中遇到过好几次,有人把大Bitmap赋值给静态变量,以为“反正GC会回收”,结果方法区里的引用一直活着,Bitmap根本释放不了。你想想看,这坑踩得多冤?

核心要点:Java内存模型决定了对象的存活路径。一个对象只要从GC Roots出发能到达,就不会被回收。GC Roots包括:栈帧中的局部变量、静态变量、活跃的线程、JNI引用等。

4.2 GC机制:别怕,它没那么玄乎

GC(垃圾回收)说白了就是帮我们打扫内存卫生的。但很多人一听到“Stop The World”就紧张,其实大可不必。Android从5.0开始全面使用ART虚拟机,GC效率比Dalvik时代好太多了。

ART的GC主要分几种:

  • Concurrent GC:大部分工作和应用线程并发执行,暂停时间很短
  • Full GC:全堆回收,暂停时间较长,一般发生在内存紧张时
  • Explicit GC:主动调用System.gc(),我建议你永远别主动调

为什么会这样?因为主动调GC不仅不会立即生效(只是建议),还会触发不必要的Full GC,反而拖慢性能。我曾经在一个项目里看到同事在循环里调System.gc(),结果页面卡成PPT。嗯,后来我帮他改成了对象池复用,问题直接解决。

我的建议:不要试图“帮”GC做决定。你只需要保证对象不再被引用,GC自然会处理。过度干预反而适得其反。

4.3 内存泄漏常见场景:这些坑我全踩过

内存泄漏,说白了就是“该回收的对象回收不了”。下面这几个场景,我敢说每个Android开发都遇到过,只是有些人还没意识到。

4.3.1 Handler泄漏:经典中的经典

这个太常见了。匿名内部类Handler持有外部Activity的引用,如果消息队列里还有未处理的消息,Activity就泄漏了。

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

// 正确写法:静态内部类 + 弱引用
private static class SafeHandler extends Handler {
    private final WeakReference<Activity> activityRef;

    SafeHandler(Activity activity) {
        activityRef = new WeakReference<>(activity);
    }

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

我在项目中遇到过最离谱的一次:一个同事在Fragment里写了Handler,然后Fragment被销毁了,但Handler里有个延迟5秒的消息。结果用户反复进出这个页面,内存直接飙到200MB。嗯,后来我们加了个removeCallbacksAndMessages(null)在onDestroy里。

避坑指南:我曾经以为只要用静态Handler就万事大吉,结果忘了在Activity销毁时清空消息队列。记住:静态Handler只是解决了内部类持有外部引用的问题,但消息队列里的回调依然可能引用已销毁的Context。

4.3.2 静态变量泄漏:最隐蔽的杀手

静态变量的生命周期和Application一样长。如果你把一个Activity或View赋值给静态变量,那这个Activity就永远别想被回收了。

public class LeakExample {
    private static Context sContext;  // 千万别这么干!

    public static void setContext(Context context) {
        sContext = context;  // 如果传入的是Activity,它就泄漏了
    }
}

正确的做法是:能用Application Context就别用Activity Context。我习惯在Application类里保存一个全局Context引用,需要时直接拿,既安全又方便。

4.3.3 单例模式泄漏:设计模式的陷阱

单例本身没问题,但单例里持有Activity引用就有问题了。比如一个网络请求的单例,里面保存了回调接口,而回调接口又引用了Activity。

public class NetworkManager {
    private static NetworkManager instance;
    private Callback callback;  // 这里可能泄漏

    public static NetworkManager getInstance() {
        if (instance == null) {
            instance = new NetworkManager();
        }
        return instance;
    }

    public void setCallback(Callback callback) {
        this.callback = callback;  // 如果callback是匿名内部类,就泄漏了
    }
}

解决方案很简单:使用弱引用,或者在不需要时主动置空。我个人更倾向于后者,因为弱引用虽然能防止泄漏,但回调可能被GC提前回收,导致逻辑异常。

4.3.4 匿名内部类泄漏:Lambda也逃不掉

很多人觉得用了Lambda就不会有内存泄漏,其实不然。Lambda只是语法糖,底层还是匿名内部类。只要它捕获了外部类的引用,一样会泄漏。

// 看似安全的Lambda
button.setOnClickListener(v -> {
    // 如果这里访问了Activity的成员变量,Lambda就持有了Activity的引用
    textView.setText("clicked");
});

那怎么办?及时解注册。在onDestroy里把监听器置空,或者使用Lifecycle感知组件自动管理。

总结一下:内存泄漏的本质是“长生命周期对象持有了短生命周期对象的引用”。只要抓住这个本质,你就能自己判断哪些代码有风险。

4.4 知识体系总览

下面这张图是我自己梳理的内存优化知识体系,帮你把今天讲的内容串起来:

Android内存优化知识体系(上) Java内存模型 堆 · 栈 · 方法区 GC机制 Concurrent · Full · Explicit 内存泄漏场景 Handler · 静态 · 单例 · 匿名类 堆(Heap) 栈(Stack) 方法区 并发GC Full GC Handler 静态变量 核心原则:长生命周期对象不应持有短生命周期对象的引用 使用弱引用 · 及时解注册 · 优先Application Context 下一章预告:内存优化(下)—— 内存分析工具、Bitmap优化、对象池、内存抖动 公众号:蓝海资料掘金营,微信deep3321

好了,这一章的内容就到这里。内存优化是个系统工程,今天讲的这些场景,你回去翻翻自己的项目代码,大概率能找到几个。别慌,发现问题就是解决问题的第一步。

最后送大家一句话:内存泄漏不可怕,可怕的是你不知道它发生了。学会用Profiler、LeakCanary这些工具,让泄漏无处遁形。