内存优化与泄漏排查
内存问题不是只有 Java heap 泄漏. 中级以上排查必须区分 Java, native, graphics, mmap / 文件映射, 线程栈和瞬时峰值, 并用证据判断增长来源. 方法论, 指标口径与跨域取舍见 性能优化总览.
一, 内存问题分类
| 类型 | 常见现象 | 典型证据 |
|---|---|---|
| Java/Kotlin 对象泄漏 | GC 后占用持续增长 | heap dump, Dominator Tree, LeakCanary |
| Native 泄漏 | Java heap 正常但 RSS/PSS 上升 | heapprofd, Perfetto, native allocation |
| Graphics/Bitmap 峰值 | 图片, 动画, Surface 页面突增 | graphics memory, 图片尺寸/并发记录 |
| mmap / 文件映射 | 数据库, 字体, dex, 模型映射占用 | /proc, Perfetto, memory map |
| 瞬时并发峰值 | 请求结束后回落, 但峰值触发 OOM | 时间序列, 并发数, 解码 / 序列化路径 |
Retained set (保留集合): 移除对象 A 后变得不可达, 可被回收的对象集合.Retained size (保留大小): 该集合中所有对象 shallow size (对象自身占用, 不含其引用对象) 的总和, 是一个数值而非集合.
Dominator Tree (支配树): 若任意一条从 GC Root 到对象 B 的引用路径都经过对象 A, 则 A 支配 B. 树记录对象之间的直接支配关系; A 的支配子树可帮助近似识别 A 的 retained set, 并据此计算其 retained size. 它不是 retained set 本身, 也不是 retained size 这个数值.
二, Android 常见泄漏
- Activity/Fragment/View 被单例, 静态集合, 长生命周期回调或未取消任务持有.
- Fragment View 生命周期结束后仍保留 binding, adapter 或 listener.
- 非静态内部类, Handler/Runnable, 匿名回调隐式持有外部对象.
- 协程或 Flow 使用了错误 scope, 页面销毁后仍持续收集.
- Cursor, 文件, ParcelFileDescriptor, 线程和 native 资源未关闭.
ViewModel 不应持有 Activity, Fragment 或 View; 需要 Context 时优先注入 Application Context, 并检查对象是否真的需要跨页面生命周期.
三, 标准排查流程
- 复现并记录页面路径, 设备内存档位, 版本和前后台次数.
- 观察 GC 后基线是否持续上升, 区分泄漏和峰值.
- Java 对象使用 heap dump/LeakCanary 找 shortest path to GC root 和 dominator.
- native/graphics/mmap 使用 Perfetto, heapprofd 和进程内存映射, 不只盯 Android Studio Java heap 图.
- 修复后重复相同场景, 比较峰值, 回落基线和线上 OOM 分布.
四, Bitmap 与资源释放
现代 Android 中不应把 Bitmap.recycle() 当常规泄漏治理方法. 只要没有引用, 普通 Bitmap 生命周期应交给 GC/native 内存管理; 手动 recycle 可能导致仍在绘制或缓存中的 Bitmap 被提前释放.
更有效的手段:
- 按目标尺寸解码, 限制并发和预取.
- 使用成熟图片库管理请求, 缓存和生命周期.
- 对动画, 视频帧和大图建立内存预算.
- 在明确所有权的底层组件中关闭 Closeable/native 资源.
五, 线上 OOM 治理
- 按系统版本, ABI, 机型内存档位, 页面和前后台状态聚合.
- 上报进程内存快照, 关键业务上下文和图片 / 模型尺寸, 但避免收集敏感数据.
- Java OOM 堆栈不一定指向真正的长期持有者; 需要与趋势, heap dump 和 native 指标关联.
- 为高风险页面设置并发, 缓存和降级策略, 不依赖崩溃后猜测.
完整泄漏演练: Heap Dump 到回归
上下文片段 (Activity): 错误示例把页面引用交给单例; 它用于复现, 不应进入生产代码. 省略 Activity, Bundle 等 imports, 布局中名为 title 的 View, 以及完整生命周期代码.
object EventBus { var listener: (() -> Unit)? = null }
override fun onCreate(state: Bundle?) { super.onCreate(state); EventBus.listener = { title.text = "updated" } }
进入并退出页面后, 在 debug 环境用 Memory Profiler 或 LeakCanary 捕获 Heap Dump. Dominator Tree 的直接支配关系用于定位 retained size 较大的支配子树; 它不等同于 GC Root, 也不能把树本身说成 “可释放集合”.沿 Activity 到 GC Root 的强引用链可得到 EventBus.listener -> lambda -> Activity/View. 修复是在 onDestroy(或更合适的 View 生命周期) 清除回调, 并避免长期对象持有 View: EventBus.listener = null. 随后重复相同进退路径, 检查引用链消失, GC 后基线回落并验证事件功能未丢失. 此流程是操作演练, 未声称任何实际 heap 数字.
高频面试题
Q1: LeakCanary 的核心思路是什么?
在对象生命周期本应结束后使用弱引用观察是否被回收, 必要时触发 heap dump 并分析到 GC Root 的引用链. 它主要帮助定位 Java/Kotlin 对象泄漏, 不能覆盖所有 native/graphics 问题.
Q2: 内存上涨就是泄漏吗?
不是. 缓存, JIT, 图片解码, 线程, mmap 和延迟 GC 都可能使占用上升. 关键看相同场景重复后基线是否持续增长, 资源是否可回收及增长归属.
易错点 / 追问
- 不把
System.gc(),recycle()当通用修复. - 不只看 Java heap 就排除 OOM 风险.
- 不把 Application Context 当成 “永远不会泄漏” 的万能对象; 它仍可能持有大缓存和注册项.
- 修复泄漏后还要验证页面功能, 并发取消和缓存命中是否被破坏.
版本与参考资料
- 最后核验: 2026-08-07
- Memory management overview
- Profile your app memory
- Perfetto heapprofd