前阵子线上版本发布后,群里被用户刷屏:“iPhone 没弹崩溃,但 App 退到桌面再点回来,居然重新加载了。” 打开数据一看,OOM率从 0.3% 一路涨到 1.2%。用户体感就是闪退、白屏、状态丢失——但崩溃平台上一个堆栈都没有。这就是 iOS OOM 治理最磨人的地方:它不是崩溃,却比崩溃更难排查。这篇文章我就把自己做 OOM 治理的完整思路、工具链和实际落地方案整理出来,希望能给正在做 iOS 性能优化、稳定性治理的同行一些参考。
1. 先搞清楚在治理什么:OOM 不等于崩溃
很多团队第一次接到 OOM 相关的工单时,第一反应是抓崩溃堆栈,结果发现平台上什么都没有。这里必须先纠正一个认知:iOS 的 OOM 和 crash 是两码事,治理思路完全不同。
1.1 Jetsam 是怎么判定“该杀你了”的
iOS 用内存压力作为核心调度依据。当一个 App 的物理内存占用过高,系统会触发 Jetsam 机制,进程被直接终止。你可以把 Jetsam 理解成一个大楼里的物业管理员:电梯、空调、人均用水量都有限,某户居民把水管开到最大,物业为了保护整栋楼的正常运转,会直接切断这户的水电,而不是先敲门商量。
关键点在这里:Jetsam 终止进程时不会给 App 任何回调,你的代码根本来不及执行。所以 OOM 没有常规的崩溃堆栈,甚至 App 内部都感知不到“我快被杀了”。这个特性决定了 OOM 治理必须依靠系统日志和事前的监控数据,而不是错误上报平台。
1.2 OOM 日志到底去哪找
虽然 App 自己不知道,但系统会把每次 JetSam 事件写入诊断日志。在 iPhone 上打开“设置 → 隐私与安全性 → 分析与改进 → 分析数据”,找前缀是 JetsamEvent 的文件,就能看到系统记录的详细情况。
要注意三个关键信息:
reason字段:如果是per-process-limit,说明是单个 App 触顶被杀;如果是vm-pageshortage或global,说明是整个系统内存紧张,你的 App 只是被选中牺牲的那个。两种原因的治理方向不一样。memlimit字段:这是系统给当前进程设置的内存上限,单位一般在日志里以页或字节形式出现,如果日志里标记了pageSize,可以用memlimit × pageSize反推具体字节数。rpages与footprint:进程被杀瞬间的物理内存占用。
iPhone 真机上还有一套更简便的途径:把设备连接到 Mac,在 Xcode 里打开“Window → Devices and Simulators → View Device Logs”,同样能看到 Jetsam 日志。实机上这个日志才是准的,模拟器上内存策略不一样,别浪费时间复现。
1.3 治理的其实是“footprint”,不是“allocated size”
很多初学者有个误区:以为 OOM 是“分配的内存太多”导致的,于是疯狂排查 malloc 分配总量。但系统在判定 OOM 时,关注的是 footprint——也就是进程中实际被映射并驻留在物理内存的页面数,等于你真实压给物理内存的重量。
比如你用 malloc 申请了 500MB,但大部分页面还没写入数据,footprint 可能只有几十MB;反过来,如果你有 500MB 的缓存对象被反复访问,这些内存都会真实落在物理页上,footprint 就很高。所以治理的重点是降低真实的物理内存占用峰值,而不是单纯减少虚拟内存分配。
理解这一点之后,再看下面的根因分类就顺理成章了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 常见的内存失控场景:我整理的三类主力
我治理过几个头部 App 的 OOM 问题,基本上所有线上 OOM 都可以归到这三类:
2.1 第一类:图片大户,解压后的位图最吃内存
这是 OOM 的第一大源头。一张普通的 2000×3000 像素照片,解压成位图后内存大小是 2000 × 3000 × 4 字节,约 24MB。听起来没多少?一个信息流页面,同时存在屏幕上和预加载的图片轻松超过 20 张,内存瞬间堆到 400-500MB。
UIImage 的加载方式影响也很大:
imageNamed:自带缓存机制,重复加载不走磁盘,但缓存的位图不释放,会增加 footprint;imageWithContentsOfFile:不缓存,用完后可以及时释放,但经常被误解为“效率低”。
实际项目中,列表页大面积使用 imageNamed:,又加上第三方图片库默认的内存缓存,峰值很容易失控。
我们曾经接到的线上 OOM 工单,绝大多数集中在图片密集的落地页、直播间礼物墙、电商详情页这几个场景。特征都一样:内存曲线像过山车,用户滑动越快,峰值越高,最终突然被 Jetsam 杀掉。
2.2 第二类:泄漏型,内存只涨不跌
循环引用、定时器未释放、Block 捕获 self、通知观察者忘记移除、WKWebView 不销毁——这些常规泄漏问题,在没有 OOM 监控的版本里可能潜伏很久。
泄漏型 OOM 和图片峰值型 OOM 最明显的区别是:内存曲线是缓慢爬坡,而不是尖峰。用户连续使用 App 半小时后,内存从 150MB 一路涨到 600MB,然后在一个随机时刻触顶被杀。这种问题用 Leaks 工具可以看到循环引用,但 Leaks 只能检测到部分 Objective-C 对象的循环引用,Swift 里的闭包捕获或者 C++ 对象泄漏往往查不出来,得配合 Allocations 看 heap growth。
2.3 第三类:缓存管理失控,“为了快”反而成为杀手动因
开发者的初衷是“缓存了重启以后不用再加载”,但缓存策略定得太粗放,导致内存缓存无限膨胀。
常见做法:
- 把图片、JSON 数据、用户信息都塞进 NSCache 或 NSDictionary;
- 以为 NSCache 会自动清理,所以完全不设上限;
- 收到系统的内存警告后,只清一部分甚至完全忽略。
NSCache 的确有自动淘汰策略,但它淘汰的是“最近最少使用”的条目,而且并不是在所有内存压力下都会立刻清空全部缓存。如果缓存里存的全是解码后的图片位图,每个条目几 MB、几十 MB,哪怕只保留了 20 个,也很容易形成一座内存小山。
好,根因清楚之后,最难的问题来了:线上 OOM 到底怎么定位?
3. 线上 OOM 快速定位:我的完整排查链路
很多文章会直接告诉你“用 Instruments 打开 Allocations 就能找到问题”。但在线上环境,用户根本不可能连 Mac,Instruments 只能用于你自己能复现的场景。真实的线上 OOM 定位,需要一条组合拳链路。
3.1 第一步:先读 Jetsam 日志,确定“凶手”类型
拿到有问题的设备日志后,优先看两件事:
- 是不是
per-process-limit。如果是,基本确定是 App 自身 footprint 过高; - 设备型号和 iOS 版本。同一份 OOM,iOS 15 和 iOS 17 的处理策略可能完全不同,不同机型的 memlimit 也不一样。
我见过一些新机型上,系统给 App 的 footprint 上限有 2GB 以上,但是老机型比如 iPhone 8,上限可能只有 1GB 左右。下表是我基于手头真机 Jetsam 日志整理的经验值,仅供参考:
| 设备 RAM | 代表机型 | 常见 memlimit(经验值) | 说明 |
|---|---|---|---|
| 2GB | iPhone 8 / SE 2 | 约 1.0-1.2GB | 老机型,系统留给 App 的空间比较紧张 |
| 3GB | iPhone XR / 12 / 13 | 约 1.5-1.8GB | 主流机型,OOM 高发区 |
| 4GB+ | 13 Pro / 14 Pro | 约 2.0-2.5GB+ | 画面富媒体、大图片更容易活下来 |
这个窗口能帮你判断到底是不是“真的低”,因为有些用户反馈“闪退”其实是某个特定机型内存已经接近上限,换台新机可能没问题。
3.2 第二步:用 footprint 曲线定位峰值跃迁
Jetsam 日志只能告诉你“被杀的时候内存有多高”,但无法告诉你“是什么操作导致了内存飙升”。所以必须在 App 自身加一层监控:周期性采集当前 footprint,并记录到日志或上报到后台。
获取 footprint 的代码很简单,我直接贴一份实际能跑的:
objective-c复制#import <mach/mach.h>
#import <mach/task_info.h>
+ (int64_t)currentFootprint {
struct task_vm_info info;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t result = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &count);
if (result != KERN_SUCCESS) {
return -1;
}
return (int64_t)info.phys_footprint;
}
拿到实时 footprint 之后,每 1-2 秒采样一次,页面退出或 App 进入后台时上报到服务端。有了时间线曲线,就能发现“用户从首页进入详情页时,footprint 从 200MB 涨到 500MB”这样清晰的窗口。这一步是整个定位链路里最核心的:没有时间线,一切都是盲猜。
我们实际遇到的一个案例:某直播间 OOM 率特别高,但 UI 流程并不复杂。通过 footprint 曲线发现,只要打开直播间礼物面板,内存会立刻飙升近 300MB;关掉面板后回落不到一半,日积月累仍然被系统追杀。定位到具体弹窗后,才发现是礼物动画加载了一整套 Gif 帧图并全部解压到内存里。
3.3 第三步:分配堆栈采样,把嫌疑对象锁定到代码行
时间线找到了大致的页面或操作,下一步就是把内存增长落到具体对象上。在开发复现阶段,用 Instruments 的 Allocations 和 VM Tracker 基本能覆盖九成问题。Allocations 里重点看 Persistent Bytes,也就是“稳定驻留不释放”的内存,然后按调用栈展开,就能找到最大的占用来源。
线上定位如果复现不了,就需要考虑在 App 里做堆栈采样。做法不复杂:在内存水位超过自定义阈值时,遍历堆上对象,按类名排序,把 Top 对象及其分配栈记录并上报。但线上全量采集分配栈的开销比较大,我的建议是只在用户反馈问题、灰度包或者条件触发时开启采样,同时加上采样比例控制,避免影响正常用户体验。
这套链路走完,绝大多数 OOM 都能落到可优化的代码上。接下来就是动刀子的阶段。
4. 治理落到地:压低峰值和消灭泄漏
定位是手段,治理才是目的。我按投入产出比,把治理动作分成几个梯队,先推最容易见效的。
4.1 图片加载:降采样是性价比最高的优化
先看清一个事实:列表页往往不可能完整展示原图。既然显示区域可能只有 100×100 像素,就没必要把 2000×3000 的原图完整解码到内存里再缩放。
这里要用到 ImageIO 的缩略图能力,核心思路是在解码前就把尺寸缩小到显示所需大小:
swift复制func downsampleImage(at url: URL, to pointSize: CGSize, scale: CGFloat) -> UIImage? {
let options = [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceShouldCacheImmediately: true,
kCGImageSourceCreateThumbnailWithTransform: true,
kCGImageSourceThumbnailMaxPixelSize: max(pointSize.width, pointSize.height) * scale
] as CFDictionary
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
guard let cgImage = CGImageSourceCreateThumbnailAtIndex(source, 0, options) else { return nil }
return UIImage(cgImage: cgImage)
}
这一项优化最直观的效果:某些页面首屏 footprint 从 200MB 降到 80MB,而且用户完全感知不到画质差异。
另外,超大图(长截图、楼层图)千万不要整张解压。用 CATiledLayer 分片加载,只渲染屏幕看到的区域,内存占用可以控制在几个 MB 级别。动画类图片优先用视频或骨骼动画方案,替代一整套 Gif 帧图常驻内存的做法。
4.2 缓存治理:给 NSCache 定好边界
缓存不是不能用,而是必须有边界。我给团队的硬性标准是:
- 所有 NSCache 必须显式设置
totalCostLimit,成本按“字节数”统计,不要用 count。如果你用 count 当作成本,1 个 1KB 的文本和 1 个 100MB 的视频会被同等对待,这显然不对; - 收到
UIApplicationDidReceiveMemoryWarningNotification时,无条件清空内存缓存,并暂停新一轮预加载至少 5 秒; - 像图片这样的昂贵资源,磁盘缓存和内存缓存分两级管理,内存缓存只保留“最近最常用”的位图,冷数据让磁盘缓存去扛。
我之前遇到过线上 OOM,查下来是团队给微信表情包做的缓存没有设 cost,用户在一个群里接收几百张表情图后,内存缓存直接吞掉几百 MB。加一行 totalCostLimit 的设置后,OOM 率降了一半。
4.3 后台任务和定时器:最容易漏掉的隐性持有
NSTimer 和 CADisplayLink 是很经典的泄漏源。NSTimer 会被 runloop 持有,而 target 又强引用 self,如果忘了 invalidate,整个控制器永远不会释放,内存只进不出。
在实际工程里,我建议:
- 所有的定时器必须成对出现:
init时创建,dealloc或viewWillDisappear时invalidate,没有例外; - 使用 Block 版本的 API 时,内部用 weak-strong dance,避免定时器捕获 self 导致循环引用;
- CADisplayLink 也是一样的道理,它默认强持有 target,且会一直运行直到显式
invalidate。
这种问题用 Leaks 未必能立即测出来,但把“所有控制器实例数量”做成监控指标后,哪一个页面不断累积未释放实例就一目了然。
4.4 数据处理和 UI 渲染的瞬时峰值
除了图片,大 JSON 解析、数据库大批量查询、富文本渲染、地图大量 Marker 加载,也会造成短时间内的内存尖峰。
几个实用策略:
- 超大 JSON 用
NSJSONSerialization之前先评估大小,超过 10MB 的考虑改用流式解析,或直接转发到服务端过滤后再下发; - 数据库查询尽量分页,避免一次性把全表记录加载成对象数组;
- 富文本里的图片用附件方式加载时,同样要走降采样流程,不要把网络原图直接塞进去;
- 地图类页面,Marker 加载分批执行,比如每帧只创建 20 个,避免长时间阻塞主线程的同时把内存瞬间拉高。
这些动作每个看着不大,但叠加在一起的效果非常明显:我们的信息流页面治理后,平均 footprint 稳住了,峰值波动幅度也小了很多。
5. 监控体系与回归策略:让 OOM 不再反复
治理是主动动作,但内存问题是动态的。新的版本、新的功能、新的第三方 SDK,随时都可能把内存打回去。所以必须建立一套“能预警、能回归、能定位”的机制。
5.1 自定义内存水位上报
我最终落地的监控方案分三层:
- 第一层:周期采样(每 2 秒一次),记录当前 footprint、CPU、页面名,采样结果上报到后台形成趋势曲线;
- 第二层:水位预警。当 footprint 超过当前机型 memlimit 的 70% 时,额外记录一份内存快照,包括 Top 内存类名、最近操作的页面路径、当前线程调用栈;
- 第三层:本地回归测试。每次提测核心主流程路径时,自动统计完成关键操作前后的 footprint 增量,和基线对比,超过 30% 增量就告警,强制开发优化后才能合入。
为什么要分三层?因为线上 OOM 往往是“低频 + 偶现”,只依赖崩溃平台会有大量漏网。有了分层监控,即使某次没触发 OOM,也能看到内存水位在悄悄爬升,在用户感知之前就把问题掐掉。
5.2 建立机型分级基线
前面提到不同机型的内存上限差异很大,所以回归测试必须在多台真机上跑,不能只看开发机。我在 CI 里维护了一个真机池,覆盖 iPhone 8(2GB)、iPhone XR(3GB)、iPhone 14 Pro(4GB+)三类典型设备。每次性能测试会输出一张表格,类似:
| 场景 | 设备 | 基线 footprint | 本次 footprint | 增量比例 | 结论 |
|---|---|---|---|---|---|
| 启动到首页 | iPhone 8 | 180MB | 175MB | -2.8% | 通过 |
| 浏览信息流 5 分钟 | iPhone XR | 220MB | 410MB | +86% | 失败 |
这个表格是线上 OOM 治理真正落地的关键。单纯“优化了一版,等线上观察”这种节奏太慢,必须有版本间的纵向对比,才能快速发现回归。
5.3 灰度包重点观察与快速回滚预案
OOM 治理的改动往往涉及图片加载、缓存策略这些底層逻辑。上线前除了常规测试,我强烈建议在灰度阶段就盯住 OOM 率曲线。不是看单天数值,而是看“改版前后同一机型分组的同比变化”。如果新版的首个灰度日 OOM 率没上涨,再逐步放大覆盖面;一旦出现上涨,立刻定位到具体机型组合和页面路径,快速做热修或者在后台动态开关降级。
这里我踩过的一个坑:某次想把图片缓存统一升级为自研 LRU,灰度 10% 无异常,全量后某低端机型 OOM 率翻倍。原因很典型——灰度阶段覆盖的中高端机型占多数,低端机内存压力完全不同。后来我们做灰度时就强制加入低端机占比筛选,并实时比对低端机 OOM 率指标。
6. 日志佐证与验证:从“我认为”到“数据说明”
治理动作做完了,怎么证明有效?这个环节容易被忽略,但恰恰是对自己和团队最有说服力的一步。
6.1 实验设计:同机型、同操作、同网络
做内存验证时,尽量控制变量。比如验证降采样优化,就固定一台 iPhone 8,清空后台进程,然后按同样路径执行:启动 → 首页 → 进详情 → 返回 → 再进详情,如此循环 10 次。用 Instruments 的 Footprint 工具记录全程曲线。
优化前和后各跑一遍,对比曲线的峰值和均值。如果一个操作路径优化后的内存峰值下降了 50% 以上,这个优化基本就是稳的。
6.2 监控数据回填:让线上数据验证线下结论
线下验证通过后,把新版灰度上线,再回到后台的 footprint 采样曲线里看对应页面的内存走势。如果下降趋势和线下实验一致,说明优化真实有效;有偏差的话,重点检查是不是某些机型引入了新的内存占用。
我们专门做了一个内存监控看板,可以按照版本、机型、页面路径三个维度交叉筛选。每次发布后,我会把核心页面的“平均 footprint”和“峰值 footprint”拉出来和上一版做对比。如果某个页面均值下降但峰值没动,说明瞬时抖动还在,需要继续深挖;如果均值峰值都下降,那这次治理基本可以收尾了。
6.3 注意误报:有些“OOM”不是真的内存问题
最后提醒一句:不要看到用户说“闪退”就归到 OOM。后台统计 OOM 率的时候,必须过滤掉以下情况:
- App 被用户在 iOS 后台划掉、主动杀掉,这类是用户主动行为,不是 Jetsam 杀的;
- 系统升级、低电量模式、屏幕录制等场景下,系统可能因为其他原因终止 App;
- 越狱设备、异常进程抢占内存导致的杀进程,并不代表你的 App 内存有问题。
我在统计 OOM 率时,只认官方 Jetsam 日志里带 per-process-limit 且进程 name 是我们 App 的样本,其他一律不纳入。这个过滤规则看着简单,但可以避免大量扯皮和误优化。
做完整套治理后回头看,iOS OOM 治理的核心其实是三个词:降峰值、防泄漏、可度量。降峰值靠图片降采样和缓存边界,防泄漏靠定时器和对象生命周期的严格管理,可度量则靠 footprint 采样、基线回归和灰度对比。只要这三件事做好了,OOM 率下降只是水到渠成的结果,而不是靠反复试错碰运气。
我个人现在每个版本发布后的固定动作,就是打开内存监控看板,先横向比机型、再纵向比版本,一旦看到某个页面 footprint 上涨超过 15%,当天就让相关开发介入排查。这个习惯坚持了小半年,线上 OOM 率一路下滑,后来基本稳定在了一个很低的水平。OOM 治理没有一劳永逸的魔法,靠的就是把每一个水位、每一段曲线都盯住,把它变成日常工作的一部分。
