【iOS】源码阅读(四)——isa与类关联的原理
文章目录
- 前言
- OC对象本质探索
- clang
- 探索对象本质
- objc_setProperty源码探索
- cls与类的关联原理
- 为什么说bits与cls为互斥关系
- isa的类型isa_t
- 原理探索
- isa与类的关联
- 总结
前言
本篇文章主要是笔者在学习和理解类与isa的关联关系时所写的笔记。
OC对象本质探索
在学习和理解类与isa的关联关系之前,我们先来探寻一下OC对象的本质。探寻其本质,我们首先要了解一个编译器:clang。
clang
Clang 是一个开源的 C/C++/Objective-C/Objective-C++ 编译器,属于 LLVM(Low Level Virtual Machine) 项目的一部分。它被设计为 GCC(GNU Compiler Collection) 的替代品,提供更快的编译速度、更低的内存占用、更清晰的错误提示以及更好的兼容性。
这个编译器是xcode自带的,主要是用于底层编译,将一些文件``输出成c++文件,例如main.m 输出成main.cpp,其目的是为了更好的观察底层的一些结构 及 实现的逻辑,方便理解底层原理。
探索对象本质
在main文件中自定义一个GGObject类,并声明一个name属性。
通过终端,利用clang将main.m文件编译成main.cpp,这种转换指令有很多,笔者这里使用的是第一个:
//1、将 main.m 编译成 main.cpp
clang -rewrite-objc main.m -o main.cpp//2、将 ViewController.m 编译成 ViewController.cpp
clang -rewrite-objc -fobjc-arc -fobjc-runtime=ios-13.0.0 -isysroot / /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.7.sdk ViewController.m//以下两种方式是通过指定架构模式的命令行,使用xcode工具 xcrun
//3、模拟器文件编译
- xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp //4、真机文件编译
- xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o main- arm64.cpp
然后我们打开编译好的main.cpp文件,找到关于GGObject的定义,我们能发现GGObject在底层会被编译成struct结构体。
GGObject的底层编译:
struct GGObject_IMPL {struct NSObject_IMPL NSObject_IVARS;//定义一个自定义类 GGObject 的底层实现(通常对应 .m 文件中的类扩展);_IMPL 后缀表明这是类的私有实现结构体,用于组织实例变量//嵌入 NSObject 的实例变量结构体(通常是私有定义,如 isa 指针);NSObject_IVARS 表示这是 NSObject 的实例变量集合。NSString *_name;
};
从上述代码中我们能看出来,GGObject_IMPL中的第一个属性是NSObject_IMPL结构体,从名称上来看,这貌似是继承自NSObject的。我们找到关于NSObject的定义及底层编译来进一步探索。
NSObject的定义:
@interface NSObject <NSObject> { // 声明 NSObject 的类接口
#pragma clang diagnostic push // 保存当前编译器警告状态
#pragma clang diagnostic ignored "-Wobjc-interface-ivars" // 禁用 "接口中声明实例变量" 警告Class isa OBJC_ISA_AVAILABILITY; // 声明一个名为 isa 的实例变量
#pragma clang diagnostic pop // 恢复之前的警告状态
}
NSObject的底层编译:
由上述,我们可以知道,GGObject_IMPL中的第一个属性其实就是 isa,是继承自NSObject,属于伪继承,伪继承的方式是直接将NSObject结构体定义为LGPerson中的第一个属性,意味着GGObject 拥有 NSObject中的所有成员变量。GGObject中的第一个属性 NSObject_IVARS 等效于 NSObject中的 isa。
GGObject类的完整定义如下:
通过上述探索,我们了解了OC对象的本质,同时我们产生了一个疑问:为什么NSObject_IMPL中的isa的类型为class?
在之前探索alloc实现过程时,提及过alloc方法的核心之一的initInstanceIsa方法,通过查看这个方法的源码实现,我们发现,在初始化isa指针时,是通过isa_t类型初始化的,而在NSObject定义中isa的类型是Class,其根本原因是由于isa 对外反馈的是类信息,为了让开发人员更加清晰明确,需要在isa返回时做了一个类型强制转换。这里在源码中代码如下:
另外,关于class类型的本质我们可以在objc.h文件中找到对应的定义:
typedef struct objc_class *Class;
这里我们可以知道,它是指向objc_class结构体的指针,存储类的元数据(即方法列表、属性列表、协议等)。
而当isa是class类型时,它直接指向对象的类:
Class cls = object_getClass(object);
总结
由以上我们可以得出:
- OC对象的本质就是结构体。
- GGObject中的isa是继承于NSObject中的isa。
objc_setProperty源码探索
在前面探索GGObject源码的时候,我们还发现了我们声明的name属性对应的set和get方法在底层的代码实现,其中set方法的实现依赖于runtime中的objc_setProperty:
我们通过xcode自带的搜索功能可以进一步对objc_setProperty源码进行探索:
然后我们通过command+点击,可以进入reallySetProperty的实现代码:
static inline void reallySetProperty(id self, //对象实例SEL _cmd, //setter方法名(未直接使用)id newValue, //新属性值ptrdiff_t offset, //属性在对象内存中的偏移量bool atomic, //是否为原子操作(决定是否加锁)bool copy, //是否用copy修饰bool mutableCopy //是否用mutableCopy修饰//copy和mutableCopy决定是否需要对newValue执行拷贝
)
{if (offset == 0) { //offset == 0 表示操作的是对象的 isa 指针(isa 是对象的第一个成员,偏移量为 0)object_setClass(self, newValue); //调用 object_setClass 动态修改对象的类(返回值为对象原来的类)//obj:要修改类的对象(实例)//cls:目标类(必须是有效的 objc_class)//这里将对象 obj 的 isa 指针从原来的类改为 cls;不修改对象的内存布局,仅改变类信息,不影响实例变量(ivar)的存储return;}id oldValue;//计算属性内存地址id *slot = (id*) ((char*)self + offset);//slot:指向属性在对象内存中的地址//(char*)self:将对象指针转为字节指针(便于偏移计算)//+ offset:跳转到属性的实际存储位置//处理copy/mutableCopy修饰if (copy) {newValue = [newValue copyWithZone:nil];} else if (mutableCopy) {newValue = [newValue mutableCopyWithZone:nil];} else {if (*slot == newValue) return;newValue = objc_retain(newValue);}//非原子性处理(不加锁,直接赋值,这样会更高效)if (!atomic) {oldValue = *slot; //保存旧值*slot = newValue; //直接赋值} else {//原子性处理(通过自旋锁,即spinlock_t保证线程安全)spinlock_t& slotlock = PropertyLocks[slot]; //PropertyLocks 是一个全局锁表,每个属性地址对应一个锁slotlock.lock(); //加锁oldValue = *slot;*slot = newValue; //赋值slotlock.unlock(); //解锁}//释放酒值(调用 objc_release 减少旧值的引用计数)objc_release(oldValue);
}
所以通过上述代码,我们可以总结出其调用链:
- 编译器生成 setName: 方法,调用 objc_setProperty。
- objc_setProperty 内部调用 reallySetProperty。
- reallySetProperty 根据修饰符(copy/atomic)处理赋值。
总结
- objc_setProperty方法是 Objective-C 运行时提供的 统一属性设置入口,本质上是一个 适配层(Adapter),负责桥接编译器生成的属性设值方法(Setter)与底层内存管理操作。(即适用于 关联上层的set方法 以及 底层的set方法,其本质就是一个接口)
- 这么设计的原因是,为了进行接口统一化和隔离变化。
- 基于上述原因,苹果采用了适配器设计模式(即将底层接口适配为客户端需要的接口),对外提供一个接口,供上层的set方法使用,对内调用底层的set方法,使其相互不受影响,即无论上层怎么变,下层都是不变的,或者下层的变化也无法影响上层,主要是达到上下层接口隔离的目的
(1) 接口统一化
问题:编译器为每个 @property 生成独立的 Setter 方法,若直接调用底层内存操作(如 reallySetProperty),会导致:
二进制体积膨胀(每个 Setter 都包含重复的内存管理代码)。
难以集中优化(如原子性、拷贝逻辑的处理)。
解决方案:通过 objc_setProperty 收敛所有属性设值逻辑,成为唯一入口。
(2) 隔离变化
上层(编译器生成的 Setter):只需关注如何传递参数(如属性偏移量、修饰符标志)。
下层(内存管理):只需处理标准化后的参数(如 atomic/copy 标志),无需关心调用来源。
上述代码中,我们看到关于分别用copy和mutableCopy修饰的底层逻辑,笔者在这里再根据之前所学进行一下回顾:
copy 修饰符
当属性声明为 copy 时,reallySetProperty 会调用 [newValue copyWithZone:nil]。生成的是 不可变副本(即使 newValue 是可变对象)。
eg:
@property (nonatomic, copy) NSString *name;
如果传入 NSMutableString,会被转为 NSString:
NSMutableString *mutableName = [NSMutableString stringWithString:@"Alice"];
obj.name = mutableName; // 实际存储的是不可变的 @"Alice" 副本
[mutableName appendString:@"Bob"]; // 不影响 obj.name(仍是 @"Alice")
当我们需要确保属性值不被外部修改时(如 NSString、NSArray、NSDictionary 等),可以使用copy来防止传入可变对象后,外部修改导致属性值意外变化。
mutableCopy 修饰符
当属性声明为 mutableCopy 时,reallySetProperty 会调用 [newValue mutableCopyWithZone:nil]。生成的是可变副本(即使newValue是不可变对象)。
eg:
@property (nonatomic, mutableCopy) NSMutableArray *items;
如果传入 NSArray,会被转为 NSMutableArray:
NSArray *immutableItems = @[@"A", @"B"];
obj.items = immutableItems; // 存储的是可变的副本
[obj.items addObject:@"C"]; // 允许修改
当我们需要属性值可被内部修改时(较少使用,通常直接声明为 NSMutableArray 类型)可以使用mutableCopy。
默认行为(无 copy/mutableCopy)
若未指定 copy 或 mutableCopy,则直接 保留(retain) 新值:
newValue = objc_retain(newValue);
新旧值相同时优化:如果 *slot == newValue,直接返回,避免无谓操作。
在刚刚 reallySetProperty 的源码中:
if (copy) {newValue = [newValue copyWithZone:nil]; // 生成不可变副本
} else if (mutableCopy) {newValue = [newValue mutableCopyWithZone:nil]; // 生成可变副本
} else {newValue = objc_retain(newValue); // 默认 retain
}
其中的copyWithZone: 和 mutableCopyWithZone:是 NSCopying 和 NSMutableCopying 协议的方法,由对象类实现。
eg:NSString的copy返回自身(不可变),mutableCopy返回NSMutableString。
copy和mutableCopy的内存管理规则
实际应用
- 优先使用copy修饰不可变对象:
@property (nonatomic, copy) NSString *text;
@property (nonatomic, copy) NSArray *items;
避免传入 NSMutableString/NSMutableArray 后,原属性值被外部修改。
- 谨慎使用mutableCopy修饰不可变对象:
除非明确需要可变副本,否则直接用 NSMutableArray 等类型。 - 自定义类支持copy:
实现NSCopying协议:
@interface Person : NSObject <NSCopying>
@end@implementation Person
- (id)copyWithZone:(NSZone *)zone {Person *copy = [[Person alloc] init];copy.name = self.name; // 深拷贝或浅拷贝按需实现return copy;
}
@end
了解到这里经常会涉及到一个经典问题:为什么NSString要用copy修饰?
这是为了防止可变字符串篡改:
NSMutableString *mutableName = [NSMutableString stringWithString:@"Alice"];
self.name = mutableName; // 若用 strong,mutableName 修改会影响 self.name
[mutableName appendString:@"Bob"];
// 若 name 是 copy,self.name 仍是 @"Alice";若 strong,会变成 @"AliceBob"
cls与类的关联原理
在这里我们有两个疑问:为什么isa的类型是isa_t ?initInstanceIsa是如何将cls与isa关联的?
首先,我们可以在objc-private.h文件中找到关于isa的类型isa_t的定义源码:
#include "isa.h"union isa_t { //联合体//构造函数isa_t() { }isa_t(uintptr_t value) : bits(value) { }uintptr_t bits;//存储完整的 isa 值(64 位整数)//私有成员:类指针(通过方法访问)
private:// Accessing the class requires custom ptrauth operations, so// force clients to go through setClass/getClass by making this// private.Class cls;//将 cls 设为私有进行直接访问限制,强制外部代码通过 setClass/getClass 方法访问。//这里的bits与cls是互斥关系public:
#if defined(ISA_BITFIELD)//条件编译:位域结构(由isa.h定义)struct {ISA_BITFIELD; // defined in isa.h};//內联引用计数相关方法(仅当ISA_HAS_INLINE_RC启用时)
#if ISA_HAS_INLINE_RCbool isDeallocating() const {return extra_rc == 0 && has_sidetable_rc == 0;}void setDeallocating() {extra_rc = 0;has_sidetable_rc = 0;}
#endif // ISA_HAS_INLINE_RC#endif//公共方法:安全访问类指针void setClass(Class cls, objc_object *obj);Class getClass(bool authenticated) const;Class getDecodedClass(bool authenti
为什么说bits与cls为互斥关系
这里就涉及到Union的内存共享特性:
内存复用:union 的所有成员共享同一块内存,大小由最大的成员决定。
互斥性:同一时间只能使用一个成员。
eg:我们现在对Union的内存复用和互斥性进行代码实感
union Example {int a;float b;
};
Example u;
u.a = 42; // 此时 u.b 的值是未定义的
u.b = 3.14; // 此时 u.a 的值被覆盖
NSLog(@"u.a = %d\nu.b = %f", u.a, u.b);
这里的输出如下:
为什么会出现这种情况呢?我们来逐行解析一下以上代码:
- 首先我们给u.a赋值为42,这是内存中存储的是整数值42的二进制表示。
- 然后给u.b赋值为3.14,这时同一块内存被浮点数3.14的二进制表示覆盖。
u.a显示为1078523331,这是因为当我们给u.b赋值为3.14时,这块内存被重新解释为整数,其整数值就是1078523331,而为什么这里3.14对应的整数值是1078523331,这里需要大概了解一下IEEE 754浮点数表示法:
我们就以3.14为例:
符号位:0(正数)
指数:128(二进制为10000000,实际指数为128-127=1)
尾数:100100011110101110000101(3.14的二进制,去掉前导1之后)
将这些二进制位组合起来得到的32位表示是:
0 10000000 100100011110101110000101
将其解释为无符号整数,得到的就是1078523331。
小结
联合体中所有成员共享相同的内存地址,所以当你通过一个成员修改内存内容时,其他所有成员的值都会被改变,因为它们查看的是同一块内存。
isa的类型isa_t
通过上面isa的类型isa_t的定义源码,我们可以得知,其是通过联合体定义的。为什么要通过联合体而非结构体进行定义呢?
这主要是为了内存优化,使用联合体定义来共享内存空间。
联合体特性:所有成员共享同一块内存,大小由最大成员决定。
isa_t 的需求:需要同时存储 类指针(Class)和 位域(如引用计数、标记位等),但无需同时使用它们。
内存节省:若用结构体(struct),每个成员独立占用内存;而联合体复用内存,避免冗余。
另外,我们从isa_t的定义中还可以知道,这里提供了两个成员,即 cls 和 bits ,由于这两者是互斥的,这就意味着,当初始化 isa 指针时,有两种初始化方式:
- 通过 cls 初始化(设置类指针):
调用 setClass 方法,修改 bits 中的类指针字段(高位),保留其他位域(如引用计数)的原始值。仅修改 bits 中类指针对应的位,其他位域(如 extra_rc)未被初始化,保持未定义的垃圾值。
初始化结果:
cls(通过 getClass 方法解密后)是有效的类指针。
bits 的其他字段(如引用计数)无默认值(可能是随机值)。
- 通过 bits 初始化(直接操作内存位域):
直接赋值 bits,覆盖所有字段(包括类指针和位域)。如果赋值的 bits 包含有效的类指针(加密后)和位域,则 cls 会同步更新;如果 bits 未正确设置类指针,则 cls 可能为无效值。
初始化结果:
bits 的所有字段(包括类指针)被显式设置。
cls 的值由 bits 中对应的位决定:如果 bits 包含有效类指针,则 cls 有效;否则为无效值(非默认值)。
所以,我们通过cls初始化,bits无默认值;通过bits进行初始化,cls有默认值。
上面提到两种初始化方法时,一直在提到位域,这到底是什么,在isa_t定义源码中提供了一个结构体定义的位域内容:
public:
#if defined(ISA_BITFIELD)//条件编译:位域结构(由isa.h定义)struct {ISA_BITFIELD; // defined in isa.h};
其中,这里的结构体成员ISA_BITFIELD是一个宏定义,我们具体来看一下这里面都有什么内容:
/*_arm64(对应ios移动端)*/
# 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; /*是否使用非指针格式*/ \uintptr_t has_assoc : 1; /*是否有关联对象*/ \uintptr_t has_cxx_dtor : 1; /*是否有C++析构函数*/ \uintptr_t shiftcls : 33; /*MACH_VM_MAX_ADDRESS 0x1000000000*/ /*存储类指针的核心字段(加密后)*/ \uintptr_t magic : 6; /*验证指针合法性 调试器将提取的 magic_value 与预定义的合法值(ISA_MAGIC_VALUE)比较*/ \uintptr_t weakly_referenced : 1; /*是否为弱饮用*/ \uintptr_t unused : 1; /*保留位(数据结构或协议中预留的未使用位)*/ \uintptr_t has_sidetable_rc : 1; /*是否使用弱引用表*/ \uintptr_t extra_rc : 19 /*内联引用计数(最大2^19-1)*/# elif __x86_64__ //__x86_64__(对应macos)(与上面一样)
# define ISA_MASK 0x00007ffffffffff8ULL
# define ISA_MAGIC_MASK 0x001f800000000001ULL
# define ISA_MAGIC_VALUE 0x001d800000000001ULL
# define ISA_HAS_CXX_DTOR_BIT 1
# define ISA_BITFIELD \uintptr_t nonpointer : 1; \uintptr_t has_assoc : 1; \uintptr_t has_cxx_dtor : 1; \uintptr_t shiftcls : 44; /*MACH_VM_MAX_ADDRESS 0x7fffffe00000*/ \uintptr_t magic : 6; \uintptr_t weakly_referenced : 1; \uintptr_t unused : 1; \uintptr_t has_sidetable_rc : 1; \uintptr_t extra_rc : 8 /*(最大为2^8-1)*/
(1) 公共字段(所有架构)
- nonpointer有两个值,表示自定义的类等,占1位
0:纯isa指针
1:不只是类对象地址,isa中包含了类信息、对象的引用计数等 - has_assoc标记对象是否有关联对象,占1位。
0:没有关联对象
1:有关联对象 - has_cxx_dtor标记对象是否有 C++ 析构函数(用于自动调用 dealloc),占1位
如果有析构函数,则需要做析构逻辑
如果没有,则可以更快的释放对象 - magic存储魔术字(如 0x001d800000000001ULL),用于验证 isa 指针合法性,占6位。
- weakly_referenced标记对象是否为弱引用,占1位。
- has_sidetable_rc标记是否使用弱引用表存储引用计数(当 extra_rc 溢出时启用),占1位。
(2)架构差异字段
- shiftcls(ARM64: 33 位 / x86_64: 44 位)存储加密后的类指针。
ARM64:33 位(地址空间较小,0x1000000000 是 MACH_VM_MAX_ADDRESS)。
x86_64:44 位(地址空间较大,0x7fffffe00000 是 MACH_VM_MAX_ADDRESS)。
- extra_rc(ARM64: 19 位 / x86_64: 8 位)内联存储引用计数。
ARM64:最大值为 2^19-1(524,287)。
x86_64:最大值为 2^8-1(255)。
溢出处理:当引用计数超过阈值时,迁移到全局弱引用表(has_sidetable_rc 置 1)。
原理探索
通过alloc --> _objc_rootAlloc --> callAlloc --> _objc_rootAllocWithZone --> _class_createInstanceFromZone方法路径,查找到initInstanceIsa,并进入其原理实现:
initInstanceIsa的底层实现代码如下:
inline void
objc_object::initInstanceIsa(Class cls, bool hasCxxDtor)
{ASSERT(!cls->instancesRequireRawIsa());ASSERT(hasCxxDtor == cls->hasCxxDtor());//初始化isainitIsa(cls, true, hasCxxDtor);
}
这里我们主要看一下初始化isa的底层代码:
inline void
objc_object::initIsa(Class cls, bool nonpointer, UNUSED_WITHOUT_INDEXED_ISA_AND_DTOR_BIT bool hasCxxDtor)
{ ASSERT(!isTaggedPointer()); isa_t newisa(0);//isa_t是联合体,这里创建全零的isa实例if (!nonpointer) {newisa.setClass(cls, this);//设置类指针(加密存储到shiftcls)} else {ASSERT(!DisableNonpointerIsa);ASSERT(!cls->instancesRequireRawIsa());#if SUPPORT_INDEXED_ISA //!nonpointer执行的流程,即isa通过cls定义ASSERT(cls->classArrayIndex() > 0);newisa.bits = ISA_INDEX_MAGIC_VALUE;// isa.magic is part of ISA_MAGIC_VALUE// isa.nonpointer is part of ISA_MAGIC_VALUEnewisa.has_cxx_dtor = hasCxxDtor;newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else //bits执行的流程newisa.bits = ISA_MAGIC_VALUE; //bits进行赋值// isa.magic is part of ISA_MAGIC_VALUE// isa.nonpointer is part of ISA_MAGIC_VALUE
# if ISA_HAS_CXX_DTOR_BITnewisa.has_cxx_dtor = hasCxxDtor;
# endifnewisa.setClass(cls, this);
#endif
#if ISA_HAS_INLINE_RCnewisa.extra_rc = 1;
#endif}// This write must be performed in a single store in some cases// (for example when realizing a class because other threads// may simultaneously try to use the class).// fixme use atomics here to guarantee single-store and to// guarantee memory order w.r.t. the class index table// ...but not too atomic because we don't want to hurt instantiationisa() = newisa;
}
这里初始化isa的底层代码逻辑主要分为两部分:通过 cls 初始化 isa和通过 bits 初始化 isa。
isa与类的关联
cls 与 isa 关联原理就是isa指针中的shiftcls位域中存储了类信息,其中initInstanceIsa的过程是将 calloc 指针 和当前的 类cls 关联起来。我们这里主要通过object_getClass函数来验证以上结论。
Objective-C 运行时提供了标准 API object_getClass,它会自动处理 isa 指针的解析(包括标签指针、元类层级等),返回对象的实际类对象。
我们在main文件中导入:
#import <objc/runtime.h>
#import "MyClass.h"
然后编写主函数文件,通过object_getClass方法来验证cls与isa是否关联:
int main(int argc, const char * argv[]) {@autoreleasepool {Class expectedCls = [MyClass class];//预期对象创建id obj = [[MyClass alloc] init];//创建实例Class actualCls = object_getClass(obj);//通过isa获取实例类// 输出验证结果NSLog(@"\n=== 类关联验证 ===");NSLog(@"预期类: %@", NSStringFromClass(expectedCls));NSLog(@"实际类: %@", NSStringFromClass(actualCls));// 断言验证if (expectedCls != actualCls) {NSLog(@"\033[31m❌ 验证失败:类关联错误!\033[0m");NSLog(@"预期类: %@", NSStringFromClass(expectedCls));NSLog(@"实际类: %@", NSStringFromClass(actualCls));assert(NO); // 明确触发断言} else {NSLog(@"\033[32m✅ 验证成功:类关联正确\033[0m");}}return 0;
}
运行代码,输出如下:
这证明cls 与isa已经完美关联。
总结
这篇文章主要探究了isa与类的关联相关内容,在代码调试过程中,笔者出现了很多问题,许多地方的代码调试步骤跟预期不一样,可能是因为obj源码的版本不同,文章中若有错误,还请斧正。
参考文章:iOS-底层原理 07:isa与类关联的原理