3-12.【OC】【Runtime】为什么方法第一次调用慢,后续调用会明显变快?

4 阅读3分钟

这正是 Objective-C **消息机制(Messaging)**设计的核心精髓。简单来说,第一次调用是在“走流程买票”,后续调用则是“刷脸进场”。

这种性能差异源于 Runtime 架构中 “快速路径(Fast Path)”“慢速路径(Slow Path)” 的分工。


1. 第一次调用:曲折的“慢速路径”

当你第一次执行 [object method] 时,由于该方法从未被调用过,类对象的缓存(cache_t)中是空的。此时 Runtime 必须进行“地毯式搜索”:

  1. 缓存查找(Cache Miss)objc_msgSend 在汇编层级扫描 cache_t,结果没找到。

  2. 进入 C 函数:由于汇编搞不定复杂的逻辑,程序跳转到 lookUpImpOrForward 这个 C 语言实现的深层函数。

  3. 方法列表搜索

    • 在当前类(Class)的 bits.methods 列表中遍历。由于列表可能是无序的,或者是需要二分查找,这涉及到内存跳转。
  4. 父类递归

    • 如果在当前类没找到,就顺着 superclass 指针往上爬,去父类、爷爷类的 methods 里挨个找。
  5. 动态决议与转发

    • 如果一圈都没找到,还要触发 resolveInstanceMethod: 尝试动态添加方法,甚至走 forwardInvocation:

结论:第一次调用涉及到大量的内存遍历指针追溯以及可能的字符串/选择子匹配,性能开销相对较高。


2. 后续调用:极致的“快速路径”

一旦第一次成功找到了方法地址(IMP),Runtime 会执行一个关键动作:填充缓存(Cache Fill)

  1. 缓存填充:将该方法的 SEL(Key)和 IMP(Value)存入类对象的 cache_t 哈希表中。

  2. 哈希闪电查找:第二次调用时,objc_msgSend 直接在汇编阶段执行:

    • index = SEL & mask
    • 读取 buckets[index]
  3. 直接跳转:发现 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