做iOS的人都知道,OC的方法调用最后都会聚集到objc_msgSend这一步。方法缓存(Method Cache)就藏在这条链路的起点上,是整个 runtime 调用过程里最频繁、也最容易被忽略的一层加速器。我最早认真看这块源码,是因为遇到一个很诡异的现象:同一个方法,第一次调用和后面调用在耗时上差出一大截,而连续调用之后又变得异常稳定。排除掉业务逻辑的干扰后,问题最终落在缓存命中与否带来的执行路径差异上。这篇就把方法缓存的完整实现拆开讲一遍,包括数据结构、哈希策略、扩容清理,以及实际调试时怎么判断缓存到底命中没有。想深入理解 OC runtime、或者准备系统源码方向面试的人,这篇文章应该能省掉不少自己翻源码的时间。
1. 为什么Objective-C需要一层方法缓存
1.1 消息发送的第一站:objc_msgSend到底做了什么
OC里写[obj doSomething],编译之后并不是直接调用某个函数地址,而是变成一次消息发送:objc_msgSend(obj, @selector(doSomething))。这个函数要完成的事情有三件:
- 从对象的
isa找到所属的类; - 在类和继承链上找到
doSomething的实现 IMP; - 跳转到 IMP 执行。
看起来简单,但第二步其实很重。一个类可能有很多方法,父类还有父类的方法,如果每次调用都去遍历一遍方法列表,性能完全没法看。所以 runtime 在类对象(以及元类对象)内部维护了一张哈希表,把最近查过的sel -> imp对应关系存下来。下次再发同一条消息,先查表,命中就直接跳转,不用再走完整的查找流程。
打个比方:这就像你去一个大型图书馆找书。第一次不知道位置,你得按分类索引一层层查;但只要查到一次,把书架号和层数记在便签上,下一次直接走过去拿。方法缓存就是那个便签,objc_msgSend就是拿着便签跑腿的人。
1.2 方法查找的代价到底高在哪里
很多人会觉得,方法列表查一次也无非是遍历几十上百个项,真能慢到哪里去?问题在于“完整查找”不只是一次线性遍历那么简单:
- 一个类的方法可能来源很多:本类实现、分类合并、动态添加、继承自父类,甚至父类的父类;
- 查找失败时要一级一级往父类爬,每层都可能有方法列表;
- 如果方法列表在运行时被修改过(比如分类加载导致顺序调整),原本的有序二分查找可能失效,runtime 要退化成线性扫描;
- 查完之后还要处理消息转发等边界情况,整套逻辑非常长。
而缓存的存在,让“查一次”变成“查一个桶”。同一个方法的第二次调用,平均复杂度直接降到 O(1),而且是真正的常数时间:一条and指令算出桶下标,一次指针比较确认 sel,命中后直接br跳走。
这里有一个容易忽略的点:缓存并不是全局一份,而是每个类和每个元类各有一份。这样设计是有道理的。如果所有类的方法都塞进一张全局大表,写入时的锁竞争、哈希冲突、扩容代价都会被放大无数倍;按类拆开,写入只影响当前类,互不干扰。相当于每个人都有自己的便签本,而不是全部门共用一本大字典。
2. 源码解剖:cache_t到底长什么样
2.1 cache_t结构体字段逐个说
在 runtime 源码里,类对象内部有一个cache_t类型的成员,结构大致是:
struct cache_t { explicit_atomic<bucket_t *> _buckets; // 哈希桶数组 mask_t _mask; // 容量减一 uint16_t _flags; // 状态标记 uint16_t _occupied; // 已使用的桶数量 };四个字段每个都要细看:
_buckets指向真正存放缓存项的内存区域,也就是 bucket 数组。_mask等于capacity - 1。为什么不是直接存容量?因为桶数组大小永远是 2 的幂,用hash & mask等价于取模,但比取模快得多。这是哈希表实现里很常见的用空间换速度的手法。_flags存放一些标记位,比如是否有过扩容标记、当前缓存是否被非法访问过等,调试时用处大,平时不必深究。_occupied表示当前已经有多少个桶被占用。它不是简单计数器,而是扩容判断的关键依据。
关于_buckets的原子性,有个很容易忽略的细节:读取缓存是不加锁的,写入缓存时才加锁。所以_buckets指针本身必须用原子操作读写,否则读线程可能拿到写线程“改到一半”的指针,直接访问就崩了。这也是为什么源码里用explicit_atomic包一层的原因。
2.2 bucket_t:sel和imp的配对存储
真正存放缓存数据的是bucket_t:
struct bucket_t { explicit_atomic<SEL> _sel; explicit_atomic<IMP> _imp; };一个桶放一对sel和imp。这里有几个特性值得说:
sel为空表示这个桶还没被占用,可以插入;sel为目标选择器,imp为对应实现地址,两者必须原子更新,避免别的线程读到“sel 已经是新的,imp 还是旧的”这种脏状态;- 在实际的 arm64 环境下,一个
bucket_t的占用内存是 16 字节,汇编代码可以直接通过buckets + index * 16定位到目标桶,非常高效。
我看到有些初学源码的人会纠结一个问题:sel本质上是一个字符串指针,为什么可以直接拿来当哈希键?因为方法选择器在 runtime 里是唯一化的字符串常量,同一个@selector(doSomething)在任何一个类里拿到的sel指针都是同一个地址。用指针值做哈希,可以直接用位运算,不需要每次做字符串比较,性能自然快。
2.3 哈希、碰撞与线性探测
缓存表的查找逻辑本质上就是“根据 sel 找桶下标”。核心计算在源码里是这样一段内联函数:
static inline mask_t cache_hash(SEL sel, mask_t mask) { return (mask_t)(uintptr_t)sel & mask; }意思是取 sel 指针地址的低位。因为容量是 2 的幂,mask的低位全是 1,& mask取到的就是低位二进制值。严格说这不是一个特别“均匀”的哈希,但由于字符串常量被唯一化后指针值带有对齐特性,分布效果在实际场景里完全够用。
有哈希就有碰撞。runtime 用的是开放寻址法,也就是线性探测:
static inline mask_t cache_next(mask_t i, mask_t mask) { return i ? i-- : mask; }如果计算出来的桶已经被别的 sel 占用了,就向前移动一个槽继续找;如果找到数组开头还没找到空位,就绕到末尾继续找。查找、插入都遵循同样的探测路径。
线性探测带来的一个直接后果是:负载因子不能太高,否则冲突会迅速聚集,探测链越来越长,缓存也就退化成线性扫描了。runtime 的扩容阈值也因此定在 3/4 而不是 1。关于负载因子和扩容的关系,下一章细说。
3. 一条方法调用是怎么命中缓存的
3.1 快速路径:汇编级CacheLookup
在 arm64 架构上,objc_msgSend的开头不是直接调用 C 函数,而是一段精心优化的汇编。它先用isa拿到类,然后立即进入CacheLookup流程。整个流程可简化成:
- 取出类的
cache_t地址,拿到_buckets和_mask; - 用
x1寄存器里的 sel 与_mask做and运算,得到桶下标; - 检查对应 bucket 的
sel是否等于目标 sel; - 相等就取
imp跳转执行;不等就继续探测下一个桶,直到遇到空桶。
这段代码在源码里是用宏实现的,不同模式下(正常发送、取 IMP、转发)行为略有差异,但核心思路一致:能不进 C 函数就不进 C 函数。一条objc_msgSend从进入到命中缓存再到跳走,可能只需要几条指令的时间。
这里我想特别强调一下:很多人以为objc_msgSend是一段“很神秘、很高深”的汇编,其实它从头到尾的目标特别朴素——把最常见的路径压缩到极致。缓存命中就是最核心的常见路径。
3.2 慢速路径:lookUpImpOrForward干了什么
快速路径 miss 之后,汇编会跳进 C 函数lookUpImpOrForward。这个函数名字很有画面感:先查(look up),查到 IMP 就返回,查不到再走转发(forward)。它做的事情如下:
- 加运行锁;
- 再次检查缓存。这一步很多人忽略,但它很重要:当前线程在进入函数前可能等了一会儿锁,期间其它线程可能已经把目标方法写进缓存了,所以需要 double-check;
- 从当前类的方法列表开始查找;
- 当前类找不到,顺着 superclass 链继续往上找;
- 找到之后,如果方法允许缓存,调用
cache_fill把结果写入当前类缓存; - 返回 IMP,跳转执行。
慢速路径里最耗时的不是方法列表本身,而是整条链上各种检查:父类是否初始化、方法是否可缓存、是否需要走转发。这些逻辑加起来远比一条哈希查找长,所以缓存 miss 的首次调用比后续调用慢几十倍甚至上百倍都可能。
3.3 找到方法后如何写进缓存
cache_fill内部真正干活的其实是insert。写缓存不是简单把sel/imp塞进一个空桶,它要处理一个前置问题:当前桶数组还放得下吗?
insert的大致步骤:
- 判断是否达到扩容阈值;
- 如果需要扩容,先按 2 倍关系算出新容量,创建新桶数组,然后把旧桶里的所有条目重新哈希搬过去;
- 在空槽里写入新的
sel/imp; - 更新
_occupied。
扩容和搬移都不加在业务线程上,但写缓存本身有全局锁保护。这里有个值得学习的工程细节:扩容的时候,runtime 不是先释放旧桶再使用新桶,而是先让新桶就位,再安全地释放旧桶,保证读线程始终不会访问到被释放的内存。这个思路在你写无锁读、有锁写的数据结构时可以直接复用。
4. 扩容、清理与失效:缓存也有生命周期
4.1 扩容阈值是如何算出来的
现在可以讲清楚_occupied和_mask的关系了。runtime 的扩容判断大致是:
if (occupied * 4 >= capacity * 3) { // 触发扩容 }也就是占用数达到容量的 3/4 就扩。为什么不是满了再扩?因为线性探测下,负载因子太高会导致冲突链变长,缓存命中率虽然还在,但探测成本已经上升。3/4 是一个很经典的折中:既保证内存不浪费太多,又保证探测链足够短。
扩容后的容量变化是:
newCapacity = oldCapacity ? oldCapacity * 2 : INIT_CACHE_SIZE;初始容量是 4,之后按 4 -> 8 -> 16 -> 32 走。因为容量始终是 2 的幂,_mask = capacity - 1这个设定才能成立。
扩容本身是有代价的:要把旧桶里所有条目重新哈希、搬到新桶。所以你会看到,一个类如果在短时间内第一次调用了一堆方法,可能连续触发几次扩容。但触发的次数有限,容量到了 16 或 32 之后,正常业务类的方法调用基本就不再触发了。
4.2 哪些行为会让缓存失效
缓存不是只进不出的。下面这些操作会导致 runtime 清空或失效缓存:
class_addMethod:往类里动态添加方法;class_replaceMethod:替换方法实现;method_exchangeImplementations:交换两个方法实现;- 分类加载时,如果分类里的方法覆盖了本类已有方法,runtime 会做缓存失效处理;
- 类本身被释放时,缓存自然销毁。
为什么要清?因为缓存里存的是旧 IMP。如果实现被替换掉而不清,那些已经缓存了旧地址的调用点会继续跑到旧实现上去,方法交换和分类覆盖就会出现“看起来没生效”的诡异现象。所以 runtime 在修改方法结构之后,会沿着所有使用了这个方法的类做缓存失效。
注意:失效不是把桶数组置零就完了,通常是直接释放这一层缓存内存,后续调用重新走慢速路径、重新填充。所以方法交换后的第一次调用会比之前慢一点点,这是正常现象。
4.3 子类会缓存父类的方法吗
会,而且这是一个很容易被忽略的设计点。
比如你有一个父类Base,方法- (void)commonMethod;两个子类ChildA和ChildB都没有重写这个方法。当你调用[childA commonMethod]时,ChildA的缓存 miss,runtime 一路找到父类方法列表里的 IMP,然后把这个映射写进 ChildA 的缓存,而不是写进 Base 的缓存。
这么做的好处显而易见:ChildA 下次再调用就不用爬到父类去查了,子类越多,省下的继承链查找就越多。代价是每个子类都保存一份相同的映射,内存上会重复。这也是为什么父类高频方法在大量子类场景下,内存中可能出现多个缓存条目的原因。
这个细节对调试也有意义:如果你看到ChildA的缓存里有个方法明明来自父类,不用惊讶,这是设计预期,不是 bug。
5. 从源码到实战:验证、排查、避坑
5.1 怎么判断方法调用到底命中缓存没有
实际开发中不需要主动关心每次调用的命中状态,但如果你想验证缓存机制,最简单的方式是写一个循环调用测试,观察首调与后续调用的差异:
#import <objc/message.h> #import <QuartzCore/QuartzCore.h> @interface DemoObj : NSObject - (void)doSomething; @end @implementation DemoObj - (void)doSomething { asm volatile(""); // 防止被优化成空方法 } @end int main(int argc, char * argv[]) { DemoObj *obj = [DemoObj new]; CFTimeInterval firstStart = CACurrentMediaTime(); [obj doSomething]; CFTimeInterval firstEnd = CACurrentMediaTime(); NSLog(@"首次调用耗时: %.9f", firstEnd - firstStart); CFTimeInterval loopStart = CACurrentMediaTime(); for (int i = 0; i < 10000000; i++) { [obj doSomething]; } CFTimeInterval loopEnd = CACurrentMediaTime(); NSLog(@"千万次调用总耗时: %.6f", loopEnd - loopStart); return 0; }这个测试没法精确到单次缓存命中的时间,因为循环本身、ARC、线程调度都在烧时间,但它能直观告诉你一件事:一旦进入缓存路径,百万级调用也只是毫秒级总耗时,慢速路径和快速路径的差距是数量级的。
如果你在真机调试时想确认某一条消息是走了快速路径还是慢速路径,可以给objc_msgSend加一个符号断点,然后在调用栈里看有没有进入lookUpImpOrForward。进过慢速路径,说明当时确实 miss 了;没进过,说明每次都在快速路径直接命中。这个观察方法比盯着计时器更有指向性。
5.2 实际操作中值得注意的几个坑
看过源码之后再回看工程,有些以前觉得“玄学”的问题其实都能对上,也总结出几个实际避坑点:
第一,不要试图直接读取或修改cache_t的内存。它是 runtime 私有结构,在不同系统版本里的字段布局、对齐方式都可能变化。网上有些文章教你通过class指针偏移去拿缓存内容,用来验证原理可以,但千万别在自己项目里依赖它,等系统升级崩了都不知道怎么回事。
第二,动态添加方法之后的第一次调用慢,是正常现象,不是性能问题。class_addMethod会触发缓存失效,后续所有调用都会重新填充,所以第一次调用一定比之后慢。如果这个“第一次”恰好发生在关键渲染路径上,那要解决的是“不要在那条路径上动态添加方法”,而不是怀疑缓存机制有 bug。
第三,方法交换后如果某个调用点仍然表现出旧行为,先确认是不是还有老线程已经缓存了 IMP。多数情况下 runtime 的方法交换 API 会自动失效缓存,但如果你在交换前后没有做好线程同步,旧线程可能已经完成了一次缓存查询,新实现发布前的那一瞬间它拿到了旧 IMP。这个问题的根因不是缓存,而是并发时序。
第四,类方法也有缓存,而且存在元类里。你写[SomeClass someClassMethod],实际查找的是元类的缓存。调试缓存问题时,object_getClass([SomeClass class])拿到的才是目标对象,别用[SomeClass class]本身去猜。
第五,转发方法不会被缓存。如果目标方法不存在、走的是_objc_msgForward路径,runtime 会标记为“不可缓存”。这么做是故意的:将来可能有人动态添加这个方法的实现,如果缓存了转发 stub,动态添加之后还会继续走转发,行为就错了。因此,依赖动态方法解析(resolveInstanceMethod)的函数,第一次调用永远比后续慢,但前提是它第一次真的被解析过。
5.3 常见问题速查表
把实际调试中高频出现的问题整理成一张表,方便检索:
| 问题现象 | 原因 | 处理建议 |
|---|---|---|
| 同一个方法首调明显慢于后续调用 | 首次调用缓存 miss,需要走完整查找和填充 | 视作正常现象,重点优化频繁修改类结构的逻辑 |
| 方法交换后用某个线程仍执行旧逻辑 | 交换瞬间已有线程取到旧 IMP,或缓存失效时机与调用时机交错 | 确保交换操作提前完成,避免在并发调用过程中交换 |
| 分类覆盖方法后行为不一致 | 分类加载导致方法列表变化,缓存失效未覆盖所有调用点 | 尽量在应用启动早期完成分类加载相关配置 |
| 动态添加的方法首调异常慢 | 类结构变更触发缓存失效 | 考虑预创建方法、避免关键路径动态添加 |
| 调试时想确认缓存命中情况 | 快速路径不会进入lookUpImpOrForward | 在objc_msgSend下断点观察调用栈走向 |
| 子类大量调用父类方法时内存上涨 | 每个子类缓存都会保存父类方法的 IMP 映射 | 属于设计预期,不必干预 |
最后分享一个小技巧
我自己看完方法缓存源码之后,有个很直接的收获:理解缓存机制,最有效的场景不是“性能调优”,而是“排除现象干扰”。当你看到某个方法耗时忽高忽低时,先判断是不是在缓存 miss 边界上跳动,再动手优化业务代码,往往能少走很多弯路。
最后分享一个调试技巧:如果你怀疑某个类缓存频繁被清空,可以在invalidateCaches这个符号上下一个符号断点,然后看调用栈是谁触发清理的。如果是某个运行时改动 API(比如method_exchangeImplementations)触发,那说明缓存失效路径是正常的;如果是某个陌生地址直接调私有函数,就要警惕是否有第三方库在偷偷改方法结构了。这个方法我实测过很多次,在排查分类加载和动态库导致的方法行为异常时特别管用。
方法缓存是 runtime 里一个很小、却很完整的模块。从哈希表设计到无锁读有锁写,再到扩容和失效,几乎每个工程问题都能在这几十行代码里找到参考答案。搞懂它,再看objc_msgSend的汇编和完整消息转发流程,都会顺手很多。