ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

isa 指针深度拆解:从裸指针到 non-pointer 位域优化

isa 指针深度拆解:从裸指针到 non-pointer 位域优化 前两天有个做 iOS 的同学问我isa到底是不是“继承用的指针”他说自己背了很多 Runtime 的内容什么“对象通过 isa 找到自己的类”“isa 指向类对象”可真到了objc_msgSend源码、到了class_getClass、到了调试器里看isa的十六进制还是对不上号。这个问题特别典型因为网上关于 isa 的资料很多但大多数只讲了“是什么”没讲清楚“为什么是这样设计”“不同系统版本下为什么长得不一样”更没讲“验证时踩到的坑”。这篇文章我就围绕 Objective-C Runtime 里的 isa 指针从内存布局、历史演进、类与元类的关系、再到实操验证这四条线把这块内容彻底拆开揉碎。内容不只适合面试前突击更适合正在深入 Runtime 机制、或者想搞懂“对象到底是怎么找到方法实现的”这类底层逻辑的人。1. isa 指针是什么从 struct objc_object 看它存的到底是什么1.1 在 Runtime 源码里isa 的字段类型并不是 Class而是 isa_t第一件需要纠正的事情很多人以为 isa 就是一个Class类型的指针实际上在 Runtime 源码中objc_object的结构定义根本不是这么简单。你可以找到开源 runtime 的objc-private.h或者objc.h核心定义大致是这样的struct objc_object { isa_t isa; }; typedef struct objc_object *id;注意这个isa_t它是一个联合体union不是普普通通的指针。在较新版本的 runtime 中isa_t大致长这样union isa_t { isa_t() { } isa_t(uintptr_t value) : bits(value) { } Class cls; uintptr_t bits; # if __arm64__ # define ISA_MASK 0x0000000ffffffff8ULL # define ISA_MAGIC_MASK 0x000003f000000001ULL # define ISA_MAGIC_VALUE 0x000001a000000001ULL struct { uintptr_t nonpointer : 1; uintptr_t has_assoc : 1; uintptr_t has_cxx_dtor : 1; uintptr_t shiftcls : 33; uintptr_t magic : 6; uintptr_t weakly_referenced : 1; uintptr_t deallocating : 1; uintptr_t has_sidetable_rc : 1; uintptr_t extra_rc : 19; }; # endif };这段代码说明了一件很重要的事在 64 位环境下isa 这个 64 位的空间被拆分成了很多段有的存类地址有的存引用计数有的存是否有关联对象、是否有弱引用等额外信息。也就是说isa 已经不是一个“干净”的指针而是一个“装了各种运行时信息的压缩包”。1.2 isa 是怎么帮你找到类对象的既然 isa 里混杂了位域那 runtime 拿到一个对象之后怎么知道它属于哪个类答案不是直接取obj-isa而是需要屏蔽掉多余的位取出shiftcls这一段再转成Class。在 arm64 架构下核心逻辑可以用这样一段伪代码表示Class object_getClass(id obj) { if (obj) { Class cls obj-getIsa(); return cls; } return nil; } inline Class objc_object::getIsa() const { if (isTaggedPointer()) return (Class)objc_tagged_isa; return (Class)(isa.bits ISA_MASK); }ISA_MASK在这里就是为了把nonpointer、has_assoc、extra_rc这些位全部清零只留下shiftcls里面的类地址信息。所以你在 LLDB 里打印一个对象的isa看到的经常是一个很奇怪的 16 进制数字并不是直接等于[obj class]就是因为你还缺了“按位与掩码”这一步。1.3 常见误解isa 不是“用来找方法”的指针有人说“通过 isa 指针可以去类里找方法实现”这个表述并不严谨。准确的说法是isa 让你从实例对象找到它的类对象Class而真正存放实例方法列表的是类对象内部的数据结构method_list_t、cache_t等。方法查找时会先查cache_t再查method_list_t再顺着superclass往上走。还有一个更隐蔽的误解类方法不是在“类对象”上找而是在“元类对象”上找。普通实例的 isa 指向类对象而类对象的 isa 指向元类对象。这条链如果没有建立起来后面所有关于消息查找的解释都会出错。2. isa 版本演进为什么“一个普通指针”被压成了位域2.1 经典 isa早期实现就是一个裸指针在早期的 Objective-C runtime32 位时代以及 64 位时代的某些早期场景里isa 确实就是Class指针直接指向对象所属的类对象。那时对象内存布局很简单对象首地址就是个指针顺着这个指针就能找到类对象、父类、方法列表。这种设计的优点是直观缺点是浪费。每个对象要完整占满 8 字节去存一个地址而大部分对象在运行时根本不需要那些额外的标志位。如果再考虑到 iOS 上可能有几十万个实例对象同时存活8 字节的浪费就会变成几 MB 甚至几十 MB 的内存开销。另外引用计数如果全走全局哈希表sidetable每次 retain / release 都是一次哈希查找性能也不是最优。2.2 Non-pointer isa把一个指针压成一张“信息卡”后来 Apple 对 isa 做了非常大的优化引入了 non-pointer isa。所谓 non-pointer不是说它不再是指针而是说它利用了 64 位地址空间里的冗余部分把一个 64 位整数拆成多个位段用其中一段保存类地址其它段保存对象状态信息。为什么能这么做根本原因是现代 CPU 在 64 位模式下虽然地址总线是 64 位但实际使用的有效地址远没有 64 位那么多。加上类对象地址按字节对齐低若干位一定是 0所以完全可以把这些“必为 0 的位”拿出来干别的。如果你把isa.bits打印出来会发现高字节里有大量零位这就是优化的关键。2.3 不同架构下的 ISA_BITFIELD 布局我最早看 arm64 和 x86_64 的ISA_BITFIELD时发现布局不太一样第一反应是“是不是源码版本不对”。后来才明白Apple 在不同指令集上对位段定义不同因为不同架构的地址对齐规则和可用位数量不同。以一个常见的 arm64 实现为例字段位数作用说明nonpointer1表示该 isa 是否启用了 non-pointer 优化。0 为原始指针1 为位域模式has_assoc1对象是否有关联对象释放时会据此决定是否清理 associationshas_cxx_dtor1对象是否有 C 析构函数影响dealloc时是否调用.cxx_destructshiftcls33类对象指针需要左移 3 位才能还原为真实地址magic6调试/校验用的魔术值帮助 runtime 识别 isa 格式weakly_referenced1对象是否被弱引用过决定是否能直接优化释放流程deallocating1对象是否正在执行 deallochas_sidetable_rc1引用计数是否过大导致溢出需要转到 sidetable 存储extra_rc19额外的引用计数通常直接把大部分 retain 计在 isa 内不必走全局表x86_64 上的字段分配会有不同但思路完全一致。你去看源码时不要死记硬背每个 bit而要理解“为什么是这些字段放进 isa”因为这些字段都是对象生命周期里高频访问的状态直接塞进 isa就能省掉多次哈希查找和内存访问。2.4 位域方案带来的三个直接收益第一省内存。每个对象省下至少 8 字节App 运行时对象数量巨大收益非常可观。第二省时间。引用计数的增减如果能在extra_rc段完成就不需要去SideTable里做哈希查找对象是否弱引用、是否有关联对象也能在isa.bits里一眼看出。第三提升缓存友好度。这些状态集中在一个缓存行内读取次数减少缓存命中率自然上去了。不过这里有个认知盲区大家经常踩non-pointer isa 不是想开就开、想关就关的它由 runtime 内部根据对象创建路径自动决定。普通alloc/init出来的对象通常是 non-pointer 模式但你如果用很底层的class_createInstance或者某些特殊路径情况可能不同。你不需要自己去控制这个开关但你要能理解为什么有的对象 isa 里能“塞这么多东西”。3. isa 与类/元类体系消息查找机制里最容易被绕晕的三角关系3.1 实例对象、类对象、元类对象一条完整的 isa 链很多人第一次接触 isa 链时容易绕晕因为这条链不是一路往上到 NSObject 就完了而是在元类那里转了个弯。让我把这条链完整地写一遍假设你有一个Person类的实例pp-isa指向Person类对象。Person类对象的isa指向Person的元类对象MetaPerson。MetaPerson这个元类对象的isa指向NSObject的元类对象。NSObject元类对象的isa指向它自己。为什么需要这条链因为 Objective-C 的方法调用是统一的objc_msgSend机制。实例方法存在类对象里类方法存在元类对象里。当给类发消息时runtime 需要知道“去元类里找方法”当给实例发消息时runtime 需要知道“去类对象里找方法”。它们共用同一套查找流程靠的就是不同对象的isa指向不同目标。顺便提一个高频面试点NSObject的元类它的父类是NSObject类对象。这就构成了一个“环”也是很多人怎么都画不对继承图的原因。理解它最好的办法是动手写代码验证而不是硬背图。3.2 isa 与 superclass 的分工isa和superclass是两条不同的查找路径经常被混为一谈。准确的分工可以这样记如果你有一个实例对象想找实例方法先走isa到类对象再看类对象的superclass链。如果你有一个类对象想找类方法先走isa到元类对象再看元类对象的superclass链。为了直观对比我整理了一个简表调用场景第一跳后继查询实例方法[p run]实例p的 isa -Person类对象类对象的 cache / method_list未命中沿 superclass 上升类方法[Person run]类对象Person的 isa -MetaPerson元类对象元类对象的 cache / method_list未命中沿 superclass 上升你平时在 debug 时打断点观察p-isa和Person.class的关系就会发现它们不是同一个概念。isa描述的是“这个对象是谁创建的”superclass描述的是“这个类继承自谁”。3.3 消息查找中 isa 的具体走查路径objc_msgSend是用汇编写的在真正逐级查找之前会先从对象里取 isa。简化后的逻辑大概是这样从obj取出isa。如果是 tagged pointer走特殊处理不走完整消息查找。用ISA_MASK还原出Class。在Class的cache中查找方法。cache miss 后去method_list_t中查找。找不到就沿着superclass指针逐级向父类查。一直查到nil还没找到就进入消息转发阶段。这个流程里有一个非常关键的细节isa只决定了第一步“从哪个类开始查”真正决定“之后往哪查”的是superclass。所以在调试时如果发现方法没查到不要只盯着 isa还要看这个类的父类链有没有断掉或者异常。3.4 isa-swizzling面试里常见但不要乱碰的黑魔法“isa swizzling”这个词在 KVO 的底层实现里会听到。KVO 在观察某个对象的属性时会动态创建一个子类然后把对象的 isa 指向这个中间子类从而拦截setter。这段逻辑在NSKVONotifying_xxx类里体现得非常明显。当你对一个对象调用addObserver:forKeyPath:之后在 LLDB 里查看它的 isa通常就不再是原来的类了而是一个带前缀的中间类。这个机制非常强大但也很危险。因为 isa 现在承载了引用计数、弱引用标志、关联对象标志等运行时状态。如果你直接写obj-isa someClass;等于把extra_rc、has_assoc、weakly_referenced这些位全部破坏。正确的做法是用 runtime 提供的object_setClass方法Class originClass object_getClass(obj); object_setClass(obj, targetClass);object_setClass会保留原有 isa 中的其它标志位只替换shiftcls。我在代码评审时见过直接对 isa 位域赋值导致 release 崩溃的案例原因就是引用计数被抹掉了。所以如果你想自己实现“伪装类身份”的逻辑请一定走 API不要碰裸 isa 字段。4. 动手验证 isa用代码和调试器看真实运行时布局4.1 用 runtime API 验证 object_getClass 与 isa 的区别理论讲太多容易虚我建议你直接写一段代码验证。先创建一个普通的 NSObject 实例然后打印它的isa、object_getClass、class看看分别是什么#import objc/runtime.h #import objc/message.h NSObject *obj [[NSObject alloc] init]; Class cls object_getClass(obj); NSLog(obj - cls: %, cls); NSLog(obj - class: %, [obj class]); Class metaCls object_getClass([NSObject class]); NSLog([NSObject class] - meta: %, metaCls); NSLog(isMetaClass? %d, class_isMetaClass(metaCls));输出结果里最值得注意的一点是object_getClass(obj)和[obj class]虽然最终返回同一个东西但实现路径并不相同。[obj class]在大多数情况下会走class方法而object_getClass是直接通过 isa 取类。如果你对isa做了 swizzling这俩的结果就可能不一致。真正要拿到“isa 实际指向的类”时应该用object_getClass。4.2 从 isa.bits 里还原出 shiftcls 的真实地址如果你开启了 non-pointer isa想亲眼验证“isa 不是一个普通的类指针”可以这样写NSObject *obj [[NSObject alloc] init]; uintptr_t bits *(uintptr_t *)(__bridge void *)obj; NSLog(raw isa bits: 0x%lx, bits); NSLog(masked class: %, (__bridge Class)((void *)(bits ISA_MASK)));在 arm64 环境下ISA_MASK通常是0x0000000ffffffff8。这个掩码把低 3 位清零、把高位移除只剩下shiftcls对应的地址位。你运行后会发现masked class的结果和object_getClass(obj)返回的类是一致的。如果这里的isa不是 non-pointer那bits直接就等于类地址掩码也不会起什么作用这种差异本身就很有教学价值。4.3 tagged pointer那些“没有 isa”的对象isa 机制里还有一个非常特殊的存在tagged pointer。像NSNumber、NSDate这种对象如果数值很小runtime 会直接把值编码到指针里不再分配真实对象内存也就不再需要完整的 isa。怎么判断一个对象是不是 tagged pointer源码里提供了_objc_isTaggedPointer这样的工具函数你可以主动验证NSNumber *num (42); NSLog(isTaggedPointer: %d, _objc_isTaggedPointer((__bridge void *)num));你会发现(42)大概率是 tagged pointer。这类“对象”的 isa 并不是真实存在的objc_object.isa字段runtime 会通过一个特殊类objc_tagged_isa来“模拟”它有类。这也是为什么你调用[num class]仍然能得到正确类名的原因它走的是 tagged pointer 专属逻辑。4.4 动态修改 isa 时的坑直接赋值会毁掉引用计数我在前面的第 3.4 节提过一次这里再展开讲因为它是我实际排查过程中遇到最多的崩溃原因之一。假设你写了一个方法想临时把一个对象伪装成另一个类// 错误示范 ((__bridge id *)(__bridge void *)obj)-isa otherClass; // 实际上你根本不能直接这样访问 // 正确做法 object_setClass(obj, otherClass);直接修改 isa 位域不仅有可能破坏extra_rc和has_sidetable_rc还会在dealloc时让 runtime 混淆对象状态。实际表现是什么常见的有NSZombie不生效、dealloc 提前跑、引用计数瞬间变成异常值、弱引用管理失败。我见过一个项目里有人在 KVO 之外自己搞了一套“isa 替换”上线后 crash 率明显上升最后只能回退到 KVO 原生机制里。所以我的原则是object_setClass已经提供了安全的替换方式不要自己写位运算去改 isa。哪怕你觉得自己很懂位域也不要赌运气。5. isa 相关坑点与排查从调试信息到面试题的底层答案5.1 为什么 LLDB 里打印 isa 看到的不是完整类地址用po obj或p obj-isa时很多初学者会困惑明明[obj class]是NSObject为什么 isa 的值看起来不像一个地址原因就是 non-pointer isa 的低位存了各种标志位高位被清空或者放入了其它信息真正的类地址藏在shiftcls段里。你看到一串奇怪的十六进制其实是0x000001a000000001...类似这种魔法值和标志位的组合。要想从调试器里快速确认对象的实际类更可靠的做法是执行po object_getClass(obj)或者image lookup -t 类名。如果你想看 isa 的位域拆分也可以直接写一个位运算表达式但更省事的是在 xcode 的变量视图里看isa.bits的二进制形态逐段对照ISA_BITFIELD。5.2 为什么 isa 要和“内存对齐”绑定在一起我之前一直没想明白shiftcls为什么不直接存类地址而要“把地址右移 3 位再存”等我看完汇编和 malloc 的原理就懂了。现代 64 位系统里类对象的内存地址通常按 16 字节甚至更大粒度对齐也就是说地址的低 3 位甚至低 4 位一定是 0。如果不利用这几位等于白白浪费。所以源码里对shiftcls的处理基本都是“先右移 3 位存入取出时再左移 3 位”。这样既能保存完整的类地址又能腾出低位给has_assoc、weakly_referenced这些状态。这也是为什么 ISA_MASK 低 3 位是 0 的原因掩码要把这些标志位全部遮掉。5.3 面试高频题class 方法、object_getClass 与 isa 的区分面试里经常出现这样一道题[obj class]和object_getClass(obj)有什么区别很多人答不上来或者只会背“前者是方法调用后者是 runtime 函数”。背后的核心是[obj class]如果对象重写了class方法返回的内容可能被改写如果没重写默认实现通过 isa 返回真实类。object_getClass(obj)则直接返回obj-getIsa()还原出的类不受class方法重写影响。当obj本身是类对象时[obj class]返回类对象自己object_getClass(obj)返回元类。这个差异在面试里问得非常高频实际开发里也容易踩坑。尤其当你实现一些动态代理、类簇、切面逻辑时如果只依赖[obj class]很可能会被重写后的结果误导。5.4 isa 与引用计数、弱引用、关联对象之间的链式影响再深入一层isa 里的标志位并不是“仅供参考”它们会直接影响内存管理流程。举例来说has_assoc 1意味着对象有关联对象dealloc时 runtime 需要去清理关联对象表weakly_referenced 1意味着有弱引用表需要处理extra_rc存了一部分引用计数如果溢出会把多的计数放到全局SideTable里并在has_sidetable_rc上做标记。所以在排查内存问题的时候大家不要只盯着retainCount或weak表其实正确阅读 isa 的这几个标志位能帮你快速定位对象状态。我记得有一次线上崩溃是在 dealloc 里访问 weak 属性时挂的最后查下来正是因为 isa 的weakly_referenced标志和实际的弱引用表不一致导致清理逻辑走了异常分支。5.5 调试 isa 时的几条实战建议我最后给几条比较实用的个人经验第一调试时优先用object_getClass不要直接去读isa.bits。除非你正在研究位域布局否则直接读裸 bits 只会增加理解的复杂度。第二如果确实需要研究位域建议固定一个系统版本研究不同版本、不同架构的位段定义会有差异不要拿着 arm64 的布局去解释 x86_64 的调试结果。第三不要在业务代码里依赖 isa 的具体位段。Apple 不保证这些布局长期稳定后续系统版本可能调整长期依赖底层位段的代码很容易在系统升级后崩溃。我自己在做技术方案时一直把 isa 当成“面试必须理解、生产尽量少碰”的知识点。它是一把理解 Runtime 的钥匙但也是一把容易割伤手的刀。理解了它的设计动机和内存模型很多 Runtime 相关的问题会迎刃而解但真要在工程里动手改它我强烈建议你先做足预案并且一定要走公开 API。关于 isa 指针的内容到这里已经讲得比较完整了。从初始的裸指针到 non-pointer isa 的位域优化再到消息查找里那套绕来绕去的元类体系整个设计都在围绕“快”和“省”这两个字做文章。我始终觉得学习这类底层知识最值钱的部分并不是背下哪个 bit 在第几位而是理解设计者在面对“几十万对象、每秒几百万次消息发送”这个现实时是怎么做取舍的。带着这个问题去看源码你会有完全不一样的收获。
返回列表