Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

内存优化与泄漏排查

内存问题不是只有 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, 并检查对象是否真的需要跨页面生命周期.

三, 标准排查流程

  1. 复现并记录页面路径, 设备内存档位, 版本和前后台次数.
  2. 观察 GC 后基线是否持续上升, 区分泄漏和峰值.
  3. Java 对象使用 heap dump/LeakCanary 找 shortest path to GC root 和 dominator.
  4. native/graphics/mmap 使用 Perfetto, heapprofd 和进程内存映射, 不只盯 Android Studio Java heap 图.
  5. 修复后重复相同场景, 比较峰值, 回落基线和线上 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 当成 “永远不会泄漏” 的万能对象; 它仍可能持有大缓存和注册项.
  • 修复泄漏后还要验证页面功能, 并发取消和缓存命中是否被破坏.

版本与参考资料