3、消息传递机制:objc_msgSend的底层原理、消息查找流程、快速查找与慢速查找

消息传递,说白了就是Objective-C的灵魂所在。你写个[obj doSomething],编译器不会直接去调用doSomething函数,而是把它翻译成一条消息发送。这个翻译过程,就是objc_msgSend的活儿。

我记得刚入行那会儿,总觉得这层间接调用是性能负担。后来在做一个高帧率动画库时,发现消息发送确实成了瓶颈——嗯,那时候我才真正去啃了一遍objc_msgSend的汇编实现。今天咱们就把这块掰开揉碎了讲清楚。

3.1 objc_msgSend 到底干了什么?

你写[obj doSomething],编译器生成的是:

objc_msgSend(obj, @selector(doSomething))

它接收两个隐式参数:

  • self:消息的接收者
  • _cmd:方法选择器(SEL)

然后objc_msgSend会做三件事:

  1. 快速查找:在接收者的类缓存里找IMP
  2. 慢速查找:缓存没命中,就去类的方法列表、父类链里找
  3. 消息转发:如果还是没找到,进入转发流程(下一章讲)

核心要点:消息发送不是函数调用,而是运行时查找。这个查找路径,决定了性能。

3.2 快速查找:汇编级别的优化

为什么快速查找要用汇编写?因为C/C++的函数调用开销太大了。苹果工程师直接在汇编层面做了个缓存查找,路径极短。

快速查找的核心逻辑在objc-msg-arm64.s里,我简化一下关键步骤:

// 伪代码,实际是汇编实现
1. 检查 receiver 是否为 nil
   - 如果是 nil,直接返回 0(给 nil 发消息不会 crash)
2. 从 receiver 的 isa 指针拿到 Class
3. 从 Class 的 cache 中查找 SEL
   - cache 是一个哈希表,key 是 SEL,value 是 IMP
   - 用 SEL 做哈希,找到 bucket
   - 如果 bucket 的 SEL 匹配,直接返回 IMP
4. 如果 cache 没命中,跳转到慢速查找

这里有个细节:cache 是散列存储的。苹果用了@selector(doSomething) & mask的方式计算索引。mask 是 cache 容量减1,所以索引计算就是一条位运算指令,快得离谱。

个人经验:我在优化一个频繁调用的 getter 方法时,发现 cache miss 率偏高。后来排查发现是因为那个类的方法太多了,cache 频繁被冲刷。解决办法是把高频方法用 +initialize 提前触发缓存填充,效果立竿见影。

3.3 慢速查找:沿着继承链往上爬

快速查找没命中,就进入lookUpImpOrForward这个C函数。名字很直白——查找IMP,或者转发。

慢速查找的流程是这样的:

  1. 检查当前类的方法列表(已排序的列表,二分查找)
  2. 没找到?沿着superclass指针往上走,重复步骤1
  3. 找到根类(NSObject或NSProxy)还没找到?进入消息转发
  4. 如果找到了,把IMP塞进当前类的cache里,下次快速查找就能命中

这里有个容易踩的坑:

我曾经遇到过:在分类里重写了系统方法,结果发现某些场景下调用的是原方法。排查半天才发现,分类的方法是在运行时才加载的,如果主类的方法已经被缓存了,分类的方法就不会被触发。解决方案是在 +load 里手动触发一次消息发送,强制更新缓存。

3.4 消息查找的完整流程图

下面我用SVG画了一张流程图,把整个消息查找路径串起来。你跟着箭头走一遍,就能理解为什么说「消息发送是OC动态性的根基」。

消息查找完整流程 objc_msgSend(receiver, SEL) receiver == nil ? 返回 0 快速查找 cache cache 命中? 返回 IMP 慢速查找 遍历方法列表+父类链 找到 IMP? 缓存IMP + 返回 进入消息转发流程

3.5 快速查找 vs 慢速查找:性能对比

我整理了一张对比表,方便你直观感受两者的差距:

对比维度 快速查找 慢速查找
实现语言 汇编(arm64) C语言
查找结构 哈希表(cache) 有序数组(二分查找)
时间复杂度 O(1) 平均 O(log n) 或 O(n)
是否遍历父类 是(沿继承链)
触发场景 方法已被调用过 首次调用或cache被冲刷
性能损耗 约 5-10 条指令 数百条指令

关键结论:快速查找是性能的命脉。一个方法如果被频繁调用,它大概率会留在cache里。但如果你的类有上千个方法,cache频繁被冲刷,慢速查找就会成为性能杀手。

3.6 避坑指南:我踩过的三个坑

做iOS开发这些年,消息传递这块我栽过不少跟头。挑三个典型的说说:

  • 坑一:分类方法不生效。我之前在分类里重写了viewDidLoad,结果发现有时候走的是原方法。原因就是主类的方法已经被缓存了,分类的方法根本没机会进cache。解决方案是在+load里调用一次[self viewDidLoad],强制更新缓存。
  • 坑二:KVO导致消息转发。KVO会动态生成子类并重写class方法。如果你在KVO监听的对象上调用class,返回的是原类而不是子类。这个「欺骗」行为就是通过消息转发实现的。
  • 坑三:消息转发死循环。有一次我在forwardInvocation:里又调用了同一个方法,结果陷入了无限递归。后来加了个标志位判断,才跳出循环。

我的建议:如果你在调试时发现某个方法调用行为诡异,先别急着怀疑业务逻辑。用instrumentObjcMessageSends(YES)打开消息日志,看看实际走的是哪条路径。这个工具能帮你省下大量排查时间。

3.7 小结

消息传递机制,说白了就是OC动态性的根基。objc_msgSend通过快速查找和慢速查找两级缓存策略,在灵活性和性能之间找到了平衡。快速查找用汇编实现,走哈希表,快如闪电;慢速查找用C实现,遍历方法列表和父类链,兜底保障。

理解了这个机制,你就能解释很多OC的「奇怪」行为——比如为什么给nil发消息不会崩溃,为什么分类方法有时候不生效,为什么KVO会改变class的返回值。嗯,这些问题的答案,都在消息查找的路径里。


公众号:蓝海资料掘金营,微信deep3321