资讯动态

从objc_msgSend到cache_t:深入iOS方法缓存的底层原理与失效机制

发布时间:2026/10/14 17:51:55 来源:尧图企业网站定制
很多iOS开发者在学习Runtime的时候第一个绕不开的词就是objc_msgSend。但在objc_msgSend的汇编实现里真正决定调用速度的不是消息转发逻辑而是方法缓存。我自己第一次想去读方法缓存的源码是因为线上遇到一个奇怪问题同一个方法第一次调用和第二次调用耗时差了将近一个数量级。当时只觉得“有缓存嘛很正常”直到后来硬着头皮把cache_t相关的源码读了几遍才意识到这里并不是简单套了一个字典而是一套教科书级的开放寻址哈希表配合着精致的扩容和失效机制。这篇文章就从runtime源码出发把OC方法缓存的底层结构、查找流程、写入扩容和失效条件完整拆开。适合已经会写Objective-C、想深入理解Runtime机制或者遇到过“改了方法实现却不生效”这类诡异问题的开发者。1. 从一次没命中的调用开始方法缓存要解决什么1.1 从 objc_msgSend 到找到 IMP 的完整路径在OC里写[obj doSomething]编译器并不会直接调用doSomething对应的函数而是把它翻译成objc_msgSend(obj, selector(doSomething));objc_msgSend要做的事情不是去方法列表里从头扫一遍。它先通过对象的isa指针拿到类对象然后定位到类对象内部的cache_t结构——这就是方法缓存——接着用selector(doSomething)作为key去缓存里查询。如果命中直接跳到对应的IMP如果没命中才进入慢速查找流程去当前类的方法列表、父类方法列表一层一层找。找到之后再把方法写入缓存方便下次调用。换句话说方法调用分为快速路径和慢速路径。快速路径只依赖缓存慢速路径才涉及完整的方法解析。我们读源码时接触到的lookUpImpOrForward就是在快速路径失败后被调到慢速路径的入口函数。1.2 缓存命中的收益到底有多大初学者容易忽略一个问题方法缓存的意义远不止省下一次“字典查找”的时间。慢速查找有多慢它要遍历方法列表比较SEL找不到还要沿着继承链往上走如果实现是动态提供的还得触发resolveInstanceMethod再找不到还会触发方法转发。也就是说一次慢速查找最坏情况下可能要执行几十条甚至上百条指令还会经过大量内存读取。而一次缓存命中在汇编层几乎就是几条指令从isa偏移取缓存指针用SEL计算索引比较两个指针相等就跳转。我在一次实际调试里观察过同一个方法调用100万次冷启动时第一次循环几乎每个方法都要走慢速路径耗时明显循环第二次开始所有方法基本都命中缓存耗时直接下降一大截。虽然真实业务里这种纯方法循环不常见但这个数量级的差异足以解释为什么“同一个操作做第一次慢、后面快”。2. 看源码前先拆结构bucket_t 和 cache_t 到底存了什么2.1 bucket_t插槽里的 SEL 与 IMP方法缓存并不是一个大字典而是一个数组数组里的每个元素是一个桶源码里叫bucket_t。不同runtime版本里它的字段稍有不同我用我手头这份源码里的简化定义来还原核心部分struct bucket_t { IMP _imp; // 方法实现的函数指针 SEL _sel; // 方法编号即selector };一个桶就负责保存一对关系哪个方法编号_sel对应哪个函数实现_imp。它比NSDictionary的键值对更轻量没有引用计数管理没有额外的哈希计算接口就是一个连续内存区域里的两个指针。所以方法缓存的“查找”本质上就是在一个bucket_t数组里根据_sel找到对应的bucket_t然后取出它的_imp。整个过程不需要给SEL发送任何消息也不需要调用hash方法这样才能支持汇编代码几乎零开销地介入。2.2 cache_t桶指针、掩码与占用水位比桶更上层的是cache_t它挂在每个类的class_rw_t或者class_ro_t之后负责管理整个缓存数组。核心字段通常是这四个struct cache_t { bucket_t *_buckets; // 指向桶数组的指针 mask_t _mask; // 容量减一用于索引位运算 uint16_t _flags; // 缓存标志位 uint16_t _occupied; // 当前已占用的桶数 };很多人第一次看_mask会困惑为什么不直接存容量_capacity因为查找时要用SEL mask来计算索引mask正好是容量减一。这个设计能保证任何时候capacity _mask 1而且要求容量必须是2的幂。_occupied是已占用的桶数量它像水表一样记录当前水位用来判断什么时候该扩容。_flags相对琐碎里面记录了一些和缓存管理相关的状态比如缓存对应的类是否正在初始化、是否需要对新加入的缓存做增量等。分析主流程时可以先不深究它。2.3 为什么这里用的是哈希表而不是字典要理解这套结构就要接受一个事实方法缓存和普通业务层缓存不一样它要服务的是几乎所有OC方法调用。如果每个类都用一个NSDictionary来缓存方法查找时要先给字典发objectForKey:消息再走一套哈希逻辑性能完全撑不住。所以runtime采用了一个极简静态哈希表方案key是SEL底层其实是一个指针值value是IMP哈希函数是SEL mask冲突用开放寻址法解决。这套方案把缓存查找压缩到了几次内存访问和几次比较而且结构固定不需要额外堆内存分配。这是方法缓存能嵌入到消息发送汇编路径中的前提。3. 命中缓存的高光时刻查找算法与掩码的二进制魔法3.1 用 SEL 计算 index为什么容量必须是 2 的幂缓存查找的第一步是把SEL映射到桶数组的下标。源码并不是调用某个复杂的哈希函数而是直接做一次位与运算mask_t index (mask_t)(uintptr_t)sel mask;因为mask是“容量减一”当容量是2的幂时sel mask等价于sel % capacity。比如容量是16mask是15一个SEL指针的十六进制是0x10015420与15取与后只保留末尾4个比特取值一定在0到15之间。这样就不需要执行耗时的取模指令一条位运算指令就能算出索引。容量必须保持2的幂也使得扩容翻倍后所有已经缓存的桶必须重新计算索引——因为mask变了同一个SEL索引出来的位置也变了。幂次扩容让这个重新分布的过程可控不会出现扩容两次后容量变成一个很别扭的数字。3.2 线性探测与回绕找不到的时候就绕着数组走一圈哈希冲突是必然的。两个不同SEL可能在同一个索引号上撞车runtime不会像字典那样拉一条链表去挂后续元素而是在桶数组里向后继续找下一个空位置。这个过程叫线性探测。查找时的逻辑也对应着线性探测。简化后的核心循环大致是static bucket_t *cache_find(cache_t *cache, SEL sel) { bucket_t *buckets cache-_buckets; mask_t mask cache-_mask; // 第一次试直接算出的索引 mask_t i (mask_t)(uintptr_t)sel mask; // 如果没命中继续看下一个桶 if (buckets[i]._sel NULL) return NULL; if (buckets[i]._sel sel) return buckets[i]; // 向后探测到末尾则回绕到开头 for (;;) { i (i 1) mask; if (buckets[i]._sel sel) return buckets[i]; if (buckets[i]._sel NULL) return NULL; } }这里有个细节值得注意判断“不存在”的标准不是走到每个桶都看过而是遇到_sel为空的桶就停下来。因为插入时也是找到第一个空桶就放进去所以如果某个SEL存在它必然在从哈希索引开始到第一个空桶之间的链路上如果在没到达空桶时还没找到那就说明不存在。这个性质让查找在平均情况下很快。回绕也很讲究当i从末尾加1时直接用 mask回到0形成一个环形数组。这不是为了炫技而是因为哈希计算出来的位置不一定在数组后半段线性探测必须允许从末尾回到开头继续查。3.3 慢速查找与缓存填充的接续关系缓存没有命中时就轮到lookUpImpOrForward这个慢速入口干活。它首先会再次看一眼缓存——因为多线程场景下缓存可能刚被其他线程填充了然后按顺序找当前类的方法列表、沿着继承链找父类的方法列表直到找到IMP或触发动态方法解析。找到IMP后有一个很关键的动作调用cache_fill或者cache_getImp等函数把这条方法写进缓存。也就是说慢速查找的目的是“找到并缓存”而不是找到就用完算了。一次慢速查找完成后后续同样方法的调用就全走快速路径了。这里额外说一点如果方法是从父类继承来的runtime在填充时通常会把父类的方法实现缓存到当前类即消息接收者的类的缓存中。因为方法缓存挂在类对象上子类和父类是两套缓存子类实例重复调用继承方法时不可能每次都上溯到父类去查找所以必须把结果记录在子类自己的缓存里。4. 写缓存和扩容的取舍cache_fill 背后的水位管理4.1 谁在调用 cache_fill慢速查找之后的必经一步在整个runtime流程里最重要的写缓存入口就是cache_fill。它接收一个类和一条方法信息核心逻辑是尝试把SEL-IMP写入cache_t。除了方法查找路径会调用它类初始化完成、分类方法加载等场景也会主动更新缓存。但无论入口有多少写缓存之前都要先处理一个矛盾桶数组已被大量占用时新写入的SEL发生冲突的概率迅速上升。所以插入前必须看一眼_occupied这个水位。我读源码时印象很深的一点是cache_fill里还有一个“占位桶”的逻辑如果当前类正在被另一个线程填充缓存可能先插入一个空的_sel占位用来防止其他线程误判缓存不存在。这说明缓存并发处理不是简单加锁而是借助原子指令做了很多精细控制。4.2 扩容阈值 3/4装载因子与性能的平衡点几乎每一个开放寻址哈希表都会遇到“什么时候扩容”的问题。runtime源码里有一个经典阈值当_occupied达到容量的3/4时就会触发扩容。为什么不是满了再扩因为开放寻址法的性能对装载因子极其敏感。当装载因子达到0.9时冲突概率已经高到线性探测的簇cluster变得很长查找效率低到接近线性扫描而控制到0.75平均查找次数还比较理想。考虑到方法缓存是热路径runtime宁可多浪费一点内存也要把冲突控制在一个很低的水平。还有一个容易忽略的细节扩容时的阈值判断不是用capacity * 3 / 4这种浮点计算而是用位运算判断比如当(_occupied 1) * 4 capacity * 3时才扩。这类源码写法让我意识到苹果在热路径上的每一条语句都在刻意避免乘除法。4.3 扩容重排旧缓存不是丢了而是重新哈希一遍扩容不能只是把数组变大然后把旧数据原样挪过去因为mask变了同一个SEL算出来的索引位置已经变了。所以扩容的正确姿势是分配一个容量翻倍的新桶数组把旧数组中所有非空桶用新数组的mask重新计算索引依次插入释放旧数组更新_buckets、_mask、_occupied。也就是说扩容是一次完整的再哈希。代价确实不小所以runtime才会用_occupied水位努力避免频繁扩容。不过扩容也不是只发生在到达3/4时某些情况下如果缓存一直很小但写入很频繁也可能提前扩容。总的来说扩容策略就是用一次较大的成本换取之后很长一段时间内的高效查找。我在看源码时曾理所当然地认为“扩容之后旧的缓存肯定清空了”结果读代码发现旧缓存会被尽力搬进新表。这个细节也否定了一些网上“缓存满了就清除”的说法。5. 缓存失效是另一门学问方法交换与分类加载的变化5.1 method_exchangeImplementations 之后为什么要 flush cache方法缓存最大的坑就是它可能让“已经改变的方法实现”暂时不生效。假设我们通过method_exchangeImplementations交换了方法A和方法B的IMP如果类的缓存里仍然保留着方法A指向旧IMP的条目那么下一次调用方法A时走的还是旧实现。所以runtime在method_exchangeImplementations一类的API里都会主动刷新缓存让当前类和继承链上相关类的缓存失效。源码里对应的动作是nmethodsChanged - flushCaches最终调用到cache_t::erase之类的方法把桶数组清空或者把相关条目清掉。这里给一个容易被坑到的场景我们交换的不是类自己的方法而是父类方法。如果只刷新父类的缓存子类缓存里仍可能残留父类方法的旧IMP因为之前子类调用继承方法时把IMP写进了子类自己的缓存。所以在替换父类实现或者交换父类方法时必须想办法把子类的缓存也一起清掉。很多线上偶发问题就是漏了这一步。5.2 分类方法加载与缓存失效的联动分类方法为什么能“覆盖”主类方法我最早以为是方法查找时会先看分类列表后来读源码才发现另一重原因分类加载时会操作类的class_rw_t中的方法列表而方法列表的变化一定会让旧缓存失效。具体流程大致是程序启动后进入read_image阶段runtime把每个分类的方法通过attachCategories挂到对应类的方法列表前面然后调用缓存刷新逻辑。下一次调用某个方法时因为缓存已经被清了只能走慢速查找而慢速查找会按顺序拿到最新的方法列表于是自然找到了分类中的方法实现。理解了这一步就能解释另一个现象如果我们在运行时动态添加分类方法或者用class_addMethod在类里插入方法必须显式处理缓存刷新否则已经有缓存的旧方法不会被新方法替代。这些API内部其实都做了缓存处理但如果你用runtime的底层接口直接改方法列表而不走标准API就得自己处理。5.3 实例释放不会清缓存对象生命周期里的缓存真相还有一个常见的误解觉得“对象释放时它用过的缓存也会被释放”。这是错的。方法缓存放在类对象内部属于整个类级别的一份共享数据。所有实例——无论存活还是释放——都共享同一个类的缓存。也就是说一个实例释放只会减少实例对象本身占用的内存类对象和它的cache_t完全不受影响。下一次创建同一个类的另一个实例依然可能直接命中之前缓存的IMP。这其实是一件好事因为高频方法的缓存可以被所有实例复用不需要每个实例重新积累。真正会清空类缓存的操作是类对象自身被释放或者某些对类结构做重大修改的runtime函数比如objc_destructInstance过程中触发类的析构、以及显式调用缓存清理函数。日常业务里基本不会碰到实例释放导致方法重新变慢的情况。6. 在实测中理解缓存一个简单验证方法与我的事后感6.1 用 lldb 查看一个类的缓存内容源码读得再多不如亲手看一次数据。我常用的方法是在调试器中直接打印某个类的cache_t结构。以某个自定义类XYZClass为例在断点处执行(lldb) p (cache_t)[XYZClass class]-cache如果能加载到runtime的头文件输出会显示_buckets、_mask、_occupied等字段。继续查看桶数组内容(lldb) p (bucket_t *)[XYZClass class]-cache._buckets[0]在循环里连续调用一个方法再打印_occupied和桶数组就能直观看到缓存被填充的过程。第一次进入断点时_occupied可能是0调用一次方法后再看就变成1甚至更多。这在debug环境里非常有意思也会真切体验到“方法缓存确实挂在类上不由某个实例持有”。6.2 从源码到实践的几个认知修正啃完方法缓存源码后我总结出几个和日常开发强相关的认知点值得单独列一遍常见认知源码里的真相方法缓存是每个对象一份缓存属于类对象同一类所有实例共享缓存用字典实现用开放寻址哈希表数组加线性探测扩容会清空旧缓存扩容会重新哈希并尽量保留旧条目方法交换后自动清所有相关缓存只清了调用者处理的那部分涉及父类/子类时需要谨慎实例释放会清缓存不会类缓存不依赖实例生命周期这些修正很多是在调试“改方法没生效”问题时才真正体会到的。比如之前有一次动态替换实例方法后子类调用还是旧实现排查了很久最后意识到是子类缓存里残留了旧的父类IMP手动清理子类缓存后问题消失。6.3 我的体会方法缓存不是银弹但理解它很值读完这份源码我最大的体会是runtime的开发者为了一个“方法缓存”倾注了非常多超出预期的细节比如掩码位运算、装载因子3/4、线性探测回绕、并发占位。这些设计看起来简单但每一点都直接决定了App里几十亿次方法调用的性能。在实际写代码时我并不建议为了“优化”去做一些反模式操作比如把所有方法都换成语录级的IMP指针缓存。objc_msgSend的快速路径已经足够快过度手动缓存反而破坏代码可读性。真正有价值的产品级结论有这么两条如果你的代码里存在高频动态方法调用比如循环里用performSelector:拼接方法名那大概率每次都在走慢速查找可以考虑改成静态调用或者一次性取出IMP再复用如果你在运行时修改方法实现一定要养成“修改后主动考虑缓存失效范围”的习惯尤其涉及继承方法时不要只盯着当前类。方法缓存不是银弹但它把“一个语言特性如何影响每一行代码”演绎得非常透彻。对Runtime有兴趣的开发者建议找个晚上静下心读一读cache相关的源码你会看到一个和网上面试八股完全不同的世界。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑