
做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_atomicbucket_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_tsel和imp的配对存储真正存放缓存数据的是bucket_tstruct bucket_t { explicit_atomicSEL _sel; explicit_atomicIMP _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的缓存 missruntime 一路找到父类方法列表里的 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的汇编和完整消息转发流程都会顺手很多。