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消息接收者和_cmdSEL。所以进入objc_msgSend时x0里是selfx1里是_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:返回 YESRuntime 会重新走一遍查找流程这次就能找到刚添加的方法了。如果返回 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:里返回了一个对象但那个对象实际上不响应这个SELRuntime 会继续走完整转发流程而不是直接崩溃。这个行为有时候会让人困惑。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的完整认知下次再看到那个崩溃栈时不再是满头问号而是能胸有成竹地说“哦原来是这里出了问题。”