乐于分享
好东西不私藏

App性能体验之OOM篇

App性能体验之OOM篇

线上报了一条 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 分配已经失败。

同时要分清三种信号:

1.Java/Kotlin 抛出 OutOfMemoryError
2.Native 分配失败、崩溃或创建线程失败;
3.系统在内存压力下杀进程, App 下次表现为冷启动,但未必留下 Java OOM 栈。

第三种在 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 :没有泄漏,却有“无限增长”的业务容器

有些对象业务上仍然可达,因此工具不会把它判成泄漏:

没有容量上限的内存缓存;
聊天、日志、埋点失败队列一直累加;
分页列表只进不出;
Map 用业务 ID 做 Key ,却从不淘汰;
重试任务把请求体和回调一起保留。
fun cache(id: String, bitmap: Bitmap) {
   imageCache[id] = bitmap
  // 没有容量、过期和淘汰策略
}

定位:按业务操作比较容器大小和 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 增长或并发峰值,还可能让问题更晚、更重地暴露。

真正有效的治理顺序是:

1.修正所有权和释放路径
2.给缓存、队列、历史记录设硬上限
3.按目标尺寸生产 Bitmap 和数据
4.用流式处理拆掉大对象重叠
5.限制并发、线程和预加载
6.建立 Java 、 Native 、 Graphics 的分项预算

预算还要按设备能力分级。旗舰机上“没复现”,不能证明低端机安全。

OOM 治理的核心不是多申请一点内存,而是让每一块内存都有主人、有上限、有释放时机。

最后

看到 OOM 就查泄漏,像看到房间塞满东西,只检查门有没有关。

有时是旧东西没扔,有时是仓库没有上限,有时只是五辆货车同时卸货,还有时东西根本堆在隔壁的 Native 房间。

下一次排查,先别急着 largeHeap,也别只导一份 Java Heap Dump 。

先确认失败的是哪类申请,内存究竟被谁持有,峰值又在哪里叠加

你们线上最难定位的一次 OOM ,最后是在 Java 堆、 Bitmap 、 Native ,还是线程和并发峰值?

参考资料

Android Developers : Overview of memory management
 https://developer.android.com/topic/performance/memory-overview
Android Developers : Capture a heap dump
 https://developer.android.com/studio/profile/capture-heap-dump
Android Developers : Record native allocations
 https://developer.android.com/studio/profile/record-native-allocations
Android Developers : Loading Large Bitmaps Efficiently
 https://developer.android.com/topic/performance/graphics/load-bitmap
Android Developers : ApplicationExitInfo
 https://developer.android.com/reference/android/app/ApplicationExitInfo
Perfetto : Native heap profiler
 https://perfetto.dev/docs/data-sources/native-heap-profiler