对于 Objective-C 对象中,都存在一个 isa 指向了该对象所属的类,并且存储了类的相关信息,今天来探索一下 isa 究竟是啥?

基础知识

Bit field

Bit-field,称之为位段或位域,是一种数据结构,它的数据以位的形式紧凑存储的,并且允许程序员对该结构的位进行操作。这种数据结构的优点如下:

  • 1、节省存储空间。
  • 2、可以直接定义和访问一个字中的位字段。

如何定义一个位域呢?

1
2
3
struct 位域结构名 {
类型说明符 位域名 : 位域长度
}

举个例子:

1
2
3
4
5
6
struct Direction {
unsigned int north : 1;
unsigned int south : 1;
unsigned int east : 1;
unsigned int west : 1;
};

那么位域是如何使用的呢?同时,我们定义一个同样形式的结构体,用于对比两者的内存占用大小。

1
2
3
4
5
6
struct Direction1 {
unsigned int north;
unsigned int south;
unsigned int east;
unsigned int west;
};
1
2
3
4
5
6
7
struct Direction d;
d.north = 1;
printf("north -> %d \n", d.north);
printf("Direction size -> %lu \n", sizeof(d));

struct Direction1 d1;
printf("Direction1 size -> %lu \n", sizeof(d1));

上面代码打印结果如下:

1
2
Direction size -> 4 
Direction1 size -> 16

对比结果很明显,使用位域可以显著减少内存空间的占用。 关于位域的更多知识可以看看 Bit field

union

union, 称为联合体或共用体,是一种有多种类型的值的数据结构。联合体中的各个元素是互斥的,它们共享同一块内存。因此一个联合体中每次只能使用其中的一个元素。更多信息参考 union

如下,我们声明一个联合体,我们可以通过 . 进行取值和赋值。

1
2
3
4
5
6
7
union Data {
char c;
short s;
int i;
float f;
double d;
};

一个联合体占用的内存大小,是联合体中最宽元素的字节大小。Data 中的最宽元素是 d,占用 8 个字节内存大小。可以通过下面的代码验证:

1
2
3
4
5
union Data d;
d.i = 5;

printf("%d \n", d.i);
printf("Data size = %lu \n", sizeof(d));

打印结果为:

1
Data size = 8

位运算

数据在计算机中是以二进制存储的。位运算就是直接对整数在内存中的二进制位进行操作。常用的操作有:

  • &
  • |
  • ~ 取反
  • << 左移
  • >> 右移

这里我们举例说明一下左移和右移,其他的比较简单,就不赘述了,详细信息可以参考 位操作

移位操作符用于将一个二进制数中的每一位全部都向一个方向移动指定位,溢出的部分会被舍弃,空缺的部分会被填入指定数字。算数移位中填充的是符号位,逻辑移位中填充的是 0 。

拿数字 138 来举例吧,转换成二进制是 1000 1010。我们分别进行逻辑左移两位和逻辑右移两位来得到 a 和 b,看 a 和 b 打印的是否如我们分析的那样。

1
2
3
4
5
6
unsigned char val = 138;        //  1000 1010   140
unsigned char a = val << 2; // 0010 1000 40
unsigned char b = val >> 2; // 0010 0010 34

printf("a = %d \n", a);
printf("b = %d \n", b);

打印结果如下:

1
2
a = 40 
b = 34

开始摸瓜

先声明一个简单 RDPet 类, 有一个 String 类型的 name 属性。RDPet.hRDPet.m 文件中的代码如下所示。

1
2
3
4
5
6
7
@interface RDPet : NSObject
@property (nonatomic, copy) NSString *name;
@end

@implementation RDPet

@end

用 Objective-C 编写的代码,会被 Clang 和 LLVM 编译成机器码,然后运行在我们的计算机上。clang 是 Apple 开发的一个 C、C++、Objective-C、Objective-C++ 编程语言的编译器前端,LLVM 是编译器后端。更多的消息参考 ClangLLVM

我们使用 Clang rewrite RDPet.m 中的代码转换成 c++ 格式的代码,然后看看会变成啥。

1
clang -rewrite-objc RDPet.m -o RDPet.cpp

在终端中蹦出了一系列的 warnings 后,我们得到了一个 RDPet.cpp 文件,用 Xcode 打开这个文件瞅瞅,如图居然有 11 万多行。

我们用 RDPet 关键词搜一下,可以找到这样的代码, 可见我们的 RDPet 类在底层转换成了 struct RDPet_IMPL

1
2
3
4
5
6
7
8
9
10
11
#ifndef _REWRITER_typedef_RDPet
#define _REWRITER_typedef_RDPet
typedef struct objc_object RDPet;
typedef struct {} _objc_exc_RDPet;
#endif

extern "C" unsigned long OBJC_IVAR_$_RDPet$_name;
struct RDPet_IMPL {
struct NSObject_IMPL NSObject_IVARS;
NSString * _Nonnull _name;
};

那么 NSObject_IMPL 是啥呢?再搜一下这个,找到了如下的代码:

1
2
3
struct NSObject_IMPL {
Class isa;
};

同时在许多熟悉的 struct 中 也找到了这个属性,这与我们前面的 alloc 文章不谋而合,这就是 isa, 用于存储每个对象所属的类的类信息。

接着打开我们的 runtime 源码,找找这个 Class 的定义。找到如下的声明:

1
typedef struct objc_class *Class;

顺藤摸瓜继续找 objc_class

1
2
3
4
5
6
7
struct objc_class : objc_object {
Class superclass;
cache_t cache;
class_data_bits_t bits;

// 其他方法
}

看看 objc_class 的父类 objc_object 是啥?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct objc_object {
private:
isa_t isa;

public:
// ISA() assumes this is NOT a tagged pointer object
Class ISA();
// rawISA() assumes this is NOT a tagged pointer object or a non pointer ISA
Class rawISA();
// getIsa() allows this to be a tagged pointer object
Class getIsa();
uintptr_t isaBits() const;

// 其他方法
}

根据 objc_classobjc_object 的关系,我们可以得出一个结论,类也是一个对象。

objc_object 中,isa 的类型是 isa_t,这是什么类型呢?

1
2
3
4
5
6
7
8
9
10
11
12
union isa_t {
isa_t() { } // 构造函数 1
isa_t(uintptr_t value) : bits(value) { } // 构造函数 2

Class cls;
uintptr_t bits;
#if defined(ISA_BITFIELD)
struct {
ISA_BITFIELD; // defined in isa.h
};
#endif
};

uintptr_t 又是什么类型呢?

1
typedef unsigned long           uintptr_t;

原来 isa_t 是一个联合体,联合体中共有三个元素,这 3 个元素是互斥的,它们共享同一块内存区间。

  • cls 是一个指向 objc_class 的指针。
  • bits 是一个长度为 8 字节的无符号整型数据。
  • 一个位域。

看看这个位域中的元素是啥, 已经提示在 isa.h 中了。

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
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
# define ISA_BITFIELD \
uintptr_t nonpointer : 1; /*是否开启 isa 优化*/ \
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; \
uintptr_t weakly_referenced : 1; /*是否被别的对象弱引用*/ \
uintptr_t deallocating : 1; /*是否正在释放*/ \
uintptr_t has_sidetable_rc : 1; /*是否额外使用 sidetable 存储引用计数 */ \
uintptr_t extra_rc : 19 /*引用对象减1的值*/
# define RC_ONE (1ULL<<45)
# define RC_HALF (1ULL<<18)

# elif __x86_64__
# define ISA_MASK 0x00007ffffffffff8ULL
# define ISA_MAGIC_MASK 0x001f800000000001ULL
# define ISA_MAGIC_VALUE 0x001d800000000001ULL
# 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 deallocating : 1; \
uintptr_t has_sidetable_rc : 1; \
uintptr_t extra_rc : 8
# define RC_ONE (1ULL<<56)
# define RC_HALF (1ULL<<7)

可以看出,这个位域的元素,根据 CPU 的架构不同,使用的定义也是不同的。根据注释可以很好的明白每个元素代表的含义。

前面的的三个常量也比较有意思:

  • ISA_MASK 的特点是 shiftcls 对应的二进制位都是 1,isa & ISA_MASK 可以快速得到 shiftcls 的值。
  • ISA_MAGIC_MASK 的特点是 magic 和 nonpointer 对应的二进制位都是 1,通过与操作可以快速取值。
  • ISA_MAGIC_VALUE 的特点是 nonpointer 是 1,在 isa 的初始化中会用到。

我们将 arm64 架构的 ISA_MAGIC_MASKISA_MAGIC_VALUE 转成二进制,看看他们的二进制位的关系, 相信你能看出他们的奥妙。

1
2
M 0b0000000000000000000000111111000000000000000000000000000000000001  
V 0b0000000000000000000000011010000000000000000000000000000000000001

吃瓜

本文创建的 demo 是 mac OS 项目,所以使用的是 \_\_x86_64__ 架构。

如下所示,我们创建一个 RDPet 对象,然后让 5 个 RDPet 类型指针指向这个对象,此时该对象的引用计数是 5,然后分析一下 isa 中的数据是否也是这样的。

1
2
3
4
5
6
7
8
RDPet *dog = [[RDPet alloc] init];
dog.name = @"WangCai";
RDPet *dog1 = dog;
RDPet *dog2 = dog;
RDPet *dog3 = dog;
RDPet *dog4 = dog;

Log(@"%@",dog);

在 Log 地方打上断点。在 debug area 使用 lldb 指令进行打印。这里对常用的 lldb 指令做一下说明。在 debug area 中输入 help 可以查看完整的 lldb 命令列表。

1
2
3
p  打印返回值  (LLDB默认格式)
po 打印返回值 (格式由程序员指定)
x 读取当前目标进程中的数据

接下来我们探索 isa 中的数据了。先看看 dog 对象的内存地址是什么?

1
2
(lldb) p dog   // 查看 dog 对象地址
(RDPet *) $1 = 0x0000000101977fc0

x/4gx 指令,以 16 进制的方式读取地址 0x0000000101977fc0 开始的 4 段数据,其中 8 字节为一段。

  • 4 表示读 4 段数据,这里可以是你想读的数据的段数。
  • g 表示以 8 字节为一段。 g 是 giant word 缩写,w 是 word 的缩写。
  • x 表示以 16 进制方式。二进制用 t 表示,八进制用 o 表示。
1
2
3
(lldb) x/4gx 0x0000000101977fc0  // 读取该地址起的4个8字节数据
0x101977fc0: 0x041d80010000340d 0x0000000100002020
0x101977fd0: 0x75736956534e5b2d 0x6369506261546c61

冒号最左边的数 0x101977fc0 表示的是内存地址编号,冒号右边的表示的是,这个地址开始,在内存中存储的数据。

读取 isa 的值。

1
2
(lldb) p 0x041d80010000340d  // 读取 isa 的值
(long) $2 = 296533892259656717

看看内存中第 2 个 8 字节的内容是啥?应该是对象的属性。

1
2
(lldb) po 0x0000000100002020   // 读取 name 属性值
WangCai // 符合

通过 & 的方式,读取第一位 bit 的值。这位表示的是否是是 nonpointer。

1
2
(lldb) p 0x041d80010000340d & 0x0000000000000001ULL >> 0     // 读取 nonpointer
(unsigned long long) $3 = 1 // 是 nonpointer

看是否有关联对象?has_assoc 位是在第 2 位, & 出来的值,需要抹掉第一位的 0,所以需要右移 1 位 来实现。

1
2
(lldb) p (0x041d80010000340d & 0x0000000000000002ULL) >> 1   // 读取has_assoc
(unsigned long long) $4 = 0 // 没有关联对象

是否有 c++ 析构函数? has_cxx_dtor 位是在第 3 位。

1
2
(lldb) p (0x041d80010000340d & 0x0000000000000004ULL) >> 2   // 读取 has_cxx_dtor 
(unsigned long long) $5 = 1 // 有 c++ 析构函数

对象的类信息 shiftcls 用 44 位来存储,是在 4-47 位。

1
2
(lldb) po 0x041d80010000340d & 0x00007ffffffffff8ULL         // 读取 shiftcls, 类信息
RDPet

magic value 用 6 位来存储,是在 48-53 位。

1
2
(lldb) p (0x041d80010000340d & 0x001f800000000001ULL) >> 47  // 读取 magic
(unsigned long long) $6 = 59

对象是否有弱引用 weakly_referenced 用 1 位类存储,是在 54 位。

1
2
(lldb) p (0x041d80010000340d & 0x0002000000000000ULL) >> 53  // 读取 weakly_referenced
(unsigned long long) $7 = 0 // 没有弱引用

对象是否正在析构 deallocating 用 1 位存储,是在 55 位。

1
2
(lldb) p (0x041d80010000340d & 0x0004000000000000ULL) >> 54  // 读取 deallocating
(unsigned long long) $8 = 0 // 没有正在析构

对象是否有辅助引用计数表 has_sidetable_rc 用 1 为存储,是在 56 位。

1
2
(lldb) p (0x041d80010000340d & 0x0008000000000000ULL) >> 55  // 读取 has_sidetable_rc 
(unsigned long long) $9 = 0 // 没有引用计数辅助

对象的引用计数-1 extra_rc 用 8 位表示,是在 57-64 位。

1
2
(lldb) p (0x041d80010000340d & 0xff00000000000000ULL) >> 56  // 读取 extra_rc
(unsigned long long) $10 = 4 // 引用计数-1的结果是4,所以引用计数是5

实操打印出来的结果和我们的分析是一致的。通过这段分析,可以看出 Apple 的工程师在底层为了内存的高效实用也是花费了不少心思。

后记

我是穆哥,卖码维生的一朵浪花。我们下次见。