iOS 的弱引用源码分析

November 15, 2017

用引用计数进行内存管理,必然会发生“循环引用”的问题,为了正确打破对象间相互引用的关系,我们一般的方法都是使用 weak 作为工具。通过 weak 修饰符表示的弱引用除了不会增加对象的引用计数外,另一个好处是,当引用的对象被释放后,这个弱引用会自动失效并且处于 nil 的的状态(zeroing)。

以下就来尝试分析苹果对 Objective-C 和 Swift 分别的实现原理。

OC 时代

OC 的 __weak 关键字是随着 iOS5 新增的 ARC 特性而来。最早的实现出现在苹果开源的 objc4-493.9。 而文中的源码来自最新的 objc4-723 版本。关于弱引用的实现主要在 objc-weak.hobjc-weak.mmNSObject.mm 这三个文件中。

初始化

当我们像下面那样初始化一个弱引用时:

// NSObject *o = ...;
__weak id weakPtr = o;

编译器会转换为下面的实现:

// NSObject *o = ...;
objc_initWeak(&weakPtr, o);

对于 objc_initWeak() 的实现:

id objc_initWeak(id *location, id newObj)
{
	// 查看对象是否有效
	// 无效对象立刻使指针置空
    if (!newObj) {
        *location = nil;
        return nil;
    }

    return storeWeak<DontHaveOld, DoHaveNew, DoCrashIfDeallocating>
        (location, (objc_object*)newObj);
}

可以看到,这个函数最后会调用 storeWeak(),而且传入的三个非类型模板参数的名字很好地解释了它们的意义:弱引用不存在已有指向对象(DontHaveOld),但需要指向新的对象(DoHaveNew),如果目标对象正在释放,那就崩溃吧(DoCrashIfDeallocating)。

再来看一下 storeWeak() 的实现:

template <HaveOld haveOld, HaveNew haveNew, CrashIfDeallocating crashIfDeallocating>
static id storeWeak(id *location, objc_object *newObj)
{
    assert(haveOld  ||  haveNew);
    if (!haveNew) assert(newObj == nil);

    // 用于标记已经初始化的类
    Class previouslyInitializedClass = nil;
    id oldObj;
    // 声明新旧辅助表
    SideTable *oldTable;
    SideTable *newTable;

    // 获取新旧值(存在的话)的辅助表,并且加锁,
    // 如果新旧值辅助表同时存在时,以锁的地址大小排序,防止锁的顺序问题
 retry:
    if (haveOld) {
        // 如果有旧值的话,通过指针获取目标对象,
        // 再以目标对象的地址为索引,取得旧值对应的辅助表
        oldObj = *location;
        oldTable = &SideTables()[oldObj];
    } else {
        oldTable = nil;
    }
    if (haveNew) {
        // 如果有新值,以新值的地址为索引,取得新值对应的辅助表
        newTable = &SideTables()[newObj];
    } else {
        newTable = nil;
    }

    // 加锁
    SideTable::lockTwo<haveOld, haveNew>(oldTable, newTable);

    if (haveOld  &&  *location != oldObj) {
   		// 线程冲突处理,
   		// 如果有旧值,但 location 指向的对象不为 oldObj,那很可能被其它线程修改过,
   		// 解锁并重试
        SideTable::unlockTwo<haveOld, haveNew>(oldTable, newTable);
        goto retry;
    }

    // 确保新值的 isa 类已经调用 +initialize 初始化,
    // 避免弱引用机制和 +initialize 机制间的死锁
    if (haveNew  &&  newObj) {
        // 获得新值的 isa 类
        Class cls = newObj->getIsa();
        if (cls != previouslyInitializedClass  &&
            !((objc_class *)cls)->isInitialized())
        {
            // 新值 isa 非空,并且未初始化,
            // 解锁
            SideTable::unlockTwo<haveOld, haveNew>(oldTable, newTable);
            // 初始化 isa
            _class_initialize(_class_getNonMetaClass(cls, (id)newObj));

            // 如果这个 isa 类正在当前线程运行 +initialize
            //(例如在 +initialize 方法里对自己的实例调用了 storeWeak ),
            // 很显然会处于一个正在初始化,但未初始化完的状态,
            // 所以设置 previouslyInitializedClass 为这个类进行标记
            previouslyInitializedClass = cls;

            // 重试
            goto retry;
        }
    }

    // 清除旧值
    if (haveOld) {
        // 从 oldObj 的弱引用条目删除弱引用的地址
        weak_unregister_no_lock(&oldTable->weak_table, oldObj, location);
    }

    // 设置新值
    if (haveNew) {
        // 把弱引用的地址注册到 newOjb 的弱引用条目
        newObj = (objc_object *)
            weak_register_no_lock(&newTable->weak_table, (id)newObj, location,
                                  crashIfDeallocating);

        // 如果 weakStore 操作应该被拒绝,weak_register_no_lock 会返回 nil,否则
        // 对被引用对象设置弱引用标记位(is-weakly-referenced bit)
        if (newObj  &&  !newObj->isTaggedPointer()) {
            newObj->setWeaklyReferenced_nolock();
        }

        *location = (id)newObj;
    }
    else {
        // 没有新值,不用更改
    }

    SideTable::unlockTwo<haveOld, haveNew>(oldTable, newTable);

    return (id)newObj;
}

可以看到,很多操作都需要对 SideTable 的实例进行操作。实际上 SideTable 也的确是作为全局对象用于管理所有对象的引用计数和 weak 表,在 Runtime 启动时就和主线程的 AutoreleasePool 一同创建。

SideTable

SideTable 的定义如下:

struct SideTable {
    spinlock_t slock;			// 用于原子操作的自旋锁
    RefcountMap refcnts;		// 引用计数哈希表
    weak_table_t weak_table;	// weak 表

    // ...
};

storeWeak() 可以看到,Runtime 是通过以下方式获取对象的 SideTable

objSideTable = &SideTables()[obj];

这个 SideTables() 方法返回的就是一个 StripedMap 的哈希表,以对象的地址作为键值返回对应的 SideTable

static StripedMap<SideTable>& SideTables() {
    return *reinterpret_cast<StripedMap<SideTable>*>(SideTableBuf);
}

reinterpret_cast 是C++标准转换运算符,用来处理无关类型之间的转换,它会产生一个新的值,这个值会有与原始参数(expressoin)有完全相同的比特位。

reinterpret_cast <new_type> (expression)

StripedMap 是一个模板类,定义于 objc-private.h 文件中,提供了一个以地址为键值的哈希结构。

template<typename T>
class StripedMap {
	// ...

	// 嵌入式系统的 StripeCount 为 8,iOS 上为 64
	enum { StripeCount = 64 };

	static unsigned int indexForPointer(const void *p) {
        uintptr_t addr = reinterpret_cast<uintptr_t>(p);

        // 哈希操作
        return ((addr >> 4) ^ (addr >> 9)) % StripeCount;
	}

public:
    T& operator[] (const void *p) {
        return array[indexForPointer(p)].value;
    }
    const T& operator[] (const void *p) const {
        return const_cast<StripedMap<T>>(this)[p];
    }

	// ...
}

在实现中,StripedMap 新定义了数组运算符,传入对象的地址即可通过哈希算法获得对应内容。从原有的注释可以看到,在 Runtime 初始化后,iOS 系统就生成了 64 个 SideTable 留作以后的使用。

SideTable 里与弱引用有直接关系的就是 weak 表。weak 表也是作为哈希表实现,将目标对象的地址作为键值进行检索,去获取对应的弱引用变量地址。另外,由于一个对象可同时赋值给多个弱引用变量,所以对于一个键值,可注册多个弱引用变量的地址。

struct weak_table_t {
    // 弱引用条目列表
    weak_entry_t *weak_entries;
    // 条目数量
    size_t    num_entries;
    // 条目列表大小
    uintptr_t mask;
    // 最大哈希偏移值
    uintptr_t max_hash_displacement;
};

weak_entries 和上面的 StripedMap 不同,StripedMap 并不需要处理冲突,但因为 weak_entries 需要对应到具体的内容,所以出现冲突后还需要再处理,苹果使用的是开放地址法max_hash_displacement 就是用于出现冲突后辅助检查查找的内容是否存在。

typedef DisguisedPtr<objc_object *> weak_referrer_t;
#define WEAK_INLINE_COUNT 4

struct weak_entry_t {
    DisguisedPtr<objc_object> referent;
    union {
        struct {
            weak_referrer_t *referrers;
            uintptr_t        out_of_line_ness : 2;
            uintptr_t        num_refs : PTR_MINUS_2;
            uintptr_t        mask;
            uintptr_t        max_hash_displacement;
        };
        struct {
            // out_of_line_ness field is low bits of inline_referrers[1]
            weak_referrer_t  inline_referrers[WEAK_INLINE_COUNT];
        };
    };

    // ...
};

以上是 weak_entry_t 的定义,目标对象和弱引用变量的指针都被封装在 DisguisedPtr 里。DisguisedPtr 用于隐藏封装对象的类型,避免内存分析工具可以轻松看到其中的类型信息。可以看到苹果为了节省内存空间和效率,特意使用了联合结构。当目标对象的弱引用数少于等于 WEAK_INLINE_COUNT 时,将会使用內联静态数组的形式来存取弱引用指针地址,否则就会以和 weak_table_t 相同的结构来存储(同样以地址作为键值的哈希表)。

zeroing

OC 的弱引用变量 zeroing 发生在目标对象的释放时候。在对象的 dealloc 过程中会调用 weak_clear_no_lock 函数:

/**
 * Called by dealloc; nils out all weak pointers that point to the
 * provided object so that they can no longer be used.
 */
void
weak_clear_no_lock(weak_table_t *weak_table, id referent_id)
{
    objc_object *referent = (objc_object *)referent_id;

    // 获取弱引用条目
    weak_entry_t *entry = weak_entry_for_referent(weak_table, referent);
    if (entry == nil) {
        return;
    }

    weak_referrer_t *referrers;
    size_t count;

    // 获取弱引用变量地址数组和数目
    if (entry->out_of_line()) {
        referrers = entry->referrers;
        count = TABLE_SIZE(entry);
    }
    else {
        referrers = entry->inline_referrers;
        count = WEAK_INLINE_COUNT;
    }

    // 把它们全置为 nil
    for (size_t i = 0; i < count; ++i) {
        objc_object **referrer = referrers[i];
        if (referrer) {
            if (*referrer == referent) {
                *referrer = nil;
            }
            else if (*referrer) {
                _objc_inform("__weak variable at %p holds %p instead of %p. "
                             "This is probably incorrect use of "
                             "objc_storeWeak() and objc_loadWeak(). "
                             "Break on objc_weak_error to debug.\n",
                             referrer, (void*)*referrer, (void*)referent);
                objc_weak_error();
            }
        }
    }

    // 移除整个条目
    weak_entry_remove(weak_table, entry);
}

小结

可以看到,不论是存放或是加载一个弱应用变量都需要:

  1. SideTable 哈希搜索一次;
  2. weak_table_t 开放地址法哈希搜索一次;
  3. weak_entry_t 同样是开放地址法哈希再搜索一次。

一套操作下来无论怎么看都是不简单的。不过一般情况下应该也用不到很多的弱引用。考虑到 iOS5 刚出那时候内存还是相当吃紧的,为了能立刻释放内存,所以就用这种时间换空间的方式吧。

Swift4 之前的实现

接着我们来看 Swift 里的实现。在 Swift 的运行时里,被分配到堆上的对象都是一个 HeapObject 类型:

/// The Swift heap-object header.
struct HeapObject {
  /// This is always a valid pointer to a metadata object.
  HeapMetadata const *metadata;

  SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS;
  // FIXME: allocate two words of metadata on 32-bit platforms

#ifdef __cplusplus
  HeapObject() = default;

  // Initialize a HeapObject header as appropriate for a newly-allocated object.
  constexpr HeapObject(HeapMetadata const *newMetadata)
    : metadata(newMetadata)
    , refCounts(InlineRefCounts::Initialized)
  { }
#endif
};

HeapMetadata 相当于 Objective-C 的 isa 字段,实际上两者也确实是可以互换的。随后的 SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS 宏就是我们要找的:

#define SWIFT_HEAPOBJECT_NON_OBJC_MEMBERS       \
      StrongRefCount refCount;                  \
      WeakRefCount weakRefCount

强弱引用计数都是这么直接的定义在里面了!可能是 Swift 的开发团队也意识到 OC 的实现方法已经相当过时和低效,从而重新设计了整套机制。新的实现里弱引用变量就是一个只存有目标对象地址的结构体。

struct WeakReference {
  uintptr_t Value;
};

初始化一个弱引用变得很直接,只是单纯地把目标对象的地址记录起来。

void swift::swift_weakInit(WeakReference *ref, HeapObject *value) {
  ref->Value = (uintptr_t)value | WR_NATIVE;
  SWIFT_RT_ENTRY_CALL(swift_unownedRetain)(value);
}

所以当需要把目标对象加载出来也很简单。下面的方法是 2015 年的实现(升级到 Swift4 之前只是为了处理多线程的情况而把一些存取操作换成了原子操作,基本意思还是这样)。

HeapObject *swift::swift_weakLoadStrong(WeakReference *ref) {
  auto object = ref->Value;
  if (object == nullptr) return nullptr;
  if (object->refCount.isDeallocating()) {
    swift_weakRelease(object);
    ref->Value = nullptr;
    return nullptr;
  }
  return swift_tryRetain(object);
}

上面几行除了有加载对象的作用外,我们也能清楚 Swift 里的 zeroing 是怎么工作了:加载一个弱引用时,如果目标对象正在被释放,那就置空这个引用,否则就尝试 retain 并且返回这对象。

接着看到 swift_weakRelease 函数:

void swift::swift_weakRelease(HeapObject *object) {
  if (!object) return;

  if (object->weakRefCount.decrementShouldDeallocate()) {
    // Only class objects can be weak-retained and weak-released.
    auto metadata = object->metadata;
    assert(metadata->isClassObject());
    auto classMetadata = static_cast<const ClassMetadata*>(metadata);
    assert(classMetadata->isTypeMetadata());
    swift_slowDealloc(object, classMetadata->getInstanceSize(),
                      classMetadata->getInstanceAlignMask());
  }
}

当对象的弱引用数减少到需要释放的时候,这才把对象真正的内存给释放出来。

小结

在 Swift 里苹果把弱引用的实现简化了,弱引用变量只保存指向目标对象的地址,并通过对象內的弱引用计数进行内存管理。并且 Swift 把对象的析构和释放时机进行了解耦,所以对象可以处于一种已经被析构但还没释放的状态。

Swift4 后

虽然上面的实现利用空间换时间的方法简化了弱引用的实现和提高了存取效率,但却有一个很大的问题。如果对象的弱引用数一直不为零,那么对象就不会真正被释放。如果对象占据的空间很大的话,也是对内存造成浪费。所以在 Swift4 以后,苹果再次加入 SideTable 的机制。

不过此 SideTable 跟 OC 的 SideTable 不一样,系统不再是把它作为全局对象使用了。 新的 SideTable 是针对每一个有需要的对象而创建,系统会为该对象分配一块新的内存来保存该对象额外的信息。因为这不是对象必须的内容,所以这个 SideTable 可有可无。对象会有一个指向 SideTable 的指针,同时 SideTable 也有一个指回原对象的指针。为了把对象里的这个指针空间也省下来,目前只有在创建弱引用时,会把对象的引用计数放到新创建的 SideTable 去,再把本来存放引用计数的空间改为 SideTable 的地址,runtime 会通过一个标志位来区分对象是否有 SideTable。在 RefCount.h 文件的注释里,Swift 开发团队已经清晰的写了:

  Storage layout:

  HeapObject {
    isa
    InlineRefCounts {
      atomic<InlineRefCountBits> {
        strong RC + unowned RC + flags
        OR
        HeapObjectSideTableEntry*
      }
    }
  }

  HeapObjectSideTableEntry {
    SideTableRefCounts {
      object pointer
      atomic<SideTableRefCountBits> {
        strong RC + unowned RC + weak RC + flags
      }
    }
  }

这样做的好处是,不但解决了上面的问题,而且还能继续使用 Swift 的弱引用机制,只不过现在弱引用变量指向的是对象的 SideTable。最后,SideTable 的实现也为以后加入更多特性提供了方便。

总结

Swift 弱引用机制的改进在提高效率的同时使得实现更加优雅。通过开源代码我们也能看到苹果在这方面是如何思考和设计的,怎样在时间和空间上进行取舍来实现需求。

References

weak 弱引用的实现方式

Swift Weak References

Swift 4 Weak References

Comments

comments powered by Disqus