android 性能优化—内存泄漏,内存溢出OOM

发布于:2025-09-10 ⋅ 阅读:(18) ⋅ 点赞:(0)

android 内存泄漏原理

Android 内存泄漏(Memory Leak)的核心原理一句话:
生命周期更长的对象,直接或间接持有了生命周期更短(本该被回收)的对象的强引用,导致 GC 无法释放这些内存,最终触发 OOM 或频繁 GC 卡顿。

下面从 引用链 → 典型场景 → GC Root → 检测原理 → 泄漏后果 5 步给你讲透

引用链模型:谁挡住了 GC?

GC Root ─► 静态变量 ─► 被销毁的 Activity ─► 大图片/Bitmap
         ↑
        Application

Activity 被 finish() 后,本应从根节点不可达;
静态变量持有它的强引用 → GC 遍历标记时仍可达 → 不会回收 → 内存持续占用。

高频泄漏场景(Top 6)

场景 泄漏持有者 被泄漏者 原因
静态变量 static Activity ctx 当前 Activity 静态生命周期 = 应用级
内部类 Handler 非静态 外部 Activity 隐式 this$0 引用
注册未反注册 BroadcastReceiver Activity 系统 → 强引用
单例 UserManager.get().listener = this Activity 单例 = 静态
动画/定时器 ValueAnimator View/Activity setTarget 持引用
Native Bitmap / long 指针 Java 对象 JNI 未 DeleteLocalRef

GC Root 有哪些?(ART 源码)

// art/runtime/gc/collector/mark_sweep.cc
VisitRoots()
  ├─ Thread stacks (局部变量)
  ├─ JNI local/global references
  ├─ Class static fields
  ├─ ArtField/ArtMethod
  └─ Monitor (synchronized 锁)

只要从 任意 Root 可达,对象就不会被回收

泄漏检测原理(以 LeakCanary 为例)

弱引用监控

WeakReference<Activity> ref = new WeakReference<>(activity, queue);

销毁后 5s 强制 GC

System.gc(); Runtime.getRuntime().gc();

若 ref.get() ≠ null → 仍可达 → dump hprof

Shark 解析 hprof最短引用链 → 输出泄漏路径

泄漏后果

程度 表现
轻度 可用内存减少 → 频繁 GC → 帧率下降(卡顿)
中度 Memory Profiler 锯齿 → 触发大量 Full GC
重度 OOM 崩溃java.lang.OutOfMemoryError

修复口诀

“静态不存 Activity,内部类改静态+弱引用,注册必反注册,单例用弱引用/生命周期感知,动画及时 cancel,Bitmap 及时 recycle,Native 内存配对 delete。”

一句话总结

Android 内存泄漏 = “长周期对象强引用短周期对象,导致 GC 无法回收;定位用 LeakCanary,修复斩断引用链即可。”

为什么 Handler 持有普通对象 不会泄漏

  • 普通对象 没有 生命周期管理器(如 AMS)去强制销毁,也 不会被系统缓存

  • 只要 消息执行完 或者 手动清空引用,对象就能被 GC;

  • 除非这个普通对象本身很大,否则 多活一会儿无感

一句话普通对象不是“高价值”内存,也不存在“必须按时回收”的契约,所以 不会被认为是泄漏

Android 官方泄漏规则

引用链 是否泄漏 原因
GC Root → Activity Activity 被系统缓存,占用大量内存(View、Bitmap、资源)
GC Root → 普通 POJO 无生命周期约束内存小延迟回收可接受
GC Root → Bitmap/大数组 大对象一次可达几百 MB必须立即释放
GC Root → 静态集合 集合无限增长等同于内存泄漏

hprof文件参数

这三个字段是 Android Studio Memory Profiler / MAT / Perfetto / dumpsys meminfo 里常见的内存指标,分别告诉你 「对象自己占多少、对象引用的 native 层占多少、对象整棵引用树一共占多少」。记住一句话:

Shallow 看自身,Native 看 JNI,Retained 看全家。

实战用途

字段 英文直译 到底指什么 实战用途
Shallow Size 浅层大小 对象本身在 Java 堆里的字节数(头 + 实例字段) 快速判断 哪个类实例多
Native Size Native 大小 因为 这个 Java 对象 而在 native 层 malloc 的内存(Bitmap 像素、DirectByteBuffer、Mesh 等) 定位 Java 层看似很小,native 却爆炸 的“假轻量”对象
Retained Size 保留大小 这个对象被 GC 回收 时,整棵引用树能一起被释放 的总字节数(Shallow + 所有仅被它引用的对象) 真正的内存大户 / 泄漏根节点

一张图秒懂

Java 对象 A(Shallow = 24 B)
├─ Bitmap 对象 B(Shallow = 56 B,Native = 16 MB)
└─ 大数组 C(Shallow = 1 MB)

假设 A 是整棵树的唯一 GC Root
→ Retained Size = 24 B + 56 B + 16 MB + 1 MB ≈ 17 MB

实战口诀

  • Shallow 排序 → 看 “谁数量多”

  • Native 排序 → 看 “谁暗度陈仓”

  • Retained 排序 → 看 “谁一旦释放能省最多”

anroid 内存泄漏分析文件-hprof

android studio打开文件

选中泄漏的代码

展示GC root调用链

它清晰地给出了一条 最短引用链(Shortest Path To GC Root)

泄漏结论(一眼定)

AppListFragment 被它的宿主 Adapter 长期持有 → Adapter 又被 ViewPager2 → Binding → Activity 强引用
导致 整棵 Activity 树(含 Bitmap、View、Context 等)无法被 GC,形成 Activity 级内存泄漏

逐层拆解(看图说话)

引用层级 对象 作用 泄漏原因
GC Root MediaProxyManager$3/4/5 匿名内部类 持有 this$0 = MediaProxyManager → 间接持 Activity
1 MediaProxyManager 单例/长生命周期 Activity 里注册但未 unregister
2 mContext Activity 实例 被单例长期当 Context 用
3 binding ViewBinding 持有 root View & ViewPager2
4 mainViewPager ViewPager2 继续持 RecyclerView
5 mRecyclerView RV 持 Adapter
6 mAdapter AppCenterPagerAdapter 内部 List<Fragment> fragments
7 Index 0 AppListFragment 本应随 Activity 销毁,仍被数组引用

引用链总长 8 级Retained Size ≈ 25.9 KB(截图),实际整个 Activity 树 可能达数 MB(含图片、缓存、Context)。

根因定位(代码层面)

AppCenterPagerAdapter 大概率写成 静态内部类 + 长期持有 Activity 的 Fragment 列表

public class AppCenterPagerAdapter extends FragmentStateAdapter {
    // ❌ 错误:把 Fragment 实例保存在 Adapter 成员里
    private final List<Fragment> fragments = new ArrayList<>();
}

或者MediaProxyManager 单例里注册监听时把 Activity 自身 传进去:

MediaProxyManager.getInstance().register(this);   // this = Activity

销毁时未反注册,导致 单例 → Activity → Binding → ViewPager2 → Adapter → Fragment 整条链泄漏。

修复方案(一步到位)

MediaProxyManager.getInstance().unregister(this);

Adapter 不存 Fragment 实例
使用 FragmentStateAdapter 正确姿势:

public class AppCenterPagerAdapter extends FragmentStateAdapter {
    public AppCenterPagerAdapter(@NonNull FragmentActivity fa) {
        super(fa);
    }
    @NonNull
    @Override
    public Fragment createFragment(int position) {
        return new AppListFragment();
    }
    @Override
    public int getItemCount() {
        return 3;
    }
}

一句话总结

AppListFragment 被 Adapter 长期持有 → Adapter 被 ViewPager2 → Binding → Activity 强引用,而 Activity 又被 MediaProxyManager 单例注册未反注册,导致整棵 Activity 树无法 GC;修复就是“反注册 + 用 FragmentStateAdapter 不存实例 + binding 置空”。

anroid 内存溢出分析文件-hprof

在Systemui中静态遍历持有率View并不会出现内存泄漏,但是连续多个持有会导致OOM

「静态遍历收集 View 并暂存到 List」本身不会触发 LeakCanary(引用链合法、生命周期一致),
一次性把 几千~几万个 View 对象 + 像素数据 全部压进内存
结果就是 “合法地把 Java 堆撑爆” —— 无泄漏,却 OOM。

场景还原(真实案例)

public class NavBarView extends ViewGroup {
    private static List<View> sAllButtons;   // 静态,全局缓存

    public void rebuildButtons() {
        sAllButtons = new ArrayList<>();
        for (int i = 0; i < getChildCount(); i++) {
            sAllButtons.add(getChildAt(i));   // 只存引用,不释放
        }
    }
}
  • 静态引用 → LeakCanary 不报(View 仍被父 View 持有,生命周期一致)。

  • sAllButtons10 000+ View → 每个 View 平均 500 B(Shallow) + 背景 Bitmap 2 MB(Native)
    → 总计 几十~上百 MB 瞬间进入 Java 堆 + Native 堆

  • SystemUI 进程默认 512 MB heap几次 rebuild 后直接顶到上限,GC 后仍 <1% → OOM 崩溃

为什么不算「泄漏」

GC Root 链 静态 List → View → Bitmap,链路可达无意外引用
生命周期 View 仍被 Window/Parent 持有,系统没要求立即回收
LeakCanary 不报,因为 KeyedWeakReference 被清除无 retained 对象
内存曲线 瞬间尖峰GC 后回落,但 尖峰已触发 OOM

持有了wms remove的View任然不会出现内存泄漏

即使你把一个已经被 WMS(WindowManagerService)remove 的 View 保存在静态变量里,也不会触发“内存泄漏”警报

项目 说明
引用链合法 View 被静态变量持有,引用链可达没有被 GC Root 意外持有(如 Activity、Context)。
生命周期不再受系统管理 View 被 remove 后,WMS 不再引用它Parent 也为 null它就是一个普通的 Java 对象
LeakCanary 不报 LeakCanary 只监控 Activity、Fragment、Context、Bitmap、Service 等关键对象,普通 View 不在监控范围内
内存占用小 单个 View 的 Shallow Size 通常 < 1KB不持有 Bitmap 或 Native 资源时,几乎无感

没有泄漏却 OOM

“没有泄漏却 OOM” 的典型场景:
对象生命周期完全合法、引用也正常,只是 “瞬间申请量 > 堆上限” —— 合法地把堆撑爆。
通俗点:不是忘了收,而是一次点太多菜,桌子(堆)直接塞不下。

常见 5 大「无泄漏 OOM」场景

场景 触发点 日志特征 快速验证
1. 大图未采样 BitmapFactory.decodeResource() 原图加载 Failed to allocate 31961100 bytes Memory Profiler → Bitmap 尺寸 = 原图像素
2. 一次查太多数据 SELECT * FROM 100w 行 Cursor window allocation of 2048 kb failed 断点看 cursor.getCount()
3. 大数组/集合 new int[10000000]List.addAll(巨集合) array size exceeds VM limit Shallow Size = 40 MB
4. 序列化/JSON 拼装 StringBuilder.append(超大字符串) char[] of length 12000000 Allocation Tracking 看 char[] 瞬间飙高
5. 线程数爆炸 new Thread() 循环创建 pthread_create (1040KB stack) failed /proc/pid/status → Threads > 400

跟「泄漏」区别

项目 内存泄漏 无泄漏 OOM
引用链 存在 GC 无法到达意外持有 引用链 正常可达业务确实需要
Heap 曲线 持续上升多次 GC 不掉 瞬间陡增GC 后回落
LeakCanary 红色警报 不报
Retained Size 巨大且根节点是静态/生命周期异常 根节点就是当前业务对象,释放即消失

OOM抛异常是抛最后一个压死骆驼的稻草

最后一行确实只是“稻草”

java.lang.OutOfMemoryError: Failed to allocate a 32 byte allocation with 5064512 free bytes ...
	at java.util.LinkedHashMap$LinkedEntrySet.iterator(LinkedHashMap.java:680)
  • 32 BLinkedHashMap 想 new 一个迭代器

  • 5064 KB 剩余 说明 堆已 99.9% 吃满

  • 这行代码本身无罪,只是 “申请不到内存”被 JVM 选中抛异常

真正的凶手一定在 heap dump 的大对象榜

复现瞬间 dump heap

adb shell am dumpheap -n <pid> /data/local/tmp/oom.hprof
adb pull /data/local/tmp/oom.hprof

用 Android Studio / MAT 打开

Profiler → Memory → Heap Dump → Classes 标签 → 按 Retained Size 降序

排名 Class Retained Size 说明
1 byte[] 420 MB Bitmap 像素(native 搬到 java 堆后)
2 java.util.ArrayList 38 MB 缓存了 80 万个对象
3 android.graphics.Bitmap 17 MB Shallow 小,但引用了上面的 byte[]

结论byte[] 420 MB 就是真凶,最后一行 32 B 只是 “申请不到内存”被 JVM 抓来抛异常

一句话总结

OOM 日志的最后一行只是“申请失败的小对象”,真正的元凶是 heap 里早已存在的“大对象/大集合”;dump heap → 按 Retained Size 排序 → Top1 就是罪魁祸首。

内存泄漏可以导致内存溢出,内存溢出不一定有内存泄漏

这是面试/排查中的 高频易错点

用一句话先背下来:

泄漏是“该死的不死”,溢出是“活的太多装不下”;
泄漏可以撑爆堆,但堆爆不一定是泄漏。**

定义对照表

概念 本质 是否违法 GC 规则 典型表现
内存泄漏 (Memory Leak) 对象 已无用仍可达GC 回收不掉 ❌ 违反 堆曲线 只涨不跌多次 GC 不掉
内存溢出 (Out Of Memory) 堆/Native 已用尽再申请就抛 OOM ⭕ 合法 一次性 瞬间尖峰 也能触发

两者关系(维恩图)

┌──────────────────────────────┐
│      内存溢出 (OOM)           │
│                              │
│   ┌─── 内存泄漏 导致的 OOM ───┐ │
│   │                          │ │
│   │  例:静态集合无限增长    │ │
│   │  例:Handler 持有 Activity│ │
│   └───  占总量 60~70%  ─────┘ │
│                              │
│   其余 30~40%                │
│   ┌─── 无泄漏 OOM ─────────┐ │
│   │  例:一次加载原图 20MB  │ │
│   │  例:分页 LIMIT 1000000│ │
│   └─── 活的太多装不下 ─────┘ │
└──────────────────────────────┘

面试标准回答(背)

  1. 泄漏 ⇒ 溢出
    “静态集合把 Activity 钉住,堆只涨不跌,最终顶到 512 MB 上限 → OOM。”

  2. 溢出 ⇏ 泄漏
    “用户选 1 张 100 MB 原图,未采样直接 decode,瞬间申请超过剩余堆 → OOM,但图片对象随后就被回收,无泄漏。”

现场判断口诀

步骤 工具 泄漏 无泄漏溢出
1. 看堆曲线 Profiler 只涨不跌 瞬间尖峰→回落
2. 看 Retained Heap Dump Top1 是 static/Context Top1 是 byte[]/ArrayList(业务需要)
3. 看 LeakCanary 红色警报 不报


网站公告

今日签到

点亮在社区的每一天
去签到