OC底层原理08:isa和类关联探索

张建 lol

前言

本文的主要目的是理解 类与isa 是如何 关联

在介绍正文之前,首先需要理解一个概念:OC对象本质 是什么?

在探索oc对象本质前,先了解一个编译器:clang

Clang

1、介绍

Clang 是⼀个由 Apple 主导编写,基于 LLVM的C/C++/Objective-C 编译器

主要是用于 底层编译,将一些文件输出成 c++ 文件,例如 main.m 输出成 main.cpp,其目的是为了更好的观察 底层 的一些 结构实现 的逻辑,方便理解底层原理。

2、 常用编译命令:

把⽬标⽂件编译成 c++ ⽂件,终端输入

clang -rewrite-objc main.m -o main.cpp

3、 xcode安装的时候顺带安装了 xcrun 命令,xcrun 命令在 clang 的基础上进⾏了
⼀些封装,要更好⽤⼀些:

xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp (模拟器)

xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m -o mainarm64.cpp (⼿机)

对象的本质

1、 第一步:自定义一个 ZJPerson 类,添加一个属性 name

2、 通过终端,利用 clangmain.m 编译成 main.cpp,有以下几种编译命令,这里使用的是第一种:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 1、将 main.m 编译成 main.cpp
clang -rewrite-objc main.m -o main.cpp

// 2、将 main.m 编译成 main.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 main.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 mainarm64.cpp

3、打开编译好的 main.cpp,找到 ZJPerson 的定义,发现 ZJPerson 在底层会被编译成 struct 结构体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 👇NSObject的定义
@interface NSObject <NSObject> {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wobjc-interface-ivars"
Class isa OBJC_ISA_AVAILABILITY;
#pragma clang diagnostic pop
}

// 👇NSObject的底层编译
struct NSObject_IMPL {
Class isa;
};

// 👇ZJPerson的底层编译
struct ZJPerson_IMPL {
// NSObject_IVARS是isa
// 结构体在c/c++可以继承的,继承自NSObject_IMPL
struct NSObject_IMPL NSObject_IVARS;
NSString *_name;
};

如下图:

  • ZJPerson 继承自 NSObject,属于 伪继承伪继承 的方式是直接将 NSObject 结构体定义为 ZJPerson 中的第一个属性,意味着 ZJPerson 拥有 NSObject 中的所有 成员变量

  • ZJPerson 中的第一个属性就是 Class isa

4、总结

  • OC对象本质 其实就是 结构体

  • ZJPerson 中的 isa继承NSObject 中的 isa

objc_setProperty

由上面可知 ZJPerson 被编译成 结构体,属性 name 被编译成对应的 setget 方法,其中 set 方法的实现是依赖 runtime API objc_setProperty 实现的。

1、我们可以通过查找 objc4 源码来查看 objc_setProperty 底层的进一步实现:

1)全局搜索 objc_setProperty,查找所在位置

2)点击进入 objc_setProperty 内部查看源码实现

3)点击 reallySetProperty 查看内部源码实现

2、总结

通过对 objc_setProperty 底层源码探索,我们可得出以下几个结论:

  • 所有set 方法最终都将会 找到 objc_setProperty LLVM中间隔离层 函数去 调用

  • objc_setProperty 用于 关联上层set 方法 和 下层reallySetProperty 的一个 接口隔离层 函数

  • 这么设计的原因是,如果上层有 很多set 方法,如果你直接调用 下层LLVM 方法,会 产生很多中间层变量,非常恶心,很难 去处理(很难

  • 那么如何区分呢,系统根据 cmd 去查找,就是说无论你 上层 怎么 变化,我下层reallySetProperty不用变化,你 下层 怎么 变化上层不会被影响

3、上层、隔离层、底层之间的关系图:

联合体和结构体

iOS-OC底层原理03:alloc&init&new探索iOS-OC底层原理07:malloc源码探索 中我们分
别探索了 alloc 核心源码中的前两个,分别是 cls->instanceSizecalloc ,今天我们来探索最后一个 obj->initInstanceIsa

在此之前我们需要先了解什么是 联合体,它和 结构体 的区别?

1、构造数据类型的方式有以下两种:

  • 结构体(struct)

  • 联合体(union,也称为共用体)

2、 结构体联合体 写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 结构体:s表示结构体类型名
struct s
{
// a,b,c表示结构体成员名
char a; //占1个字节 (0)
int b; //占4个字节 (4,5,6,7)
short c; //占2个字节 (8,9)
}s1;
// s1表示结构体变量名
// 访问该结构体内部成员时可以采用 s1.a=1;其中 "点" 表示结构体成员运算符

// 联合体
// u1表示联合体类型名
union u1
{
// a,b,c表示联合体成员名
char a; //占1个字节
int b; //占4个字节
short c;//占2个字节
}u1;

在 main.m 中打印一下

1
2
3
4
5
6
7
8
9
10
11
12
13
int main(int argc, const char * argv[]) {
@autoreleasepool {
printf("%lu\n",sizeof(s1));
printf("%lu\n",sizeof(u1));
NSLog(@"Hello, World!");
}
return 0;
}

******查看打印结果******
12
4
(lldb)

由结果可知:

  • 结构体 的大小为 12 ,最大成员变量的倍数 4*3 = 12
  • 联合体 的大小为 4 ,最大成员变量 4

3、结构体(struct)

结构体:各成员各自拥有 自己的内存,各自使用 互不干涉,同时存在的,遵循 内存对齐 原则。一个 struct 变量的 总长度 等于 所有成员长度之和

  • 缺点:所有属性都分配内存,比较浪费内存,假设有 4个int 成员,一共分配了 4*4=16 字节的内存,但是在使用时,你只使用了 4 字节,剩余的 12 字节就是属于内存的浪费

  • 优点:存储容量较大,包容性强,且成员之间不会相互影响

4、联合体(union)

联合体:各成员 共用 一块 内存空间,并且同时只有一个成员可以得到这块内存的 使用权(对该内存的读写),各变量 共用一个内存首地址。因而,联合体比结构体更 节约内存。一个 union 变量的总长度至少能容纳 最大 的成员变量,而且要满足是所有 成员变量 类型大小的 整数倍

  • 缺点:包容性弱

  • 优点:所有成员共用一段内存,使内存的使用更为精细灵活,同时也节约内存

5、两者的区别

1)内存占用 情况

  • 结构体 的各个成员会 占用不同内存 ,互相之间没有影响

  • 共用体 的所有成员 占用同一段 内存,修改一个成员会影响其余所有成员

2)内存分配大小

  • 结构体 内存 >= 所有成员占用的内存总和(成员之间可能会有缝隙)

  • 共用体占用的内存等于最大的成员占用的内存

isa的类型

1、我们知道 isa 的类型是 isa_t联合体isa_t 类型使用 联合体原因 也是基于 内存优化 的考虑;这里的内存优化是指在 isa 指针中通过 char + 位域(即二进制中每一位均可表示不同的信息)的原理实现。通常来说,isa 指针占用的 内存 大小是 8 字节(1字节=8位),即 8*8=64 位,已经足够存储很多的信息了,这样可以极大的 节省内存,以 提高性能

isa_t源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 联合体
union isa_t {
// isa的两种初始化方法
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
// 提供了 cls 和 bits ,两者是互斥关系
Class cls; // 存储cls信息
uintptr_t bits; // 存储多位的信息
#if defined(ISA_BITFIELD)
struct {
ISA_BITFIELD; // defined in isa.h
};
#endif
};

2、从 isa_t 的定义中可以看出:

提供了两个成员,clsbits,由联合体的定义所知,这两个成员是 互斥 的,也就意味着,当初始化 isa 指针时,有两种初始化方式:

  • 如果不是 nonpointer,通过 isa_t(uintptr_t value) : bits(value) { } 实现初始化
1
2
3
4
5
// 如果不是nonpointer,初始化isa,返回cls信息
if (!nonpointer) {
// 初始化isa
isa = isa_t((uintptr_t)cls);
}
  • 如果是 nonpointer ,通过 isa_t() 初始化
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 如果是nonpointer的初始化方式
else {
ASSERT(!DisableNonpointerIsa);
ASSERT(!cls->instancesRequireRawIsa());

// 初始化isa
isa_t newisa(0);

// 返回0
#if SUPPORT_INDEXED_ISA
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_VALUE
newisa.has_cxx_dtor = hasCxxDtor;
newisa.indexcls = (uintptr_t)cls->classArrayIndex();

// 64位走这个地方
#else

// 赋值bits
newisa.bits = ISA_MAGIC_VALUE;

// 赋值cxx
newisa.has_cxx_dtor = hasCxxDtor;

// 赋值类信息
newisa.shiftcls = (uintptr_t)cls >> 3;
#endif

// 赋值isa
isa = newisa;

}

3、SUPPORT_INDEXED_ISA :表示 isa_t 中存放的 Class 信息是 Class 的地址,还是一个 索引 (根据该 索引 可在 类信息表 中查找该类结构地址)。目前接触到的iOS的设备上 SUPPORT_INDEXED_ISA0 ,如下代码:

1
2
3
4
5
6
7
8
// 如果不是64位
#if __ARM_ARCH_7K__ >= 2 || (__arm64__ && !__LP64__)
# define SUPPORT_INDEXED_ISA 1

// 如果是64位
#else
# define SUPPORT_INDEXED_ISA 0
#endif

4、isa_t 还提供了一个结构体定义的 位域 ,用于存储类信息及其他信息,结构体的成员 ISA_BITFIELD ,这是一个宏定义,有两个 架构__arm64__(真机)__x86_64__(macOS/模拟器),以下是它们的一些宏定义,如下图所示:

  • nonpointer:表示是否对 isa 指针 开启指针优化

    • 0:如果没有 nonpointer 就是 纯isa指针
    • 1:如果有 nonpointer,不⽌有 类对象地址 ,isa 中包含了 类信息、对象的引⽤计数
  • has_assoc关联对象标志位

    • 0没有
    • 1存在
  • has_cxx_dtor:该对象是否有 C++ 或者 Objc析构器,如果有析构函数,则需要做析构逻辑, 如果没有,则可以更快的释放对象

  • shiftcls: 存储类指针的值。开启指针优化的情况下:

    • __arm64__ 架构中有 33 位⽤来存储 类指针dealloc 函数
    • __x86_64__ 架构中有 44 位用来存储类指针。
  • magic:⽤于调试器判断当前对象是 真的对象 还是 没有初始化空间

  • weakly_referenced:指对象是否被 指向 或者 曾经指向 ⼀个 ARC 的弱变量

    • 如果 ,则在调用 delloc 进行 释放
    • 如果 没有,弱引⽤的对象可以 更快释放
  • deallocating:标志对象是否 正在释放 内存

  • has_sidetable_rc:当对象 引⽤计数⼤于 10 时,则需要借⽤该变量 存储进位

  • extra_rc:当表示该对象的 引⽤计数值,实际上是引⽤计数值 减 1

    • 如果对象的引⽤计数为 10,那么 extra_rc9
    • 如果引⽤计数⼤于 10,则需要使⽤到 上⾯has_sidetable_rc

针对两种不同平台,其 isa存储情况 如图所示:

由上面的分析得出,大部分 自定义的类 都是属于 nonpointer_isa,不光存储了isa,还存储了其他一些 信息

dealloc释放 重点

我们通过 dealloc释 查看 通过dealloc 释放流程:

1、dealloc 调用 _objc_rootDealloc

1
2
3
4
// Replaced by NSZombies
- (void)dealloc {
_objc_rootDealloc(self);
}

2、_objc_rootDealloc 调用 rootDealloc :

1
2
3
4
5
6
7
8
void
_objc_rootDealloc(id obj)
{
ASSERT(obj);

obj->rootDealloc();
}

3、rootDealloc 内部进行一些判断,释放 isa 相关信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
inline void
objc_object::rootDealloc()
{
if (isTaggedPointer()) return; // fixme necessary?
// isa有nonpointer,更快释放
if (fastpath(isa.nonpointer &&
!isa.weakly_referenced &&
!isa.has_assoc &&
!isa.has_cxx_dtor &&
!isa.has_sidetable_rc))
{
assert(!sidetable_present());
free(this);
}
// 没有nonpointer,调用object_dispose
else {
object_dispose((id)this);
}
}

4、object_dispose 内部调用 objc_destructInstance

1
2
3
4
5
6
7
8
9
10
id 
object_dispose(id obj)
{
if (!obj) return nil;

objc_destructInstance(obj);
free(obj);

return nil;
}

5、objc_destructInstance 内部调用 clearDeallocating 清除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void *objc_destructInstance(id obj) 
{
if (obj) {
// Read all of the flags at once for performance.
// 是否有c++/oc析构函数
bool cxx = obj->hasCxxDtor();
// 是否有关联对象
bool assoc = obj->hasAssociatedObjects();

// This order is important.
// 析构函数
if (cxx) object_cxxDestruct(obj);
// 移除关联对象
if (assoc) _object_remove_assocations(obj);
// 清除
obj->clearDeallocating();
}

return obj;
}

具体细节待完善…

isa 和 类关联

isacls 关联 原理 就是 isa 指针中的 shiftcls位域中存储了类信息 ,其中 initInstanceIsa 的过程是将 calloc 指针和当前的 类cls 关联起来,有以下几种验证方式:

  • 【方式一】通过 initIsa 方法中的 newisa.shiftcls = (uintptr_t)cls >> 3验证

  • 【方式二】通过 isa指针 地址与 ISA_MSAK 的值 & 来验证

  • 【方式三】通过 runtime 的方法 object_getClass 验证

  • 【方式四】通过 位运算 验证

【方法一】通过 initIsa 方法中的 newisa.shiftcls = (uintptr_t)cls >> 3验证

1、首先通过 main 中的 ZJPerson 断点 –> initInstanceIsa –> initIsa –> 走到 else 中的 newisa.bits = ISA_MAGIC_VALUE 这一行:

2、 通过lldb调试打印 p newisa 初始化的信息:

这时 isa 通过 newisa 进行了初始化,但是还未被赋值

3、往下走一步,走到下一行 newisa.has_cxx_dtor = hasCxxDtor

继续断点调试,在上一行 newisa.bits = ISA_MAGIC_VALUE 已经为 isabits 成员赋值,执行lldb命令 p newisa,得到的结果如下:

通过与前一个 newisa 对比我们发现,isa 指针中有一些变化,如下图所示:

  • 赋值 bits 会对 cls 进行追加 0x001d800000000001 默认值

  • 赋值 bits 里面的 ISA_BITFIELD 宏中所有的信息

    • nonpointer = 1,代表 是nonpointer
    • magic = 59 ,为什么呢?往下看

4、打开 计算器 我们查看一下 16 进制 0x001d800000000001 转化为 二进制,和 10 进制 59 转化为 二进制,进行对比一下:

由对比可知,0x001d800000000001 转化为 二进制 ,从 47(因为前面有4个位域,共占用46位) 位开始往后数6位是 110111; 和 10 进制 magic = 59 转换成 二进制,从 0 开始 往后数 6 位的 值相等;

5、继续往下执行断点到 newisa.shiftcls = (uintptr_t)cls >> 3

  • shiftcls 存的是 类的信息

  • clsZJPerson 进行 编码 之后往 右移3位

1
2
3
4
(lldb) p (uintptr_t)cls
(uintptr_t) $2 = 4294975720
(lldb) p $2 >> 3
(uintptr_t) $3 = 536871965

6、继续往下走一到 执行断点到 isa = newisa

我们通过 lldb 调试一下 newisa

我们看到 cls = ZJPerson,而 shiftcls = 536871965 与上面的 $3 = 536871965 刚好吻合

7、最后用一张图来表示一下具体流程:

8、为什么在 shiftcls 赋值时需要类型强转?

因为内存的存储不能存储字符串,机器码 只能识别 0 、1 这两种数字,所以需要将其转换为 uintptr_t 数据类型,这样 shiftcls 中存储的类信息才能被 机器码理解, 其中 uintptr_tlong

9、为什么需要右移3位?

主要是由于 shiftcls 处于 isa 指针地址的 中间 部分,前面还有 3 个位域,为了不影响前面的3个位域的数据,需要 右移 将其 抹零

【方法二】:通过 isa 指针地址与 ISA_MSAK 的值 & 来验证

1、在方法一执行完后,回到 _class_createInstanceFromZone
中,此时 isacls 已经关联完成,我们走到下面这个方法,打上断点:

2、执行 po obj,输出对应的值,再执行 x/4gx 0x10181ba70 得到 isa 指针的地址 0x001d8001000020e9,将 isa 指针地址 & ISA_MASK ,即 po 0x001d8001000020e9 & 0x0000000ffffffff8ULLpo 0x001d8001000020e9 & 0x00007ffffffffff8ULL ,得出 ZJPerson

  • __arm64__中,ISA_MASK 宏定义的值为 0x0000000ffffffff8ULL

  • __x86_64__中,ISA_MASK 宏定义的值为 0x00007ffffffffff8ULL

【方法三】:通过 object_getClass

通过查看 object_getClass 的源码实现,同样可以验证 isa 关联的原理,有以下几步:

1、main 中导入 #import <objc/runtime.h>

2、通过 runtimeapi,即 object_getClass 函数获取类信息

1
object_getClass(<#id  _Nullable obj#>)

3、查看 object_getClass函数 源码的实现

4、点击进入 object_getClass 底层实现

5、点击 getIsa 进入查看底层实现:

6、点击 ISA 进入查看底层实现,可以看到如果是 SUPPORT_INDEXED_ISA 类型,执行 if 流程,反之 执行的是 else 流程,我们这里走的是 else:

  • 在else流程中,拿到 isabits 这个位,再 & ISA_MASK,这与方式二中的原理是一致的,获得当前的类信息

  • 从这里也可以得出 isacls 已经完美关联

【方法四】:通过位运算

1、回到 _class_createInstanceFromZone 方法。通过 x/4gx obj 得到 obj内存分布情况 ,当前类的信息存储在 isa 指针中,且 isa 中的 shiftcls 此时占 44 位(因为处于macOS环境)

2、想要读取中间的 44类信息(shiftcls),就需要经过 位运算 ,将 右边3位,和 左边17除去44位以外 的部分都 抹零,其 相对位置是不变的。其位运算过程如图所示,其中shiftcls即为需要读取的类信息:

3、将isa地址右移3位:p/x 0x001d8001000020e9 >> 3 ,得到 0x0003b0002000041d

  • 在将得到的 0x0003b0002000041d 左移20位:p/x 0x0003b0002000041d << 20 ,得到 0x0002000041d00000

  • 为什么是左移20位?因为先右移了3位,相当于向右偏移了3位,而左边需要抹零的位数有17位,所以一共需要移动20位

  • 将得到的0x0002000041d00000 再右移17位:p/x 0x0002000041d00000 >> 17 得到新的0x00000001000020e8

  • 获取cls的地址 与 上面的进行验证 :p/x cls 也得出0x00000001000020e8,所以由此可以证明 isacls 是关联的

4、为什么右移3位,左移20位,再右移17位?

因为 initIsa(初始化isa) 传入的是 cls(即ZJPerson),只有中间 shiftcls存储着类的信息 ,在 __x86_64__ 架构是 44 位,在 __arm64__ 架构是 33 位,只有在 33位/44位 才存 储着类信息,当右移3位,左移20位,再右移17位,相当于复位,不足64位 系统 自动帮我们 用0补全

isa返回Class类型的验证

1、我们在前面探索了 对象本质结构体,它的第一个 属性继承NSObjectisaClass 类型的,而后面我们通过【方法三】object_getClass 验证 isacls 关联流程得知, isaisa_t 类型的:

2、那么为什么我们在 外层 得到的是 Classisa 呢?

我们其实在上面的【方法三】,已经验证了其原因,看源码 return (Class)(isa.bits & ISA_MASK); 这一句代码将其 强转Class 类型提供给外层使用。

  • Post title:OC底层原理08:isa和类关联探索
  • Post author:张建
  • Create time:2020-09-18 15:04:23
  • Post link:https://redefine.ohevan.com/2020/09/18/OC底层原理/OC底层原理08:isa和类关联探索/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.