iOS 内存管理:Associated Objects (关联对象)

1. 概述

关联对象(Associated Objects)允许我们把一段额外数据与某个对象建立运行时关系,常用于给 Category 的属性补充存储。它的使用体验像“给对象增加属性”,但不会改变类的实例大小,也不是真正把 ivar 塞进对象内存。

  • objc_setAssociatedObject
  • objc_getAssociatedObject
  • objc_removeAssociatedObjects

2. 底层存储结构

关联对象不是存储在对象本身的内存中(对象的内存布局在编译期固定),而是存储在全局的 AssociationsManager 中。

// 示意结构,不同 objc4 版本的命名和锁实现可能变化
AssociationsHashMap: object -> ObjectAssociationMap
ObjectAssociationMap: key -> ObjcAssociation(policy, value)

存储层级

  • AssociationsHashMap:全局单例。

    • Key: DisguisedPtr\ (即对象的地址)。

    • Value: ObjectAssociationMap。

  • ObjectAssociationMap:属于某个对象的所有关联对象表。

    • Key: const void *key (设置时的 key)。

    • Value: ObjcAssociation (包含 value 和 policy)。

  • ObjcAssociation

    • uintptr_t _policy (如 COPY, RETAIN_NONATOMIC)。

    • id _value (实际值)。

3. 核心逻辑

3.1 设置值

  • 在 Runtime 的同步保护下找到或创建宿主对象对应的关联表。

  • 根据对象地址找到对应的 ObjectAssociationMap。

  • 根据 key 找到对应的 ObjcAssociation。

  • 按 policy 对新旧值执行 retain、copy 或 assign 语义。Runtime 会谨慎安排加锁区间与对象释放时机,避免在持锁状态下执行可能产生副作用的用户代码。

  • 更新表。

3.2 释放对象

当对象销毁时 (dealloc):

  • Runtime 调用 objc_destructInstance。

  • 检查 isa.has_assoc 标记位。

  • 如果有,调用 _object_remove_assocations。

  • 从全局 Map 中移除该对象的所有关联数据,并对 Value 发送 release。

宿主对象正常销毁时,Runtime 会移除并释放其关联值,但这不等于“关联对象不会造成内存泄漏”。如果宿主强持有关联对象,而关联对象又强持宿主,就会形成 ARC 无法自动打破的强引用环。

4. Association Policy 应该怎么选

常见策略与属性语义大致对应:

Policy常见用途注意点
OBJC_ASSOCIATION_ASSIGN非拥有关系不具备 weak 的自动置 nil 能力,目标释放后可能留下悬空地址,通常不建议拿它模拟 weak
OBJC_ASSOCIATION_RETAIN_NONATOMIC非原子强引用最常见,适合多数 UI 与业务扩展
OBJC_ASSOCIATION_COPY_NONATOMICBlock、字符串或需要值语义的对象Block 通常应 copy,具体仍要看数据语义
OBJC_ASSOCIATION_RETAIN / COPY带原子化获取/设置语义有额外同步成本,也不等于让一组复合操作自动线程安全

关联 key 推荐使用地址稳定且模块内唯一的静态变量地址:

static void *UserTokenKey = &UserTokenKey;

objc_setAssociatedObject(self,
                         UserTokenKey,
                         token,
                         OBJC_ASSOCIATION_COPY_NONATOMIC);

也可以使用 selector 作为 key,例如 getter 的 @selector(userToken)。关键不是字符串内容,而是 const void * 地址在进程生命周期内稳定且不会和别处冲突。

5. 线程安全边界

关联对象 API 内部需要保护全局数据结构,所以单次 get/set 不会把表本身写坏。但这不代表你的业务逻辑天然线程安全。

例如“先读取数组、追加元素、再写回”是三个步骤。两个线程同时执行仍然可能丢数据。即使选择原子 policy,也只能约束单次访问,无法替你保护跨多步的不变量。需要一致性时,仍应在更高层使用锁、串行队列或 actor 风格的隔离方案。

6. 什么时候不该用关联对象

  • 数据是类型核心状态,应优先放进正式类定义,而不是藏在 Category。
  • 需要真正的 weak 语义时,不要用 ASSIGN 冒充。
  • 生命周期复杂、容易形成环时,应明确画出宿主与关联值之间的强弱关系。
  • 高频热路径上大量读写时,要意识到它包含哈希查找与同步开销,不能当作普通 ivar 等价替代品。

总结:关联对象会随宿主清理,但错误的持有关系仍然会泄漏;API 的单次访问受 Runtime 保护,也不等于整段业务逻辑线程安全。