[翻译]Objective-C内部探秘8:持有对象

(原文出处:Objective-C Internals | Always Processing)

Objective-C 内存通过引用计数方案进行管理,它从一个相对简单的API发展成为一个复杂的、高度优化的实现,同时保持源代码和ABI兼容性。

背景 OS X 10.7 和 iOS 5 引入了自动引用计数(ARC),通过消除样板代码并减少引用计数错误(泄漏和超释放)的表面积,提高了Objective-C程序员的生产效率。

在ARC之前,-[NSObject retain]、-[NSObject release] [1]和-[NSObject autorelease] [2]方法是管理对象引用计数的唯一接口。直到OS X 10.8和iOS 6之前,NSObject的实现是Foundation的一部分,而不是Objective-C运行时的一部分。

ARC的设计者确定了一个关键要求,以提高该功能成功的可能性,从Apple试图向Objective-C添加垃圾回收的失败尝试中吸取教训:自动引用计数必须在同一进程中与手动引用计数透明地互操作,而不需要重新编译现有代码(例如,第三方二进制库)。

在macOS的早期版本中,一些对象重写了引用计数方法[3],以使用自己的实现,通常是出于性能原因。ARC必须支持与这些自定义引用计数实现的透明互操作,以满足上述要求。

入口点

引用计数操作有两个接口:长期存在的NSObject API和由ARC使用的编译器私有API,两者都调用一个核心实现。以下两个小节将分别检查每个接口的保留实现,下一节将讨论核心实现。

NSObject -[NSObject retain]的实现[4]是微不足道的,它只是调用_objc_rootRetain来保留self。

C 复制代码
// runtime/NSObject.mm 第 2502-2504 行

(id)retain {
    return _objc_rootRetain(self);
}
  • 术语"root"表示对象类层次结构中的根类收到了保留消息。因此,类没有覆盖-retain或覆盖调用了超类方法,所以保留操作保证使用运行时的实现。(正如我们将在下面的小节中看到的,不是所有的入口点都有这个保证。)

接下来,_objc_rootRetain函数,这也是微不足道的,调用objc_object::rootRetain()。

C 复制代码
// runtime/NSObject.mm 第 1875-1881 行

id _objc_rootRetain(id obj) {
    ASSERT(obj);
    return obj->rootRetain();
}

这个函数的存在是一个历史遗留物。在第一个ARC实现中,这个函数是保留实现,但是在随后的版本中的各种重构中,它仍然存在。这个函数的唯一其他调用者是遗留的Object类,它是用Objective-C++实现的,因此可以直接调用objc_object::rootRetain()。

最后,objc_object::rootRetain()调用rootRetain的一个重载版本。

C 复制代码
// runtime/objc-object.h 第 607-611 行

id objc_object::rootRetain() {
    return rootRetain(false, RRVariant::Fast);
}

tryRetain 启用对加载弱引用的支持[5]。该参数为false,因为弱引用无法执行此代码路径。 (运行时必须首先从弱引用加载对象,然后对象才能接收消息,而通过加载操作获得的对象引用是强引用。)

variant 提供了有关调用路径的上下文,使核心实现能够省略不必要的工作。通过NSObject执行的保留操作使用RRVariant::Fast,以跳过检查类是否具有自定义引用计数实现的步骤,因为通过根类执行该操作在定义上不是自定义的。

自动引用计数

启用ARC时,编译器通过一个专用于ARC的编译器私有API执行引用计数操作,作为性能优化。该API允许引用计数操作直接调用Objective-C运行时,跳过发送消息的开销。

C 复制代码
// runtime/NSObject.mm 第 1772-1777 行

id objc_retain(id obj) {
    if (_objc_isTaggedPointerOrNil(obj)) return obj;
    return obj->retain();
}

这个函数首先检查对象指针的值,并且如果它不引用堆上的对象,则立即返回。这可能发生在两种情况下:

指针为nil。向nil发送消息是合法的,因此必须支持对-[NSObject retain]的这种优化也支持nil指针。

指针是标记指针。标记指针是Objective-C运行时的实现细节,编译器无法看到,因此编译器无法消除保留操作。标记指针不参与引用计数(没有跟踪堆分配),所以不需要继续执行。

如果对象指针的值引用堆上的对象,则函数调用objc_object::retain()执行保留操作。

C 复制代码
// runtime/objc-object.h 第 589-596 行

inline id objc_object::retain() {
  ASSERT(!isTaggedPointer());
  return rootRetain(false, RRVariant::FastOrMsgSend);
}

这个函数调用核心实现(尽管在这一点上rootRetain中的root是一个误称):

tryRetain 设置为false,原因与上面在NSObject入口点中讨论的一样。

RRVariant::FastOrMsgSend 作为variant。请注意,这个函数的名称不包含术语root,因为尚未进行introspection,无论是直接的(参见下面的rootRetain)还是间接的(通过消息发送,参见上面的NSObject),所以尚不知道对象的类是否覆盖了任何引用计数方法。名称中的MsgSend部分指示核心实现在需要时执行必要的introspection,以通过消息发送执行操作。

rootRetain

objc_object::rootRetain(bool, RRVariant)函数有一些规模较大,因此我们将逐个部分进行分析。

C 复制代码
// runtime/objc-object.h 第 622 行

if (slowpath(isTaggedPointer())) return (id)this;

尽管ARC入口点检查标记指针,NSObject入口点却不会。我暂时无法立即理解为什么NSObject的实现不执行此检查,但必须在某个地方执行,而在这个运行时版本中,它在这里执行。

接下来,运行时加载对象的isa值。

C 复制代码
// runtime/objc-object.h 第 624-630 行

bool sideTableLocked = false;
bool transcribeToSideTable = false;
isa_t oldisa = LoadExclusive(&isa().bits);
isa_t newisa;

isa存储了对象的引用计数在所有现代Apple平台上。Objective-C运行时使用ARM的独占监视同步原语来管理arm64架构上的并发性,这就是LoadExclusive函数的名称。在包括arm64e在内的所有其他体系结构上,Objective-C运行时使用C11原子操作。(我不确定这里是arm64还是arm64e是异常情况,或者为什么。)

如果编译器私有API是保留操作的入口点,运行时必须检查类是否覆盖了任何引用计数方法。

C 复制代码
// runtime/objc-object.h 第 632-642 行

if (variant == RRVariant::FastOrMsgSend) {
  // These checks are only meaningful for objc_retain()
  // They are here so that we avoid a re-load of the isa.
  if (slowpath(oldisa.getDecodedClass(false)->hasCustomRR())) {
    ClearExclusive(&isa().bits);
    if (oldisa.getDecodedClass(false)->canCallSwiftRR()) {
      return swiftRetain.load(memory_order_relaxed)((id)this);
    }
    return ((id(*)(objc_object *, SEL))objc_msgSend)(this, @selector(retain));
  }
}

自定义引用计数实现很少见,所以运行时使用其slowpath()宏来提示CPU的分支预测单元,这条路径不太可能运行。getDecodedClass()返回对象的Class对象,它有一个标志,指示类是否覆盖了任何引用计数方法。这个快速检查为ARC入口点提供了支持自定义引用计数实现的必要类introspection,开销最小。

getDecodedClass()中的"decoded"一词可能是指从非指针isa中提取类对象指针。这个函数的具体细节取决于目标体系结构:

arm64_32:Apple Watch ABI使用32位指针,因此其非指针isa存储在一个表中的索引中,该表存有类对象(没有足够的位数可以使用指针值存储额外数据)。

如果isa不是指针,则函数调用classForIndex()来从表中获取类对象。

否则,如果isa是指针,则它是一个指向类对象的指针,因此函数将isa位按原样返回。

在所有其他目标体系结构上,这个函数是getClass()的别名,它通过从isa值中掩码出非指针位来返回类对象。

arm64e:如果启用了isa指针认证,则函数使用编译器计算的掩码提取类对象指针值。但是,函数会跳过认证,因为调用者为authenticate传递false。不进行认证的文档原因是认证是作为objc_msgSend的一部分进行的,因此不需要额外的认证。

否则,函数使用静态掩码定义提取类对象指针值。

如果类具有自定义引用计数实现,运行时会发送一个-retain消息给对象,以完成ARC启动的保留操作。请注意,对象随后可能会调用-[NSObject retain],但是此代码块不会再次执行,因为variant将是RRVariant::Fast。

纯Swift类(即,不是继承自NSObject的类)为了支持将纯Swift对象桥接到Objective-C,派生自SwiftObject类(仅适用于Apple平台)。由于Swift使用自己的引用计数系统,所以SwiftObject实现了引用计数方法,以支持将纯Swift对象桥接到Objective-C。为了优化这种情况,Objective-C运行时直接调用Swift运行时的swift_retain()函数[6](而不是通过发送消息保留对象)。

接着继续下一个代码块。

C 复制代码
// runtime/objc-object.h 第 644-651 行

if (slowpath(!oldisa.nonpointer)) {
  // a Class is a Class forever, so we can perform this check once
  // outside of the CAS loop
  if (oldisa.getDecodedClass(false)->isMetaClass()) {
    ClearExclusive(&isa().bits);
    return (id)this;
  }
}

类对象永远不会被释放,因此不需要引用计数。因此,如果对象是类对象,则函数返回对象本身,而不执行任何其他操作。

比较和交换循环

比较和交换循环是保留实现的核心部分。它从(重新)初始化循环的起始状态开始。

C 复制代码
// runtime/objc-object.h 第 654-655 行

do {
  transcribeToSideTable = false;
  newisa = oldisa;

它将newisa设置为当前的isa值(即oldisa),循环将更新它以反映增加的保留计数。接下来的小节将查看transcribeToSideTable的使用。

C 复制代码
// runtime/objc-object.h 第 656-660 行

if (slowpath(!newisa.nonpointer)) {
    ClearExclusive(&isa().bits);
    if (tryRetain) return sidetable_tryRetain() ? (id)this : nil;
    else return sidetable_retain(sideTableLocked);
}

首先,循环检查对象实例是否具有非指针isa。如果没有,则将保留计数记录在侧表中[7]。这个检查在循环中执行,因为如果此线程在比较和交换之前丢失了比较和交换,这可能是由于另一个线程以一种方式突变了对象,以使其不再使用非指针isa。

接下来,循环检查是否丢失了另一个比赛。

C 复制代码
// runtime/objc-object.h 第 661-673 行

// 不要检查newisa.fast_rr;我们已经调用了任何RR重写
if (slowpath(newisa.isDeallocating())) {
    ClearExclusive(&isa().bits);
    if (sideTableLocked) {
        ASSERT(variant == RRVariant::Full);
        sidetable_unlock();
    }
    if (slowpath(tryRetain)) {
        return nil;
    } else {
        return (id)this;
    }
}

在线程尝试在(至少)以下三种情况下保留对象的同时,对象可能正在进行销毁:

tryRetain为true,并且此线程在开始释放对象之前失去了加载弱对象的竞争。函数返回nil,表示无法获得强引用。在这种情况下,调用者objc_loadWeakRetained()持有弱引用侧表的锁,防止对象被释放,因此从对象指针读取isa是定义行为。

另一个线程释放了对象,导致它被销毁,通常是由于在进程同时从强非原子属性读取和写入时发生的竞争条件。这种情况下的一切都是未定义行为。函数返回self以满足-retain合同,但它将成为一个悬挂指针,几乎肯定会在线程的不久将来导致崩溃。在竞争条件中触发此代码路径是"幸运"的。实际上,通过悬挂指针读取的isa位可能会将这个函数引导到任意数量的方向,导致不可预测的效果。

在-dealloc中的逻辑导致进行保留操作(例如,-dealloc实现将self传递给清理例程,ARC编译器会发出retain/release对)。请注意,这种情况不是与上述两种情况一样的竞争条件,因为保留是在执行释放的同一线程上尝试的。然而,如果执行保留操作的函数需要对象实例在其调用范围内存活(例如,在另一个对象的强属性中存储self),这种情况可能导致未定义行为,因为当销毁完成时,指针将变为悬挂指针。

最后,我们来到实际的递增部分。

C 复制代码
// runtime/objc-object.h的674-675行

uintptr_t carry;
newisa.bits = addc(newisa.bits, RC_ONE, 0, &carry); // extra_rc++

回想一下,非指针isa是一个位字段,有三种变体。RC_ONE的值是当将位字段视为整数时表示保留计数为1的位。保留计数存储在isa的最高位中,因此如果所有保留计数位都在使用中(在下一小节中讨论),则会发生溢出或进位。如果没有溢出发生,newisa包含递增的保留计数,如果溢出发生,则准备将溢出保留计数的一半发送到侧表。

C 复制代码
// runtime/objc-object.h的691行

} while (slowpath(!StoreExclusive(&isa().bits, &oldisa.bits, newisa.bits)));

如果&isa()的值与&oldisa中的值匹配,则比较和交换操作成功,并将newisa的值写入&isa(),循环结束。

否则,&isa()的值自从这个线程将其加载到oldisa中以来发生了变化。比较和交换操作失败,并将&isa()中的新值写入&oldisa。循环继续,直到线程赢得比较和交换操作,或者另一个线程将对象状态更改为激活上面的一个返回路径。

完整变体

如果保留计数溢出了非指针isa中的位,运行时将使用侧表来存储保留计数的一部分。

C 复制代码
// runtime/objc-object.h的677-690行

if (slowpath(carry)) {
    // newisa.extra_rc++溢出
    if (variant != RRVariant::Full) {
    ClearExclusive(&isa().bits);
    return rootRetain_overflow(tryRetain);
}
// 保留计数的一半内联,准备将另一半复制到侧表中。
if (!tryRetain && !sideTableLocked) sidetable_lock();
sideTableLocked = true;
transcribeToSideTable = true;
newisa.extra_rc = RC_HALF;
newisa.has_sidetable_rc = true;
}

如果此函数调用使用Fast或FastOrMsgSend变体,则它停止保留操作的尝试,并将责任转移到rootRetain_overflow()。

C 复制代码
// runtime/objc-object.h的1372-1376行

NEVER_INLINE id objc_object::rootRetain_overflow(bool tryRetain) {
    return rootRetain(tryRetain, RRVariant::Full);
}

我猜测此函数的目的是为了在堆栈跟踪中提供一个框架,以帮助Apple工程师排除运行时中的保留崩溃问题,因为侧表锁定(使用非可重入自旋锁)的相互作用可能很难推理。

如果在Full变体中保留计数溢出,则实现会将保留计数的一半发送到侧表,并将另一半保留在非指针isa中。将保留计数分成一半是为了最小化侧表访问的数量(需要较少的CPU指令和较少的锁获取)。如果实现只发送了溢出位到侧表,溢出边界值处的引用计数操作可能会对系统产生性能影响。

C 复制代码
// runtime/objc-object.h的693-700行
    if (variant == RRVariant::Full) {
    if (slowpath(transcribeToSideTable)) {
    // 将保留计数的另一半复制到侧表中。
    sidetable_addExtraRC_nolock(RC_HALF);
    }
    if (slowpath(!tryRetain && sideTableLocked)) sidetable_unlock();
    }

在比较和交换成功之后,如果有必要,更新侧表:

返回self

在比较和交换成功之后,如果有必要,更新侧表,然后返回self。

C 复制代码
// runtime/objc-object.h的702-703行

if (slowpath(variant == RRVariant::Full && !tryRetain)) {
    sidetable_unlock();
}
return (id)this;
rootRetain_overflow()

rootRetain_overflow()实现非常简单。如果传入的tryRetain参数为true,它会返回nil,因为不能成功获取强引用。

C 复制代码
// runtime/objc-object.h的1378-1379行

id objc_object::rootRetain_overflow(bool tryRetain __unused) {
    return nil;
}

否则,它将执行与rootRetain()的完整变体相同的逻辑,但无需执行比较和交换操作。这意味着它会在对象引用计数的一半增加到侧表之前返回。

C 复制代码
// runtime/objc-object.h的1381-1385行

    return sidetable_retain(tryRetain, RC_ONE, 0, false);
}

rootRetain的完整变体将处理此调用,然后继续执行完整的逻辑。

结论

Objective-C内存管理的核心是引用计数。此文章深入探讨了引用计数操作的内部机制,涵盖了从NSObject的保留方法开始,一直到在对象的isa值上执行比较和交换操作,以及在溢出情况下将引用计数的一半复制到侧表中的情况。

理解Objective-C内存管理的内部机制可以帮助开发人员编写更健壮的代码,并更好地利用ARC等自动引用计数工具。这对于构建高性能和稳定的iOS和macOS应用程序至关重要。

相关推荐
小白学大数据2 分钟前
Snapchat API 访问:Objective-C 实现示例
开发语言·macos·objective-c
键盘敲没电9 小时前
【iOS】KVC
ios·objective-c·xcode
吾吾伊伊,野鸭惊啼9 小时前
2024最新!!!iOS高级面试题,全!(二)
ios
吾吾伊伊,野鸭惊啼9 小时前
2024最新!!!iOS高级面试题,全!(一)
ios
不会敲代码的VanGogh10 小时前
【iOS】——应用启动流程
macos·ios·objective-c·cocoa
Swift社区13 小时前
Apple 新品发布会亮点有哪些 | Swift 周报 issue 61
ios·swiftui·swift
逻辑克14 小时前
使用 MultipeerConnectivity 在 iOS 中实现近场无线数据传输
ios
dnekmihfbnmv18 小时前
好用的电容笔有哪些推荐一下?年度最值得推荐五款电容笔分享!
ios·电脑·ipad·平板
Magnetic_h2 天前
【iOS】单例模式
笔记·学习·ui·ios·单例模式·objective-c
归辞...2 天前
「iOS」——单例模式
ios·单例模式·cocoa