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会做三件事:
- 快速查找:在接收者的类缓存里找IMP
- 慢速查找:缓存没命中,就去类的方法列表、父类链里找
- 消息转发:如果还是没找到,进入转发流程(下一章讲)
核心要点:消息发送不是函数调用,而是运行时查找。这个查找路径,决定了性能。
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,或者转发。
慢速查找的流程是这样的:
- 检查当前类的方法列表(已排序的列表,二分查找)
- 没找到?沿着superclass指针往上走,重复步骤1
- 找到根类(NSObject或NSProxy)还没找到?进入消息转发
- 如果找到了,把IMP塞进当前类的cache里,下次快速查找就能命中
这里有个容易踩的坑:
我曾经遇到过:在分类里重写了系统方法,结果发现某些场景下调用的是原方法。排查半天才发现,分类的方法是在运行时才加载的,如果主类的方法已经被缓存了,分类的方法就不会被触发。解决方案是在 +load 里手动触发一次消息发送,强制更新缓存。
3.4 消息查找的完整流程图
下面我用SVG画了一张流程图,把整个消息查找路径串起来。你跟着箭头走一遍,就能理解为什么说「消息发送是OC动态性的根基」。
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