尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
Runtime方法缓存cache_t源码拆解:从结构查找到扩容全解析
直接把 runtime 源码翻到cache_t这一段的时候你会发现整个 objc4 里最性感的代码不是那些复杂的方法查找逻辑而是这个看着只有两个字段的小结构体。方法缓存不是“顺便加的优化”它直接决定了 ObjC 的消息发送能不能跑出接近 C 函数调用的性能。这篇我就把cache_t从结构、查找到扩容完整拆一遍顺便聊聊大家在日常开发里最容易踩的缓存相关坑比如方法改了不生效、热路径突然变慢以及怎么用 LLDB 亲眼看到缓存的内容。整个拆解以常规版本源码为主线适合已经了解消息发送和 SEL、IMP 基础概念的开发者如果你对 runtime 的调用流程还比较模糊建议先补一下objc_msgSend到lookUpImpOrForward这条主链路。1. 为什么要把方法缓存嵌进运行时里1.1 消息发送的完整链路和缓存位置一个普通 OC 方法调用编译后就是一个objc_msgSend。这个函数做的事远不止“调函数”这么简单它先通过对象的 isa 拿到 class再在 class 里查方法列表查不到就沿着 superclass 往父类走都查不到还要走动态解析、消息转发。如果没有缓存同一个方法被调用一万次这一整套流程就要原原本本执行一万次。所以苹果在 class 对象里塞了一个cache_t专门存“最近查过的 sel - imp”映射。缓存的位置很有讲究它不在实例对象里也不在全局表里而是嵌在类对象自身和 isa、superclass 平级。这样objc_msgSend在执行时拿到 class 之后顺手就能访问到 cache不需要额外走一个全局结构内存局部性好cache 命中时几乎就是几个加载指令和一次比较。流程大概可以这么理解objc_msgSend - 通过 isa 拿 class - 访问 class-cache查找 sel - 命中直接跳到 imp - 未命中进入 lookUpImpOrForward 慢速查找 - 找到 imp 后顺手写回 cache这里还有一个容易忽略的点查缓存时用的是“当前传入的 sel”匹配上了才算命中。因为 OC 方法名就是 sel同一个指令地址可能被多个 sel 共用所以必须拿 sel 当 key不能拿 ip 当 key。1.2 缓存放类对象而不是实例对象的理由有人会问为什么不把缓存放到每个实例里如果放实例里每个对象都得维护一份缓存内存开销直接爆炸。方法表是类级别的共享资源同一个类创建一万个对象方法本身只有一份缓存也只需要类级别共享一份就够。放类对象还有个好处子类可以命中自己的缓存也可以沿继承链命中父类的缓存。但你要注意runtime 实际查找时会先从当前类的 cache 找找不到再沿继承链逐级查。父类的方法会被父类的缓存兜底这也是为什么子类重写方法后子类缓存会被填充成自己的实现但父类缓存里的旧实现并不会被删掉只是消息发送根本走不到那一步而已。从性能角度看类对象在内存里是稳定存在的冷启动后地址不变cache 被填充之后就一直是热数据CPU 缓存友好度也高。全局缓存表虽然也能做到类似效果但多一次全局索引访问就多一拍对objc_msgSend这种全 App 调用次数第一的函数来说每一拍都值得抠。1.3 缓存命中前后调用成本差多少没有缓存时一次方法调用最坏要走完整个继承链的所有方法列表还要处理二分查找、方法排序、动态解析等等。如果在父类链上多次 miss消息发送的开销可以比缓存命中慢几十倍甚至更多。有缓存时整个查找就是把 sel 哈希到某个桶比对一下 key命中后直接取 imp基本就是几条原生指令的事。实际开发里一个类的热方法往往就是那五六个viewDidLoad、layoutSubviews、点击事件、网络回调。缓存的意义就是把这五六个方法变成“永久命中”状态而不是重复走慢速流程。所以你可以简单理解方法缓存是 runtime 给每个类准备的“高频入口”快捷通道缓存的设计目标不是把所有方法都塞进去而是把“当前最可能再次调用”的方法塞进去。2. 源码结构拆解cache_t 和 bucket_t2.1 bucket_t一个桶装什么先看最小单位桶。不同版本的源码实现略有差异但核心就是一对 key-value。// 简化后的 bucket_t去掉了平台宏 struct bucket_t { SEL _sel; // 方法名 IMP _imp; // 方法实现 };这个结构很好理解_sel是查找的 key_imp是查到的结果。整个缓存表就是一堆 bucket 组成的数组名字叫 buckets每个 bucket 要么存了一对有效 sel/imp要么是一个空桶空桶的_sel是 NULL。注意在较新的 arm64 真机上苹果把_sel和_imp压成了一个uintptr_t也就是把 key 和 value 编码进同一个 word读取时一次 load 就能原子完成既省内存又优化了并发。但这个优化不影响你理解整体逻辑你可以简单认为一个桶就是 sel imp。桶的读取有专门的接口不要直接去操作成员因为不同架构下内存布局不一样。2.2 cache_t 的四个字段和一张布局表看类对象内部真正落地的 cache 结构通用版本是这样struct cache_t { bucket_t *_buckets; // 桶数组指针 mask_t _mask; // 掩码用于哈希定位 mask_t _occupied; // 已经使用的桶数量 uint16_t _flags; // 新版本里出现记录缓存状态 };如果你打开新版源码还会看到_originalPreoptCache之类的字段那是给 dyld 预优化用的不影响主线理解。这四个字段是把整篇源码串起来的关键。_buckets指向一片连续内存_mask是桶数量减一_occupied是当前占用了多少个桶。为什么既有_mask又有桶数量因为哈希定位是拿 sel 和_mask做与运算而不是拿桶数量取模这背后是性能考量下一节展开。_flags新版本里用于标记缓存是否处于特殊状态比如是不是空缓存、是不是正在被并发修改Debug 时挺有用。下面这张表可以直接对照记忆字段作用关键约束_buckets桶数组首地址数组容量必须为 2 的幂_mask哈希掩码永远等于桶数量减 1_occupied已占用桶数达到阈值触发扩容_flags状态标记新版本引入保留扩展空间2.3 mask 和 2 的幂哈希之所以快的秘密哈希表要做散列常规做法是hash % count但取模运算在二进制层面并不是一条指令。苹果的哈希定位本质上就是static inline mask_t cache_hash(SEL sel, mask_t mask) { uintptr_t key (uintptr_t)sel; key ^ key 16; // 位混合让高位信息参与运算 return (mask_t)(key mask); }关键在于桶数量恒为 2 的幂所以count - 1的二进制里所有低位都是 1key mask和key % count结果完全一样但位与只需要一条指令比除法快一个量级。这也是为什么扩容时必须翻倍因为只有保持 2 的幂掩码才能好使。理解了这点很多“不合理”就说得通了。为什么初始桶数是 4 而不是 1因为是 2 的幂。为什么扩容是 2 倍因为要保持 2 的幂。为什么扩容后旧桶没法直接复用因为桶数量变了同样的 sel 哈希出来的下标全变了所有旧位置的映射关系全部失效。3. 查找与填充方法缓存的一生3.1 哈希查找 cache_getImp 的完整思路objc_msgSend内部走的查找函数核心思路很简单先算出起始下标然后从那个位置开始找。命中就返回 bucket遇到空桶就说明缓存里没有这个 sel直接返回没找到。查找时不只比对当前位置还要处理冲突。因为两个不同的 sel 可能映射到同一个下标所以源码里会有探测逻辑老版本是线性往后探测新版本在某些配置下会做二次探测避免堆积但思路都是一样的。// 核心查找逻辑去掉并发和编译宏 static bucket_t *cache_lookup(cache_t *cache, SEL sel, mask_t mask) { bucket_t *buckets cache-buckets(); mask_t begin cache_hash(sel, mask); for (mask_t i begin; ; ) { bucket_t *bucket buckets[i]; if (bucket-sel() sel) { return bucket; // 命中 } if (bucket-sel() (SEL)0) { return NULL; // 遇到空桶肯定没有 } i (i 1) mask; // 探测下一个位置 } }这里有个很重要的细节为什么遇到空桶就可以断定“没有”因为缓存只支持整表清空不支持删除单个桶。某个位置是空桶说明这个槽位从未被占用或者整表被清空重建过那么后续探测序列里也不会有这个 sel。如果 runntime 支持单个删除并把它标记成墓碑那遇到墓碑还得继续往后找但缓存实现选择了更简单的全清空策略所以“空桶即终点”是有严格保障的。3.2 cache_fill 什么时候写、怎么写缓存写入不是任何时候都执行。objc_msgSend首次查不到 sel 时会走慢速查找lookUpImpOrForward。这个函数里找到 imp 之后会调用cache_fill把结果写进缓存。也就是说只有 miss 过一次的方法才有资格进缓存写完后下次调用就能命中。填充过程也不是直接往里一塞就完事。它要检查当前占用率如果太满就扩容然后再找一个合适的空位。源码逻辑大概长这样static inline void cache_fill_nolock(cache_t *cache, SEL sel, IMP imp) { if (cache-isConstantEmptyCache()) { cache-reallocate(0, INIT_CACHE_SIZE, false); } mask_t mask cache-mask(); // 已占用数量接近 3/4 时先扩容再写入 if (cache-occupied() 1 cache-capacity() * 3 / 4) { cache-expand(); mask cache-mask(); } bucket_t *bucket cache-findInsertPlaceholder(sel, mask); bucket-set(sel, imp); cache-incrementOccupied(); }写完后_occupied加一。这个计数看着简单但它牵涉到锁竞争和并发优化新版本里incrementOccupied是用原子操作配合内存屏障做的性能敏感点就在这附近。3.3 扩容判断occupied 的 3/4 红线为什么是 3/4不是“满了再扩”或者“用一半就扩”因为这是开放寻址哈希表的经典负载因子选择。开放寻址意味着所有元素都放在同一个数组里没有链表拖尾如果数组填得太满冲突概率会急剧上升查找时探测链会变长缓存命中的代价就从“几条指令”变成“循环好多次”。3/4 是一个空间和时间的平衡点。低于这个值内存浪费多一些但几乎不会出现长探测链高于这个值内存省了但冲突增多插入和查找都变慢。苹果在objc_msgSend这个地球上调用次数最多的函数上选择了保守的 3/4可以说是拿 25% 的桶容量换最稳定的命中性能。还有一个容易踩的误区occupied不是类的方法总数而是“当前这个类实际往缓存里写入过的方法数量”。一个类有 200 个方法但热方法只有 10 个那occupied可能一直只有 10 左右缓存根本不会扩容。3.4 常见误区缓存不存所有方法这一点必须反复强调方法缓存不是方法列表的副本它是“最近查找历史”的窗口。类的方法列表是完整的包含所有方法包括父类的缓存只是其中一小撮被调用过的。所以看缓存源码时别用“为什么这个类方法这么多缓存却只有几个桶”这种思路去质问。缓存设计本来就只关心时间局部性也就是“刚刚调过的方法接下来大概率会被再次调用”。你对一个类循环调用同一个方法一万次缓存里可能就永远只写着那一个方法它也没必要扩容这才是最理想的状态。如果在项目里遇到“缓存占用很高”的情况那往往发生在启动阶段一次性调用了大量不同的方法但之后热度迅速下降。4. 扩容与“清空”策略看完才知道苹果有多抠4.1 翻倍扩容过程和初始桶大小当occupied越过 3/4 红线会触发expand()。扩的过程并不复杂重新分配一个容量翻倍的新桶数组把旧桶里的内容做迁移或直接放弃更新_buckets和_mask重置_occupied。初始容量常见是 4 个桶也就是mask 3。为什么这么小因为绝大多数类根本不会同时缓存很多方法用 4 个桶起步内存占用几乎可以忽略。随着调用热方法增多容量按 4、8、16、32 这样翻倍增长。翻倍而不是按需加 1本质上和 OC 数组扩容、哈希表扩容的思想一致用偶尔一次的 O(n) 扩容成本换来长期 O(1) 的操作。新版的reallocate做了一些并发优化分配新桶和迁移旧桶不是一口气完成的尤其是现在的_buckets是原子指针读线程可能在迁移过程中看到新桶或者旧桶但无论看到哪个都不能拿到错误结果。这一段源码在真机上读起来比书上复杂得多你能抓住主线就够了扩容是翻倍翻倍后旧缓存整体作废。4.2 扩容后为什么要清空旧桶这正是新版源码里最值得琢磨的地方。哈希表扩容后旧的 sel - imp 映射关系理论上可以重新哈希进新桶但苹果的处理是丢弃大部分旧缓存只保留少量关键项。直觉上这很浪费刚填充的热方法怎么说扔就扔原因有两个。第一桶数量变了所有元素重新哈希后位置全变迁移所有旧桶的时间成本不低而且旧桶可能已经积累了相当多“调用过一次就不再调用”的冷方法第二扩容触发点往往发生在某段代码集中调用新方法的场景里比如页面初始化时一口气跑了几十个 setter这时旧缓存里除了 top 方法其他值热度和新调用的方法差很远全量迁移是典型的负收益。所以苹果干脆用“局部推倒重建”的方式新桶全部置空然后让后续方法调用通过 miss 重新填充。这看起来浪费实际却是最贴合真实调用习惯的选择——高频方法很快又会重新进入缓存。4.3 新版本是怎么做“热方法保温”的新版本源码不是无脑清空它聪明地做了一点“保温”。扩容时会把当前正在执行的 sel 以及少数self、_cmd相关的关键方法重新放进新桶。因为扩容往往发生在某个方法内部比如你在viewDidLoad里调用了大量方法触发了扩容那么viewDidLoad自己极大概率还会再次被系统框架调用把它的 imp 保下来是低成本高收益的。这部分逻辑在reallocate里就是新增老桶扫描逻辑把一小批有用的 bucket 复制到新桶其他全部丢弃。面试题里常说的“扩容后缓存清空只保留最近插入的缓存项”指的就是这一行为。理解它以后再看到运行时日志里某个方法 hash miss不要立刻怀疑是 bug那很可能只是这个类的缓存刚被扩容翻新过第一次 miss 只是重新填充的开始。4.4 负载因子的取舍3/4 不是随便写的有一点值得展开3/4 这个值既不来自经验拍脑袋也绝不是随便定的。开放寻址哈希表中负载因子越高探测链期望长度越接近发散状态特别是当方法 sel 的哈希分布不够均匀时冲突链会形成“扎堆”。3/4 在数学上保证了探测链的期望长度非常短同时内存利用率还有 75%这对objc_msgSend的场景是最划算的。如果你的代码里曾经手动实现过哈希表你可能习惯用load factor 0.7。OC 缓存选 0.75 也是同一个量级所以不要把它当成特例。它和INIT_CACHE_SIZE2 的幂、增量翻倍又保持 2 的幂放在一起看才是一个完整的哈希表设计容量生长曲线、负载阈值、掩码寻址三者完全自洽。5. 真机调试亲眼看看方法缓存5.1 用 LLDB 直接读取类对象缓存区说了这么多不如实操一把。在调试器里类对象的 isa 在偏移 0superclass 在偏移 8cache 区从偏移 16 开始。你可以把这块内存强转成cache_t来看(lldb) po [obj class] (lldb) p/x *(cache_t *)((char *)$0 16)这段命令先取到对象所属的类再偏移 16 字节读取缓存结构。如果 LLDB 能识别运行时符号输出会类似(cache_t) $2 { _buckets 0x0000000280e6d4c0 _mask 15 _occupied 7 }看到_mask 15说明这个类的缓存桶数量是 16已经用了 7 个负载率还没到扩容阈值处于稳定状态。如果_occupied和_mask 1的比值长期保持在 0.7 以上说明这个类的方法调用面比较广扩容可能比较频繁。5.2 定位某个方法到底有没有命中缓存拿到了_buckets指针接下来就可以遍历桶数组看某个 sel 在不在缓存里。方法很简单取第一个桶地址按 bucket 大小步进打印每个桶的_sel(lldb) p (bucket_t *)$2._buckets (lldb) po $3[0]._sel (lldb) po $3[1]._sel实际调试时你可能会看到大量 NULL 或者系统方法名。如果遍历完整个数组都找不到你想找的那个 sel说明它当前不在缓存里下一次调用大概率要触发慢速查找并重新写缓存。这里有一个实用技巧想看某个方法“第一次调用 vs 第二次调用”的差别可以在慢速查找函数里下一个条件断点条件是sel selector(你要的方法名)然后让它跑完再回到 LLDB 重新遍历 bucket。这个方法比凭空猜缓存行为可靠得多这也是我能给的性价比最高的一个调试思路。5.3 动态加方法后缓存失效的经典场景开发中最容易遇到的和缓存相关的坑是动态修改方法以后方法不生效。典型场景是你在运行时给一个类用class_addMethod加了方法然后立刻调用它。这个流程本身没问题但如果这个类之前已经存在同名方法的缓存旧缓存不会自动失效运行时可能直接命中缓存里的老 IMP让你觉得“我明明加了方法怎么调的还是旧的”。class_addMethod和method_setImplementation这类 API 内部会调用缓存刷新接口把相关类的缓存清掉。但清缓存的范围和执行时机在部分私有场景下并不可靠尤其是你在分类里覆盖方法、通过转换机制替换 IMP 时缓存清理可能只做了标记真正刷新要等下一次消息发送流程才生效。所以我建议动态改方法后不要在同一帧立刻用消息发送去调它可以在下个 runloop 里调用或者先通过class_getMethodImplementation强制慢速路径刷一遍缓存。真遇到“改了方法不生效”的问题第一反应不是怀疑 runtime 没执行而是先清缓存或者打印缓存内容确认。5.4 日常性能优化中怎么利用缓存规律方法缓存能帮你解释很多性能表象。一个方法在耗时测试里第一次调用通常比后续调用慢不少这就是缓存 miss 带来的额外开销。做基准测试时应该在正式计时之前先循环调用几次做 warmup把所有相关方法都暖进缓存再开始计时。不然你测出来的不是方法的真实执行时间而是带上了 runtime 查找的开销。反过来如果你发现某个类的缓存频繁扩容说明这个类的调用方法面太广或者调用模式太分散。可以想想是不是所有方法都走动态转发或者都走了消息发送再看能不能用直接函数调用来回避消息发送。不过在实际开发中大部分类的方法缓存一辈子都不会扩几次因为真正高频调用的就那几个方法所以过度优化反而没人需要。我自己的习惯是排查“某个类变慢”的问题时先花十几秒把_mask和_occupied打出来看一眼。如果占用率长期低位慢的根源肯定不在缓存 miss 上而是方法实现本身如果占用率很高且扩容频繁那才会继续顺着缓存往下挖。这招在定位线上性能问题的时候帮我省过不少时间隔了很久自己都忘了的缓存细节写代码时又总会被它拉回正轨。
RELATED

相关推荐

SAMA V2.0:轻量级跨端状态管理契约与漂移治理方案

SAMA V2.0:轻量级跨端状态管理契约与漂移治理方案

简介:SAMA V2.0 是一款面向中初级C开发者的常用代码集合与功能扩展库,聚焦日常开发高频场景,如数据处理、文件I/O、网络通信、日志记录、XML解析、注册表操作及跨平台资源管理,显著降低重复造轮子成本。资源包共139个文件&#xf…

📅 2026/10/14 21:36:12
SSM+Vue个性化商铺系统毕设:从用户行为到推荐算法全解析

SSM+Vue个性化商铺系统毕设:从用户行为到推荐算法全解析

每到毕设季,总有人在后台问我类似的问题:老师给的题目是"个性化商铺系统",技术栈限定ssmvue,要求论文和程序一起交,这活儿到底怎么干才不翻车。说实话,这类题目看起来遍地都是,但真正…

📅 2026/10/14 21:36:12
JS弹出窗口居中显示完全指南:从坐标计算到多屏适配的避坑实战

JS弹出窗口居中显示完全指南:从坐标计算到多屏适配的避坑实战

简介:这是一份面向Web前端开发者的实用脚本资料,针对页面中弹出新窗口默认位置不理想、影响用户操作的问题,提供基于JavaScript的居中显示解决方案。资料中详细解析了MM_openBrWindow函数的完整实现,涵盖window.open()方法参数配置…

📅 2026/10/14 21:31:07
MORE NEWS

更多资讯

📰

小龙虾好吃的关键在技能组合:预处理到收汁全拆解

1. 做小龙虾翻车几次之后,我才意识到问题出在“技能组合”上 你可能也遇到过这种情况:照着热门菜谱买了一堆调料,锅里油也烧得冒烟了,葱姜蒜爆香的味道直往鼻子里钻,结果成品端上桌,剥开虾壳——肉是散的&a…

📰

免费SU模型网站哪里找?6个渠道+下载修模实操一次说清

做室内、建筑、景观方案阶段的朋友,估计都有过这种经历:方案明天要汇报,模型里还缺一个款式合适的沙发、一棵姿态好看的树,或者一套完整的中式隔断。这时候,“草图大师模型哪里有完全免费的下载网站”就成了最现实的搜…

📰

最大子数组和与Kadane算法:从暴力到分治,搞定LeetCode 53

最近后台访问日志里有个自称“搜索机器人”的访客连续刷了好几条警告,我一边清日志一边刷题,正好复习到题库里的第 53 题 Maximum Subarray(最大子数组和)。这道题我前前后后用四种思路解过,每次回头看都有新收获。这篇…

📰

Linux内核启动早期内存映射:idmap与swapper_pg_dir深度解析

1. 这块“内存映射”到底在干什么先从最直接的印象说起。一提到“内存映射”,很多人脑子里立刻跳出mmap、page table、缺页中断这些东西,觉得这是一套非常庞大、非常难啃的模块。但“idmap & swapper”这个标题点到的,其实是Linux内核启动…

📰

SAP HANA Merge机制与快捷登录:运维实战排查指南

很多搞SAP HANA的同事,最初都会把目光放在数据加载、备份恢复、性能调优这些大方向上,反而容易忽视两个看起来“不起眼”却在实际运维中天天要打交道的细节:一个是Merge(合并),另一个是快捷登录。这两个东西…

📰

SSE实时推送从原理到生产实践:比WebSocket更轻量

最近有个项目,需要在页面上实时展示服务端的运行状态、任务进度和告警信息。一开始想着直接用轮询,每秒钟去拉一次接口,实现简单但是心里总觉得浪费。后来查了一下,发现SSE这个方案比我印象中成熟得多——浏览器原生支持&#xff…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬