线上报了一条 OutOfMemoryError,第一反应通常是:查泄漏。
于是团队打开 LeakCanary ,盯着 Activity 引用链排了两天,结果一个明显泄漏都没有。
这并不奇怪。
OOM 不是“Java 堆里有泄漏”的同义词,而是某次内存申请无法被满足。
申请可能来自 Java 对象、 Bitmap 、 Native malloc 、线程栈、 WebView 、相机缓冲区,也可能只是多个正常任务在同一时刻撞到一起。
先建立一张完整的内存地图
一个 Android 进程的内存,不只有 Java/Kotlin Heap 。
还包括 Native Heap 、线程栈、 Bitmap 像素、 DirectByteBuffer 、 GL 纹理、 MediaCodec 缓冲区、映射文件和共享页等。
这解释了一个常见现象: Heap Dump 看起来不大,进程 PSS 却很高;或者 Java 堆还有空间, Native 分配已经失败。
同时要分清三种信号:
OutOfMemoryError;第三种在 Android 11 及以上可结合 ApplicationExitInfo、系统日志和线上退出原因判断,不能只搜 OOM Crash 。
OOM 风险 ≈ 常驻内存 + 瞬时峰值 + 并发叠加 − 当前可用预算

第一类现场:对象活得太久,堆只进不出
Case 1 :生命周期泄漏把页面留在内存里
典型错误包括:单例持有 Activity 、 Observer 注册后未移除、 Handler/Runnable 捕获页面、协程或 Rx 订阅跨越生命周期、 WebView 被错误挂到全局容器。
这类 OOM 的特征不是一次峰值,而是重复进入页面后, GC 基线一阶一阶抬高。
定位:先看 Heap Dump 的 Dominator Tree 和 Retained Size ,再沿 GC Root 查引用链。 LeakCanary 适合快速发现 Android 常见泄漏,但线上问题仍要结合复现路径和堆快照确认所有权。
最优解:让长生命周期对象不引用短生命周期对象;注册与注销成对;异步任务绑定生命周期并可取消;必须传 Context 时优先判断是否真的需要 Activity 。
Case 2 :没有泄漏,却有“无限增长”的业务容器
有些对象业务上仍然可达,因此工具不会把它判成泄漏:
定位:按业务操作比较容器大小和 Retained Size ;检查谁在持有大量同类对象,而不是只找 Activity 。线上监控要记录队列长度、缓存项数和单项平均大小。
最优解:每个容器都要有容量、过期时间、淘汰策略和降级行为;缓存预算按字节而不是按条数计算;失败队列必须有重试上限和落盘策略。
“对象仍然有用”不等于“对象可以无限保留”。

第二类现场:对象没活多久,峰值已经把进程击穿
Case 3 : Bitmap 按原图解码,缓存和变换一起放大
图片文件只有几百 KB ,解码后却按宽 × 高 × 每像素字节数占内存。超长图、大图列表、相册预览、截图和模糊变换尤其危险。
更常见的峰值来自同一张图的多个版本:原图 Bitmap 、裁剪中间图、圆角结果、上传 ByteArray 、内存缓存和纹理上传同时存在。
定位:同时看 Bitmap 实例、 Native/Graphics 内存、图片真实像素和请求尺寸;复现快速滑动、切图与前后台切换。只看压缩文件大小会严重低估成本。
最优解:请求阶段指定目标尺寸;缩略图和原图分离;大图分块或区域解码;限制预加载数量;缓存按字节设置预算。不要在列表里先解码原图再交给 ImageView 缩放。
Case 4 : JSON 、压缩、上传制造短暂的“三份大对象”
一次 40MB 文件处理,可能同时出现输入 ByteArray 、解压结果、 String 、 JSON 树、 DTO 列表和最终模型。它们很快会释放,但在重叠的几秒内已经超过预算。
大对象还可能需要较大的连续空间。此时总空闲内存看起来并非为零,申请仍可能失败。
定位:记录操作前、峰值和结束后的内存,而不是只留最终 Heap Dump ;按时间线查看大数组、 String 、 ByteArray 和集合何时重叠;检查 Crash 栈中的申请大小。
最优解:流式读取、分块解压、分页解析、边读边写;避免 ByteArray ↔ String 反复转换;上传走文件流;大任务串行或限并发。减少同时存在的副本,比在结束后主动 GC 更有效。

第三类现场: Java 堆没满,进程照样倒下
Case 5 : JNI 、 DirectByteBuffer 与第三方 SDK 吃掉 Native Heap
C/C++ 的 malloc 、 DirectByteBuffer 、图片库、音视频库、地图和热修复 SDK ,可能在 Java Heap 之外持有大量内存。
Java 对象有时只是一个很小的壳,真正的大块内存在 Native 层。只导 Java Heap Dump ,当然看不到根因。
定位:用 dumpsys meminfo 先判断 Java 、 Native 、 Graphics 等分类趋势;再使用 Android Studio Native Allocations 或 Perfetto heapprofd 查看 Native 分配栈。 JNI 资源必须检查创建与释放是否一一对应。
最优解:为 Native 对象建立明确的 owner ; Close/Release 必须幂等并覆盖异常路径; DirectBuffer 控制池大小;升级或隔离异常 SDK 。不要用 Java 堆很小来证明进程内存健康。
Case 6 : WebView 、 Camera 、 MediaCodec 、 GL 资源没有及时释放
这些组件背后往往连接系统进程、 Surface 、纹理和硬件缓冲区。页面销毁了, Java 壳对象变少,不代表底层资源已经回收。
常见现场包括:多 WebView 预创建、播放器切集不释放旧实例、 Camera Image 未关闭、 Surface/Texture 重建、 GL 纹理只创建不删除。
定位:严格执行“打开 → 使用 → 关闭”的固定脚本,比较每轮后的 Native/Graphics/PSS ;观察 Surface 、 BufferQueue 、 Codec 和资源数量是否持续增加。问题只在特定厂商出现时,还要按系统版本和机型分组。
最优解:资源生命周期与页面状态机绑定;切换失败和异常回调也必须 Release ;复用重量级实例要有明确上限;后台不可见时停止不必要的预加载和渲染。
Case 7 :并发任务和线程爆炸,把正常峰值叠成 OOM
单个图片解码、下载、数据库查询都能跑过,不代表十个一起跑也安全。
无界线程池会带来线程栈和任务队列;多个协程虽然不等于多个线程,却仍可能让图片、响应体和中间结果同时驻留。预加载、重试和页面刷新叠在一起,峰值会突然翻倍。
定位:把任务并发数、线程数、队列长度与内存时间线对齐;看到 unable to create native thread 一类错误时,优先检查线程数量和创建来源,而不是继续扩大 Java 堆。
最优解:对图片、网络、解析和上传分别设置并发上限;取消离屏与过期任务;重试使用退避并避免同一请求重复执行;让背压从入口生效,而不是等队列堆满后清理。
很多线上 OOM 不是某个任务太大,而是太多“合理任务”同时活着。
一套不靠猜的 OOM 定位顺序
第一步,先给问题分类。
有 Java OOM 栈,就看失败类型、申请大小和调用栈; Native 崩溃看 malloc 、线程创建和底层库;无 Crash 只有进程重启,则查 ApplicationExitInfo 与系统低内存信号。
第二步,画出场景时间线。
记录进入页面、加载数据、解码图片、切后台和退出页面的时间点。 OOM 往往发生在最后一次申请,但根因可能是此前十分钟积累的保留对象。
第三步,选择对应工具。
| 现象 | 优先工具 |
|---|---|
| Java 对象持续增长 | Heap Dump 、 Dominator Tree 、 LeakCanary |
| Native Heap 增长 | Native Allocations 、 Perfetto heapprofd |
| Bitmap/Graphics 增长 | Memory Profiler 、 dumpsys meminfo 、图片请求审计 |
| 线程数持续增长 | 线程快照、线程创建栈、任务队列指标 |
| 无堆栈进程重启 | ApplicationExitInfo 、系统日志、退出原因 |
第四步,验证“谁拥有、何时释放、最大多少”。
对每类大对象回答三个问题: Owner 是谁?释放条件是什么?数量或字节上限是多少?答不出来的地方,通常就是风险点。
第五步,在低预算设备做峰值回归。
覆盖低内存机型、大图、大列表、弱网重试、快速切页和前后台切换;验收峰值 PSS 、 Java/Native 分类、线程数、缓存大小和进程退出率。
最优解不是 largeHeap ,而是预算和所有权
android:largeHeap="true" 可能提高部分设备上的堆上限,但它不会修复泄漏、无界缓存、 Native 增长或并发峰值,还可能让问题更晚、更重地暴露。
真正有效的治理顺序是:
预算还要按设备能力分级。旗舰机上“没复现”,不能证明低端机安全。
OOM 治理的核心不是多申请一点内存,而是让每一块内存都有主人、有上限、有释放时机。
最后
看到 OOM 就查泄漏,像看到房间塞满东西,只检查门有没有关。
有时是旧东西没扔,有时是仓库没有上限,有时只是五辆货车同时卸货,还有时东西根本堆在隔壁的 Native 房间。
下一次排查,先别急着 largeHeap,也别只导一份 Java Heap Dump 。
先确认失败的是哪类申请,内存究竟被谁持有,峰值又在哪里叠加。
你们线上最难定位的一次 OOM ,最后是在 Java 堆、 Bitmap 、 Native ,还是线程和并发峰值?
参考资料
https://developer.android.com/topic/performance/memory-overview
https://developer.android.com/studio/profile/capture-heap-dump
https://developer.android.com/studio/profile/record-native-allocations
https://developer.android.com/topic/performance/graphics/load-bitmap
https://developer.android.com/reference/android/app/ApplicationExitInfo
https://perfetto.dev/docs/data-sources/native-heap-profiler
夜雨聆风