1. 从一个崩溃日志说起:为什么要死磕 objc_msgSend
如果你写过或者维护过 iOS/macOS 上的 Objective-C 代码,大概率见过这种崩溃栈:objc_msgSend + 32、objc_msgSend + 48,后面跟着一串看不懂的地址。很多人看到这个就头大,直接丢给系统框架去背锅。但我想说的是,objc_msgSend恰恰是 Objective-C Runtime 里最值得花时间啃下来的一块硬骨头,没有之一。
这篇文章要聊的,就是objc_msgSend这个东西到底是什么、它为什么快、它内部做了哪些事、汇编层面长什么样、缓存机制怎么设计的、消息转发又是怎么接上的。适合谁看?适合已经会写 OC 代码、但对 Runtime 底层一知半解、想真正搞明白“方法调用到底发生了什么”的开发者。也适合那些被unrecognized selector sent to instance折磨过、想彻底搞清楚崩溃原因的人。
先把结论摆出来:Objective-C 的方法调用,本质上不是“调用”,而是“发消息”。[obj doSomething]这行代码,编译器会把它翻译成objc_msgSend(obj, @selector(doSomething))。也就是说,方法调用在运行时才决定到底执行哪段代码。这个“运行时才决定”的能力,就是 OC 动态性的根基,而objc_msgSend就是承载这个根基的那根柱子。
很多人对 Runtime 的理解停留在class_copyMethodList、method_exchangeImplementations这些 API 层面,觉得会用这些就算懂 Runtime 了。但真正的分水岭在于:你知不知道objc_msgSend是用汇编写的?知不知道它为什么必须用汇编?知不知道它内部有快慢两条路径?知不知道缓存命中时它到底省掉了哪些步骤?这些问题搞清楚了,你对 OC 的理解会上一个台阶。
我自己的经验是,第一次认真读objc_msgSend的汇编源码时,前前后后看了三四遍才把逻辑串起来。第一遍看个热闹,第二遍知道大概流程,第三遍才注意到那些寄存器分配和缓存查找的细节。所以这篇文章我会尽量把每一步都拆开讲,让你少走我走过的弯路。
2. 消息发送的整体设计:为什么必须是汇编
2.1 从编译器视角看方法调用的翻译过程
先看一段最普通的代码:
Person *p = [[Person alloc] init]; [p sayHello];编译器看到[p sayHello],会把它翻译成:
objc_msgSend(p, @selector(sayHello));注意这里有个关键点:@selector(sayHello)在编译期就已经确定,它是一个SEL类型的值,本质上就是一个指向方法名字符串的指针。但p到底属于哪个类、这个类有没有实现sayHello、实现体在内存的哪个位置,这些统统要到运行时才知道。
这就是 OC 和 C++ 最大的区别之一。C++ 的虚函数调用虽然也是运行时决定,但编译器会生成一个虚函数表偏移量,调用时直接查表跳转,结构非常固定。而 OC 的消息发送要灵活得多——你可以在运行时给一个类动态添加方法、替换方法实现、甚至把消息转发给另一个对象处理。这种灵活性是有代价的,代价就是每次调用都要走一遍查找流程。
那为什么不用 C 函数来实现objc_msgSend,非要用汇编?这个问题我当初也想了很久。后来搞明白了,核心原因有三个。
第一个原因是参数传递的不确定性。objc_msgSend本身不知道你要传多少个参数、什么类型。它只负责找到方法实现然后跳过去。如果用 C 写,你得先声明一个固定签名的函数,但方法调用的参数是千变万化的。汇编可以直接操作寄存器和栈,把参数原封不动地传给目标方法,不需要知道参数具体是什么。
第二个原因是性能。objc_msgSend是每一次方法调用都要走的路径,它的性能直接决定了整个 App 的运行效率。汇编可以做到极致优化,避免 C 函数调用时的栈帧开销、寄存器保存恢复等额外操作。苹果官方文档里提到过,objc_msgSend的执行速度必须快到“感觉不到它的存在”。
第三个原因是尾调用优化。找到方法实现后,objc_msgSend需要直接跳转到目标函数的入口,而不是调用它再返回。汇编里的jmp指令可以做到这一点,C 语言里虽然也有尾调用,但编译器不保证一定优化。
2.2 消息发送的三个阶段
从宏观上看,一次完整的消息发送会经历三个阶段,我把它叫做“快路径、慢路径、转发路径”。
快路径就是缓存查找。每个类对象里都有一个方法缓存(cache),用来存储最近调用过的方法。如果这次要调用的方法恰好在缓存里,objc_msgSend就能以极快的速度找到实现并跳转。这是绝大多数方法调用的实际情况,因为程序运行时的热点方法就那么几个,缓存命中率非常高。
慢路径是缓存没命中时的完整查找。这时候要沿着类的继承链一层一层往上找,先查自己的方法列表,再查父类的,一直查到根类。找到之后,把结果写入缓存,方便下次快速命中。如果整条继承链都找不到,就进入第三个阶段。
转发路径是消息转发的完整流程。当对象收到一个自己无法处理的消息时,Runtime 不会立刻崩溃,而是给对象三次机会来补救:动态方法解析、快速转发、完整转发。只有这三步都失败了,才会抛出unrecognized selector异常。
这三个阶段的设计非常精妙,它把“常见情况”和“罕见情况”彻底分开了。99% 的调用走快路径,几乎零开销;剩下 1% 走慢路径或转发,虽然慢但能保证正确性。这种分层设计是很多高性能系统的通用思路,值得我们在自己的项目里借鉴。
2.3 为什么缓存要设计成散列表
方法缓存的数据结构是一个散列表(hash table),这一点很多人知道,但为什么用散列表、散列表具体怎么组织的,可能就不太清楚了。
用散列表的核心目的是 O(1) 查找。方法调用太频繁了,如果用数组或者链表来存缓存,每次查找都要遍历,性能根本扛不住。散列表通过SEL的地址做哈希,直接定位到桶的位置,理想情况下一次就能找到。
但散列表有个绕不开的问题:哈希冲突。不同的SEL可能哈希到同一个位置。OC 的缓存用的是开放寻址法里的线性探测——如果目标位置被占了,就往后找下一个空位。查找的时候也一样,先看目标位置是不是要找的SEL,不是就往后顺延。
这里有个细节值得注意:缓存的容量是有限的,而且会随着方法调用动态扩容。当缓存装得太满时,查找效率会急剧下降,所以 Runtime 会在缓存使用率达到一定阈值时进行扩容。扩容时会重新分配更大的空间,把旧缓存里的条目重新哈希放进去。这个过程虽然耗时,但发生频率很低,对整体性能影响可以忽略。
提示:理解缓存机制对排查性能问题很有帮助。如果你发现某个方法调用特别慢,可以想想是不是缓存频繁失效导致的。比如在运行时大量动态添加方法,就可能让缓存不断重建。
3. objc_msgSend 汇编实现逐行拆解
3.1 入口处的寄存器约定
要读懂objc_msgSend的汇编,首先得知道 ARM64 架构下的寄存器约定。在 ARM64 上,函数调用的参数传递规则是这样的:前八个参数依次放在x0到x7寄存器里,超过八个的走栈。返回值放在x0。
对于objc_msgSend来说,它至少接收两个参数:self(消息接收者)和_cmd(SEL)。所以进入objc_msgSend时,x0里是self,x1里是_cmd。如果方法还有额外参数,它们会依次放在x2、x3等寄存器里。
这里有个非常关键的设计:objc_msgSend在找到方法实现后,会直接跳转过去,此时x0到x7里的值必须保持原样,因为目标方法需要这些参数。所以objc_msgSend在查找过程中,绝对不能破坏这些寄存器。这就是为什么它要用汇编——C 函数没法保证不碰这些寄存器。
3.2 快路径:缓存查找的汇编逻辑
快路径的汇编代码大致是这样的逻辑(我用伪汇编来描述,方便理解):
objc_msgSend: // x0 = self, x1 = _cmd // 检查 self 是否为 nil cbz x0, return_nil // 从 self 取出 isa 指针 ldr x13, [x0] // 从 isa 中取出缓存对象 // 这里涉及 isa 的位域解析 and x13, x13, ISA_MASK ldr x13, [x13, #CACHE_OFFSET] // 用 _cmd 计算哈希索引 // 通常是 _cmd & mask ... // 在缓存中查找 // 比较 SEL 是否匹配 ... // 命中,跳转到方法实现 br x13第一步检查self是否为 nil 非常重要。在 OC 里,给 nil 发消息不会崩溃,而是直接返回 0 或 nil。这个特性就是在这里实现的——如果x0是 0,直接跳到返回 nil 的逻辑,不进行任何查找。
第二步取isa指针。isa是对象里指向类对象的指针。在 64 位系统上,isa不是直接用的,而是经过位域编码的,需要用一个掩码(ISA_MASK)取出真正的类对象地址。这个设计是为了支持 tagged pointer 等优化。
第三步从类对象里取出缓存。缓存对象里存着散列表的起始地址、容量、已用数量等信息。
第四步用_cmd计算哈希索引。SEL本质上是一个指针,直接拿它的地址做哈希,通常是(SEL >> 2) & mask之类的操作,因为指针的低位通常是对齐的,直接取低位会有很多冲突。
第五步在缓存里查找。先看目标桶里的SEL是不是等于_cmd,如果是就命中;如果不是,往后顺延,直到找到或者遇到空桶。
第六步命中后,从缓存条目里取出方法实现的地址(IMP),然后br跳过去。注意是br不是bl,因为不需要返回objc_msgSend,目标方法执行完直接返回给调用者。
3.3 慢路径:从缓存未命中到完整查找
如果快路径没找到,就会跳到慢路径。慢路径的入口通常是一个 C 函数,比如__objc_msgSend_uncached。这里之所以能切换到 C,是因为慢路径不要求保持寄存器原样了——它会把参数保存到栈上,然后调用 C 函数来查找。
慢路径的查找逻辑是这样的:
- 从
self的isa拿到类对象。 - 先查这个类的方法列表,用二分查找或者线性查找找
_cmd。 - 如果没找到,沿着
superclass指针往上找父类,重复步骤 2。 - 一直找到根类(通常是
NSObject),如果还没找到,进入消息转发流程。 - 找到后,把
SEL和IMP写入缓存,然后跳转到IMP。
这里有个优化细节:方法列表在编译期就已经排好序了,所以查找时可以用二分查找,效率是 O(log n)。但缓存查找是 O(1),所以慢路径虽然叫“慢”,其实也不算太慢,只是相对快路径而言。
注意:慢路径里有一个容易忽略的点——如果找到的方法实现是
_objc_msgForward或者类似的转发入口,Runtime 会直接跳过去,而不是写入缓存。因为转发是特殊情况,缓存它没有意义。
3.4 寄存器保护与尾调用优化
前面提到objc_msgSend必须保持参数寄存器不变,这一点在汇编里是怎么保证的?答案是:快路径全程只使用x13及以上的寄存器,不碰x0到x7。x13到x15在 ARM64 调用约定里是临时寄存器,调用者不保证它们的内容,所以可以随便用。
尾调用优化体现在最后的br指令上。br x13直接跳转到x13里存的地址,不保存返回地址,也不创建新的栈帧。目标方法执行完ret时,直接返回到objc_msgSend的调用者,中间没有额外的栈开销。这就是为什么objc_msgSend能做到“感觉不到它的存在”。
如果不用尾调用,每次方法调用都要多一次压栈和出栈,累积起来对性能的影响是很大的。苹果在这一点上抠得非常细,这也是 OC 虽然动态但性能依然可用的原因之一。
4. 缓存机制深度剖析:从数据结构到扩容策略
4.1 缓存对象的内部结构
方法缓存在源码里对应的是cache_t这个结构体。它主要包含三部分信息:散列表的桶数组、已用容量、以及一些标志位。
桶数组里每个桶叫一个bucket_t,里面存着SEL和IMP两个指针。查找时先比较SEL,匹配了再取IMP。这里有个细节:SEL和IMP的存储顺序在不同架构上可能不一样,有的是SEL在前,有的是IMP在前。这是为了配合汇编里的比较指令,让比较和取值能更紧凑。
已用容量用来判断缓存是否装得太满。当已用容量超过总容量的某个比例(通常是 3/4)时,就会触发扩容。扩容不是简单地扩大数组,而是重新分配一块更大的内存,然后把旧缓存里的条目重新哈希放进去。这个过程叫 rehash。
4.2 哈希函数与冲突处理
哈希函数的设计直接决定了缓存的查找效率。OC 用的哈希函数很简单,基本就是拿SEL的地址右移几位然后取模。为什么这么简单?因为SEL本身就是方法名的唯一标识,不同方法名的SEL地址不同,分布也比较均匀,不需要太复杂的哈希函数。
冲突处理用的是线性探测。假设_cmd哈希到索引 5,但索引 5 已经被另一个SEL占了,那就看索引 6,再看索引 7,直到找到空位或者匹配的SEL。查找时也一样,从哈希位置开始往后顺延,直到找到匹配的SEL或者遇到空桶(空桶意味着这个SEL不在缓存里)。
线性探测的缺点是容易产生“聚集”——连续的位置都被占用,导致查找时需要顺延很多次。但因为缓存容量通常远大于实际存储的方法数,聚集现象不会太严重。而且缓存会定期扩容,也能缓解这个问题。
4.3 扩容时机与性能权衡
扩容的时机很关键。扩得太早,浪费内存;扩得太晚,查找效率下降。OC 选择的策略是当缓存使用率达到 3/4 时扩容。这个阈值是经过实测的平衡点。
扩容时的新容量通常是旧容量的两倍。比如原来能存 16 个条目,扩容后能存 32 个。翻倍的好处是 rehash 时索引计算简单(掩码多一位),而且扩容频率不会太高。
扩容本身是有成本的:要分配新内存、遍历旧缓存、重新计算哈希、写入新缓存。如果扩容太频繁,这个成本会累积起来影响性能。所以 Runtime 会尽量避免频繁扩容——只有当缓存真的装不下时才会扩。
提示:如果你在运行时动态给类添加大量方法,可能会触发多次缓存扩容。这种情况下,可以考虑在初始化阶段一次性添加完,避免在热点路径上扩容。
4.4 缓存与多线程的那些事
方法缓存是每个类对象共享的,多个线程可能同时读写同一个缓存。这就带来了线程安全问题。OC 的缓存设计是无锁的——读操作不加锁,写操作也不加锁,而是通过一些技巧来保证正确性。
具体来说,缓存的写入是原子的。当一个线程在查找缓存时,另一个线程可能正在写入新条目。查找线程可能会看到“半成品”状态,但因为写入是原子的,它要么看到旧值要么看到新值,不会看到中间状态。如果查找时发现SEL匹配但IMP还没写完,它会重新查找或者走慢路径。
这种无锁设计的好处是避免了锁竞争带来的性能损耗。方法调用太频繁了,如果每次都要加锁解锁,性能会惨不忍睹。无锁设计虽然实现复杂,但换来了极高的并发性能。
5. 消息转发:当对象说“我不会”之后
5.1 动态方法解析:第一次补救机会
当慢路径查遍整条继承链都没找到方法实现时,Runtime 不会立刻崩溃,而是先调用+resolveInstanceMethod:或+resolveClassMethod:,给类一个机会来动态添加方法实现。
这个机制的实际用途是:有些方法的实现可以延迟到第一次调用时才提供。比如你用@dynamic声明了一个属性,编译器不会生成 getter 和 setter,而是期望你在运行时动态提供。这时候就可以在resolveInstanceMethod:里用class_addMethod把实现加进去。
+ (BOOL)resolveInstanceMethod:(SEL)sel { if (sel == @selector(dynamicProperty)) { class_addMethod(self, sel, (IMP)dynamicGetter, "@@:"); return YES; } return [super resolveInstanceMethod:sel]; }如果resolveInstanceMethod:返回 YES,Runtime 会重新走一遍查找流程,这次就能找到刚添加的方法了。如果返回 NO,进入下一步。
5.2 快速转发:把消息转给别人
第二次机会是forwardingTargetForSelector:。这个方法让你返回一个能处理该消息的对象,Runtime 会把消息转发给这个对象。
- (id)forwardingTargetForSelector:(SEL)aSelector { if (aSelector == @selector(someMethod)) { return self.helper; } return [super forwardingTargetForSelector:aSelector]; }这个机制常用于实现“多继承”的效果——一个对象可以把部分消息委托给内部的其他对象处理。比如一个视图控制器可以把数据源方法转发给一个专门的数据管理对象。
快速转发的“快速”体现在它不需要创建NSInvocation对象,开销比完整转发小得多。如果只是想把消息转给另一个对象,优先用这个。
5.3 完整转发:最后的机会
如果前两步都没能处理消息,就进入完整转发流程。Runtime 会调用methodSignatureForSelector:获取方法的签名,然后调用forwardInvocation:并传入一个NSInvocation对象。
- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector { NSMethodSignature *sig = [super methodSignatureForSelector:aSelector]; if (!sig) { sig = [self.helper methodSignatureForSelector:aSelector]; } return sig; } - (void)forwardInvocation:(NSInvocation *)anInvocation { if ([self.helper respondsToSelector:anInvocation.selector]) { [anInvocation invokeWithTarget:self.helper]; } else { [super forwardInvocation:anInvocation]; } }完整转发最灵活,因为你可以修改NSInvocation的参数、返回值,甚至把一次调用拆成多次。但它的开销也最大,因为要创建NSInvocation对象、解析方法签名、打包参数。所以能用快速转发就别用完整转发。
5.4 三步都失败之后
如果三步转发都失败了,Runtime 会调用doesNotRecognizeSelector:,默认实现就是抛出unrecognized selector sent to instance异常。这就是我们最常见的那个崩溃。
理解了这个流程,你就能明白为什么有些崩溃日志里会看到forwardingTargetForSelector:或者forwardInvocation:的调用栈——那说明消息已经走到了转发阶段,只是最终没被处理。
注意:
respondsToSelector:和forwardingTargetForSelector:的配合要小心。如果你在forwardingTargetForSelector:里返回了一个对象,但那个对象实际上不响应这个SEL,Runtime 会继续走完整转发流程,而不是直接崩溃。这个行为有时候会让人困惑。
6. 实操中踩过的坑与排查技巧
6.1 常见崩溃场景速查表
| 崩溃信息 | 可能原因 | 排查方向 |
|---|---|---|
unrecognized selector sent to instance | 方法不存在且转发失败 | 检查resolveInstanceMethod:、forwardingTargetForSelector:实现 |
objc_msgSend + 32崩溃 | 消息接收者已释放 | 检查是否有野指针、悬垂指针 |
objc_msgSend栈溢出 | 递归调用或转发循环 | 检查forwardInvocation:是否造成无限递归 |
| 缓存相关崩溃 | 运行时动态修改方法导致缓存不一致 | 检查method_exchangeImplementations的调用时机 |
6.2 用汇编断点定位消息发送问题
在 Xcode 里,你可以给objc_msgSend下断点,但因为它太频繁了,直接断会卡死。更好的做法是用条件断点,只在特定SEL时断下来。
具体操作:在断点导航器里添加一个符号断点,符号填objc_msgSend,然后设置条件为$x1 == (SEL)@selector(yourMethod)。这样只有当_cmd是yourMethod时才会断下来。断下来之后,你可以看x0里的self是什么对象,从而定位是谁在发这个消息。
这个技巧在排查“谁调用了这个方法”时特别有用。我当初排查一个循环引用问题时,就是用这个方法找到了那个隐藏的调用方。
6.3 动态方法解析的坑
resolveInstanceMethod:有一个容易踩的坑:它只会在方法第一次被调用时触发。如果你在第一次调用时返回了 NO,之后即使你再添加方法,也不会再触发解析了。所以如果你打算动态添加方法,一定要在第一次就加好。
另一个坑是线程安全。resolveInstanceMethod:可能在多个线程上同时被调用,如果你在里面修改了类的结构,需要自己保证线程安全。通常的做法是加锁或者用dispatch_once。
6.4 消息转发的性能陷阱
完整转发的开销比普通方法调用大一到两个数量级。如果你的代码在热点路径上大量使用完整转发,性能会明显下降。我见过一个项目用完整转发来实现 AOP(面向切面编程),结果在列表滚动时卡顿严重。后来改成用方法交换(method swizzling)在编译期就替换实现,性能问题才解决。
所以我的建议是:转发机制适合处理“罕见情况”,比如兼容旧接口、实现委托链。不要把它用在每次调用都会走的热点路径上。
6.5 缓存失效的隐蔽问题
方法缓存是每个类独立的。如果你在运行时给一个类添加了方法,但另一个类已经缓存了“这个方法不存在”的结果,那另一个类的缓存不会自动更新。不过 OC 的缓存只缓存“找到”的结果,不缓存“没找到”,所以这个问题实际上不存在。
但有一个相关的问题:如果你用method_exchangeImplementations交换了两个方法的实现,已经缓存的SEL到IMP的映射不会自动更新。不过因为交换的是IMP,而缓存里存的也是IMP,交换后缓存里的IMP就指向了错误的实现。所以交换方法后,需要手动清理缓存,或者确保交换发生在任何调用之前。
提示:
method_exchangeImplementations之后调用_objc_flush_caches可以清理缓存,但这个函数是私有的,不建议在生产环境使用。更稳妥的做法是在+load或+initialize里做交换,这时候还没有调用发生,缓存是空的。
7. 从 objc_msgSend 看 Runtime 的设计哲学
7.1 动态性与性能的平衡术
objc_msgSend最让人佩服的地方,是它在“完全动态”和“极致性能”之间找到了一个平衡点。理论上,每次方法调用都要查继承链、找实现,这个开销是很大的。但通过缓存机制,99% 的调用变成了 O(1) 的散列表查找,性能接近静态调用。
这种“快慢路径分离”的设计思路,在很多高性能系统里都能看到。比如 CPU 的分支预测、数据库的查询缓存、Web 服务器的静态资源缓存,本质上都是把常见情况优化到极致,罕见情况保证正确性。
7.2 汇编与 C 的边界划分
objc_msgSend用汇编实现快路径,用 C 实现慢路径和转发,这个边界划分非常讲究。快路径要求极致的性能和寄存器保护,所以用汇编;慢路径和转发逻辑复杂,用 C 写更易维护,而且它们不是性能瓶颈,用 C 完全没问题。
这种“该用汇编的地方用汇编,该用 C 的地方用 C”的务实态度,值得我们学习。不要为了炫技而全部用汇编,也不要为了省事而全部用 C。关键是看这个代码在性能关键路径上的位置。
7.3 对日常开发的启示
理解了objc_msgSend,对日常开发有几个实际帮助。
第一,你知道给 nil 发消息是安全的,所以不需要到处写if (obj) { [obj doSomething]; },直接[obj doSomething]就行。这能让代码简洁不少。
第二,你知道方法调用有缓存,所以不用担心“方法太多导致调用变慢”。只要不是动态添加海量方法,缓存都能扛住。
第三,你知道消息转发有三步,所以在设计 API 时可以利用这个机制实现一些巧妙的功能,比如用forwardingTargetForSelector:实现委托链,用forwardInvocation:实现动态代理。
第四,你知道objc_msgSend的崩溃栈意味着什么,排查问题时能更快定位方向。
7.4 后续可以深入的方向
如果你把objc_msgSend搞明白了,接下来可以往几个方向深入。一个是isa的位域结构和 tagged pointer,这是 OC 对象模型的基础。另一个是NSInvocation的内部实现,它和消息转发紧密相关。还有一个是 Swift 的方法派发机制,对比着看能更深刻地理解 OC 的设计取舍。
我自己在搞明白objc_msgSend之后,再看其他语言的运行时(比如 JVM 的方法调用、JavaScript 的原型链查找),会有一种“似曾相识”的感觉。很多底层原理是相通的,只是实现细节不同。这种跨语言的视野,对提升技术深度很有帮助。
最后分享一个我自己的小习惯:每次遇到objc_msgSend相关的崩溃,我都会把崩溃栈完整看一遍,看看消息发送到了哪个阶段。是缓存查找阶段?是慢路径查找阶段?还是转发阶段?这个习惯帮我快速定位过很多疑难问题。希望这篇文章能帮你建立起对objc_msgSend的完整认知,下次再看到那个崩溃栈时,不再是满头问号,而是能胸有成竹地说:“哦,原来是这里出了问题。”