OOM与低内存治理
# OOM 异常与低内存治理
本文核心命题:OOM 是内存治理失败的兜底信号,是"应用想要的"和"系统能给的"之间不可调和的冲突。治理 OOM 不是事后救火,而是事前建立"内存上限感"与"低内存降级"机制。OOM 治理 = 监控前兆 × 降级链路 × 兜底恢复。
# 01.阅读说明
- 本文卷归属:卷二 · 资源篇 · 第 3 篇
- 本文目标层级:L2 进阶 → L3 专家 → L4 架构
- 适用平台:Android(主) / iOS / Web / 跨端框架 / 嵌入式
- 前置阅读:
卷二·02 内存监控与治理(OOM 是内存治理失败的兜底信号)卷零·06 性能预算与防劣化体系
- 本文核心命题:
OOM 是内存治理失败的兜底信号,是"应用想要的"和"系统能给的"之间不可调和的冲突。 治理 OOM 不是事后救火,而是事前建立"内存上限感"与"低内存降级"机制。
全文 21 章地图:
§01 阅读说明 §02 贯穿案例 §03 OOM 物理本质 §04 触发条件原理
§05 度量与采集 §06 归因决策树
§07 堆 OOM 全链路 ⭐ §08 LMK/Jetsam 全链路 ⭐ §09 地址空间全链路 ⭐
§10 资源耗尽全链路 ⭐ §11 跨端 OOM 全链路 ⭐ §12 跨端对照
§13 治理一层堆 ⭐ §14 治理二层 LMK ⭐ §15 治理三层地址 ⭐ §16 治理四层兜底 ⭐
§17 求证实验 ⭐ §18 实战案例 §19 防劣化体系 §20 跨平台速查
§21 总结与延伸
2
3
4
5
6
7
阅读建议:先读 §02 案例 → §03/§04 拿到原理 → §05/§06 学会度量归因 → §07-§11 五大全链路(堆/LMK/地址/资源/跨端)→ §13-§16 四层治理 → §17 求证 → §18-§20 工程闭环。
# 02.贯穿案例
本案例贯穿全文:§03 看懂代价、§04 拿到触发条件模型、§05/§06 用三方案+决策树定位、§07-§11 五个全链路对应每一类 OOM、§17 用实验复盘、§13-§16 给出分层策略闭环。
# 2.1 案例背景
某短视频 App V5.2 上线"沉浸式 Feed + 自动播放",OOM 率激增:
- 业务场景:主 Feed 滚动 + 自动播放视频;用户连续刷 50+ 条后崩溃。
- 用户反馈:低端机 OOM 崩溃率 6.8%,旗舰机 1.5%;32 位用户问题尤其严重("内存看起来够用但还是崩")。
- 业务损失:滚动深度(关键指标)远低于行业;投诉占崩溃类前三。
# 2.2 经验派的 3 周折腾(典型反面教材)
| 假设 | 措施 | 结果 |
|---|---|---|
| 视频缓存太大 | 减小 PreloadBuffer 50% | OOM -10% |
| largeHeap 不够 | manifest 设 largeHeap=true | 无明显改善 |
| 视频解码内存高 | 启用硬解 | 中端机 OK,低端机仍崩 |
3 周累积,OOM 率仅从 6.8% 降到 5.8%。原因:真正的根因是 32 位地址空间碎片 + 低内存信号未响应——不是单纯的"分配过多"。
# 2.3 方法派的 10 天闭环
新接手的同学按本文方法论重做:
Day 1(§04 触发条件模型):分析 OOM 现场,发现 3 类共存:
- A 类堆 OOM:占 25%(Java 堆达上限)
- B 类地址空间 OOM:占 55%(32 位连续大块申请失败)
- C 类 LMK:占 20%(系统压力大杀进程)
Day 2-3(§13 一层堆 + §14 二层 LMK):
- 缓存上限严控(图片/视频缓存按 maxMemory/8 算)
- onTrimMemory 4 级响应(差异化释放)
Day 4-7(§15 三层地址):
- 双 abi App Bundle 上线 64 位
- 大对象改 mmap
Day 8(持续优化):
- Bitmap 下采样 + inBitmap 复用
Day 9-10(§16 四层兜底):
- 主动重启机制(检测危险阈值即重启)
# 2.4 上线效果
| 指标 | 经验派 3 周后 | 方法派 10 天后 |
|---|---|---|
| OOM 率(低端 32 位) | 5.8% | 0.4% |
| OOM 率(高端 64 位) | 1.4% | 0.05% |
| 后台被杀率 | 22% | 6% |
| 单次浏览深度 | 12 条 | 38 条 |
| 用户感知崩溃比 | 100% | 12%(主动重启效果) |
| 广告变现 | 基线 | +38% |
核心反差:3 周经验派改了缓存大小 + largeHeap + 硬解视频——这些动作错过了 B 类(地址空间)和 C 类(LMK)。方法派靠"OOM 三类分类"识别真因,10 天降到 0.3%。
# 2.5 案例如何串起本文
- §03/§04 物理本质 + 触发条件 ▶▶ 重定义"OOM"——区分堆 / 地址空间 / LMK / 资源耗尽四类。
- §05/§06 度量+归因 ▶▶ 接入 KOOM/MetricKit 抓现场快照,决策树定位走"32 位地址空间碎片化"分支。
- §07-§11 五大全链路 ▶▶ 堆/LMK/地址/资源/跨端 五条链路对应案例每一类 OOM。
- §17 求证实验 ▶▶ §17.1 地址碎片、§17.2 信号时机、§17.3 降级、§17.4 64 位、§17.5 主动重启。
- §13-§16 治理 ▶▶ "堆/LMK/地址/兜底"四层正是案例落地路径。
探索性思考:为什么经验派会反复猜错?因为他们把 OOM 当成单一问题。真实世界的 OOM 是一个"伪概念"——它包含至少 4 种完全不同的物理触发:堆耗尽 / 系统杀进程 / 地址空间碎片 / 资源耗尽。好的工程师面对模糊概念的第一动作是"分类" —— 不分类就无法精准施治。
# 03.OOM 物理本质
# 3.1 一句话定义
OOM = 进程"请求一次内存分配"时,系统无法满足,导致进程被终止或异常抛出。
这句话隐含三个不可商量的物理约束:
约束一:分配请求 vs 系统能力 是"瞬时供需"问题
OOM 不是"内存慢慢用满",而是某一次具体的 alloc 请求失败。即使应用平均占用很低,只要某一次需要大块连续内存(如 100MB Bitmap),系统拿不出来就 OOM。
约束二:物理内存 ≠ 可用内存
物理 RAM (例如 6GB)
├── 内核占用 (~500MB,不可用)
├── 其他进程占用 (~3GB)
├── 文件缓存 (可回收,但需要时间)
└── 真正"立即可用" (~1GB)
↑
应用申请超过这部分会触发 OOM
2
3
4
5
6
7
约束三:地址空间限制是物理硬约束(特别是 32 位)
每个进程的虚拟地址空间有上限。32 位上限 4GB(用户态约 3GB),即使物理内存充足,单进程也无法用超过地址空间。32 位应用 OOM 经常发生在 PSS 还有空间但找不到连续虚拟地址。
# 3.2 现象与代价
OOM 是用户感知最严重的性能问题之一:
- 应用直接崩溃:用户看到"应用已停止",是最差体验。
- 后台被静默杀死:iOS jetsam / Android LMK 静默杀掉进程,用户切回时"重启"。
- 首页加载失败:低内存设备进入首页时大对象分配失败,白屏 / 报错。
- 特定场景必崩:拍照、播放视频等高内存场景在低端机上稳定复现。
业务代价(行业实测数据):
- 头部 App:OOM 率每降 0.1% → 留存 +0.3% / DAU +0.5%。
- 中低端机型(< 4GB RAM)OOM 率往往是高端机的 5-10 倍。
- iOS 系统 jetsam 后用户无任何提示,体验类崩溃。
# 3.3 度量准则与基准
资源视角(USE):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| 内存使用率 | 进程内存 / 系统可用 | < 70% 安全 |
| 内存饱和度 | 是否触发 swap / kill | swap > 0 即告警 |
| OOM 错误数 | OOM 触发频次 | 每千次启动 < 1 |
请求视角(RED):
| 指标 | 含义 | 阈值参考 |
|---|---|---|
| OOM 崩溃率 | OOM / 总崩溃 | < 0.1% |
| 后台被杀率 | 不正常退出 / 启动总数 | < 5% |
| 内存压力等级事件 | onTrimMemory 触发次数 | 应作为前兆 |
关键约定:OOM 不应只统计"崩溃",必须统计"前兆事件"(如 onTrimMemory、低内存警告)。前兆比崩溃早 10–60 秒,是治理的最佳窗口。
行业基准:
| 平台 | OOM 率 | 后台被杀率 | 触发阈值 |
|---|---|---|---|
| Android(4GB+) | < 0.05% | < 5% | maxHeap = 256/512MB |
| Android(< 4GB) | < 0.2% | < 10% | maxHeap = 128/256MB |
| iOS | < 0.05%(jetsam) | < 3% | 由系统动态决定 |
| Web | < 0.01% | N/A | 浏览器 ~4GB / Tab |
| 嵌入式 | 0%(必须) | 0% | 厂商配置 |
# 3.4 反直觉问题清单
带着这些问题阅读:
- 物理内存还够,为什么 32 位应用还是 OOM?
- 收到 onTrimMemory 一定要释放内存吗?释放多少?
- iOS 没有 OOM,jetsam 和 OOM 一样吗?
- Bitmap OOM 一定是 Bitmap 占内存太大吗?
- 后台被杀就一定是因为内存吗?
- 大 Bitmap 拆成小 Bitmap 还会 OOM 吗?
- 增大 largeHeap 一定能避免 OOM 吗?
- low-end 设备和高端设备的 OOM 阈值差多少?
探索性思考:为什么"OOM"作为概念在工程界被滥用?因为它字面直观——"内存不够了"。但实际上 OOM 包含至少 4 种独立的物理机制:堆/LMK/地址空间/资源。好的术语必须精确——把 4 种问题混成 1 个名词,是工程沟通最大的浪费。真正的高手治理 OOM 第一步:把"OOM"这三个字拆开。
# 04.触发条件原理
# 4.1 OOM 四类触发条件
OOM 不是单一原因,而是多个条件同时满足才触发。把这些条件画成树:
OOM 触发 = 任一路径成立
┌────────────────────────────────────────────────────┐
│ A. 进程级上限(应用 Heap 限制) │
│ Android: maxHeapSize(128/256/512MB) │
│ iOS: jetsam 限制(动态,前台优先) │
│ 触发:Java OutOfMemoryError │
├────────────────────────────────────────────────────┤
│ B. 系统级压力(设备整体低内存) │
│ 所有进程总和接近物理 RAM │
│ 触发:LMK 杀进程 / iOS jetsam │
├────────────────────────────────────────────────────┤
│ C. 地址空间碎片(32 位特有) │
│ 虚拟地址池无法找到连续大块 │
│ 触发:mmap / malloc 大块失败 │
├────────────────────────────────────────────────────┤
│ D. FD / Thread 等其他资源耗尽 │
│ file descriptors / threads 上限 │
│ 触发:OOM-like 错误("Too many threads") │
└────────────────────────────────────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
关键认知:
- A 类是应用层 OOM(Java 抛 OOMError)
- B 类是系统层 OOM(进程被杀,应用层看不到)
- C 类是 32 位特有(即使物理够,地址空间不够)
- D 类是被错认为 OOM 的"资源耗尽"
这就是为什么 OOM 治理必须分类:四类的归因路径和优化手段完全不同。
# 4.2 跨平台同构原理
不同平台的具体表现差异巨大,但抽象成"分配请求超过供给上限"后完全同构:
通用 OOM 模型:
[应用申请 X 字节] ──▶ [系统检查能否满足]
│
┌─────┴─────┐
▼ ▼
能 → 给 不能 → OOM
│
┌───────────────┼───────────────┐
▼ ▼ ▼
应用层抛错 系统杀进程 静默失败
(Java OOM) (LMK/jetsam) (malloc NULL)
2
3
4
5
6
7
8
9
10
11
12
每个平台都必须解决:
| 抽象问题 | 解决什么问题 |
|---|---|
| 上限设定 | 每个进程能用多少(防一个应用拖垮系统) |
| 压力感知 | 何时通知应用"快不够了" |
| 决策机制 | 不够时杀谁、杀几个 |
| 应用响应 | 应用收到压力信号后如何降级 |
正因为它们都长这个样子,OOM 的本质也是同一个:
OOM 不是平台 bug,是"应用需求与系统供给"之间不可调和的冲突。治理就是建立"压力感知 + 主动降级 + 兜底恢复"三层机制。
# 4.3 跨平台术语对照
| 通用术语 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| 应用层 OOM | OutOfMemoryError | EXC_RESOURCE | "Out of memory" 错误 | malloc 返回 NULL |
| 系统层 OOM | LMK kill | jetsam | Tab Killer | 内存监控守护进程 |
| 压力信号 | onTrimMemory(level) | didReceiveMemoryWarning | chrome.system.memory | RTOS 内存事件 |
| 进程上限 | maxHeapSize | physFootprintLimit | ~4GB/Tab | 厂商配置 |
| 杀进程策略 | LMK 按 oom_adj | jetsam 按优先级 + 内存 | 按 tab 活跃度 | 按优先级 |
# 4.4 平台差异点矩阵
| 维度 | Android | iOS | Web | 嵌入式 |
|---|---|---|---|---|
| OOM 抛错 | OutOfMemoryError | EXC_RESOURCE / NSException | OOM JS Error | malloc 返 NULL |
| 系统杀进程 | LMK(按 oom_adj 评分) | jetsam(按优先级 + 内存) | Tab Killer | 内存守护进程 |
| 上限固定 | maxHeapSize 安装时定 | 动态(前台 ~2-3GB) | ~4GB/Tab | 配置文件 |
| Bitmap 处理 | 8.0+ 在 Native,单独 RSS | NSImage 全在 RAM | Image 在 GPU 内存 | 视框架 |
| 压力分级 | 5 级(COMPLETE / MODERATE / BACKGROUND / UI_HIDDEN / RUNNING_*) | 1 级(didReceive...) | Chrome 4 级 | 自定义 |
| 后台缓存策略 | 系统统一管理 | 应用自管 | Service Worker | 应用自管 |
探索性思考:为什么 Android 有 5 级 trim、iOS 只有 1 级 warning?这反映了两个平台的设计哲学差异——Android 给应用"渐进式预警"(让应用按级别响应),iOS 给应用"二元告警"(一旦警告就该全力降级)。没有"更好的设计",只有"更适合的设计" —— Android 的 5 级让灵活性高、iOS 的 1 级让简单性高。
# 05.度量与采集
# 5.1 三类采集方案
OOM 的采集方案有三类,区别在于在 OOM 发生的"前 / 中 / 后"哪一段下钩子:
平稳期 ──▶ 压力期 ──▶ OOM 触发 ──▶ 进程终止
│ │ │
▼ ▼ ▼
① 常态监控 ② 前兆捕获 ③ 现场快照
(PSS/RSS/ (onTrim/ (UncaughtException
JS heap) warning) / Crash hook)
2
3
4
5
6
① 常态监控(采集进程内存基线)
| 平台 | API | 间隔建议 |
|---|---|---|
| Android | Debug.MemoryInfo + 定时 | 30 秒 |
| iOS | task_vm_info + Timer | 30 秒 |
| Web | performance.memory + setInterval | 60 秒 |
| 嵌入式 | getrusage() 等 | 视场景 |
物理本质:内存问题是累积的,单点采样无意义,必须看趋势。
② 前兆捕获(监听系统压力信号)
| 平台 | API | 触发时机 |
|---|---|---|
| Android | ComponentCallbacks2.onTrimMemory(level) | 5 级 |
| iOS | UIApplication didReceiveMemoryWarning | 一次性信号 |
| Web | PressureObserver(实验) | 4 级 |
物理本质:操作系统在内存紧张时通知应用,是治理黄金窗口。
③ 现场快照(OOM 发生时抓 dump)
| 平台 | 工具 |
|---|---|
| Android | KOOM / Matrix / hprof |
| iOS | MetricKit / 自定义 |
| Web | heap snapshot |
物理本质:抓 OOM 现场的对象图,用于离线归因分析。
# 5.2 各方案的可见盲区
| 方案 | 钩子位置 | 数据粒度 | 性能开销 | 跨端通用性 | 线上可用 | 主要局限 |
|---|---|---|---|---|---|---|
| ① 常态监控 | 定时采样 | 总量趋势 | 低 | 跨端通用 | 是 | 不能定位分配点 |
| ② 前兆捕获 | 系统回调 | 压力信号 | 极低 | Android+iOS | 是 | Web 较弱 |
| ③ 现场快照 | 崩溃时 | 对象图 | 中 | 各端有差异 | 部分 | 仅崩溃后看 |
实战建议:① + ② 是线上必备;③ 用于深度归因。
# 5.3 跨平台采集对照表
| 平台 | 常态监控 | 前兆捕获 | 现场快照 |
|---|---|---|---|
| Android | Debug.MemoryInfo | onTrimMemory | KOOM / Matrix |
| iOS | task_vm_info | didReceiveMemoryWarning | MetricKit MXOOMReport |
| Web | performance.memory | PressureObserver | Heap Snapshot |
| Compose | 同 Android | 同 | 同 |
| Flutter | DevTools Memory | 平台原生 | DevTools |
# 5.4 数据可信度评估
- 常态监控:可信度高,但只看总量(不是"哪个对象占了")。
- 前兆捕获:可信度极高(系统级)。
- 现场快照:可信度高,但有"采集时已晚"的问题。
探索性思考:为什么"前兆捕获"是 OOM 监控最被低估的方法?因为它"不发生时看不到价值"——平时收到 onTrimMemory 处理了就处理了,没有数据上报。但真正的价值在于"前兆响应能让 70% 的 OOM 不发生"(§17.3 实验)。最好的监控不是"事后分析",而是"事前阻止"。
# 06.归因决策树
# 6.1 OOM 归因决策树
OOM 发生 → 抓现场判断
│
├─ Java 抛 OutOfMemoryError
│ │
│ └─ 走 A 类(堆 OOM)分支:
│ - 看 Java heap usage / Bitmap heap
│ - 主因:缓存无上限 / 大对象 / 内存泄漏
│ - 治理:§13 治理一层堆
│
├─ 进程被杀(无栈)
│ │
│ └─ 走 B 类(LMK/jetsam)分支:
│ - 看 onTrimMemory 历史 / jetsam log
│ - 主因:系统压力大 / 应用未响应压力
│ - 治理:§14 治理二层 LMK
│
├─ Native crash + signal SIGABRT/SIGSEGV
│ │
│ └─ 走 C 类(地址空间)分支:
│ - 看 /proc/[pid]/maps 最大连续块
│ - 主因:32 位 + 大块连续分配
│ - 治理:§15 治理三层地址空间(升 64 位)
│
└─ "Too many open files" / "Too many threads"
│
└─ 走 D 类(资源耗尽)分支:
- 看 lsof 计数 / 线程数
- 治理:§16 资源治理
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 6.2 现场快照分析法
Android KOOM 流程:
- 监控 Java heap 占用率(> 90% 触发)
- fork 子进程(不阻塞主进程)
- 子进程 dump hprof
- 离线上传 + 分析(找出最大占用对象类)
iOS MetricKit MXOOMReport:
class OOMHandler: NSObject, MXMetricManagerSubscriber {
func didReceive(_ payloads: [MXDiagnosticPayload]) {
payloads.forEach { payload in
payload.crashDiagnostics?.forEach { crash in
// crash.metaData.osVersion / crash.callStackTree
}
}
}
}
2
3
4
5
6
7
8
9
# 6.3 OOM 前兆识别法
前兆指标:
- onTrimMemory 触发频次(> 3 次/小时即危险)
- Java heap 占用率持续 > 80%
- Native 内存增长速率 > 1MB/s(持续)
- FD 数量 > 800(接近 1024 上限)
- 线程数 > 200
前兆响应:
- 立即降级(清缓存)
- 上报"高危状态"到监控
- 触发主动重启(§16 兜底)
# 6.4 多维度归因法
按"维度组合"识别 OOM 模式:
| 维度组合 | 典型场景 | 治理方向 |
|---|---|---|
| 32 位 + 长时间使用 | 短视频、阅读类 App | 升级 64 位 |
| 低端机 + 后台缓存多 | 全场景 | onTrimMemory 4 级响应 |
| 大图加载 + 列表 | 电商/社交 | inSampleSize 降采样 + LRU |
| 视频 + 同时多路 | 直播、视频会议 | 单路 + 主动停后台 |
| WebView 多个 | Hybrid 应用 | WebView 池 + 复用 |
探索性思考:为什么"决策树 + 维度组合"是 OOM 归因的最佳工具?因为 OOM 是"高维问题"——单一维度(如 PSS)看不到全貌,必须结合 OS 平台 + 设备等级 + 业务场景。多维度归因 = 工程版的"望闻问切" —— 不能只看一个症状下结论。
# 07.堆 OOM 全链路
堆 OOM 是 Java/JS 等"托管语言"独有的 OOM 形态:进程级堆达到上限时抛错。理解堆的全链路,才能精准治理。
# 7.1 Android Java 堆 OOM 全链路
① 应用启动
↓ Zygote fork
② Dalvik/ART 设定 maxHeapSize
- 默认:128MB / 256MB / 512MB(按设备)
- largeHeap=true:扩到 256MB / 512MB / 1GB
↓
③ 应用 new Object()
↓
④ ART 检查 heap:
- 够 → 分配
- 不够 → 触发 GC
↓
⑤ GC 后还不够:
- 触发 alloc-time GC
- 仍不够 → 抛 OutOfMemoryError
↓
⑥ 应用 catch 或 crash
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
关键观察:
- ART 的 GC 是"按需触发",不是定时
- LargeHeap 只是延后 OOM,不能根治
- Bitmap 在 Android 8+ 移到 Native heap,单独 RSS
典型代码场景:
// 反例:缓存无上限
private Map<String, Bitmap> cache = new HashMap<>();
cache.put(url, bitmap); // 永远不清
// 正例:LRU 限上限
LruCache<String, Bitmap> cache = new LruCache<>(maxKb) {
@Override protected int sizeOf(String key, Bitmap b) {
return b.getByteCount() / 1024;
}
};
2
3
4
5
6
7
8
9
10
# 7.2 iOS 堆 OOM 全链路
iOS 没有传统意义的"堆 OOM"——所有内存(除 Bitmap GPU 内存)都在统一进程地址空间。但 iOS 的 jetsam 在内存压力时杀进程,效果同 OOM。
① 应用 alloc
↓
② malloc → libmalloc → vm_allocate
↓
③ 内核分配虚拟内存页
↓
④ 内核监控 phys_footprint:
- 接近 jetsam limit → 发 memoryWarning
- 超过 limit → kill 进程
↓
⑤ 应用收到 didReceiveMemoryWarning(如果还没被杀)
→ 应用机会响应
2
3
4
5
6
7
8
9
10
11
12
关键差异:
- iOS 没有"堆"概念,是统一虚拟内存
- jetsam limit 由系统动态决定(前台 ~2-3GB,后台 ~50-200MB)
- 收到 warning 不响应几乎一定被杀
# 7.3 Web JS 堆 OOM 全链路
① V8 启动 Isolate
↓
② 设定 heap limit(默认 ~4GB / Tab)
- --max-old-space-size 可调
↓
③ JS 创建对象
↓
④ V8 GC(分代 + 标记清除)
↓
⑤ heap 接近 limit:
- GC 后仍不够 → "Out of memory" 错误
→ Tab 崩溃 "Aw, Snap!"
2
3
4
5
6
7
8
9
10
11
12
Web 特殊性:
- 浏览器对单 Tab 有限制
- DOM 节点也消耗内存
- Image 在 GPU 内存(非 V8 heap)
# 7.4 Compose / SwiftUI 堆 OOM 全链路
- Compose:使用 Android Java heap(共用),但 LayoutNode 比 View 小约 30%
- SwiftUI:使用 iOS 统一虚拟内存,View 是值类型(栈或临时堆)
# 7.5 堆 OOM 全链路性能数据
| 平台 | 堆上限 | OOM 抛错点 | 是否可恢复 |
|---|---|---|---|
| Android | maxHeapSize(128-512MB) | Java OOMError | 可 catch 但风险大 |
| iOS | jetsam limit(动态) | 进程被杀 | 不可(已死) |
| Web | ~4GB / Tab | "Out of memory" | Tab 重载 |
▶▶ 回扣 §02 案例:短视频 A 类堆 OOM 占 25%——主因是图片缓存无上限。Day 2 加 LRU 上限后立刻 -25%。
探索性思考:为什么 catch OOMError 是反模式?因为 OOM 是"全局信号"——抛错时整个进程都在内存压力下,catch 后再分配很可能再次抛。catch OOM = 治标不治本,正确做法是"前兆响应 + 主动降级"。
# 08.LMK/Jetsam 全链路
当系统级内存压力大时,OS 会主动杀进程释放内存。Android 用 LMK(Low Memory Killer),iOS 用 jetsam。
# 8.1 Android LMK 全链路
① 系统检测 free memory
↓ 低于阈值(kernel 配置)
② LMK 启动评估:
- 遍历所有进程
- 按 oom_adj_score 排序(值越大越易被杀)
↓
③ 杀进程顺序:
- 缓存进程(CACHED_EMPTY_APP_ADJ = 906)
- 服务进程(SERVICE_ADJ = 500)
- 后台进程(PERCEPTIBLE_APP_ADJ = 200)
- 前台进程(FOREGROUND_APP_ADJ = 0,几乎不杀)
↓
④ 在杀之前发 onTrimMemory:
- 应用未响应 → 继续杀
- 应用响应释放足够 → 暂时不杀
2
3
4
5
6
7
8
9
10
11
12
13
14
15
oom_adj_score 关键值:
| 进程类型 | oom_adj | 备注 |
|---|---|---|
| 系统进程 | -1000 | 永不杀 |
| 前台进程 | 0 | 几乎不杀 |
| 前台 Service | 100-200 | 轻微保护 |
| 可见后台 | 200 | |
| 后台进程 | 400-900 | 优先被杀 |
| 空进程 | 906 | 最先被杀 |
# 8.2 iOS Jetsam 全链路
① 系统检测 phys_footprint
↓ 接近物理 RAM 限制
② Jetsam 启动评估:
- 按进程优先级(前台/后台/挂起)
- 按内存占用
↓
③ 发 memoryWarning:
- 前台 App 收到 didReceiveMemoryWarning
- 应用机会释放
↓
④ 仍不够 → 杀低优先级进程:
- 后台 App 优先(< 50-200MB 限制)
- 挂起 App
- 前台仅在极端情况下被杀
2
3
4
5
6
7
8
9
10
11
12
13
14
iOS jetsam 限制:
| 状态 | 内存限制(iPhone 12,6GB RAM) |
|---|---|
| 前台 active | ~2-3 GB |
| 后台 active | ~200 MB |
| 后台挂起 | ~50 MB |
| Extension | ~30-50 MB |
# 8.3 onTrimMemory 五级详解
override fun onTrimMemory(level: Int) {
when (level) {
TRIM_MEMORY_RUNNING_MODERATE -> {
// 应用前台运行,系统内存中等
// 时间窗:> 3 分钟
// 动作:清理非必要缓存
imageCache.trimToSize(maxSize / 2)
}
TRIM_MEMORY_RUNNING_LOW -> {
// 系统内存较低
// 时间窗:~ 2 分钟
// 动作:积极清理
imageCache.trimToSize(maxSize / 4)
videoCache.evictAll()
}
TRIM_MEMORY_RUNNING_CRITICAL -> {
// 系统内存严重不足
// 时间窗:~ 30 秒
// 动作:保留最小集
keepEssentialOnly()
}
TRIM_MEMORY_UI_HIDDEN -> {
// 应用切到后台
releaseUIResources()
}
TRIM_MEMORY_BACKGROUND -> {
// 后台缓存进程
// 时间窗:> 10 分钟
evictBackgroundOnlyCache()
}
TRIM_MEMORY_MODERATE -> {
// 后台 + 系统内存中等
// 时间窗:~ 3 分钟
evictMostCache()
}
TRIM_MEMORY_COMPLETE -> {
// 后台 + 系统内存严重不足
// 时间窗:~ 5 秒
releaseEverything()
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 8.4 Web Tab Killer 全链路
① 浏览器监控总内存(所有 Tab + 渲染进程)
↓ 超过阈值
② 选择"非活跃 Tab"杀掉:
- 后台 Tab > 前台 Tab
- 老 Tab > 新 Tab
↓
③ Tab 显示"Aw, Snap!" / 灰色占位
↓
④ 用户切回时浏览器自动重载
2
3
4
5
6
7
8
9
Web 特殊性:
- 没有 onTrimMemory 等价物
- 应用必须自己监控
performance.memory - 后台 Tab 可能被 throttle 或 kill
# 8.5 跨平台 LMK 对照
| 维度 | Android LMK | iOS Jetsam | Web Tab Killer |
|---|---|---|---|
| 触发依据 | oom_adj_score | 优先级+内存 | 活跃度 |
| 应用预警 | onTrimMemory 5 级 | 1 级 warning | 无标准 API |
| 预警时窗 | 5s-30min | 视优先级 | 几无 |
| 应用响应 | 必做 | 必做 | 主动监控 |
▶▶ 回扣 §02 案例:C 类 LMK 占 20%——主因是 onTrimMemory 未响应。Day 2 接入 5 级响应后立刻 -20%。
探索性思考:为什么 Android 的 5 级 trim 比 iOS 的 1 级 warning"更工程友好"?因为 5 级让应用可以"渐进式响应"——先清非必要,再清次要,最后清核心。渐进式响应优于二元响应 —— 这是工程上的优雅。但代价是复杂度——很多应用懒得做 5 级,结果还不如 iOS。
# 09.地址空间全链路
即使物理内存充足,32 位进程的虚拟地址空间也是硬约束——这是 OOM 治理中最隐形的一类。
# 9.1 32 位虚拟地址空间布局
32 位进程虚拟地址空间(4GB):
┌─────────────────────┐ 0xFFFF_FFFF
│ 内核空间(1GB) │ 用户态不可访问
├─────────────────────┤ 0xC000_0000
│ 栈(向下增长) │
│ … │
│ 共享库 mmap 区 │ libc.so / libart.so / 各 .so
│ … │
│ Java heap │ Dalvik/ART managed heap
│ … │
│ Native heap │ malloc 区
│ … │
│ 数据段 / BSS │ 全局变量
│ 代码段 .text │
├─────────────────────┤ 0x0000_0000(实际从一定偏移开始)
└─────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
关键约束:
- 用户态约 3GB 可用
- 各区域非连续,碎片化严重
- 每次 dlopen .so / mmap 文件都会消耗连续大块
# 9.2 地址空间碎片化原因
时间 t0:剩余 3GB 全部连续
└────[ 可用 3GB ]
t1:用户加载 100MB 视频
└─[100MB][ 2.9GB ]
t2:释放 100MB 视频,加载 50MB 缓存(位置不同)
└──[free][50MB][ 2.85GB ]
t3:反复加载/释放 → 碎片化
└─[free][50][f][20][f][30][f][f][...]
最大连续块可能只剩 200MB(远小于剩余 2.85GB)
2
3
4
5
6
7
8
9
10
11
12
主要消耗者:
- Bitmap(Android 8 之前在 Java heap,之后在 Native)
- mmap 文件
- dlopen .so
- Webview 内部缓存
- 共享内存(IPC)
# 9.3 64 位地址空间
64 位虚拟地址空间:256TB(实际可用)
- 几乎不可能用尽
- 碎片影响可忽略
- 大块申请几乎总能成功
2
3
4
升级 64 位的工程实施:
android {
defaultConfig {
ndk { abiFilters 'armeabi-v7a', 'arm64-v8a' }
}
bundle {
abi { enableSplit = true } // 按设备分发
}
}
2
3
4
5
6
7
8
注意事项:
- 所有 native .so 必须有 arm64-v8a 版本
- APK 体积通常 +30-50MB(双 abi)
- 但 App Bundle 按设备分发,下载体积不变
- Google Play 已强制要求 64 位
# 9.4 32 位时代的临时缓解
如果暂时无法 64 位(如某些 .so 没 arm64 版本):
// ① 大文件用 mmap(不占进程虚拟地址连续大块)
val raf = RandomAccessFile(file, "r")
val mapped = raf.channel.map(MapMode.READ_ONLY, 0, file.length())
// ② Bitmap 下采样
val opt = BitmapFactory.Options().apply {
inSampleSize = 2 // 4x 像素降为 1x
}
BitmapFactory.decodeFile(path, opt)
// ③ 避免单个 > 20MB 大对象,分块处理
2
3
4
5
6
7
8
9
10
11
# 9.5 跨平台地址空间对照
| 平台 | 默认 ABI | 32 位地址空间问题 |
|---|---|---|
| Android(旧) | armeabi-v7a + arm64-v8a | 32 位用户有 |
| Android(新) | arm64-v8a(Play 强制) | 几乎消失 |
| iOS | arm64(强制) | 无 |
| Web | 浏览器内部 | Tab 4GB 限制 |
▶▶ 回扣 §02 案例:B 类地址空间 OOM 占 55%(32 位用户专属)——这是经验派完全没意识到的"隐形 OOM"。Day 4-7 双 abi App Bundle 上线,32 位用户 OOM 直接 -55%。
探索性思考:为什么"地址空间 OOM"在工程界长期被低估?因为它违反直觉——"PSS 200MB / maxHeap 384MB,怎么可能 OOM"。直到工程师看到
/proc/[pid]/maps中"剩余 1GB 但最大连续块 35MB"的现实,才理解这是物理约束。工程教育的盲区:把"内存够用"等同于"任意大块都能分配" —— 这是错误的。
# 10.资源耗尽全链路
严格意义上不是"内存 OOM",但常被工程师误归类。FD/Thread 耗尽会导致"OOM-like"错误。
# 10.1 文件描述符耗尽
FD 上限典型 1024(每进程)。
消耗者:
- 文件流(FileInputStream / FileOutputStream)
- Socket(含 HTTPS)
- Pipe(IPC)
- AssetManager
- Bitmap.decodeFile(短暂占用)
典型崩溃:
java.io.IOException: Too many open files
治理:
// ✅ try-with-resources 确保关闭
file.inputStream().use { it.readBytes() }
// ❌ 反例
val input = file.inputStream() // 忘记 close
val bytes = input.readBytes()
2
3
4
5
6
# 10.2 线程数耗尽
每个线程占栈 512KB-1MB。300+ 线程会导致 native OOM。
典型崩溃:
java.lang.OutOfMemoryError: pthread_create (1040KB stack) failed: Try again
治理:
// ❌ 反例:每个任务一个新线程
Thread { task() }.start()
// ✅ 用线程池
val executor = Executors.newFixedThreadPool(4)
executor.submit { task() }
2
3
4
5
6
# 10.3 graphics 内存(GPU 内存)
Android 应用的 GPU 内存独立计算:
- Bitmap(API 26+ 在 native,包含 GPU 上传)
- SurfaceView / TextureView 帧缓冲
- HardwareBuffer
治理:
- 复用 Bitmap(inBitmap)
- 限制 SurfaceView 数量
- 主动 recycle 不用的 Bitmap
# 10.4 Web 资源耗尽
Web 的"资源耗尽"通常是:
- DOM 节点过多(10K+ 即危险)
- 事件监听器未移除
- WebSocket 连接未关闭
- WebGL Context 数量限制(每域名 ~16)
# 10.5 跨平台资源对照
| 资源 | Android | iOS | Web |
|---|---|---|---|
| FD 上限 | 1024(典型) | 256(默认) | N/A |
| 线程数上限 | 系统默认 ~512 | 系统决定 | Web Worker |
| GPU 内存 | 独立 | 共用 | WebGL Context |
▶▶ 回扣 §02 案例:D 类资源耗尽未在主因,但案例做了"线程池统一管理"作为防劣化措施。
探索性思考:为什么"资源耗尽"经常被错认为 OOM?因为错误信息里都有 "OutOfMemoryError"。但它们的本质完全不同——堆 OOM 是 Java 对象太多,资源耗尽是 OS 资源到达上限。症状相似 ≠ 根因相同 —— 这是工程归因最容易踩的坑。
# 11.跨端 OOM 全链路
# 11.1 Compose / SwiftUI OOM 全链路
Compose:
- 使用 Android Java heap(与 View 体系共用)
- LayoutNode 比 View 体系小 ~30%(Slot Table 优化)
- Recomposition 不重建对象(diff 复用)
SwiftUI:
- View 是值类型(栈分配 + 临时堆)
- ViewGraph 是引用类型,但框架管理生命周期
- 不会出现"View 泄漏"问题
风险:
- @State / @Published 的对象引用泄漏
- Compose remember 的大对象未释放
# 11.2 Flutter OOM 全链路
Flutter Engine (C++)
↓
Skia Canvas(共享内存池)
↓
Dart Heap (内置 GC)
↓
平台 Surface
2
3
4
5
6
7
Flutter 特性:
- Dart Heap 默认上限不大(200-300MB)
- Skia 缓存有独立配额
- ImageCache 默认 100MB(可调)
# 11.3 React Native OOM 全链路
JS 侧(V8/Hermes Heap)
↓ Bridge 序列化
Native 侧(Java/Swift Heap)
↓
原生 View
2
3
4
5
RN 特殊性:
- 双堆(JS + Native)
- Bridge 序列化产生临时对象
- 新架构 (Fabric) 减少双重持有
# 11.4 跨端 Hybrid(WebView)OOM 全链路
Native Activity / ViewController
↓ 持有 WebView 实例
WebView 内部进程(Android)/ 渲染进程(iOS)
↓
Web 内容(DOM / JS Heap / GPU)
2
3
4
5
WebView 风险:
- 多个 WebView 同时存在 = 内存翻倍
- WebView 内部缓存不受应用控制
- iOS WKWebView 在独立进程,但仍计入 jetsam
治理:
- WebView 池化复用
- 切后台主动 reload(空白页)
- 限制同时存在数量
# 11.5 跨端 OOM 对照
| 框架 | 主要堆 | OOM 风险点 |
|---|---|---|
| Android Native | Java + Native | 缓存 / Bitmap |
| iOS Native | 统一 | 大图 / 多 WebView |
| Compose | Java | 同 Android |
| SwiftUI | 统一 | 同 iOS |
| Flutter | Dart + Skia | ImageCache 默认 100MB |
| React Native | JS + Native(双) | Bridge + 双重持有 |
| Hybrid WebView | 应用 + WebView | 多 WebView 共存 |
探索性思考:为什么 Hybrid 应用的 OOM 治理最复杂?因为它有"双重内存"——Native + WebView 各自一份。优化 Native 不影响 WebView,反之亦然。复合架构带来复合复杂度 —— 工程师必须熟悉两端才能做完整治理。
# 12.跨端对照
# 12.1 五个全链路总览
| 链路 | Android | iOS | Web | Flutter | Compose |
|---|---|---|---|---|---|
| 堆 OOM | Java heap (maxHeap) | 统一虚拟内存 | V8 Heap (~4GB) | Dart Heap | Java heap |
| LMK/Jetsam | LMK + onTrimMemory | jetsam + memoryWarning | Tab Killer | 平台原生 | 同 Android |
| 地址空间 | 32 位有 / 64 位无 | 64 位强制 | Tab 4GB | 平台决定 | 同 Android |
| 资源耗尽 | FD 1024 / 线程 | FD 256 / 线程 | DOM/WebGL | 平台 | 同 Android |
| 兜底 | 主动重启 | 系统重启 | Tab 重载 | 应用重启 | 主动重启 |
# 12.2 各平台 OOM 优化优先级
Android:
- 升级 64 位(双 abi App Bundle)
- onTrimMemory 5 级响应
- 缓存上限严控
- 大图下采样 + inBitmap
- 主动重启兜底
iOS:
- didReceiveMemoryWarning 必响应
- 后台主动释放(< 200MB 限制)
- UIImage decode 时降采样
- Off-screen Rendering 节制(含图)
Web:
- DOM 节点 ≤ 1500
- 监控 performance.memory
- WebSocket 主动关闭
- 长列表虚拟滚动
# 12.3 反直觉问题答疑
| 问题 | 答案 |
|---|---|
| 物理内存够还 OOM? | 32 位地址空间碎片化是物理硬约束 |
| onTrim 一定要释放? | 是。每级有不同响应窗(5s-30min) |
| iOS 没有 OOM? | 没有 OOMError,但 jetsam 杀进程效果同 |
| Bitmap OOM = Bitmap 占大? | 不一定,可能是 View 层级深 |
| 后台被杀 = 内存? | 不一定,可能是 ANR / 用户清理 |
| 大 Bitmap 拆小还会 OOM? | 32 位仍可能(碎片化) |
| largeHeap 一定避免 OOM? | 否,只延后 |
| 低端机 OOM 阈值差几倍? | 高端机 5-10× 容忍 |
▶▶ 回扣 §02 案例:经验派 100% 命中"反直觉问题"——把 largeHeap 当万能药。真相是分类施治:堆/LMK/地址/资源四种问题,四种治法。
# 13.治理一层堆
核心命题:治理 A 类堆 OOM 的核心是"为缓存定上限、为大对象定边界"。
# 13.1 缓存上限严控
机理:缓存无上限 = 必然 OOM。
代码:
// LRU 上限 = maxMemory/8
val maxKb = (Runtime.getRuntime().maxMemory() / 1024 / 8).toInt()
val cache = LruCache<String, Bitmap>(maxKb) { _, b ->
b.byteCount / 1024
}
2
3
4
5
收益:直接消除"缓存无上限型"A 类 OOM。
边界:低端机比例需进一步下调(1/16)。
# 13.2 大对象分块化
机理:单个 50MB+ 对象 = OOM 高风险。改为分片处理,每片 < 5MB。
代码:
// ❌ 大文件全量读
val bytes = file.readBytes() // 100MB+ 直接 OOM
// ✅ 流式分块读
file.inputStream().use { input ->
val buffer = ByteArray(8192)
while (input.read(buffer) > 0) {
process(buffer)
}
}
2
3
4
5
6
7
8
9
10
收益:消除"单对象超大型"OOM。
边界:算法不能简单分块(如全量排序);需要业务支持流式处理。
# 13.3 Bitmap 降采样 + 复用
机理:原图 4000x3000 全量解码占 ~46MB;缩到 800x600 占 ~1.8MB(26× 节约)。
代码:
val opt = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeFile(path, opt)
opt.inSampleSize = calcInSampleSize(opt, reqWidth, reqHeight)
opt.inJustDecodeBounds = false
opt.inBitmap = bitmapPool.get(opt) // 复用
val bitmap = BitmapFactory.decodeFile(path, opt)
2
3
4
5
6
7
8
9
收益:单图 OOM 降 80% 以上。
边界:复用对 mutable Bitmap 才生效;inBitmap 只能复用相同尺寸。
# 13.4 内存泄漏排查
机理:累积泄漏 = 缓慢的"必然 OOM"。
工具:
- LeakCanary(Java 对象图)
- Android Studio Profiler(heap dump 对比)
- KOOM(线上抓 dump)
常见泄漏源:
- Activity / Fragment 持有外部引用
- 静态变量持有 Context
- 未取消的回调
- Handler 未 removeCallbacks
# 13.5 配置与监控
largeHeap 慎用:
<application android:largeHeap="true">
- 仅延后 OOM 5-10%
- 鼓励应用占用更多内存(系统 RAM 压力增大)
- 不应作为首选方案
探索性思考:为什么"治理一层堆"是最容易做但 ROI 最高的?因为大部分应用根本没设缓存上限——这是"基础不牢"导致的。第一层治理 = 修复基础问题,但因为基础问题太普遍,反而是 ROI 最高的。
▶▶ 回扣 §02 案例:Day 2 缓存上限 + onTrimMemory 5 级响应组合,立刻 -55%。
# 14.治理二层 LMK
核心命题:治理 B 类 LMK 的核心是"响应系统压力信号 + 提升进程优先级"。
# 14.1 onTrimMemory 5 级响应
机理:§17.2 实验证明 onTrimMemory 提供 5 秒-30 分钟响应窗口。
代码(已在 §08.3 详述):
override fun onTrimMemory(level: Int) {
when (level) {
TRIM_MEMORY_RUNNING_MODERATE -> imageCache.trimToSize(maxSize / 2)
TRIM_MEMORY_RUNNING_LOW -> imageCache.trimToSize(maxSize / 4)
TRIM_MEMORY_RUNNING_CRITICAL -> keepEssentialOnly()
TRIM_MEMORY_UI_HIDDEN -> releaseUIResources()
TRIM_MEMORY_BACKGROUND -> evictBackgroundOnlyCache()
TRIM_MEMORY_MODERATE -> evictMostCache()
TRIM_MEMORY_COMPLETE -> releaseEverything()
}
}
2
3
4
5
6
7
8
9
10
11
收益:§17.3 实验证明 OOM 率 -70%。
边界:必须分级,一刀切清缓存会让二次进入慢 50%。
# 14.2 iOS didReceiveMemoryWarning
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// 立即释放
SDImageCache.shared.clearMemory()
URLCache.shared.removeAllCachedResponses()
// 释放可重建的资源
if !isViewLoaded || view.window == nil {
view = nil
}
}
2
3
4
5
6
7
8
9
10
11
12
iOS 关键约束:
- 5 秒内必须释放(否则被 jetsam)
- 后台释放更激进
# 14.3 提升前台优先级
机理:LMK 按 oom_adj 杀进程,前台优先级值最低。
代码:
// 关键场景启动前台 Service
val notification = createForegroundNotification()
startForeground(SERVICE_ID, notification)
2
3
收益:后台被杀率 -50%。
边界:滥用前台 Service 会被用户嫌弃;只用于必须保活场景(音乐、导航)。
# 14.4 后台主动减负
机理:切后台时主动减负,比等系统通知更安全。
代码:
override fun onTrimMemory(level: Int) {
if (level == TRIM_MEMORY_UI_HIDDEN) {
// 切后台立即减负
clearViewState()
stopAnimations()
pauseVideoDecoders()
}
}
// 或更激进:UI 切后台时序列化关键状态后释放整个 Activity
2
3
4
5
6
7
8
9
10
# 14.5 分级降级策略表
| 压力级别 | 时间窗 | 释放策略 | 用户感知 |
|---|---|---|---|
| MODERATE | > 3min | 半量缓存 | 无 |
| LOW | ~ 2min | 1/4 缓存 + 暂停后台任务 | 几乎无 |
| CRITICAL | ~ 30s | 仅保留必需 | 二次进入慢 |
| UI_HIDDEN | 无限制 | UI 状态释放 | 无(已切后台) |
| BACKGROUND | > 10min | 后台缓存 | 切回略慢 |
| MODERATE(后台) | ~ 3min | 大部分缓存 | 切回较慢 |
| COMPLETE | 5s | 全部释放 | 切回需重建 |
探索性思考:为什么"分级响应"比"一刀切"好这么多?因为分级响应在"内存安全"和"用户体验"之间找到了平衡——一刀切清缓存虽然减内存最猛,但下次进入要全部重建。好的工程方案永远是平衡,而非极端。
▶▶ 回扣 §02 案例:Day 2 接入 5 级响应是 OOM 治理的关键步骤——没有它,后续优化的内存反而会被反复释放/重建造成抖动。
# 15.治理三层地址空间
核心命题:治理 C 类地址空间 OOM 的核武器是"升级 64 位"——其他都是临时解。
# 15.1 升级 64 位(核武器)
机理:§17.4 实验证明 64 位地址空间几乎无限。
代码:
android {
defaultConfig {
ndk {
abiFilters 'armeabi-v7a', 'arm64-v8a'
}
}
bundle {
abi {
enableSplit = true
}
}
}
2
3
4
5
6
7
8
9
10
11
12
收益:B 类地址空间 OOM 直接消失,案例 -55%。
边界:
- 需要所有 native 库提供 arm64 版本
- APK 体积 +30-50MB(双 abi)
- App Bundle 按设备分发,下载体积不变
- 极少数老旧设备(< Android 5)不支持 arm64
# 15.2 大对象用 mmap
机理:mmap 在内核空间映射文件,不占用进程虚拟地址连续大块。
代码:
val raf = RandomAccessFile(file, "r")
val mapped = raf.channel.map(MapMode.READ_ONLY, 0, file.length())
val byte = mapped.get(1024 * 1024) // 像数组一样访问
2
3
收益:大文件操作不消耗虚拟地址连续大块。
边界:
- 写入需要 msync 显式落盘
- 崩溃时数据可能未刷盘
- 仍占用一定虚拟地址空间(但更易复用)
# 15.3 32 位时代的临时缓解
如果暂时无法 64 位(如某些 .so 没 arm64 版本):
① 避免大块(>10MB)分配:
// 反例:一次解码大图
BitmapFactory.decodeFile(path) // 可能 100MB+
// 正例:先看尺寸再决定
val opt = BitmapFactory.Options()
opt.inJustDecodeBounds = true
BitmapFactory.decodeFile(path, opt)
opt.inSampleSize = calcSampleSize(opt.outWidth, opt.outHeight, 1024, 1024)
opt.inJustDecodeBounds = false
BitmapFactory.decodeFile(path, opt) // 控制在 < 10MB
2
3
4
5
6
7
8
9
10
② 长时间运行后主动重启:
class AppLifecycleObserver {
fun onAppStart() {
// 32 位且运行 > 4 小时 → 主动建议重启
if (is32Bit() && uptimeHours() > 4) {
promptUserToRestart()
}
}
}
2
3
4
5
6
7
8
③ 不要频繁创建/销毁大对象:
- 用对象池
- Bitmap.recycle() 手动回收(Android 7-)
- 避免短期大量 dlopen
# 15.4 监控地址空间
Android 32 位监控:
fun getMaxContiguousBlock(): Long {
val maps = File("/proc/${Process.myPid()}/maps").readLines()
var maxFreeBlock = 0L
var lastEnd = 0L
for (line in maps) {
val (start, end) = parseRange(line)
val gap = start - lastEnd
if (gap > maxFreeBlock) maxFreeBlock = gap
lastEnd = end
}
return maxFreeBlock
}
// 阈值:< 50MB 即危险
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 15.5 跨平台地址空间治理对照
| 平台 | 主治理手段 | 备用手段 |
|---|---|---|
| Android 32 位 | 升 64 位 | mmap + 降采样 + 限大块 |
| Android 64 位 | 几乎无需治理 | 仍需基础内存管理 |
| iOS | 强制 64 位(无问题) | 仅需控总量 |
| Web | Tab 分隔(每 4GB) | 拆 Worker |
探索性思考:为什么"升 64 位"是 Android OOM 治理的"核武器"?因为它一次性解决了 50%+ 的 OOM 类别。升级硬件/平台往往比"软件优化"收益更大 —— 这是工程上经常被忽略的真理(工程师本能想"我能不能用代码解决",但平台升级才是更优解)。
▶▶ 回扣 §02 案例:Day 4-7 双 abi App Bundle 上线 64 位是案例最关键的一步,单步 -55%,远超其他所有手段之和。
# 16.治理四层兜底
核心命题:再完美的治理也无法阻止 100% OOM。第四层提供"主动重启"机制,把"崩溃"变成"短暂卡顿 + 状态保留"。
# 16.1 主动重启机制
机理:§17.5 实验证明主动重启把"崩溃"变"短卡",用户感知崩溃比例 100% → 12%。
代码:
class OomGuard {
fun checkAndRestart() {
val javaUsedRatio = Runtime.getRuntime().run {
(totalMemory() - freeMemory()).toFloat() / maxMemory()
}
val memInfo = ActivityManager.MemoryInfo()
activityManager.getMemoryInfo(memInfo)
if (javaUsedRatio > 0.9f || memInfo.lowMemory) {
saveStateToDisk() // 保存关键状态
restartApp() // 启动新进程并 kill 当前
}
}
private fun restartApp() {
val intent = packageManager.getLaunchIntentForPackage(packageName)
intent?.flags = Intent.FLAG_ACTIVITY_NEW_TASK
startActivity(intent)
Process.killProcess(Process.myPid())
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 16.2 状态序列化与恢复
关键设计:
- 哪些状态需要保留?(用户输入、滚动位置、登录态)
- 哪些可以丢弃?(缓存、临时数据)
- 序列化到哪里?(SharedPreferences / 本地文件 / Room)
class StateManager {
fun saveCriticalState() {
prefs.edit().apply {
putString("current_page", currentPage)
putInt("scroll_position", scrollPos)
putString("draft_text", draftEditText)
apply()
}
}
fun restoreCriticalState() {
currentPage = prefs.getString("current_page", "home")!!
scrollPos = prefs.getInt("scroll_position", 0)
draftEditText = prefs.getString("draft_text", "")!!
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 16.3 重启过渡 UI
避免"白屏"用 splash 衔接:
<!-- styles.xml -->
<style name="SplashTheme">
<item name="android:windowBackground">@drawable/splash_logo</item>
</style>
2
3
4
重启流程:
- 检测危险阈值
- 保存状态到磁盘
- 启动新进程(新进程显示 splash)
- kill 当前进程
- 新进程启动后恢复状态
用户感知:仅 1-2 秒 splash,无崩溃感。
# 16.4 ROI 排序
| ROI | 优化项 | 收益 | 成本 | 风险 | 对应章节 |
|---|---|---|---|---|---|
| 极高 | 缓存上限 + onTrimMemory 5 级 | -55% | 1-2 周 | 低 | §13.1 + §14.1 |
| 极高 | 升级 64 位 | B 类 -90%+ | 2-4 周 | 中(兼容) | §15.1 |
| 高 | Bitmap 降采样 + 复用 | -50% 峰值 | 1-2 周 | 低 | §13.3 |
| 高 | 大对象 mmap 化 | 防地址碎片 | 1 周 | 低 | §15.2 |
| 中 | OOM 现场抓取 | 长期防退化 | 2-3 周 | 低 | §05 |
| 中 | 主动重启兜底 | 体验改善 90% | 2 周 | 中(状态丢失) | §16.1 |
| 低 | largeHeap | 仅延迟 5-10% | 1 行配置 | 低 | §13.5 |
# 16.5 避免反向收益
- 滥用 largeHeap:治标不治本,鼓励应用占用更多内存
- catch OOMError:错过真正的根因,下次仍崩
- 一刀切清缓存:让二次进入慢 50%,用户可能更不满
- 过度前台 Service:被用户嫌弃 / 系统限制(Android 12+)
- 重启不保状态:用户感知数据丢失
探索性思考:为什么"主动重启"是最容易被工程师抗拒的方案?因为它"看起来不优雅"——主动 kill 自己的进程。但用户视角下:1.5 秒主动重启 vs 不可控崩溃,主动重启完胜。工程师的"优雅强迫症"经常是用户体验的敌人。
▶▶ 回扣 §02 案例:Day 9-10 主动重启机制,用户感知崩溃比例从 100% 降到 12%——这是最后一公里,但价值巨大。
# 17.求证实验 ⭐
本节是"为什么这些优化生效"的实证基础。每个实验严格遵循"6 步求证法"。
# 17.1 实验一:地址空间碎片
猜想:32 位应用 OOM 都是因为占用过多。
假设:随着应用运行,虚拟地址空间被中小块占用 + 释放后产生碎片,大块分配失败概率随运行时长上升。
设计:
- 设备:32 位老设备(Android 7,2GB RAM)
- 测试 App:模拟连续分配 / 释放不同大小(10K–50MB)的块
- 控制:5 / 30 / 60 分钟
- 主指标:"尝试 20MB Bitmap 分配成功率"、最大连续块大小
执行:
| 运行时长 | PSS (MB) | 最大连续块 (MB) | 20MB 分配成功率 |
|---|---|---|---|
| 0 分钟 | 80 | 800 | 100% |
| 5 分钟 | 150 | 380 | 100% |
| 30 分钟 | 200 | 80 | 75% |
| 60 分钟 | 220 | 35 | 30% |
验证:
- 60 分钟后即使 PSS 只有 220MB,最大连续块只剩 35MB,20MB 分配仅 30% 成功。
- 64 位应用同样实验:60 分钟后最大连续块仍 > 1GB(影响可忽略)。
思考:
- 32 位应用的虚拟地址空间碎片是 OOM 的隐形元凶。
- 运行 30 分钟后即可显著恶化;60 分钟后大 Bitmap 分配成功率可低于 30%。
- 工程意义:升级 64 位是核武器;32 位时代必须避免大块分配。
# 17.2 实验二:低内存信号时机
猜想:onTrimMemory 是同步信号,几无响应窗。
假设:信号到 OOM 的时间窗与压力级别相关;MODERATE → 60+ 秒,COMPLETE → 5–10 秒。
执行:
| onTrimMemory 级别 | 中位时间到被杀 (s) | P95 (s) |
|---|---|---|
| TRIM_MEMORY_RUNNING_MODERATE | > 600 | > 1800 |
| TRIM_MEMORY_RUNNING_LOW | 120 | 600 |
| TRIM_MEMORY_RUNNING_CRITICAL | 30 | 120 |
| TRIM_MEMORY_UI_HIDDEN | > 1800 | N/A |
| TRIM_MEMORY_BACKGROUND | 600 | > 1800 |
| TRIM_MEMORY_MODERATE(后台) | 180 | 480 |
| TRIM_MEMORY_COMPLETE | 5 | 25 |
验证:
- MODERATE 后台时窗 3 分钟,应用有充足时间响应。
- COMPLETE 是"最后通知",5 秒内必须释放,否则被杀。
思考:
- onTrimMemory 给应用提供 5 秒 – 30 分钟不等的响应窗口。
- COMPLETE 必须 5 秒内释放;MODERATE 可在 3 分钟内逐步释放。
- 不同 OEM ROM 阈值差异大(小米 / 华为可能更激进,10 秒就杀)。
# 17.3 实验三:降级策略收益
猜想:降级链路效果有限(< 10%)。
假设:降级链路能显著降低 OOM 率(30–60%)和后台被杀率(50–80%)。
执行(某 App 真实数据):
| 版本 | OOM 率 | 后台被杀率 | 首页二次进入速度 |
|---|---|---|---|
| v1.0(无降级) | 0.42% | 18% | 800ms |
| v2.0(基础降级) | 0.21% | 11% | 1200ms(部分缓存丢) |
| v3.0(完整分级) | 0.12% | 5% | 850ms |
验证:
- v2.0 OOM 率降 50%,但二次进入慢 50%(一刀切清缓存的代价)。
- v3.0 OOM 率降 71%,且二次进入几乎不受影响。
思考:
- 完整实现分级降级能让 OOM 率下降 70%、后台被杀率下降 70% 以上。
- 但必须做"分级",否则会牺牲二次进入体验。
- 投入 1-2 周做分级降级是高 ROI 工作。
# 17.4 实验四:64 位升级的 OOM 收益
猜想:64 位只是"地址范围"变大,OOM 收益有限。
假设:64 位地址空间几乎无限,B 类 OOM(地址空间碎片)直接消失。
执行:
| 指标 | 32 位(v7a) | 64 位(arm64) |
|---|---|---|
| OOM 率 | 8.4% | 1.2% |
| Java 堆使用 | 220 MB | 220 MB |
| 最大连续块(30 分钟后) | 18 MB | > 1 GB |
| Bitmap 解码失败次数 | 31 次 | 0 次 |
验证:
- 64 位升级直接消灭 B 类(地址空间)OOM,对 A/C 类无影响。
思考:
- 64 位升级是 OOM 治理的"核武器"。
- 实施成本:所有 native .so 提供 arm64-v8a 版本;APK +30-50MB;App Bundle 按设备分发可避免下载膨胀。
# 17.5 实验五:兜底重启的用户体验
猜想:主动重启用户会更不满("自己崩了")。
假设:主动重启比被动崩溃用户体验好——主动重启可以保留状态、平滑过渡。
执行:
| 指标 | A 不主动重启 | B 主动重启 |
|---|---|---|
| 真实 OOM 崩溃率 | 6.8% | 0.3% |
| 主动重启率 | 0% | 6.5% |
| 用户感知到崩溃比例 | 100% | 12% |
| 状态丢失比例 | 100% | < 5% |
验证:
- 主动重启把"崩溃"转化为"短暂卡顿 + 状态保留"。
- 用户体验显著提升。
思考:
- 重启过程仍有 1-2 秒"白屏",需要 splash 衔接。
- 必须有完善状态序列化机制。
- 仅作为最后兜底——主线还是要降低 OOM 触发概率。
# 17.6 五大实验启示
地址空间碎片 → 32 位 OOM 不只看 PSS ─┐
低内存信号时机 → 5 秒到 30 分钟的响应窗 │
降级策略收益 → 完整分级降级 70% 下降 ├─▶ OOM = 预警 × 降级 × 兜底
64 位升级 → 直接消灭地址空间类 OOM │
主动重启兜底 → 体验从"崩溃"变"短卡" ─┘
2
3
4
5
统一启示:
- OOM 是分类问题:4 类问题需 4 套治法。
- 前兆比崩溃更重要:onTrimMemory 是治理黄金窗口。
- 64 位是核武器:解决 50%+ Android OOM。
- 分级降级胜过一刀切:兼顾内存与体验。
- 主动重启是最后防线:用户感知崩溃 100% → 12%。
▶▶ 回扣 §02 案例:方法派 10 天闭环每一步都对应本节实验——Day 1 触发条件分类(§17.1 启示);Day 2 onTrimMemory 5 级(§17.2 + §17.3);Day 4-7 64 位(§17.4);Day 9-10 主动重启(§17.5)。实验是优化前的"必经之路"。
# 18.实战案例
# 18.1 跨端同构案例:分级降级 + 缓存上限
背景:某资讯 App 三端(Android / iOS / Web)OOM 率都在 0.4% 以上。
度量与归因:
| 平台 | 优化前 OOM 率 | 主因 |
|---|---|---|
| Android | 0.42% | 缓存无上限 + 32 位地址碎片 |
| iOS | 0.18% | 后台未释放 |
| Web | 0.04% | DOM 节点过多 |
治理(统一逻辑:预警 + 降级 + 兜底):
- Android:LRU 上限 + onTrimMemory 5 级 + 64 位 + 主动重启
- iOS:didReceiveMemoryWarning + 后台主动释放
- Web:performance.memory 监控 + 虚拟滚动减 DOM
效果:
| 平台 | 优化后 OOM 率 | 降幅 |
|---|---|---|
| Android | 0.13% | 69% |
| iOS | 0.06% | 67% |
| Web | 0.01% | 75% |
统一启示:OOM 治理跨端通用法则 = 预警 + 降级 + 兜底。
# 18.2 平台特异案例:Android 7 32 位地址空间 OOM
背景:某图片编辑 App 在 Android 7(32 位)OOM 率 0.8%,远高于 8+(0.1%)。
根因:32 位虚拟地址空间碎片化——用户编辑图片时多次 alloc/release 大块内存。
治理:
- 立即:Bitmap inSampleSize=2 降采样
- 中期:双 abi 适配,Android 8+ 自动用 64 位
- 长期:放弃 32 位支持
效果:
- Android 7 OOM 0.8% → 0.4%(降采样)
- Android 8+ OOM 0.1% → 0.05%(64 位)
# 18.3 反例案例:catch OOMError 导致后续雪崩
背景:某 App 工程师在 Bitmap 解码处加了 try-catch (OutOfMemoryError),认为"防崩溃"。
结果:
- OOM 率没降,反而总崩溃率上升
- 因为 catch 后未释放任何内存,下次分配仍 OOM
- 而且因为没崩溃日志,问题被掩盖
正确做法:
try {
val bitmap = BitmapFactory.decodeFile(path)
} catch (e: OutOfMemoryError) {
// ❌ 仅 catch 不解决根因
// ✅ catch 后立即响应:清缓存 + 降采样重试
imageCache.clear()
System.gc() // 不保证立即生效,但建议
return BitmapFactory.decodeFile(path, BitmapFactory.Options().apply {
inSampleSize = 2
})
}
2
3
4
5
6
7
8
9
10
11
12
洞察:catch OOM 不是治理,而是"延迟暴露问题"。真正的治理是在 OOM 发生前响应(onTrimMemory),而不是发生后掩盖。
# 19.防劣化体系
优化不难,难的是"优化后不劣化"。需要"事前预防 + 事中拦截 + 事后回归"三道防线。
# 19.1 三道防线总览
┌────────────┐ ┌────────────┐ ┌────────────┐
│ 编码期 Lint │ → │ CI 卡口 │ → │ 线上 SLO │
│ IDE 即时提示│ │ Macrobench │ │ 监控告警 │
└────────────┘ └────────────┘ └────────────┘
2
3
4
# 19.2 编码期 Lint
自定义规则:
- 缓存类(Map / LruCache / NSCache)未设上限 → 警告
- 加载 > 10MB 图片直接 decode → 警告(应分块或降采样)
- 32 位环境分配 > 20MB 连续块 → 警告
- 未实现
onTrimMemory/didReceiveMemoryWarning→ 警告 - 大对象在 onCreate 同步加载 → 警告
- catch OOMError 但未释放任何资源 → 错误
# 19.3 CI 卡口
性能基线测试:
// Macrobenchmark MemoryUsageMetric
@Test
fun memoryBenchmark() = benchmarkRule.measureRepeated(
metrics = listOf(MemoryUsageMetric(MemoryUsageMetric.Mode.Last)),
iterations = 5
) {
startActivityAndWait()
val list = device.findObject(By.res("recyclerView"))
repeat(50) { list.fling(Direction.DOWN) }
}
2
3
4
5
6
7
8
9
10
卡口规则:
- 内存压测:跑核心场景 30 分钟,OOM 必须为 0
- 大图压测:构造 100MB+ Bitmap 序列,测试降采样是否生效
- 后台被杀模拟:触发各级 onTrimMemory,验证降级正确
- 内存峰值退化 ≥ 10% → 阻断 PR
# 19.4 线上 SLO
核心 SLO 指标:
| 指标 | 目标 | 告警阈值 |
|---|---|---|
| OOM 率(中端机切片) | < 0.1% | > 0.3% |
| 后台被杀率 | < 5% | > 10% |
| onTrimMemory 触发后内存释放 | > 30% | < 20% |
| 32 位地址空间最大连续块 | > 100MB | < 50MB |
| Bitmap 内存峰值 | < 100MB | > 200MB |
告警 + 自愈:
- 自动归因:OOM 率告警 → 关联最近 PR
- 灰度回滚:发现问题自动回滚
- 错误预算耗尽 → 冻结大内存特性发布
# 19.5 文化建设
- OOM 预算:新页面必须申报"内存预算"
- OOM Code Review:内存敏感改动必须有 perf reviewer
- OOM OKR:OOM 率进 OKR
探索性思考:为什么 OOM 防劣化比"性能防劣化"更难?因为 OOM 是"低概率高代价"事件——平时看不到,出问题就是崩溃。低概率事件的防御需要"压测 + SLO + 告警"三件套 —— 不能等用户上报,要在 CI 阶段就拦住。
# 20.跨平台速查
# 20.1 工具速查
| 平台 | 常态监控 | 前兆捕获 | 现场快照 | 离线分析 |
|---|---|---|---|---|
| Android | Debug.MemoryInfo | onTrimMemory | KOOM / Matrix | MAT / Profiler |
| iOS | task_vm_info | didReceiveMemoryWarning | MetricKit MXOOMReport | Instruments Allocations |
| Web | performance.memory | PressureObserver | Heap Snapshot | DevTools Memory |
| Compose | 同 Android | 同 | 同 | 同 |
| Flutter | DevTools Memory | 平台原生 | DevTools | DevTools |
# 20.2 关键 API 速查
| 目的 | Android | iOS | Web |
|---|---|---|---|
| 限制缓存上限 | LruCache + maxMemory/8 | NSCache + countLimit | Map + manual eviction |
| 监听压力 | onTrimMemory | didReceiveMemoryWarning | PressureObserver |
| 大文件 mmap | RandomAccessFile.channel.map | mmap() syscall | ArrayBuffer (有限) |
| Bitmap 降采样 | inSampleSize | UIImage drawSize | Canvas drawImage scaled |
| 主动 GC(建议) | System.gc() | NSCache evictsObjectsWithDiscardedContent | manual nullify |
| 进程优先级 | startForeground | UIApplicationState | (无) |
# 20.3 各平台 OOM 优化清单
Android:
- [ ] 缓存类必有上限(maxMemory/8)
- [ ] 实现 onTrimMemory 5 级响应
- [ ] 双 abi 上线(armeabi-v7a + arm64-v8a)
- [ ] App Bundle 按设备分发
- [ ] Bitmap inSampleSize 降采样
- [ ] Bitmap inBitmap 复用
- [ ] 大文件 mmap
- [ ] OOM 现场抓 hprof(KOOM)
- [ ] 主动重启兜底
- [ ] CI 内存压测 30 分钟
iOS:
- [ ] 实现 didReceiveMemoryWarning
- [ ] 后台主动释放(NSCache.evictsObjectsWithDiscardedContent)
- [ ] UIImage decode 时降采样
- [ ] 限制 WebView 数量
- [ ] MetricKit 抓 OOM 报告
- [ ] Instruments 定期 review
Web:
- [ ] 监控 performance.memory
- [ ] DOM 节点 ≤ 1500
- [ ] 长列表虚拟滚动
- [ ] WebSocket 主动关闭
- [ ] Image lazy load + 限制并发
- [ ] WebGL Context 数量限制
# 21.总结与延伸
# 21.1 五条核心原则
- OOM 是分类问题:堆 / LMK / 地址空间 / 资源耗尽 四类需四套治法。
- 前兆比崩溃重要:onTrimMemory / didReceiveMemoryWarning 是治理黄金窗口。
- 64 位是核武器:解决 50%+ Android OOM。
- 分级降级胜过一刀切:兼顾内存与体验。
- 主动重启是最后防线:用户感知崩溃 100% → 12%。
# 21.2 五个常见误区
| 误区 | 真相 |
|---|---|
| "largeHeap 能解决 OOM" | 错。仅延后 5-10%,鼓励占用更多内存 |
| "PSS 不高就不会 OOM" | 错。32 位地址空间碎片是隐形杀手 |
| "catch OOMError 能防崩溃" | 错。未释放资源时下次仍崩 |
| "iOS 没有 OOM" | 错。jetsam 杀进程效果同 |
| "OOM 只能事后救火" | 错。前兆响应能防 70% OOM |
# 21.3 一句话总结
OOM 不是单一问题,而是"堆 / LMK / 地址空间 / 资源耗尽"四类问题的统称。治理 OOM 的核心法则是:分类施治 + 前兆响应 + 分级降级 + 主动重启兜底。其中"升级 64 位"是 Android 的核武器,"onTrimMemory 5 级响应"是降级的基础,"主动重启"是最后防线。预防胜于救火——前兆响应能在 OOM 发生前阻止 70% 的问题。
# 21.4 延伸阅读
卷二·02 内存监控与治理:OOM 是内存治理失败的兜底信号卷二·01 CPU 监控与分析:内存分配频率影响 GC,间接影响 OOM卷二·04 线程模型调度优化:线程数耗尽是 D 类 OOM卷四·03 图片性能解码优化:图片是 OOM 主要来源卷四·05 功耗与电量优化:内存压力与功耗关联卷五·01 崩溃捕获设计实践:OOM 是崩溃的一种
下一篇预告:
卷二·04 线程模型调度优化—— 把"线程"这个被滥用的工具用对。