
金九银十面试季iOS八股文里“分类和关联对象”是绕不开的组合拳分类的源码机制往运行时一深入基本就能筛掉一批只会背答案的候选人而关联对象往往是接着分类往下追问的“第二连击”比如“分类不能加成员变量那怎么加属性”这类问题本质就是在考察你对关联对象底层存储结构的理解。这篇文章我把分类和关联对象从编译期到运行期的完整源码链路拆开讲一遍适合正在准备iOS面试的开发者也适合想把运行时机制真正吃透、不再停留在表面结论的人。1. 面试官问分类时真正想确认的边界在哪1.1 分类在iOS日常开发里的定位没你想的那么简单Category分类是Objective-C里提供的一种不需要修改原有类代码、就可以为既有类扩展方法、协议、属性的语言机制。日常开发里最常见的三个用途给系统类批量扩展工具方法比如UIColorHex、把大模块按业务拆成多个分类文件降低维护成本、用分类声明私有协议实现类内部解耦。但面试官问分类通常不是真的想听你列举用途。他们更在意的是你知不知道分类的边界在哪里以及这个边界是怎么被语言机制决定的。最典型的问题“分类里能添加成员变量吗”标准答案是“不能”。但如果你停留在这个层面面试官会继续追问“为什么不能”这时候就需要从源码角度给出解释因为成员变量ivar的内存布局在编译期就已经固化到类对象里了而分类是运行期才被加载进内存的机制它的编译产物category_t里压根没有存放实例变量的字段。你无法在运行期再去改变一个已经确定大小的对象内存布局否则所有基于isa偏移量访问实例变量的代码都会崩掉。在iOS里面试官经常把分类和关联对象放在一起考察原因也在于此分类这个“不能加成员变量”的限制催生了关联对象这套专门在运行期为对象追加存储的机制。这俩是一体两面离开源码谈任何一个都会显得悬浮。1.2 面试题背后真正想考的底层逻辑我把面试中常见的分类相关问题整理了一下可以发现它们对应的是三层递进面试问题表面考察底层源码考点分类能添加成员变量吗分类的语法边界category_t没有 ivar 字段分类中的 property 生效吗属性的自动合成机制分类不会触发_ivar自动合成只有 getter/setter 声明分类同名方法为什么优先方法查找顺序attachLists把分类方法列表插入类方法列表头部分类的 load 什么时候调用生命周期load_images-call_load_methods不走消息发送多个分类同名方法谁生效编译顺序分类列表的追加顺序影响方法列表位置所以这篇文章后面所有的源码解析本质都是在给这张表做注解。把每一个结论落到代码层面你回答任何追问都不会慌。2. category_t 的编译产物为什么它没有 ivar 字段2.1 用 clang 重写看分类的“真身”先写一个最普通的分类// StudentExam.h interface Student (Exam) property (nonatomic, copy) NSString *examName; - (void)takeExam; end然后在终端跑一下 clang 的 rewrite 命令把它重写成 C 源码clang -rewrite-objc StudentExam.m -o StudentExam.cpp在生成的 C 代码里能找到类似这样的结构不同编译器版本字段顺序可能有差异struct _category_t { const char *name; struct _class_t *cls; const struct _method_list_t *instance_methods; const struct _method_list_t *class_methods; const struct _protocol_list_t *protocols; const struct _property_list_t *instance_properties; }; static struct _category_t _OBJC_$_CATEGORY_Student_$_Exam __attribute__ ((used, section (__DATA,__objc_const))) { Student, 0, (const struct _method_list_t *)_OBJC_$_INSTANCE_METHODS_Student_$_Exam, 0, 0, (const struct _property_list_t *)_OBJC_$_PROP_LIST_Student_$_Exam, };这里的_category_t对应运行时源码里的category_t它的字段只有类名、类指针、实例方法列表、类方法列表、协议列表、实例属性列表。注意从头到尾没有 ivar_list 字段。这是“分类不能添加成员变量”最直接的代码证据——编译产物里根本没有存放实例变量的位置。跟类的真实结构对比一下类对象里有class_ro_t其中包含instanceStart / instanceSize这样的字段用来约束实例变量在对象内存中的偏移而分类结构体里没有这个能力它只是把若干方法、属性、协议“挂在”类名下面等运行时统一并入真正的类结构。2.2 property 在分类里的真实下场既然category_t有instance_properties字段那分类中声明property到底会怎样很多人有个误解以为属性就是成员变量分类声明属性就等同于添加了成员变量。实际上property在没有synthesize的情况下编译器只会做两件事声明一个 getter 方法、声明一个 setter 方法。在_category_t里这个属性被记录在instance_properties中运行时会把它并入类的属性列表但不会自动生成对应的_examName实例变量也不会自动生成 getter/setter 的实现。也就是说Student *stu [[Student alloc] init]; stu.examName math; // 这里调用 setter如果没有手动实现运行时直接崩溃必须自己在分类的 .m 文件里实现 getter/setter才能让这个属性真正“可用”。而最常见的实现方式就是内部用关联对象存储数据static char kExamNameKey; - (void)setExamName:(NSString *)examName { objc_setAssociatedObject(self, kExamNameKey, examName, OBJC_ASSOCIATION_COPY_NONATOMIC); } - (NSString *)examName { return objc_getAssociatedObject(self, kExamNameKey); }这也就是为什么面试官喜欢从“分类的属性”无缝衔接到“关联对象”——分类属性本身没有存储能力是关联对象补上了这个缺口。3. 分类并入类的运行时链路从 _read_images 到 attachLists3.1 启动阶段分类是怎么被 runtime 发现的分类的编译产物放在 Mach-O 的__DATA段的__objc_catlist节里。App 启动时dyld 加载完所有镜像后会调用 runtime 的初始化方法_objc_init随后_read_images被触发整个扫描过程就是在读取这些 section。_read_images里对分类的处理大致分两条路如果类还没有被 realize还没完成内存布局和合并分类分类会被暂时挂到全局的unattachedCategories表里。如果类已经 realize就直接调用remethodizeClass把分类合并进去。这里需要先理解一个概念类的 realize 分为“懒加载”和“非懒加载”。有load方法的类属于非懒加载类在_read_images阶段就会立刻触发realizeClassWithoutSwift完成类结构的内存初始化没有load的类走懒加载路径直到第一次收到消息第一次调用它的方法时才会通过lookUpImpOrForward触发 realize。不管是哪条路径realize 之后都会进入一个关键函数methodizeClass而这个函数内部会调用attachCategories把分类的方法列表正式并入类的方法数组。如果类已经 realize 了但新加载的镜像里又带了一个还没有合并过的分类runtime 还会通过remethodizeClass再次执行attachCategories保证分类不丢。3.2 attachLists 的“头插法”同名方法覆盖的真相这里必须先纠正一个说法。很多人喜欢讲“分类方法覆盖类方法”这个描述在语义上不准确。真实情况是类原本的方法并没有被移除也没有被替换分类方法只是被插入到了类方法列表的最前面消息查找时先查到了分类方法而已。attachCategories最终会把分类整理成一个方法列表数组然后调用attachLists。attachLists的核心逻辑是内存搬移void attachLists(List* const * lists, uint32_t mcount) { if (mcount 0) return; if (hasArray()) { uint32_t oldCount array()-count; uint32_t newCount oldCount mcount; setArray((array_t *)realloc(array(), array_t::byteSize(newCount))); array()-count newCount; // 把旧的方法列表整体向后移动 memmove(array()-lists mcount, array()-lists, oldCount * sizeof(array()-lists[0])); // 把分类方法列表拷贝到数组最开始的位置 memcpy(array()-lists, lists, mcount * sizeof(array()-lists[0])); } else { // 从单一 list 转换为数组形式再走同样的插入逻辑 } }如果你想要个生活化的类比原来类的方法列表是一排人在排队现在来了一个“关系户”插队到最前面原本排在第一位的人并没有消失只是被往后挤了。所以当你调用分类和类都实现过的同名方法时runtime 在方法列表中线性查找现代 runtime 通常用二分查找 缓存优化查到第一个匹配的 SEL 就停下来于是分类方法“赢了”。这个方法查找过程本身也值得提一句objc_msgSend 会先把消息转成sel_registerName拿到的 SEL然后在类的 cache 中查找cache miss 再去遍历方法列表。所以如果你问“分类方法覆盖了原方法那原方法还能调用吗”答案是技术上原方法还在内存里只是正常的消息发送流程永远先命中的是分类方法。想绕过分类直接调原类的实现就得直接拿方法实现或者用 method_exchangeImplementations 这类运行时 API这种骚操作在生产环境里极不推荐。3.3 多个分类同名方法谁说了算这是面试的一个高频追问如果同一个类挂了两个分类两个分类都实现了同一个方法最终调用谁的答案藏在分类的收集顺序里attachCategories传入的分类数组是按分类的加载顺序排列的。通常情况下后编译后链接进二进制文件的分类在其分类列表cats里的位置更靠后但在最终attachLists时反而会被放到更靠前的位置。实际工程里这个顺序受 Xcode Build Phase 中 Compile Sources 的顺序影响而且苹果官方从未承诺某个确定性顺序。所以正确姿势是永远不要在业务里依赖“哪个分类同名方法优先”这种隐式规则那是给自己埋雷。面试官如果追问你只要答出“取决于编译顺序运行时不做排序保证”就已经到位了。4. load、initialize 与 Extension三组高追问度对比4.1 load 不走消息发送分类的 load 为什么一定会执行如果把分类和类里同时写了load两个都会被调用这在面试里是个高频考察点。原因在于load极其特殊它不走objc_msgSend的消息查找流程。运行时的调用链是dyld 调用load_images→call_load_methods→ 先遍历类的列表调用类的load再遍历分类列表调用分类的load。调用方式是直接通过函数指针找到 IMP 执行而不是按 SEL 去方法列表里查。所以这里要记住三个结论分类的load一定会被执行哪怕类和分类都实现了同名load也不会互相覆盖。调用顺序上父类的load先于子类类的load先于分类的load不同类之间按编译顺序执行。因为不走消息发送所以load里的方法不会走“分类覆盖类方法”的那套逻辑。load的调用时机是整个程序启动过程中非常靠前的位置。我个人的习惯是除非必须做方法交换Swizzling或非常早期的环境配置否则尽量别在load里写太多逻辑启动时间每一毫秒都很贵。这也算是在实际工程里踩过坑之后的体会。4.2 initialize 与分类不是同一套规则如果有人把load和initialize搞混面试基本就露馅了。initialize的触发时机是类第一次收到消息之前runtime 会调用一次initialize。它走的是正常的objc_msgSend流程所以分类的initialize会覆盖类的initialize。补充一点initialize还有继承链调用的坑。如果子类没实现initialize那么第一次给子类发消息时会调用父类的实现。面试官要是继续深挖你可以顺势说“所以initialize内部不能用self [ClassName class]来做判空操作因为有继承链的情况下父类和子类共用同一个实现self可能不是父类本身需要用self与当前类作比较来判断是否首次初始化。”这个细节很加分。到这里可以对比一下load是启动时静态调用的不依赖类是否被使用initialize是第一次发消息时懒触发的依赖消息发送机制所以分类对这两个方法的“覆盖”表现完全不同。这个对比放在简历项目里描述为“我对运行时生命周期做过系统性梳理”面试官会更容易认可。4.3 Category 与 Extension一个运行期一个编译期在 iOS 面试中分类和 Extension 的对比也是必问项。很多人背了“Extensioin 可以添加成员变量Category 不能”但不知道为什么。Extension 在语法上叫“类扩展”常见写法是interface Student () { NSString *_privateName; } property (nonatomic, assign) NSInteger age; - (void)privateMethod; endExtension 里的声明在编译期会直接合并进当前类的implementation对应的结构里相当于“类定义的一部分”。所以它声明的_privateName会真实参与类内存布局编译器也会照常自动合成 setter/getter 和成员变量。而分类Category是独立的编译单元生成的_category_t结构在上面已经看过了里面没有 ivar 字段。它要在运行期通过attachLists才能被并入类的方法列表错过了编译期确定内存布局的窗口。这就是两者能力的本质差异。5. 关联对象的存储骨架AssociationsManager 与三层哈希表5.1 全局大表对象地址到关联值的映射关系分类不能加成员变量但业务上又确实需要存储东西于是系统从 iOS 3.1 开始提供了关联对象。我先把它的核心数据结构摆出来以开源 runtime 源码为参考class AssociationsManager { static AssociationsHashMap *_map; static spinlock_t _lock; public: AssociationsManager() { _lock.lock(); } ~AssociationsManager() { _lock.unlock(); } AssociationsHashMap associations() { return *_map; } }; typedef DenseMapconst void *, ObjectAssociationMap * AssociationsHashMap; typedef DenseMapconst void *, ObjcAssociation ObjectAssociationMap; class ObjcAssociation { uintptr_t _policy; id _value; };这其实是三层哈希表嵌套第一层AssociationsHashMapkey 是被关联对象object的地址value 是一个ObjectAssociationMap *。第二层ObjectAssociationMapkey 是调用objc_setAssociatedObject时传入的keyvalue 是ObjcAssociation。第三层ObjcAssociation存的是关联策略_policy和真正的关联值_value。所以你调用objc_setAssociatedObject(obj, key, value, policy)实际做的事情是先拿obj的地址在第一层哈希表里找到属于这个对象的“第二层小表”再在“第二层小表”里以key为键找到对应的格子把 value 和 policy 放进去。这也回答了一个常见的误区关联对象并不是存储在对象本身的内存里的。对象自身的isa、实例变量布局都没有任何关联对象的信息。对象只是作为一个“查找用的钥匙”存在 runtime 的全局哈希表里。5.2 objc_setAssociatedObject 的核心实现加锁、换值、释放旧值看简化版的_object_set_associative_reference逻辑void _object_set_associative_reference(id object, const void *key, id value, uintptr_t policy) { ObjcAssociation old_association; { AssociationsManager manager; // 构造时加锁 AssociationsHashMap associations(manager.associations()); // 找到 object 对应的 ObjectAssociationMap如果没有就创建一个 ... if (value) { ObjcAssociation new_association(policy, value); // 根据 policy 对 value 做 retain 或 copy ... // 如果 map 里已有这个 key保存旧 association后面统一释放 auto it refs-find(key); if (it ! refs-end()) { old_association it-second; it-second new_association; } else { refs-insert(std::make_pair(key, new_association)); } } else { // value nil 表示移除关联 auto it refs-find(key); if (it ! refs-end()) { old_association it-second; refs-erase(it); } } } // 出作用域时解锁 // 锁外释放旧值防止在持锁状态下发生重入 old_association.releaseHeldValue(); }这里有三个值得展开的细节。第一AssociationsManager是典型的 RAII 锁管理。构造函数里调用_lock.lock()析构函数里_lock.unlock()所以只要你看到AssociationsManager manager;这行代码就意味着后续整个代码块都处于加锁状态。全局只有一把锁所有关联对象的读写都是串行的这也是为什么高频调用关联对象会有性能开销。第二旧值不是立刻释放的而是先保存到old_association等出了锁的作用域、锁释放之后再调用releaseHeldValue释放。为什么这么设计因为释放旧值可能触发被释放对象的dealloc而dealloc里又可能操作关联对象如果还持着全局锁就会造成不必要的等待甚至死锁风险。把这步挪到锁外执行是一个很务实的细节讲出来会让面试官觉得你真读过源码。第三releaseHeldValue的释放逻辑依托_policy如果是RETAIN系策略就对 value 做 release如果是COPY系策略就对 value 做 release因为 copy 产生的新值本身需要释放。这保证了内存管理与 ARC 语义一致。5.3 为什么 key 推荐用 static char 或 selector关联对象的 key 参数类型是const void *底层哈希表直接拿这个指针的地址值来比较。这是理解 key 选择的关键它比较的是指针地址不是字符串内容。所以 key 选择的本质要求只有一条在整个生命周期里每次访问同一个关联对象时传入的指针对应的地址值必须保持不变且唯一。为什么推荐写成static char kKey;然后用kKey因为static变量的地址在加载后是固定的而且这个变量不需要真的存储什么有意义的数据它只是一个“用于区分彼此的地址锚点”。为什么也可以用selector(xxx)因为 selector 全局唯一SEL在进程内会被统一注册selector(xxx)在不同位置拿到的指针是同一个稳定且唯一。为什么不推荐直接用keyString字符串字面量理论上字符串常量池可能合并相同内容的地址但这不是语言规范保证的不同编译单元、不同位置的字面量不一定保证地址相同。用它做 key 容易在某些边界场景下 get 不到值平白给自己造 bug。我面试候选人时问这一条能筛掉相当一部分没写过真实关联对象代码的人。6. 关联对象从 set 到 dealloc完整生命周期与生产环境中的坑6.1 对象 dealloc 时关联对象是怎么被清掉的关联对象挂在全局哈希表上那对象释放时runtime 是怎么找到属于这个对象的关联项并释放的答案在 dealloc 流程里。-dealloc底层最终会走objc_destructInstance简化的核心逻辑是void *objc_destructInstance(id obj) { if (obj) { bool cxx obj-hasCxxDtor(); bool assoc obj-hasAssociatedObjects(); if (cxx) object_cxxDestruct(obj); if (assoc) _object_remove_assocations(obj, /*deallocating*/true); obj-clearDeallocating(); } return obj; }_object_remove_assocations会根据对象地址在全局AssociationsHashMap里找到对应的ObjectAssociationMap然后遍历里面所有的ObjcAssociation对每个 value 执行释放操作按 policy最后删掉这个对象的第二层小表。换句话说key 可以不管value 的内存会被 runtime 自动管理。你在 set 的时候用什么 policy释放时就用对应的语义把 value 释放掉。这里有一个容易被忽略的细节hasAssociatedObjects是一个标志位。一旦这个对象曾经设置过任何关联对象isa 上的这个标志就会被置位。runtime 通过判断这个标志避免对没有关联对象的对象做无谓的哈希查找。6.2 生产环境里的四个经典坑第一个坑OBJC_ASSOCIATION_ASSIGN并不等同于 weak。它只是简单的赋值不做 retain也不做 copy。如果被关联的对象提前释放了你再用objc_getAssociatedObject拿回这个值拿到的就是野指针访问会崩溃。这是个很容易踩的雷因为很多初学者以为 ASSIGN 就是 weak。在源码层面ObjcAssociation只是记了一个_value指针没有weak注册机制自然不会自动置 nil。第二个坑不要随手调用objc_removeAssociatedObjects来清理单个关联。这个函数的语义是“移除这个对象上的所有关联对象”它遍历整个ObjectAssociationMap一把全删。如果你在一个公共对象上清理自己的关联很可能会把其他模块挂在同一个对象上的关联一起删掉诱发线上诡异问题。删单个关联的正确姿势是objc_setAssociatedObject(object, key, nil, policy);第三个坑关联对象全局只有一把锁所以“高频调用”是个性能隐患。关联对象的 get/set 每次都要竞争全局锁。如果有一段循环代码频繁读写关联对象性能会明显劣化。我在做列表页性能优化时排查过这类问题最终是把一次设多次取的关联对象改为存到缓存变量里避免循环内反复objc_getAssociatedObject。第四个坑对象持有关联对象而关联对象又持有了这个对象会形成循环引用。比如你用OBJC_ASSOCIATION_RETAIN_NONATOMIC把一个“回调 block 里持有了 self”的对象挂到 self 上而这个关联值又被 self 强引用引用环就形成了。dealloc 只有在环被打破时才会触发一旦触发不了_object_remove_assocations永远走不到。6.3 面试追问关联对象会不会真的导致对象内存膨胀有人问过“给一个对象设置一万个关联对象这个对象本身会不会变大”。从源码我们已经知道答案不会。关联对象存在全局哈希表里对象本身的内存布局没有任何变化只是 isa 上多了一个“有关联对象”的标志位。真正带来的成本是全局哈希表的存储开销和锁竞争不是对象内存大小的变化。如果面试官继续追问“实现一个 weak 关联对象该怎么做”你可以直言 runtime 没有直接提供OBJC_ASSOCIATION_WEAK这个策略至少在开源代码和公开 API 中不存在要模拟 weak 语义需要自己用 block 包装或者引入一个中间对象来做弱引用管理。能答到这里基本已经很扎实了。最后分享一点个人经验。我面试别人时最反感的不是候选人不懂源码而是把所有结论背得滚瓜烂熟一追问到“这个结论怎么来的”就哑火。分类和关联对象这两个知识点恰好是最适合用来检验真懂还是假懂的内容一个是编译期的结构限制一个是运行期的全局表操作两者都指向同一个核心能力——对 Objective-C 运行时机制的理解深度。如果你还在准备面试我建议你找个晚上自己拿着clang -rewrite-objc把分类重写一遍再去开源代码里把_read_images、attachLists、_object_set_associative_reference这三个函数各读三遍比你刷几十道题都管用。实践一圈回来看面试题你会发现自己已经不太需要背答案了。