【iOS】OC底层系列八 - runtime

Objective-C是一门动态性比较强的编程语言,跟C、C++等语言有着很大的不同。

Objective-C的动态性是由Runtime API来支撑的。Runtime API提供的接口基本都是C语言的,源码由C\C++\汇编语言编写。

一、isa详解

要想学习Runtime,首先要了解它底层的一些常用数据结构,比如isa指针。

前面的章节有简单介绍过isa指针,比如isa的指向(实例对象isa -> 指向父类对象isa -> 元类对象isa -> 基类对象isa -> 指向基类对象自己),以及64bit之前isa是直接指向内存的,64bit之后的isa指向地址是需要经过位运算得出的。

在arm64架构之前,isa就是一个普通的指针,存储着Class、Meta-Class对象的内存地址。

从arm64架构开始,对isa进行了优化,变成了一个共用体(union)结构,还使用位域来存储更多的信息。

疑问:64bit后的isa为什么要经过位运算(ISA&ISA_MASK)?这样设计的目的是什么?

1.1. 按位存储

示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@interface DBPerson : NSObject

@property (nonatomic, assign, getter=isRed) BOOL red;
@property (nonatomic, assign) BOOL green;
@property (nonatomic, assign) BOOL blue;

@end

@implementation DBPerson

@end

int main(int argc, const char * argv[]) {
@autoreleasepool {
DBPerson *person = [[DBPerson alloc] init];
person.red = YES;
person.green = YES;
person.blue = YES;

NSLog(@"%zu", class_getInstanceSize([person class]));
// 输出:16
}
return 0;
}

上面代码中的person对象至少需要16个字节(isa(8) + red(1) + green(1) + blue(1) = 11,由于内存对齐是8,所以最终打印16个字节)。

DBPerson类中有三个BOOL类型的属性,每一个属性的值只有0和1两种结果,但占用了1个字节。这种情况下1个二进制位就可以表示1个属性的值,三个BOOL类型属性只需要1个字节中的3个二进制位就可以表示各自的值,相比之下更加节省内存。按照这个思路,我们尝试改进上面的代码。

char类型只占1个字节,因此使用char记录三个BOOL属性的值。例如:char类型变量color存储的二进制是0b0000 0000,三个属性的值在低二进制位存储,假设red = 1,green = 0,blue = 1,变量color最终存储的就是0b0000 0110

怎样操作二进制位呢?使用位运算可以让对应二进制位的值发生改变。

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
@interface DBPerson : NSObject

- (void)setRed:(BOOL)red;
- (void)setBlue:(BOOL)blue;
- (void)setGreen:(BOOL)green;
- (BOOL)isRed;
- (BOOL)isBlue;
- (BOOL)isGreen;

@end

// 掩码,一般用来进行按位与(&)运算
// 可以使用二/八/十/十六进制等,但是建议使用位移运算符(枚举会经常用到位移运算符)
//#define DBRedMask 0b00000001 // 二进制
//#define DBRedMask 1 // 十进制
//#define DBRedMask 0x00000001 // 十六进制
#define DBRedMask (1<<0)
#define DBGreenMask (1<<1)
#define DBBlueMask (1<<2)

@interface DBPerson ()
{
char _color;
}
@end

@implementation DBPerson

- (instancetype)init {
self = [super init];
if (self) {
// 假设color的二进制是0000 0101,即:blue = 1,green = 0,red = 1
_color = 0b00000101;
}
return self;
}

/* 设置red的值
使用按位或(|)运算可以让指定位的值产生变化而不影响其他位的值

如果red是YES:使用"按位或"可以让第1位变为1,其位不变
0000 0110(_color二进制)
| 0000 0001(第1位代表red)
-----------
0000 0111

如果red是NO:使用"按位与"可以让第1位变为0,其位不变。按位取反(~)可以让0变1,1变0
0000 0111(_color二进制)
& 1111 1110(第1位代表red)
-----------
0000 0110
*/
- (void)setRed:(BOOL)red {
if (red) {
_color |= DBRedMask;
} else {
_color &= ~DBRedMask;
}
}

/* 获取red的值
使用按位与(&)运算可以直接获取指定位的值是0还是1
0000 0110(_color二进制)
& 0000 0001(第1位代表red)
-----------
0000 0001

结果只要有值就代表大于0(YES),否则就是NO
使用取反符号"!"可以自动转为BOOL类型,使用两个取反符号"!!"就可以获得真实的BOOL结果
*/
- (BOOL)isRed {
return !!(_color & DBRedMask);
}

- (void)setGreen:(BOOL)green {
if (green) {
_color |= DBGreenMask;
} else {
_color &= ~DBGreenMask;
}
}

- (BOOL)isGreen {
return !!(_color & DBGreenMask);
}

- (void)setBlue:(BOOL)blue {
if (blue) {
_color |= DBBlueMask;
} else {
_color &= ~DBBlueMask;
}
}

- (BOOL)isBlue {
return !!(_color & DBBlueMask);
}

@end

上面代码就把三个变量值合在一个字节中存储了。

注意:上面代码仅仅是为了研究isa底层,平时编写代码时不建议这样写,但是操作系统等底层源码会大量使用上面的写法,因为比较高效。

1.2. 位域

如果要存储的变量比较多,上面的代码就变得很难维护。使用位域可以解决这个问题,并且代码更加简洁。

位域格式:变量类型 变量名 : 变量占用位数(注意:变量类型只是访问类型,和实际存储没有关系)。

1
2
3
4
5
struct {
char red : 1;
char green : 1;
char blue : 1;
} _color;

如上定义的_color结构体里面有3个变量,分别占用1位,一共占用1个字节,变量在内存中的布局是0b0000 0bgr

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
35
36
37
@interface DBPerson ()
{
struct {
char red : 1;
char green : 1;
char blue : 1;
} _color;
}
@end

@implementation DBPerson

- (void)setRed:(BOOL)red {
_color.red = red;
}

- (BOOL)isRed {
return !!_color.red;
}

- (void)setGreen:(BOOL)green {
_color.green = green;
}

- (BOOL)isGreen {
return !!_color.green;
}

- (void)setBlue:(BOOL)blue {
_color.blue = blue;
}

- (BOOL)isBlue {
return !!_color.blue;
}

@end

上方代码中green如果是YES,那么green的十六进制就是0xff,二进制是0b11111111,十进制是255,表示的是-1(有符号)。判断BOOL变量只需要判断是否有值即可,因此也需要加上!!

上面的代码使用位域可读性比较高,但是没有位运算的效率高。共用体不仅可以提高代码可读性/可维护性,还能使用位运算提高运行效率。

1.3. 共用体

共用体的意思就是结构体所有成员共享一段内存。

1
2
3
4
5
6
7
8
union Date {
int year;
char month;
};

union Date date;
date.year = 2018;
date.month = 8;

int占用4个字节,char占用1个字节,所以共用体Date占用4个字节,成员year和month共用这4个字节。

使用共用体需要注意:由于是共用一段内存,存储时会覆盖之前存储的内容。

共用体结合位域:

1
2
3
4
5
6
7
8
union {
char bits; // 共用体成员变量
struct {
char red : 1;
char green : 1;
char blue : 1;
};
} _color; // 共用体变量名

在共用体中,结构体的成员仅仅是为了可读性(red占1位,green占1位,blue占1位),没有任何实际意义(可以理解为:结构体仅仅是为了表示共用体的内存布局,可以不写),实际存储的内容还是自己控制的。

上面代码的含义:创建一个共用体变量_color,里面有一个成员变量char bits,相当于整个共用体占用1个字节,内存布局是0b0000 0bgr

使用共用体把之前1.1中DBPerson的代码进行改写:

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
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
#define DBRedMask (1<<0)
#define DBGreenMask (1<<1)
#define DBBlueMask (1<<2)

@interface DBPerson ()
{
union {
char bits;
struct {
char red : 1;
char green : 1;
char blue : 1;
};
} _color;
}
@end

@implementation DBPerson

- (instancetype)init {
self = [super init];
if (self) {

}
return self;
}

- (void)setRed:(BOOL)red {
if (red) {
_color.bits |= DBRedMask;
} else {
_color.bits &= ~DBRedMask;
}
}

- (BOOL)isRed {
return !!(_color.bits & DBRedMask);
}

- (void)setGreen:(BOOL)green {
if (green) {
_color.bits |= DBGreenMask;
} else {
_color.bits &= ~DBGreenMask;
}
}

- (BOOL)isGreen {
return !!(_color.bits & DBGreenMask);
}

- (void)setBlue:(BOOL)blue {
if (blue) {
_color.bits |= DBBlueMask;
} else {
_color.bits &= ~DBBlueMask;
}
}

- (BOOL)isBlue {
return !!(_color.bits & DBBlueMask);
}

@end

在objc源码中,isa是isa_t类型的共用体。isa占用8个字节,在64bit之后,bits里面不仅存储着类、元类对象的内存地址,还有其他信息,这些信息共享这8个字节的内存(这就是64bit后isa的优化 — 充分利用存储空间)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
union isa_t
{
Class cls;
uintptr_t bits;
struct {
uintptr_t nonpointer : 1;
uintptr_t has_assoc : 1;
uintptr_t has_cxx_dtor : 1;
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;
uintptr_t extra_rc : 19;
};
};
  • nonpointer:0代表普通的指针,bits存储着Class、Meta-Class对象的内存地址。1代表isa优化过,使用位域存储更多的信息。
  • has_assoc:是否有设置过关联对象(如果设置过,即使又被设为nil,也算设置过),如果没有,释放时会更快。
  • has_cxx_dtor:是否有C++的析构函数(.cxx_destruct,类似于OC类中的dealloc方法),如果没有,释放时会更快。
  • shiftcls:存储着Class、Meta-Class对象的内存地址信息。
  • magic:用于在调试时分辨对象是否未完成初始化。
  • weakly_referenced:是否有被弱引用指向过(如果被引用过,即使弱引用指针置为nil,也算被弱引用过),如果没有,释放时会更快。
  • deallocating:对象是否正在释放。
  • has_sidetable_rc:引用计数器是否过大导致无法存储在isa中。如果为1,那么引用计数会存储在一个叫SideTable的类的属性中。
  • extra_rc:里面存储的值是引用计数减1。

1.4. ISA_MASK

在64bit后,isa需要和ISA_MASK进行按位与(&)计算才能获得真实的类/元类地址。因为使用的是共用体的其中33位存储类、元类的地址值,所以需要进行按位运算才能取出(这也是使用ISA_MASK的原因)。

1
#define ISA_MASK        0x0000000ffffffff8ULL

ISA_MASK对应的二进制:

ISA_MASK的二进制中有33个1,这33位就是用来存储shiftcls的。最后3位是0,0和任意位进行按位与计算,结果都是0。所以在OC中,对象的二进制地址值最后三位一定是0(从共用体中结构体的定义也可以看出shiftcls前面有3个位存放其他信息)。

假设一个对象的地址值是0x10051f360,由于1个16进制位代表4个二进制,所以后3位一定是0。假设一个元类对象的地址值是0x100008228,最后一位是8,十六进制的8对应的二进制就是1000,所以后3位也是0。


面试题:对isa的理解?

解答:isa是一个指针,实例对象isa指向类对象,类对象的isa指向元类对象,元类对象的isa指向基类对象。在arm64之前,isa就是一个普通的指针,直接存储了类和元类的地址。但是在arm64之后,对isa进行了优化,采用共用体结构把一个64bit的数据分开存储不同的信息,而其中的33位是用来存储类和元类的具体地址。如果要想取出类和元类的地址,就需要对isa的地址和ISA_MASK(ISA_MASK是isa的掩码,确定了类/原来在共用体中存储的位置)进行按位运算,计算出的结果就是真实的类/元类地址。另外由于isa在共用体中存储位置的原因,导致计算的二进制内存地址后3位一定是0(十六进制下以0和8结尾的内存地址)。