这正是 Objective-C **消息机制(Messaging)**设计的核心精髓。简单来说,第一次调用是在“走流程买票”,后续调用则是“刷脸进场”。
这种性能差异源于 Runtime 架构中 “快速路径(Fast Path)” 与 “慢速路径(Slow Path)” 的分工。
1. 第一次调用:曲折的“慢速路径”
当你第一次执行 [object method] 时,由于该方法从未被调用过,类对象的缓存(cache_t)中是空的。此时 Runtime 必须进行“地毯式搜索”:
-
缓存查找(Cache Miss) :
objc_msgSend在汇编层级扫描cache_t,结果没找到。 -
进入 C 函数:由于汇编搞不定复杂的逻辑,程序跳转到
lookUpImpOrForward这个 C 语言实现的深层函数。 -
方法列表搜索:
- 在当前类(Class)的
bits.methods列表中遍历。由于列表可能是无序的,或者是需要二分查找,这涉及到内存跳转。
- 在当前类(Class)的
-
父类递归:
- 如果在当前类没找到,就顺着
superclass指针往上爬,去父类、爷爷类的methods里挨个找。
- 如果在当前类没找到,就顺着
-
动态决议与转发:
- 如果一圈都没找到,还要触发
resolveInstanceMethod:尝试动态添加方法,甚至走forwardInvocation:。
- 如果一圈都没找到,还要触发
结论:第一次调用涉及到大量的内存遍历、指针追溯以及可能的字符串/选择子匹配,性能开销相对较高。
2. 后续调用:极致的“快速路径”
一旦第一次成功找到了方法地址(IMP),Runtime 会执行一个关键动作:填充缓存(Cache Fill) 。
-
缓存填充:将该方法的
SEL(Key)和IMP(Value)存入类对象的cache_t哈希表中。 -
哈希闪电查找:第二次调用时,
objc_msgSend直接在汇编阶段执行:index = SEL & mask- 读取
buckets[index]
-
直接跳转:发现 Key 匹配,立刻
br(Branch) 到IMP的机器码地址。
3. 性能对比:为什么差异巨大?
为了让你直观感受两者的差距,我们可以通过底层操作来对比:
| 维度 | 第一次调用 (Slow Path) | 后续调用 (Fast Path) |
|---|---|---|
| 主要操作 | 列表遍历、递归查找、动态决议 | 位运算 (&)、内存寻址 |
| 语言层级 | C / C++ 逻辑复杂 | 纯汇编 (Assembly) 极致优化 |
| CPU 周期 | 几百甚至上千个周期 | 数个到十几个周期 |
| 内存访问 | 多次非连续内存读取 | 缓存友好的局部内存读取 |
打个比方:
第一次调用就像是在大图书馆里找一本从未借过的书,你需要看索引图、跑楼层、翻书架;
后续调用就像是这本书已经被你放在了枕头边,伸手即得。
4. 实际开发中的“坑”
虽然缓存让 OC 变得飞快,但作为开发者,有两点需要注意:
- 缓存失效(Cache Flushing) :如果你手动向类添加了方法,或者发生了 Method Swizzling,Runtime 为了保证正确性,会清空该类的整个方法缓存。这意味着接下来的调用会再次变慢。
- 负载因子:当缓存占满到一定比例(通常是 3/4)时,哈希表会扩容。扩容会直接清空旧缓存,因此在某些性能临界点,你会发现原本已经变快的方法偶尔又变慢了一次。
下一步建议:
如果你在做性能敏感的业务(如每秒几万次的图像处理),可以通过 IMP 缓存 来进一步压榨性能——即直接在 App 侧保存 methodForSelector: 返回的函数指针,彻底跳过 objc_msgSend。