第2章 常见内存问题:泄漏、抖动与OOM

做Android开发这几年,处理最多的性能问题就是内存相关的。说实在的,很多刚入行的同学分不清内存泄漏、内存抖动和OOM到底有什么区别。

我个人习惯把它们比作「慢性病」「抽风」和「猝死」。下面展开聊聊,每个问题是怎么定义的,又怎么表现出来。

2.1 内存泄漏(Memory Leak)—— 慢性病

定义:对象在生命周期结束后,仍然被GC根引用链可达,导致无法被回收。说白了就是你不需要它了,系统却以为你还用得着,就一直占着坑。

表现:可用内存一步步缩水,就像慢性失血。你开着App刷朋友圈,刷着刷着越来越卡,最后可能闪退。我们看内存监控曲线,可用内存稳步下降,不反弹。

典型场景:

  • 静态变量持有Activity/Context引用
  • 内部类(尤其是匿名内部类)隐式持有外部类引用
  • 未取消的Handler发送延迟消息,Activity销毁后仍被引用
  • 资源未关闭:Cursor、Socket、File流
  • 单例中传入Context未使用ApplicationContext

我在项目中遇到过最经典的案例:一个单例缓存类,里面放了个List<Activity>用于管理Activity栈。结果退出几个Activity后,那个列表还引用着它们,导致十几MB内存一直释放不掉。嗯,后来改成弱引用才解决问题。

2.2 内存抖动(Memory Churn)—— 抽风式GC

定义:短时间内大量创建和销毁对象,导致GC被频繁触发。GC一次就STW(暂停所有线程),频率一高,卡顿感就来了。

表现:滑动列表掉帧、动画卡顿、Logcat里频繁出现GC_FOR_ALLOC或GC_CONCURRENT。你想象一下:每几秒就有一个GC暂停,用户操作能不卡吗?

// 典型反面案例 —— 在onDraw里创建对象
@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    Paint paint = new Paint();  // 每次绘制都new
    paint.setColor(Color.RED);
    canvas.drawCircle(100, 100, 50, paint);
}

上面这段代码,我建议你永远不要写。18个月内,我至少帮三个团队排查过类似问题——一旦列表快速滑动,每秒创建几十个Paint对象,GC瞬间爆炸。

2.3 内存溢出(OOM)—— 猝死

定义:当Java堆内存已满且无法再分配对象时,JVM抛出OutOfMemoryError。注意:它是个Error,不是Exception,你一般catch不住。

表现:App直接闪退,日志里能找到java.lang.OutOfMemoryError。用户正在下单,突然App没了,体验极差。

OOM主要有三类:

类型表现特征常见原因
Java Heap OOM堆内存不足,报"Failed to allocate a ... byte allocation with ... free bytes"内存泄漏积累、大图片加载、加载超大数组
Native Heap OOMNative层内存耗尽,通常不报Java堆栈Bitmap解码忘记recycle、大量线程栈、JNI未释放资源
线程创建OOM无法创建新线程,报"pthread_create failed"线程池无限制,疯狂创建线程

我记得有一回线上OOM率飙到3%,查到最后是一张超清壁纸在列表里每个item都解码了一次,而且没有复用Bitmap。说白了就是没有走三级缓存,每个瀑布流卡片都独自做BitmapFactory.decodeStream。换成Glide后,OOM率直接降到0.1%以下。

Android常见内存问题关系图 内存泄漏 对象无法被GC回收 内存抖动 频繁创建和销毁对象 OOM(内存溢出) 内存耗尽,直接崩掉 可用内存持续下降 类似慢性失血 频繁GC → 卡顿 抽风式暂停 最终都会指向 —— APP崩溃 / 闪退 积累到临界

2.4 三者的关系与对比

画个表格帮你梳理,也好记忆:

问题本质核心表现排查线索我的经验
内存泄漏对象活着不该活内存只升不降,最终OOMLeakCanary抓取、MAT分析十有八九是静态引用或内部类
内存抖动对象频繁生灭GC频繁,滑动掉帧Profile Tracer看分配onDraw里new对象最常见
OOM内存耗尽闪退,日志Error崩溃堆栈、堆转储不要指望catch OOM,提前预防

这里我想强调一点:三个问题不是孤立的。内存泄漏积累多了必然触发OOM;内存抖动导致频繁GC也会加剧碎片化,间接增加OOM风险。很多新手以为抖动只是性能问题,其实抖得厉害时对象分配不出去,同样会OOM。

2.5 避坑指南

我曾经踩过的坑,你现在就别踩了:

  • 静态集合类一定要控制生命周期。 我一个项目里有个static Map<String, Object>做缓存,结果忘了清理,活动被强引用,内存泄漏跑不了。
  • 匿名内部类尽量改成静态内部类 + 弱引用。 尤其是做回调的时候,Handler、Runnable、AsyncTask,哪一个不是泄漏大户?
  • 用ApplicationContext而不是Activity.this。 单例、广播接收器、Service里一律用getApplicationContext()。
  • 不要在onDraw / onBindViewHolder里分配对象。 我建议你把Paint、Rect这些对象定义成成员变量,复用它们。
  • Bitmap一定要复用或按需压缩。 一次加载原图解码,再牛逼的手机也扛不住。用BitmapFactory.Options.inSampleSize配合Glide。

一个小技巧:如果你怀疑某个页面有内存泄漏,快速测试方法就是反复进入退出该页面,然后手动触发GC(Android Studio里的垃圾回收按钮),看内存是否降回基线。不降?恭喜,你又找到一个泄漏点。