5 minute read

基于 objc4-950 版本。

这篇主要想回答 4 个问题:

  1. 一个 Objective-C 对象最底层到底长什么样?
  2. isa 为什么从公开头文件里的 Class,变成了 runtime 内部的 isa_t
  3. 类对象本身的结构由哪些部分组成?
  4. class_ro_tclass_rw_tclass_data_bits_t 分别负责什么?

NSObject

NSObject 在公开头文件里的结构非常简单,看起来几乎只有一个 Class 类型的 isa 成员,其他信息都被隐藏起来了。

@interface NSObject <NSObject> {
    Class isa  OBJC_ISA_AVAILABILITY;
}

这其实更像是公开头文件里的 ABI 兼容声明,用来让调试器和旧代码还能看到一个 isa。在现代 runtime 里,它已经被标记为 deprecated,真实内部实现也不是直接围绕这个公开 ivar 展开的;在实际开发中,也不应该直接去操作它,而是通过 object_getClass 之类的方法来获取信息。

接着往下看内部的真实结构。

对象结构

objc_object

在对外公开的定义里,还是使用 Class 来表示 isa,主要还是为了 ABI 兼容。

struct objc_object {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
};

进入 objc-private.h 之后,再看内部实现:

struct objc_object {
private:
    char isa_storage[sizeof(isa_t)];

    isa_t &isa() { return *reinterpret_cast<isa_t *>(isa_storage); }
    const isa_t &isa() const { return *reinterpret_cast<const isa_t *>(isa_storage); }
...省略...
    id retain();
    void release();
    id autorelease();

我这里省略了一部分代码,但如果继续往下看,就会发现 id 这个类型的定义其实是:

typedef struct objc_object *id;

实际上,id 就是指向 objc_object 的指针。

这里可以顺手解释一下 id 和其他常见类型的不同。

  • NSObject(或具体类指针): NSObject 是“静态类型为某个具体类”的对象指针;id 是“动态类型的任意对象指针”。二者在运行时都指向以 objc_object 开始的对象头,但编译期类型约束不同(id 更宽泛,可接收任何对象)。
  • Class: Class 是“指向 objc_class 的指针”,而 objc_class 继承自 objc_object,说明“类对象也是对象”;id 则用于表示任意实例对象(也可指向类对象,但习惯上 Class 更明确)。
  • void: void 只是原始地址,没有对象语义;id 有强约定的对象头结构,运行时 API 基于它的 isa、位域标志、引用计数等执行操作。

通过这些定义,我们大概可以先得到一个直观认识:从对象模型的角度来说,除了少数特殊情况(比如 Tagged Pointer),大多数 Objective-C 对象最终都可以看成是“以 objc_object 为对象头”的一块内存结构。而为了支持非指针 isa、内联引用计数和 ptrauth,runtime 又把这部分封装成了 isa_t

这里可以先下一个简单结论:

公开头将其声明为 Class 以维持 ABI 稳定;内部实现用封装过的 isa_t 以支持非指针 isa、内联 RC 与 ptrauth。Tagged Pointer 是对象模型的一个特例,不是常规“结构体对象”。

isa_t

isa_t 是一个 union 结构,里面同时提供了整字 bits、私有的 cls,以及平台相关的位域定义。放在 64 位平台上看,它本质上就是 8 字节、64 位的一段数据,只不过 runtime 根据不同场景给了它不同的访问方式。

这里的 bits 可以用来整体读写整段内存,而位域只能操作各自对应的那几位。cls 被放在私有区域里,不能随便直接读写,必须通过 setClass/getClass 之类的方法来处理;这里面一个很重要的原因就是 ptrauth 需要结合对象地址来做签名和认证。

在非指针 isa 的情况下,位域里的 shiftcls/indexcls 用来保存类信息,extra_rc 用来管理内联引用计数,has_sidetable_rc 则表示这个对象是否还有一部分引用计数放在 SideTable 里。

cls 放成私有,本质上也是为了防止绕过 ptrauth 的这套流程,强制你通过受控接口来访问。

下面是 isa_t 的部分源码,其他架构和模拟器相关的定义我去掉了。

#include "isa.h"

union isa_t {
    isa_t() { }
    isa_t(uintptr_t value) : bits(value) { }
    uintptr_t bits;
private:
    Class cls;  
public:
#if defined(ISA_BITFIELD)
    struct {
        ISA_BITFIELD;  // defined in isa.h
    };
...省略
# if __arm64__
#     define ISA_MASK        0x0000000ffffffff8ULL
#     define ISA_MAGIC_MASK  0x000003f000000001ULL
#     define ISA_MAGIC_VALUE 0x000001a000000001ULL
#     define ISA_HAS_CXX_DTOR_BIT 1
#     define ISA_BITFIELD                                                      \
        uintptr_t nonpointer        : 1;    /* 非指针 isa 标志:1=编码(packed/indexed),0=原始指针 */ \
        uintptr_t has_assoc         : 1;    /* 对象存在或曾存在关联对象 */        \
        uintptr_t has_cxx_dtor      : 1;    /* 类具有 C++/ObjC 析构 */            \
        uintptr_t shiftcls          : 33;   /* 类指针>>3(MACH_VM_MAX_ADDRESS 0x1000000000) */ \
        uintptr_t magic             : 6;    /* 与 ISA_MAGIC_MASK/ISA_MAGIC_VALUE 比较,用于识别/校验 packed-isa;非“初始化完成”标志 */ \
        uintptr_t weakly_referenced : 1;    /* 对象存在或曾存在弱引用 */          \
        uintptr_t unused            : 1;    /* 保留位 */                           \
        uintptr_t has_sidetable_rc  : 1;    /* 引用计数在侧表中扩展存储 */         \
        uintptr_t extra_rc          : 19    /* 内联引用计数增量;见 RC_ONE/RC_HALF */
#     define ISA_HAS_INLINE_RC    1
#     define RC_HAS_SIDETABLE_BIT 44
#     define RC_ONE_BIT           (RC_HAS_SIDETABLE_BIT+1)
#     define RC_ONE               (1ULL<<RC_ONE_BIT)
#     define RC_HALF              (1ULL<<18)
#   endif

class 结构

前面讲的是“对象头”,这里开始看“类对象本身”的结构。

objc_class 继承自 objc_object,这也说明一句我们经常会说的话在 runtime 结构上是成立的:类对象本身也是对象

里面最重要的 3 个成员变量是:

  • superclass: 指向父类的 Class 指针,用于 isKindOfClass/isSubclassOfClass 等向上链,与父类相关的操作使用它;
  • cache: 方法缓存,用于加速消息派发,与缓存相关的方法通过 cache_t 操作,消息发送先查缓存再查方法列表与父类链。
  • bits: 封装了指针与快速标志的 class_data_bits_t,使用位打包保存“数据指针与快速标志”。这是 objc_class 的核心指针/标志存储。与 ro/rw 视图、快速标志相关的方法才主要通过 bits.
struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // formerly cache pointer and vtable
    class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags

    Class getSuperclass() const {
    ...省略
    }
    class_rw_t *data() const {
        // 取运行时可变视图(class_rw_t*),含 ptrauth 验证/剥离,并用 FAST_DATA_MASK 掩码指针
        return bits.data();
    }
    void setData(class_rw_t *newData) {
        // 设置运行时可变视图指针(class_rw_t*)
        bits.setData(newData);
    }

    const class_ro_t *safe_ro() const {
        // 并发安全地获取只读视图(class_ro_t*),当 bits 指向 rw 时自动跳转 rw->ro
        return bits.safe_ro();
    }
}


关于 bits 可以单独细说一下:

bits 是一个封装类型 class_data_bits_t(不是裸的 uintptr_t),用位打包的方式存储“数据指针 + 快速标志”。其中指针部分可以指向 class_rw_tclass_ro_t,标志部分则会包含一系列 FAST_* 位,比如 FAST_HAS_DEFAULT_RRFAST_IS_SWIFT_STABLE/LEGACY 等。

class_rw_t 和 class_ro_t

class_data_bits_t 紧密相关的还有两个很重要的结构:class_rw_tclass_ro_t。很多我们熟悉的 method listprotocol listproperty list,最终都和它们有关。

先看 class_ro_t

struct class_ro_t {
    // 编译期只读标志位(如 RO_META、RO_HAS_SWIFT_INITIALIZER 等)
    uint32_t flags;
    // 本类实例变量区起始偏移(对象头之后,用于布局/拷贝扫描)
    uint32_t instanceStart;
    // 实例总大小(包含父类与本类的 ivar)
    uint32_t instanceSize;
    // 非元类:ivarLayout 为强引用布局位图;元类:nonMetaclass 指向对应普通类
    union {
        const uint8_t * ivarLayout;
        Class nonMetaclass;
    };

    // 类名指针(显式原子),保证并发读取的一致性
    explicit_atomic<const char *> name;
    // 基础方法列表(PointerUnion:单列表或相对列表数组;含 ptrauth)
    objc::PointerUnion<method_list_t, relative_list_list_t<method_list_t>, method_list_t::Ptrauth, method_list_t::Ptrauth> baseMethods;
    // 基础协议列表(PointerUnion:单列表或相对列表数组;含 ptrauth)
    objc::PointerUnion<protocol_list_t, relative_list_list_t<protocol_list_t>, PtrauthRaw, PtrauthRaw> baseProtocols;
    // 本类声明的 ivar 列表(不包含父类)
    const ivar_list_t * ivars;

    // 弱引用 ivar 布局位图(用于拷贝时弱引用修复)
    const uint8_t * weakIvarLayout;
    // 基础属性列表(PointerUnion:单列表或相对列表数组;含 ptrauth)
    objc::PointerUnion<property_list_t, relative_list_list_t<property_list_t>, PtrauthRaw, PtrauthRaw> baseProperties;
...省略

在程序刚编译出来的时候,class_data_bits_t 这一侧主要对应的是 class_ro_t 这类只读视图。它本身是不可变的,通常放在 Mach-O 的常量区里,也会结合 ptrauth 做保护。等进入运行时阶段之后,runtime 才会在 realize 过程中逐步建立 class_rw_t 这套可写视图。

那接着看 class_rw_t

struct class_rw_ext_t {
    DECLARE_AUTHED_PTR_TEMPLATE(class_ro_t)
    // 指向只读 ro 的已签名指针(ptrauth),描述编译期常量视图
    class_ro_t_authed_ptr<const class_ro_t> ro;
    // 运行时聚合后的方法数组(包含类别/动态添加)
    method_array_t methods;
    // 运行时聚合后的属性数组
    property_array_t properties;
    // 运行时聚合后的协议数组
    protocol_array_t protocols;
    // Swift 类名的反混淆缓存(demangle 后的可读名)
    const char *demangledName;
    // 类版本号(class_getVersion/class_setVersion)
    uint32_t version;
};

struct class_rw_t {
    // Be warned that Symbolication knows the layout of this structure.
    // 运行时可变标志位(如构造/已实现/扩展等状态)
    uint32_t flags;
    // 已知类快速路径见证值:缓存数据段范围索引,用于 isKnownClass 快速判断
    uint16_t witness;
#if SUPPORT_INDEXED_ISA
    // 用于 indexed ISA 的类索引(平台支持时存在)
    uint16_t index;
#endif

    // 指向 ro 或 rw 扩展的原子指针(PointerUnion 封装,含签名与内存序)
    explicit_atomic<uintptr_t> ro_or_rw_ext;

    // 父类的第一直接子类指针(维护子类链表头)
    Class firstSubclass;
    // 同一父类下的下一个兄弟类(维护兄弟链表)
    Class nextSiblingClass;
    ...省略
}

这里的 class_rw_t 可以理解成类在运行时的“可写头部与管理者”,负责状态位、层级链表,以及 ro/rw 扩展指针的管理;而 class_rw_ext_t 则更像一个“按需分配的扩展载体”,在需要时承载聚合后的方法/属性/协议数组,以及 Swift 反混淆名、版本号之类的附加数据。

这里可以举几个比较典型的例子:

  1. 添加 Category :当包含类别的镜像被 dyld 映射并交给 ObjC 运行时,运行时会“附加类别”的方法/属性/协议到目标类上,需聚合这些增量数据到类的 rw 扩展
  2. 在代码里动态新增或替换方法:调用 class_addMethod/class_replaceMethod/class_addMethodsBulk/class_replaceMethodsBulk,会生成新的方法列表并附加到类,触发 rw 扩展分配
  3. 动态添加属性:调用 class_addProperty/class_replaceProperty,会构建属性列表并附加,触发 rw 扩展分配

如果读过老版本代码,应该会对 realizeClass 这条链比较熟悉。新版本在这部分做了比较明显的拆分和优化:

realize 时线程安全的读取 ro,常规类分配 rw 并将 ro 关联到 rw,然后把类的数据指针从 ro 切换到 rw 并标记 RW_REALIZED;随后完成缓存、ISA 索引、父类/元类实现、ivar 对齐与方法/类别安装;Swift 类会在释放锁后由 Swift 运行时协同完成 realize

void objc_class::realizeIfNeeded() {
    if (fastpath(isRealized()))
        return;

    mutex_locker_t lock(runtimeLock);
    realizeIfNeeded_nolock();
}
void objc_class::realizeIfNeeded_nolock() {
    lockdebug::assert_locked(&runtimeLock.get());
    realizeAndInitializeIfNeeded_locked(nil, (Class)this, false);
}

之后,将“实现 + 可选初始化”合并为一个锁内流程

static Class
realizeAndInitializeIfNeeded_locked(id inst, Class cls, bool initialize)
{
    lockdebug::assert_locked(&runtimeLock.get());
    if (slowpath(!cls->isRealized())) {
        cls = realizeClassMaybeSwiftAndLeaveLocked(cls, runtimeLock);
    }
    if (!cls || !cls->ISA())
        return nil;

    if (slowpath(initialize && !cls->isInitialized())) {
        cls = initializeAndLeaveLocked(cls, inst, runtimeLock);
    }
    return cls;
}

另外,如果遇到的是 Swift 类,还需要单独处理:

static Class
realizeClassMaybeSwiftMaybeRelock(Class cls, mutex_t& lock, bool leaveLocked)
{
    lockdebug::assert_locked(&lock);

    if (!cls->isSwiftStable_ButAllowLegacyForNow()) {
        // Non-Swift class. Realize it now with the lock still held.
        // fixme wrong in the future for objc subclasses of swift classes
        cls = realizeClassWithoutSwift(cls, nil);
        if (!leaveLocked) lock.unlock();
    } else {
        // Swift class. We need to drop locks and call the Swift
        // runtime to initialize it.
        lock.unlock();
        cls = realizeSwiftClass(cls);
        ASSERT(cls->isRealized());    // callback must have provoked realization
        if (leaveLocked) lock.lock();
    }

    return cls;
}

总结

最后再试着从整体上总结一下:objc4-950 相比老版本,在这部分到底做了什么优化。

在老版本里,调用 [MyClass alloc],实际流程大概是:

  1. objc_msgSend(MyClass, @selector(alloc))
  2. 查找缓存 :在元类(MetaClass)的 Cache 中查找 alloc
  3. 缓存未命中 :如果首次调用,缓存大概率为空,进入 lookUpImpOrForward
  4. 方法列表搜索 :遍历方法列表。
  5. 动态解析/转发 :如果还没找到,可能触发 resolveClassMethod 等。
  6. 最终调用 :找到 +allocIMP(通常是 _objc_rootAlloc ),然后执行。
  7. 再次消息发送 : _objc_rootAlloc 内部通常还会调用 [self allocWithZone:nil] ,这又是一次完整的 objc_msgSend 流程。

这个过程会涉及不少内存访问、条件判断和跳转。对于 alloc 这种极高频操作来说,这些开销累积起来其实很可观。

到了 objc4-950,编译器和运行时会配合把 [MyClass alloc] 转成 objc_alloc(MyClass)。内部直接进入 callAlloc;如果类没有自定义 alloc/allocWithZone,那就直接调用 _objc_rootAllocWithZone

static ALWAYS_INLINE id
callAlloc(Class cls, bool checkNil, bool allocWithZone=false)
{
    if (slowpath(checkNil && !cls)) return nil;
    
    // 核心优化判断:如果类没有自定义 alloc/allocWithZone 实现
    if (fastpath(!cls->ISA()->hasCustomAWZ())) {
        // 直接调用底层 C 函数,完全跳过 objc_msgSend
        return _objc_rootAllocWithZone(cls, nil);
    }

    // 慢路径:如果有自定义实现,只能老老实实走消息发送
    if (allocWithZone) {
        return ((id(*)(id, SEL, struct _NSZone *))objc_msgSend)(cls, @selector(allocWithZone:), nil);
    }
    return ((id(*)(id, SEL))objc_msgSend)(cls, @selector(alloc));
}
------------
id _objc_rootAllocWithZone(Class cls, objc_zone_t)
{
    // allocWithZone under __OBJC2__ ignores the zone parameter
    // 1. 确保类已实现(如果没实现,这里会触发 realize)
    cls->realizeIfNeeded();
    // 2. 直接分配内存,创建实例
    return _class_createInstance_realized(cls, 0, OBJECT_CONSTRUCT_CALL_BADALLOC);
}

这些结构和流程上的优化,最终会直接体现在 App 的运行速度上:

  1. 按照旧流程,alloc -> allocWithZone: 至少会带来两次 objc_msgSend,也就意味着两次缓存查询、两次方法查找的成本;新版本把这部分尽量压缩掉了。
  2. App 冷启动过程中,大量类都是第一次被创建和使用。如果都走 objc_msgSend,就会触发很多方法缓存填充和方法列表查找;而新版本更多会直接落到 _objc_rootAllocWithZone,先做一次 realizeIfNeeded(如果还没 realize),后面基本就是纯内存分配。
  3. fastpath(!cls->ISA()->hasCustomAWZ()) 也利用了 CPU 的分支预测。绝大多数类都不会重写 allocallocWithZone,所以这条分支天然就是一个非常适合优化的热点路径。

总之,新版本把原本复杂的一段动态消息分发流程,在绝大多数情况下优化成了一次更直接的 函数调用。对于 alloc 这种在 App 生命周期里会被调用成千上万次的操作,这种路径上的优化累积起来是非常可观的。

疑问

为什么只优化 alloc 而没有优化 init ?

答案也不复杂,我们先看一个最常见的例子:

id obj = [[MyClass alloc] init];

在这里:

  • [MyClass alloc]:可以走快路径,不一定需要填充消息缓存。
  • [obj init]:这是一个普通的实例方法调用,仍然必须经过 objc_msgSend。第一次调用时,也会触发查找并把 init 填进缓存。
id objc_alloc_init(Class cls)
{
    // callAlloc: 尝试走 fast path 分配内存(优化部分)
    // [ ... init]: 对结果发送 init 消息(未优化部分,走标准 objc_msgSend)
    return [callAlloc(cls, true/*checkNil*/, false/*allocWithZone*/) init];
}

我们可以这么理解:

alloc 的任务很单一:大多数情况下就是“开一块内存,把对象头初始化好”。这件事非常适合由 runtime 做统一优化。

init 的任务则完全不同。开发里几乎人人都会重写 init 去做自己的初始化逻辑,系统无法提前知道它会干什么,所以这里依然必须保留动态消息分发机制。