iOS Runtime 深度剖析:从底层结构到架构设计
iOS Runtime 深度剖析:从底层结构到架构设计

在 iOS 高级开发与架构师面试中,Runtime 往往不只是考察 API 的调用,更是考察候选人对对象模型、内存管理、编译期与运行时的交互以及系统性能优化策略的深度理解。本文将通过底层数据结构、内存布局演进、对象存在形式及消息发送机制四个维度,构建完整的 Runtime 知识体系。
1. 核心流程概览
汇编层缓存查找} A -->|Cache Hit| IMP[调用 IMP] A -->|Cache Miss| B{2,Slow Path
C++层慢速查找} B -->|Found| FillCache[写入 Cache] --> IMP B -->|Not Found| C{3,动态决议
Method Resolution} C -->|Resolved| Retry[重新查找] C -->|Failed| D{4,消息转发
Message Forwarding} D -->|Fast| Target[备用接收者] D -->|Normal| Invo[NSInvocation] D -->|All Failed| Crash[Crash]
第一部分:对象的本质与底层结构 (The Anatomy of Objects)
在 Objective-C 中,万物皆对象,而对象的本质是结构体。理解 Runtime 的第一步是解剖 objc_object。
1.1 objc_object 与 Non-pointer isa
所有对象的核心都是 isa_t isa。在 64 位架构下,Apple 引入了 Non-pointer isa 优化,将 isa 设计为一个 Tagged Union(位域),利用 64 位空间存储更多信息,而不仅仅是一个内存地址。
-
nonpointer: 0 表示纯指针,1 表示包含额外信息。
-
has_assoc: 是否有关联对象。如果有,释放时需清理全局关联对象表。
-
has_cxx_dtor: 是否有 C++ 析构或 ObjC dealloc,决定是否快速释放。
-
shiftcls: 存储真正的类地址(Class Pointer)。
-
magic: 调试魔数。
-
weakly_referenced: 是否被弱引用指向。释放时需检查 SideTable。
-
deallocating: 是否正在释放。
架构思考:这种设计极大地减少了访问 SideTable(全局辅助表)的频率,提升了内存访问效率。
第二部分:类的内存模型 (Class Memory Model)
类(objc_class)本身也是一个对象,它继承自 objc_object。其内部维护了三个核心结构,决定了 Runtime 的动态能力与性能表现。我们可以将其比喻为**“一家经营了几十年的老字号餐厅”**。
2.1 class_ro_t:编译期产物 (The Foundation)
-
定义:Read-Only。这是编译器确定的“死数据”,属于 Clean Memory。
-
内容:类名、成员变量布局 (Ivar Layout)、原始方法列表、遵循的协议。
-
比喻:餐厅的地基与祖传装修。
- 成员变量布局就像餐厅的承重墙和桌椅位置,编译完就定死了。这也是为什么 Category 不能添加成员变量的原因——你不能在营业时强行砸墙加座,这会破坏内存对齐(地基坍塌)。
2.2 class_rw_t:运行时容器 (The Active Menu)
-
定义:Read-Write。这是 Runtime 在类初始化(Realize)时在堆区生成的 Dirty Memory。
-
内容:方法列表、属性列表、协议列表(二维数组)。
-
Category 原理:
-
比喻:活页菜单本。
-
当加载 Category 时,Runtime 不会修改 ro(祖传菜谱),而是将 Category 的方法列表当作“新的一页”,插入到 rw 方法列表的最前面。
-
“覆盖”假象:查找方法时,就像翻菜单,最先看到的是插在前面的 Category 方法,找到了就停止,后面的原始方法(在 ro 或 rw 后部)并未被删除,只是被“屏蔽”了。
-
2.3 cache_t:极速缓存 (The Sticky Notes)
-
定义:为了解决 rw 列表遍历慢的问题,Runtime 引入的哈希表结构。
-
结构:buckets (存储 SEL-IMP) + mask (掩码) + occupied (占用数)。
-
比喻:大堂经理手腕上的速记条。
-
客人点菜(发消息),经理不翻菜单,先看手腕。
-
扩容策略 (Cache Flush):如果手腕写满了(缓存满),Runtime 不会进行复杂的拷贝扩容,而是直接清空旧缓存,开辟新空间从零开始。因为消息发送追求极致速度,处理哈希冲突和数据迁移的成本远高于重新缓存。
-
第三部分:iOS 14+ 的演进优化 (class_rw_ext_t)
这是区分“熟练工”与“架构师”的关键知识点。Apple 发现很多类在运行时并不会被修改(无 Category,无 Swizzling),为它们分配完整的 class_rw_t 拷贝一份 ro 数据是巨大的浪费。
3.1 优化机制:Dirty Memory vs Clean Memory
-
Clean Mode (无 Category):
-
class_rw_t 依然存在,但结构极简。它不包含方法数组,直接指向 class_ro_t 读取数据。
-
不分配 class_rw_ext_t,节省大量内存。
-
-
Dirty Mode (有 Category):
-
一旦给类动态添加方法,Runtime 触发 Copy-on-Write 机制。
-
动态申请 class_rw_ext_t。
-
将 ro 的原始数据拷贝到 ext 中,并将 Category 数据拼接进去。rw 指针指向这个 ext。
-
3.2 架构总结
class_rw_t 始终存在作为运行时容器,但 class_rw_ext_t 是按需分配的。这大幅降低了系统内存占用。
第四部分:对象类型的存在形式 (The Hierarchy of Truth)
理清 rw / ro 到底存在于哪里,是理解元类的关键。
4.1 实例对象 (Instance Object)
-
只有数据:存储成员变量的具体值(如 _age = 18)和 isa 指针。
-
没有 rw / ro:实例对象不存储方法,它通过 isa 找到类对象去获取方法。
4.2 类对象 (Class Object)
-
存储元数据:存储实例方法 (-)、属性、协议。
-
拥有 rw / ro:当我们调用实例方法 [obj method] 时,Runtime 在类对象的 rw 中查找。
-
Category 实例方法:添加进类对象的 rw 中。
4.3 元类对象 (Meta-Class Object)
-
存储类的元数据:存储类方法 (+)。
-
拥有 rw / ro:当我们调用类方法 [Class method] 时,Runtime 在元类对象的 rw 中查找。
-
Category 类方法:添加进元类对象的 rw 中。
第五部分:消息发送的全流程 (Message Sending Flow)
当调用 [receiver message] 时,系统转换为 objc_msgSend(receiver, SEL),流程如下:
5.1 快速查找 (Fast Path - Assembly)
为了极致性能,这部分由汇编编写(objc-msg-arm64.s)。
-
Check Nil:若 receiver 为 nil,直接返回(防止 Crash)。
-
Get Class:通过 isa 掩码获取类对象。
-
Cache Lookup:利用 SEL & mask 哈希算法在 cache_t 查找。
-
Hit:直接调用 IMP。
-
Miss:跳转 C++ 慢速查找。
-
5.2 慢速查找 (Slow Path - C++)
进入 lookUpImpOrForward:
-
Realize Class:检查类是否初始化,若无则初始化(分配 rw 等)。
-
Binary Search:在当前类 class_rw_t 的方法列表中二分查找。
- 注意:优先找到 Category 方法。
-
SuperClass Loop:若未找到,沿着 superclass 向上找。
-
先查父类 Cache。
-
再查父类 rw 列表。
-
-
Cache Filling:找到后,将方法缓存到 Receiver 的类中(不是父类),以便下次快速调用。
5.3 消息转发 (Forwarding)
若慢速查找也失败,进入三次“急救”:
-
动态决议 (Resolve):resolveInstanceMethod。允许动态添加 IMP。
-
快速转发 (Fast Forwarding):forwardingTargetForSelector。将消息“甩锅”给其他对象,成本低。
-
常规转发 (Normal Forwarding):methodSignatureForSelector + forwardInvocation。创建 NSInvocation 对象,成本高,灵活性最大(可用于 AOP、JSPatch)。
总结:架构师视角的 Runtime
理解 Runtime 不仅仅是为了会背诵 objc_msgSend 的流程,而是为了理解以下核心设计哲学:
-
空间换时间:cache_t 的设计,以及牺牲内存扩容时丢弃旧缓存的策略,都是为了方法调用的极致速度。
-
动静分离:class_ro_t(静)与 class_rw_t(动)的分离,既保证了内存结构的稳定性(Ivar 布局),又提供了强大的动态扩展能力(Category)。
-
懒加载与按需分配:从 initialize 方法的调用时机,到 iOS 14 的 class_rw_ext_t 优化,系统始终在推迟昂贵操作的执行,直到不得不做。
掌握这些底层原理,能帮助你在设计 APM 监控系统(利用 Forwarding)、无侵入埋点(利用 Method Swizzling)以及优化应用启动时间(理解 load/initialize)时,做出更安全、高效的决策。
Q1: 请简述一下 objc_msgSend 的完整执行流程?
【高分回答逻辑】: 不要像背书一样只报流程名。要用**“三级火箭”**的模型来描述:
“
objc_msgSend的执行流程本质上是一个为了性能极致优化的三级查找机制:
第一级:汇编快速通道 (Fast Path)。 这是最高频的路径。利用 CPU 寄存器和缓存机制,在类的
cache_t中查找。为了速度,这部分完全用汇编实现,且不加锁。第二级:C++ 慢速通道 (Slow Path)。 如果缓存没命中,就会进入
lookUpImpOrForward。这里会加读写锁 (runtimeLock),从当前类开始,沿着继承链(superClass)找method_list。找到后,会把 IMP 回填到 Cache 中,下次就能走快速通道了。第三级:动态救赎通道 (Forwarding)。 如果继承链走完了都没找到,Runtime 提供了三次自救机会:动态方法决议(临时加方法)、快速转发(找个替身对象)、标准转发(完整的消息重组)。
只有这三级都失败了,才会抛出
unrecognized selector异常。”

一、 为什么寄存器(Registers)不需要加锁?
核心原因:寄存器是“线程私有”的(Thread Local Context)。
-
物理上的独享(时间片轮转): 虽然 CPU 只有一套物理寄存器(x0-x29, pc, sp 等),但在任意一个纳秒,一个 CPU 核心只能运行一个线程。 当 CPU 在运行线程 A 时,这一整套寄存器都是线程 A 的“私有草稿纸”。线程 B 根本不在这个核上,或者还没轮到它跑,所以根本不存在“竞争”。
-
上下文切换的保护(Context Switch): 当操作系统把线程 A 暂停,切换到线程 B 时(Context Switch),内核会做一件事:
-
把线程 A 当前所有寄存器的值,保存到线程 A 的内存栈里(保护现场)。
-
把线程 B 之前保存的寄存器值,恢复到 CPU 寄存器里(恢复现场)。
-
结论: 对线程来说,它感觉自己一直独占着寄存器。既然没有两个人同时碰这张纸,自然就不需要锁。
二、 为什么 objc_msgSend 读缓存(cache_t)不加锁?
这个问题更复杂。cache_t 存放在堆内存的 Class 对象里,是所有线程共享的。如果线程 A 在读,线程 B 刚好在扩容(写),按理说是会 Crash 的。
但在 objc_msgSend 的汇编快速路径(Fast Path)里,确实没有加锁。
原因有三点:
1. 性能的刚需(Performance is King)
-
锁太慢了:
objc_msgSend是 iOS 整个系统运行的基石,一秒钟可能调用几百万次。 -
即使是最高效的自旋锁(SpinLock),加锁和解锁也需要几十个 CPU 时钟周期。如果每次发消息都加锁,App 的运行速度会慢 10 倍以上。这是不可接受的。
2. 指针读取的原子性(Atomicity)
-
在 64 位架构(arm64)下,读取一个指针(Bucket 指针或 IMP 指针)的操作是原子的。
-
也就是说,CPU 读取一个 8 字节的地址,要么读到“旧值”,要么读到“新值”,绝不会读到“写了一半的值”(比如前 4 字节是新的,后 4 字节是旧的)。
-
这保证了即使不加锁,读取到的数据在位级上也是完整的。
3. 核心黑科技:内存屏障与垃圾回收(Memory Barrier & Garbage Collection)
那么,怎么解决“线程 A 正在读,线程 B 扩容把旧缓存释放了,线程 A 读到野指针 Crash”的问题?
Runtime 采用了一套巧妙的 Copy-On-Write + 内存屏障 机制(这也体现了高 P 的技术深度):
-
写屏障(Store-Release): 当慢速路径需要更新缓存(扩容)时,它不会直接把旧内存
free掉。它会先把新桶(New Buckets)准备好,把指针指向新桶。 关键点:Runtime 会保证在这一步操作完成前,所有正在读旧桶的线程都能安全读完。 -
资源延迟释放(延后 free): 在旧版本的 Runtime 中,扩容后的旧内存不会立即释放,而是留存一段时间,或者丢到一个垃圾回收链表里。这样即使线程 A 还在读旧表,它读到的也是有效内存,只是数据可能稍微旧一点(Old Cache)。 注:在最新版 iOS Runtime (objc4-818+) 中,策略更激进,扩容时直接丢弃旧缓存数据,只开辟新空间,利用
cache_t的版本号或标志位来控制,但读取侧依然依靠原子性不加锁。 -
读到脏数据怎么办? 如果因为多线程并发,导致线程 A 在缓存里读到了一个“错误”的位置(比如哈希冲突探测时,别的线程改了数据),
objc_msgSend的汇编逻辑里有校验机制(对比bucket->sel和传入的sel)。-
如果匹配:说明读对了,放心跑。
-
如果不匹配:说明可能有并发修改干扰,或者单纯没命中。只要不匹配,就跳去慢速查找(Slow Path)。
-
慢速查找里是加了锁的 (runtimeLock)。
三、 面试时的“降维打击”回答
如果面试官问:“CPU 寄存器和缓存为什么不加锁?”
你可以这样回答,体现架构师思维:
“这里要分两部分看:
对于寄存器: 它们不需要锁,因为它们是硬件级的线程私有资源。操作系统通过上下文切换(Context Switch)时的‘保存-恢复’机制,保证了每个线程看到的寄存器都是独立的,不存在并发竞争。
对于软件缓存(cache_t): 它本质上是共享内存,按理说是需要锁的。但
objc_msgSend处于系统调用的最底层,加锁的性能成本太高(会造成严重的 CPU 停顿)。 因此,Runtime 选择了**无锁编程(Lock-Free)**策略:
利用 硬件原子性:保证指针读取要么是旧值,要么是新值,不会是乱码。
利用 容错回退机制:快速路径只负责‘尝试读取’,一旦发现数据不对劲(SEL 不匹配),立即回退到慢速路径。
而在慢速路径里,为了保证数据的一致性,是**必须加锁(runtimeLock)**的。
简而言之:寄存器是靠隔离来避开锁,缓存是靠原子性和容错机制来省略锁。”
Q2: (深挖) 快速查找中的 Cache 机制具体是怎么设计的?为什么这么设计?
【核心考点】:哈希策略、局部性原理。
“Runtime 的 Cache 设计非常有意思,它没有使用常见的‘拉链法’来解决哈希冲突,而是使用了开放寻址法 (Open Addressing) 中的 线性探测 (Linear Probing)。
具体逻辑是: 通过
selector & mask算出下标。如果该位置已经存了别的方法(哈希冲突),系统会index - 1向前查找,直到找到空位或者目标方法。设计原因 (The Why):
节省内存:拉链法需要额外的指针存储链表,而开放寻址法只需要数组。
缓存局部性 (Cache Locality):数组在物理内存上是连续的。线性探测能更好地利用 CPU 的 L1/L2 Cache Line,减少 CPU 访问内存的次数。对于
msgSend这种高频调用,哪怕减少几次内存访问,性能提升都是巨大的。”
Q3: (深挖) 慢速查找时的 method_list 是怎么遍历的?二分还是遍历?
【核心考点】:排序优化、Category 的覆盖原理。
“这取决于方法列表的状态。
二分查找 (Binary Search):如果类的方法列表是 Sorted (已排序) 的(通常是编译期生成的 clean memory),Runtime 会直接用二分查找,复杂度是 O(logN)。
线性遍历 (Linear Search):如果类被动态修改过(比如加载了 Category),列表变成了 Unsorted,就会退化成线性遍历。
关于 Category 的覆盖: Runtime 在加载 Category 时,会把分类的方法列表 attach 到主类方法列表的最前面。 因为查找是顺序进行的,一旦在前面找到了名字一样的方法,就会直接返回。这就是 Category 方法能‘覆盖’主类方法的本质——主类的方法还在后面,只是访问不到了。”
Q4: (深挖) 为什么 objc_msgSend 必须用汇编写?用 C 不行吗?
【核心考点】:Trampoline、参数转发、ABI。
“这不仅仅是为了快,更是为了正确。核心原因有三点:
Trampoline (跳板机制):
objc_msgSend本质上不是一个‘函数调用’,而是一个跳板。它的任务是找到 IMP,然后直接跳转 (Jump) 过去。 如果用 C 写,编译器会自动生成 Prologue (开场代码),把参数压栈。而objc_msgSend必须对所有参数(包括未知的变长参数)保持透明,原封不动地交给 IMP。只有汇编能控制寄存器不做多余的操作。尾调用优化 (Tail Call Optimization): 汇编最后使用的是
br(Branch) 或jmp指令,而不是call。 这意味着当 IMP 执行完后,会直接返回给最初的调用者,而不会再回到objc_msgSend里。这样就少了一层栈帧,效率极高。寄存器参数的不确定性: C 语言无法定义一个‘参数类型和个数都未知’的函数接口,也无法直接把接收到的所有寄存器(x0-x8, q0-q7)直接转发给下一个函数。”
Q5: 在消息转发中,Runtime 是如何防止“动态决议”进入死循环的?
【核心考点】:标志位、状态机。
“防止死循环的关键在于
triedResolver这个布尔标记位。当查找失败进入
resolveInstanceMethod时:
Runtime 会先判断
triedResolver是否为 false。如果是,将其置为
true,然后调用决议方法。决议方法执行完后,Runtime 会触发一次
retry(goto 重新查找)。如果这次重试还是没找到 IMP,虽然逻辑上会再次走到决议的入口,但因为
triedResolver已经是 true 了,系统就不会再给机会,而是直接进入下一步(快速转发)。这就保证了
+resolveInstanceMethod:在同一次消息发送中只会被调用一次。”
💡 总结:给面试官的“最后一击”
如果面试官问完以上问题,你可以做一个升华总结:
“所以总结来说,
objc_msgSend的设计哲学就是:在极高频的路径上(Fast Path)做到极致的无锁和寄存器复用;在低频路径上(Slow Path/Forwarding)保证线程安全和灵活性。 这是一个非常经典的系统级优化案例。”
Objective-C 的方法调用 [obj foo] 在编译阶段会被转化为 C 函数调用:objc_msgSend(obj, @selector(foo))。其执行流程可以分为三大阶段:
- 快速查找(Fast Path - 汇编层):查找 Cache。
- 慢速查找(Slow Path - C/C++层):查找 method_list。
- 动态处理:动态方法决议 & 消息转发。
2. 详细执行步骤
2.1 快速路径 (Assembly)
objc_msgSend 是用汇编(arm64/x86_64)编写的,逻辑如下:
- 判空:检查 receiver 是否为
nil。如果是,直接返回(这就是为什么给 nil 发送消息不会崩溃)。 - 获取 Class:通过
isa & ISA_MASK获取类对象。 - 查找 Cache:
- 根据
@selector(foo)和掩码计算哈希下标。 - 访问
cache_t中的buckets。 - 命中:直接调用
imp。 - 未命中:进入
CheckMiss,跳转到慢速查找。
- 根据
2.2 慢速路径 (C++)
进入 lookUpImpOrForward 函数:
- 二分查找:在当前类的
bits->methods(method_list) 中使用二分法查找匹配的方法。 - 父类递归:如果当前类没找到,沿着
superclass指针向上查找,直到NSObject。 - 缓存填充:如果在慢速路径找到方法,会将其插入到
cache_t中,以便下次快速调用。
3. 动态方法决议与转发
如果方法列表里找不到 IMP,Runtime 会给你三次 “自救” 的机会:
-
动态方法决议 (Method Resolution)
- 调用
+resolveInstanceMethod:或+resolveClassMethod:。 - 开发者可以在此利用
class_addMethod动态添加方法实现。 - 如果返回
YES,重新执行消息查找流程。
- 调用
-
快速转发 (Fast Forwarding)
- 调用
-forwardingTargetForSelector:。 - 返回一个可以处理该消息的其他对象。
- 如果返回非 nil,消息会被重定向到新对象。
- 调用
-
标准转发 (Normal Forwarding)
- 调用
-methodSignatureForSelector:获取方法签名。 - 创建
NSInvocation对象。 - 调用
-forwardInvocation:。开发者可以在此修改调用目标、参数等。
- 调用
🔥 面试深挖:为什么 objc_msgSend 用汇编写?
这是一个经典的 P7+ 面试题,主要基于三个原因:
-
性能极致优化 (Performance):
- 方法调用是 iOS App 中最高频的操作。
- C 语言函数调用会有 Prolog (开场) 和 Epilog (收尾) 操作(如保存/恢复栈帧、寄存器压栈),而
objc_msgSend实际上是一个 Jump (跳转) 指令,不需要建立完整的栈帧。 - 汇编可以直接操作寄存器,减少内存读写。
-
参数不确定性 (Varargs):
- C 语言很难定义一个 “参数个数和类型都未知” 的函数并将其完美转发给另一个函数。
- 汇编通过寄存器协议(如 ARM64 的 x0-x7 传递参数),可以直接将所有接收到的寄存器状态透传给目标 IMP。
-
绕过 ABI 限制:
- 能够实现 “Tail Call Optimization”(尾调用优化),直接
br(Branch) 到目标函数的地址,让目标函数执行完后直接返回给最初的调用者,而不是返回给objc_msgSend。
- 能够实现 “Tail Call Optimization”(尾调用优化),直接