android 性能优化—内存泄漏,内存溢出OOM
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@Overridepublic Fragment createFragment(int position) {return new AppListFragment();}@Overridepublic 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 持有,生命周期一致)。
但
sAllButtons
里 10 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 B 是 LinkedHashMap 想 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│ │
│ └─── 活的太多装不下 ─────┘ │
└──────────────────────────────┘
面试标准回答(背)
泄漏 ⇒ 溢出:
“静态集合把 Activity 钉住,堆只涨不跌,最终顶到 512 MB 上限 → OOM。”溢出 ⇏ 泄漏:
“用户选 1 张 100 MB 原图,未采样直接 decode,瞬间申请超过剩余堆 → OOM,但图片对象随后就被回收,无泄漏。”
现场判断口诀
步骤 | 工具 | 泄漏 | 无泄漏溢出 |
---|---|---|---|
1. 看堆曲线 | Profiler | 只涨不跌 | 瞬间尖峰→回落 |
2. 看 Retained | Heap Dump | Top1 是 static/Context | Top1 是 byte[]/ArrayList(业务需要) |
3. 看 LeakCanary | 红色警报 | 不报 |