这是 iOS 内存深度剖析系列的第一篇——原理篇。 本系列共三篇:
上篇 · 原理篇(本文):从虚拟内存到 ARC,jetsam 杀进程的真实逻辑。 中篇 · 场景与优化篇:图片解码 / Autorelease 池 / 循环引用 / 大对象生命周期。 下篇 · 实战篇:5 类 OOM 形态 / Memory Graph / MetricKit / 线上止损 Playbook。
同一个 Notification Service Extension,调试时跑得好好的,上线后大量用户反馈"通知图片不显示"。一查崩溃日志全是 OOM——内存才用了 28MB,怎么就被系统杀了?
这是 iOS 工程师最常见的困惑之一。背后有一个反直觉的事实:你看到的"内存",其实是 5 种完全不同的东西混在一起。它们的成本天差地别、被系统看待的方式也不同,但大多数 iOS 工程师从来没把它们分清楚。
这一篇我们就把这 5 种内存讲透——从虚拟内存到物理内存、从 ARC 引用计数到 isa 指针、从 dirty page 到 jetsam 杀进程的真实决策——让你下次看到内存崩溃报告,能立刻知道问题出在哪一层。
全文阅读时间约 15 分钟,建议收藏后慢慢看。
讨论 iOS 内存之前,先建立一个最重要的认知:你在代码里看到的所有指针、所有内存地址,都是"假地址"——准确说叫虚拟地址(Virtual Address)。
NSObject *obj = [NSObject new];NSLog(@"%p", obj); // 输出 0x00006000017c0040这个 0x00006000017c0040不是物理内存上的真实位置,而是 iOS 内核给你这个进程分配的"虚拟地址空间"里的一个编号。每个进程都有自己独立的虚拟地址空间——64-bit 系统理论上每个进程的虚拟地址空间能到几百 TB(2^47量级),iOS 实际给用户态进程的范围比这小,但仍远超物理内存能提供的。
但你的设备只有 6GB / 8GB 物理内存,怎么可能给你这么大的虚拟空间?答案是:虚拟地址要通过页表(page table)映射到物理地址才能真正使用。
进程视角(虚拟地址空间) 物理内存(实际 RAM)──────────────────────── ─────────────────0x00006000017c0000 ──┐ ┌─── 真实的 16KB 物理页 ├── 页表映射 ──────┤0x00006000017c4000 ──┤ ├─── 另一张物理页 │ │0x00006000017c8000 ──X (没映射) └─── 还没分配关键认识:
这个机制是 iOS 内存管理的基石——后面所有内容都建立在这个认知之上。
打开 Instruments 看一个真实 App 的内存分布,会发现它由很多种"段"组成:
__TEXT**(代码段) | ||
__DATA**(数据段) | ||
| Heap(堆) | malloc[NSObject alloc]分配的对象 | |
| Stack(栈) | ||
| Mapped File(mmap) | mmap映射的文件,如大图、数据库 | |
| VM Allocate | vm_allocate | |
| Image I/O / IOSurface | ||
| CALayer Backing Store |
这些只是用户态可见的部分。内核态还有页表、文件 cache 等,但开发者一般不需要关心。
最直观的方式是用 vmmap命令(macOS 自带):
# 模拟器 / Mac Catalyst App:直接用 pidvmmap <pid> | head -50真机上 vmmap受限不能直接调用,几条替代路径:
vmmap输出输出长这样(这只是简化版本,§4.6 会展示完整版做 footprint 推导):
==== Writable regions for process YourApp [12345]REGION TYPE VIRTUAL RESIDENT DIRTY__TEXT 350M 80M 0K ← 代码段(clean)__DATA 18M 16M 16Mmapped file 200M 50M 0K ← mmap 文件MALLOC heaps 160M 150M 150M ← 堆(malloc / new)Image IO / IOSurface 60M 60M 60M ← 解码图片CoreAnimation 8M 8M 8MStack 16M 2M 2M ← 主线程 + 子线程栈关键三列:
反直觉的事实:
__TEXT段虚拟空间 350MB,但 dirty 是 0——代码段只读,永远不脏mapped file200MB,但 dirty 也是 0——mmap 文件不修改就不脏Stack那行 VIRTUAL=16M 是所有线程的栈虚拟空间总和(每个线程预留 ~1MB / 512KB),RESIDENT=2M 才是实际访问到的——栈只在用到的部分占物理内存这就引出了核心结论:内存压力 = dirty page 数量,不是 RESIDENT 总量。这个区分是后面所有内存优化的基础。
记住一句话:决定你的 App 会不会被系统杀的不是"看着用了多少内存",而是 dirty page 有多少。后面 §四 会把"为什么是 dirty"这件事彻底拆开讲。
桌面操作系统(macOS / Linux / Windows)在内存不够时会把不活跃的页写到磁盘 swap 分区,腾出物理内存。但 iPhone 上没有传统 swap——闪存写入次数有限,写入也太慢。
iOS 的替代方案是 memory compressor(内存压缩器)——iOS 7 / macOS 10.9(2013 年)就引入了:
内存压力小: active page → 留在物理内存内存压力大: inactive page → 用 WKdm 算法压缩 → 仍留在物理内存 (压缩比通常 2:1 ~ 3:1)内存压力极大: 还压不下 → 系统通过 jetsam 杀进程这是为什么你看到的 RESIDENT 数字有时候比实际"在用"的内存大——一部分已经被压缩了。vmmap输出里的 Compressed那一行就是压缩内存的统计。
iPad 是个例外:iPadOS 16.1 起,搭载 M1 / M2 芯片且 8GB+ RAM 的 iPad 引入了真正的 swap(Apple 称为 "Virtual Memory Swap"),系统按需将不活跃内存写入闪存。iPhone 至今没有 swap。
ObjC 的 isa / SideTable / weak 机制网上深度文章很多(draveness、sunnyxx 等),重复讲意义不大。这一章只讲三件讲机制时常被遗漏、但和性能直接挂钩的事:① 引用计数到底有多便宜 ② weak 自动 nil 的代价 ③ 一行属性赋值的真实代价。
很多教程会讲"每个对象都有一个 retainCount 字段"——这是简化但不准确的说法。真实情况:64-bit 时代起,引用计数被压进了 isa 指针的 19 bit 位域里(叫 nonpointer isa)。
为什么能塞进去?因为:
Apple 把它做成了位域,最关键的字段:
shiftcls | |
has_sidetable_rc | |
extra_rc(19 bit) | 引用计数(减 1) |
完整定义在 libobjc 的
objc-private.h。位域 bit 数在不同架构(arm64 真机 / arm64 模拟器 / x86_64)有差异,但设计思想一致。
这个设计的真正性能意义:
[obj retain] 的成本: 99% 情况:一次 CAS 原子操作(修改 isa 的 extra_rc 位)→ 几纳秒 1% 情况:引用计数 > 524288,溢出到 SideTable → 加锁 + 查表 → 几百纳秒也就是说——绝大多数 retain/release 没有锁、不查表。这是 ObjC ARC 在 iOS 这种单核时代过来的语言里仍然能保持高性能的根本。
SideTable 是什么:当 isa 的 19 bit 不够时(极少见)、或者需要存 weak 引用列表时,runtime 会把数据外置到一个全局哈希表 SideTable——按对象地址哈希到 64 张表中的一张(分散降锁竞争)。
weak 指针自动置 nil 的实现也在这里:
__weak赋值时,runtime 把 weak 指针的地址记到对应对象的 weak_table 里性能代价:
__weak赋值比 __strong慢一个数量级(要查表 + 加锁)for cell in cells { __weak typeof(self) wself = self; ... },wself应该提到循环外只创建一次self.obj = newObj的真实代价ARC 的本质是编译器自动帮你插 retain/release 代码——它是编译期机制,不是运行时机制。
你写的:
self.obj = newObj;编译器实际展开为:
objc_storeStrong(&self->_obj, newObj);// 内部:objc_retain(newObj) → 写指针 → objc_release(prev)真实成本:1 次原子 retain + 1 次指针赋值 + 1 次原子 release。
如果 release 后引用计数归 0,还会链式触发完整的 dealloc 流程:
-deallocfree把内存归还给分配器实战结论:在性能敏感的代码里——比如列表 cell 的 layoutSubviews、动画的每帧回调——减少不必要的 strong 属性赋值能带来可观的性能收益。这是大多数"ObjC 源码分析"文章会漏掉的实操结论:他们只讲机制,不讲"机制对你的代码意味着什么"。
记住一句话:ARC 不是运行时机制——它是编译器在你看不到的地方插了 retain/release 代码。你写得越简洁,它替你插得越多。
self.obj = newObj是 3 次操作,不是 1 次。
Swift 的内存管理本质上和 ObjC 是同一套机制——都是 ARC + 引用计数。但它在三个地方有显著不同。
ObjC 只有 strong和 weak,Swift 多了一个 unowned:
| strong | |||
| weak | |||
| unowned | 运行时错误 |
Swift 的
unowned默认是unowned(safe)——仍维护一份轻量的 unowned reference count,访问已释放对象时 runtime 能检测到并抛错(不会任意访问垃圾内存)。如果用unowned(unsafe)才是真正的裸指针,性能更好但完全不安全。
unowned(safe)比 weak性能更好——不需要维护 weak_table、不需要 Optional 解包——但用错就崩溃。常见安全用法:
classPerson{let name: Stringlazyvar greet: () -> String = { [unownedself] inreturn"Hello, \(self.name)"// self 一定存在,因为闭包是 self 的属性 }init(name: String) { self.name = name }}实操建议:90% 场景用
weak,只在确信生命周期的关键热点路径用unowned。
Swift 的 struct/ enum/ tuple是 value type——赋值是复制,没有引用计数。但有个反直觉的事实:
Swift 的 Array / Dictionary / String / Set 都是 struct,但内部用了引用计数 + COW(Copy-On-Write):
var a = [1, 2, 3, 4, 5] // a 持有一个引用计数为 1 的 buffervar b = a // 没有复制!b 和 a 共享同一个 buffer,引用计数变为 2b.append(6) // 触发 COW:检测到旧 buffer 引用计数 > 1// → 给 b 复制一份新 buffer// → b 释放对旧 buffer 的引用// 最终:a 持有旧 buffer(计数 1)、b 持有新 buffer(计数 1)这意味着:
let a = bigArray; let b = a看起来是值类型复制——实际不复制isKnownUniquelyReferenced(&buffer)在 runtime 完成性能特征:
isKnownUniquelyReferenced检查,遇到共享时复制 buffer——这是为什么大型 SwiftUI 项目里偶尔出现 "Array 操作慢" 的根因这一节相对底层,对日常开发不感兴趣的可以跳过——只需要记住结论:"性能热点路径上,泛型比协议类型快"。
Swift 比 ObjC 多了一类内存开销:Existential Container(存在性容器)。当你把一个值类型装进协议类型时,Swift 需要一个固定大小的容器来存它:
protocolDrawable{ funcdraw() }structCircle: Drawable{ var x, y, r: Double; funcdraw() { /* ... */ } }let circles: [Drawable] = [Circle(x: 0, y: 0, r: 1)]// ^^^^^^^ 这里发生了什么?[Drawable]数组里每个元素必须固定大小(因为数组要按 index 计算偏移),但 Circle是 24 字节、其他实现 Drawable的类型可能更大。Swift 用 Existential Container解决这个问题(单协议存在性容器):
Existential Container(5 word = 40 字节,arm64)┌─────────────────────────────────────────────────┐│ Inline storage (3 word = 24 字节) │ ← 不超过 24 字节直接存这里│ Type metadata pointer (1 word = 8 字节) │ ← 类型元数据│ Witness table pointer (1 word = 8 字节) │ ← 协议方法的虚函数表└─────────────────────────────────────────────────┘Witness table 可以理解为"协议方法的虚函数表"——每个实现该协议的类型都有一份,记录了协议要求的方法实现地址。
Circle三个 Double 正好 24 字节):直接 inline 存储,零开销性能影响:
// 性能不好:每个元素都要走 Existential Container,大对象还要堆分配let shapes: [Drawable] = [...]shapes.forEach { $0.draw() } // 每次调用走 witness table 间接分发// 性能好:泛型在编译期被特化(specialization)——编译器为每种 T 生成专门的代码funcdrawAll<T: Drawable>(_ items: [T]) { items.forEach { $0.draw() } // 直接调用,无虚分发}实操建议:性能敏感的代码用泛型而不是协议类型——在热点路径上能换来 2-5 倍性能提升。
记住一句话:Swift 的引用计数比 ObjC 少了 SideTable 哈希查找的开销,但多了 Existential Container 这个"装协议类型的容器"——前者是优化,后者是新增成本。
这一章是后面所有内存优化的基础——读者如果只看一章,建议看这章。
iOS 上的内存页按"成本"可以分成 5 类:
| dirty | ||||
| clean | 直接丢弃 | __TEXT__DATA_CONST常量段 | ||
| compressed | ||||
| swap | ||||
| mmap | mmap映射文件 |
三个反直觉的结论:
__TEXT段(代码)几百 MB 不算内存压力——它是 clean,系统压力大就丢,再要用从磁盘读回为什么 dirty page 是关键?因为它不能被丢弃。这是和 clean page 最本质的区别:
clean page:物理内存不够 → 丢掉它 → 数据还在磁盘 → 要用时从磁盘读回dirty page:物理内存不够 → 丢不掉 → 必须压缩 / 写 swap / 杀进程iOS 内核在内存压力大时,会按这个顺序处理:
① 释放 clean page(free 即可,无副作用) ↓ 还不够② 压缩 inactive 的 dirty page(compressed memory) ↓ 还不够③ 写入 swap(仅高端 iPad) ↓ 还不够④ 触发 jetsam,杀掉一个进程释放它的所有 dirty page这就把"footprint"这个核心指标解释清楚了:
clean page 的典型例子是 __TEXT段——你的 App 二进制里的代码,被 mmap 进内存,永远只读、永不修改。下篇 §4.6 会展示完整 vmmap 输出——你会看到 __TEXT那行 RESIDENT 几十甚至上百 MB,但 dirty 永远是 0。
这就是为什么大型 App(QQ 几百 MB 二进制)启动后看着内存占用很大,但实际不会因此被杀。
Apple 在这点上做了大量优化:
__DATA_CONST段:fixup 后变只读,从 dirty 转为 clean__DATA_DIRTY段:明确标识"这一块永远是 dirty 的",方便监控iOS 7 起内置的 memory compressor 工作机制:
进程 A 的 dirty page: active 时:留在物理内存(占 16KB) inactive 后:用 WKdm 压缩 → 占 4KB-8KB(仍在物理内存) 再被访问时:解压缩 → 重新变成 16KB dirty page效果: 原来 100MB dirty → 实际占用 35-50MB 压缩比一般 2:1 ~ 3:1对开发者的影响:
mmap是个特殊存在——它把磁盘文件映射到虚拟地址空间:
int fd = open("big_file.dat", O_RDONLY);void *ptr = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);// 现在 ptr 指向的虚拟内存就是文件内容关键特征:
MAP_PRIVATE):触发 COW,复制一份变成 dirty page实战用法:
mmap加载——只占虚拟地址空间,物理内存按需读Data(contentsOf: url, options: .alwaysMapped)也是 mmap陷阱:mmap一个超大文件不会立刻 OOM,但遍历访问它会让所有页都被 page-in 进物理内存,瞬间内存暴涨。
理解了 5 类页之后,我们用一个真实场景把它们串起来——你 App 在 Xcode Memory Debug 里看到的"200 MB",到底有多少会被 jetsam 算进 footprint?
假设一个中型 App 的 vmmap 摘要长这样:
REGION TYPE VIRTUAL RESIDENT DIRTY SWAPPED__TEXT 350M 80M 0K 0K__DATA_CONST 40M 38M 0K 0K__DATA 18M 16M 16M 0Kmapped file 200M 50M 0K 0KMALLOC heaps 160M 150M 150M 0KImage IO / IOSurface 60M 60M 60M 0KCoreAnimation 8M 8M 8M 0KStack 16M 2M 2M 0KCompressed 0K 30M 30M 0K─────────────────────────────────────────────────────────────────Sum ~852M ~434M ~266M 0KXcode 顶部显示的 "Memory: 434 MB"≈ RESIDENT 总和。但这个数字不是 jetsam 真正看的。
真实 footprint 的算法(简化版):
footprint = 各行的 dirty 之和 + Compressed = 16M[__DATA] + 150M[MALLOC] + 60M[ImageIO] + 8M[CoreAnimation] + 2M[Stack] + 30M[Compressed] = 266M也就是说:
__TEXT80MB / __DATA_CONST38MB / mapped file50MB 完全不算——它们是 clean,系统压力大就丢掉Compressed30MB 要算进去——压缩了仍然是 dirty 的"另一种形态"这解释了几个常见困惑:
"为什么我减小图片资源 App 占用没降?":图片资源放在 Bundle 里,加载时是 clean page(mmap),减它对 footprint 没影响——除非你减的是运行时解码后的位图(IOSurface)。
**"为什么大型 App(QQ、微信)二进制几百 MB 还能跑?"**:因为 __TEXT几乎全是 clean,不计入 footprint。这就是为什么文章上一节 "QQ 几百 MB 二进制启动后实际不会因此被杀"——现在你看到了完整推理。
"为什么后台 App 看起来用了 100MB 还是被杀了?":因为后台 App footprint 上限只有 ~50-200 MB,你 100MB 全是 dirty,已经接近红线。
实操方法:怎么算自己 App 的真实 footprint?
# 1. 模拟器 / Mac 上:直接 vmmap 看vmmap <pid> -summary# 2. 真机上:sysdiagnose 报告里的 vmmap 输出# 或:Xcode Memory Graph → Statistics 面板里直接显示 footprint# 3. 代码里实时获取(推荐):#import <mach/mach.h>task_vm_info_data_t info;mach_msg_type_number_t count = TASK_VM_INFO_COUNT;kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t)&info, &count);if (kr == KERN_SUCCESS) { NSLog(@"Footprint: %llu MB", info.phys_footprint / 1024 / 1024); // ↑ 这就是 jetsam 看的那个数字}phys_footprint是关键——它是内核暴露给用户态的"真正会被 jetsam 算账的内存"指标,iOS 9+ 起在 TASK_VM_INFO里可以直接读取。所有正经的内存监控 SDK(OOMDetector、FBMemoryProfiler 等)都是基于这个 API 做的。
记住一句话:5 类页里,只有 dirty 和 compressed 真正贡献"被杀概率"——clean / mmap-未写 / 系统能丢的都不算数。所以"减少内存"的真正含义是"减少 dirty page",而不是"减少看起来的内存数字"——
phys_footprint才是你应该监控的指标。
很多人以为"App 用了太多内存就被杀"——这话不全错,但远没那么简单。jetsam是 iOS 内核的内存压力守护机制,它的决策远比"超限就杀"复杂。
iOS 把所有进程按优先级分档(XNU 内核里的 JETSAM_PRIORITY_*常量):
JETSAM_PRIORITY_IDLE | ||
JETSAM_PRIORITY_BACKGROUND | ||
JETSAM_PRIORITY_AUDIO_AND_ACCESSORY | ||
JETSAM_PRIORITY_FOREGROUND | 10 | 前台 App |
JETSAM_PRIORITY_CRITICAL |
具体数值会随 iOS 版本微调,但相对顺序稳定。可以参考 XNU 源码里的
kern/kern_memorystatus.h。
内存压力来时,jetsam 从优先级数值最低的开始杀——优先级相同时,按"占内存多少"决定。这就是为什么你的 App 在后台被杀的概率远大于前台。
iOS 用三档信号通知系统当前的内存压力:
正常运行 │ ▼DISPATCH_MEMORYPRESSURE_NORMAL ← 没事 │ ▼ 内存开始紧张DISPATCH_MEMORYPRESSURE_WARN ← 警告(低优先级进程开始被杀) │ ▼ 内存极度紧张DISPATCH_MEMORYPRESSURE_CRITICAL ← 严重(前台进程都可能被杀)App 可以监听这些信号,在收到 WARN 时主动释放缓存:
// 假设 ImageCache 是你自己实现的图片缓存类let source = DispatchSource.makeMemoryPressureSource( eventMask: [.warning, .critical], queue: .main)source.setEventHandler {if source.data.contains(.critical) {// 紧急释放:清空所有缓存、放弃非关键操作ImageCache.shared.removeAll() } elseif source.data.contains(.warning) {// 警告:清理一半缓存ImageCache.shared.trim(toLowerLimit: 50) }}source.resume()实战价值:及时响应这些信号,能让你的 App 在内存压力下比别人晚被杀——优先级一样的情况下,jetsam 会先杀占内存多的。
很多人以为"iPhone 8GB RAM,App 能用 8GB"——错。iOS 给每个 App 设了 footprint 上限,超过就直接杀(不管整机内存多富裕)。
这个上限动态决定,影响因素:
| 设备总 RAM | |
| App 状态 | |
是否打开 Increased Memory Limitentitlement | |
| 当前系统内存压力 |
参考经验值(iPhone 14 Pro,6GB RAM):
Extension 内存上限在你启动系列中篇 §1.1 已经讲过——为什么这个数字这么小,根因就是这一节。
设备总 RAM 6GB├─ 内核 + 系统服务 ~1.5 GB├─ SpringBoard 等 ~300 MB├─ 前台 App 上限 ~2-3 GB ← 你的 App├─ 后台 App(每个) ~50-200 MB└─ Extension 24-50 MBExtension 的上限为什么这么严格?因为它是临时进程——系统希望它快进快出,不能像主 App 那样常驻。这就是你启动系列中篇强调"Extension 不能 link 主 App 全家桶 framework"的根因。
这里也解答了开头那个问题——Notification Service Extension 跑到 28MB 就被杀,正是因为它的 footprint 上限只有 24MB(见 §5.3)。Extension OOM 在生产环境里比主 App 崩溃更难定位——下篇会专门讲。
当 App 因内存被杀时,Crash 报告主要有两种形态。
形态一:单进程超限(per-process-limit)——你的 App 自己用太多了:
Exception Type: EXC_RESOURCEException Subtype: MEMORY (per-process-limit)Termination Reason: PER_PROCESS_LIMITMemory Footprint: 50 MB ← 实际内存占用Memory Limit: 50 MB ← 上限形态二:系统压力下被 jetsam 杀——你没超限,但系统整体内存吃紧:
Termination Reason: Namespace SIGNAL, Code 0x9 (SIGKILL)Termination Description: jettisoned ...关键识别要点:
EXC_RESOURCE+ MEMORY子类型 = 形态一(单进程超限,你自己的问题)jettisoned或 MEMORY关键字、且没有完整业务调用栈 = 形态二(系统压力下被牺牲)具体字段会随 iOS 版本而变。识别 OOM 主要靠两个信号:① Termination Reason / Description 里出现
MEMORY/jettisoned/per-process-limit;② 没有可用的崩溃调用栈。启动期就被 OOM 杀的情况,参见启动系列下篇 §1.1 五类 launch crash。
记住一句话:jetsam 不是"超内存就杀",而是"按优先级 + footprint 排队,从最弱的开始杀"——这就是为什么 Extension 限额最严、后台 App 最容易被回收、前台 App 相对安全。
到这里我们已经走完了 iOS 内存的"原理面":
phys_footprint才是你该监控的数字,不是 RESIDENT 总和记住一句话:iOS 内存优化的本质,是减少 dirty page 数量——不是减少 RESIDENT 总量、不是减少虚拟空间。
中篇我们继续讨论:
中篇见。
参考资料:Apple WWDC 2018《iOS Memory Deep Dive》、WWDC 2021《Detect and diagnose memory issues》、libobjc 源码、Mike Ash《Friday Q&A》系列。