在学习Runtime之前首先要对isa和superclass有一定了解,关于isa和superclass,可以看一下我的另一篇文章iOS中OC对象的本质详解(附面试题) - 底层原理总结
每个OC对象都有一个isa指针,在arm64架构之前,isa仅仅是一个指针,保存着对象在内存中的地址,实例对象通过isa可以直接拿到类对象,类对象通过isa可以直接拿到元类对象。而在arm64架构之后,苹果对isa进行了优化,变成了一个共用体结构(union),同时使用位域来存储更多的信息。这时候要想通过实例对象要想通过isa拿类对象,就要通过位运算从isa中获取类对象在内存中的地址,类对象中的isa也是如此。下面具体来看一下。
一、共用体 - 苹果用它在二进制层面优化isa
源码中的isa
struct objc_object {
private:
isa_t isa;
}
isa内部
union isa_t
{
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
#if SUPPORT_PACKED_ISA
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
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;
# 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
struct {
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)
};
# else
# error unknown architecture for packed isa
# endif
#endif
源码中的isa_t是union类型,union表示共用体。什么是共用体呢?先来看看比较拗口的官方定义,看完可能有点懵,不过没关系,接着往下看就会搞明白的。
共用体:在进行某些算法的C语言编程的时候,需要使几种不同类型的变量存放到同一段内存单元中。也就是使用覆盖技术,几个变量互相覆盖。这种几个不同的变量共同占用一段内存的结构,在C语言中,被称作“共用体”类型结构,简称共用体,也叫联合体。
二、模仿底层使用共用体
2.1 很多时候,我们在浪费存储空间
在arm64之前,OC对象中的isa仅仅是一个普通的指针,存着对象的地址。在arm64之后,苹果对isa进行了优化,变成了一个共用体结构,同时使用位域来存储更多的信息。
同样的内存空间,共用体如何做到存储更多的信息?我们来看看下面的代码。
@interface Person : NSObject
@property (nonatomic, assign, getter = isTall) BOOL tall;
@property (nonatomic, assign, getter = isRich) BOOL rich;
@property (nonatomic, assign, getter = isHansome) BOOL handsome;
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *ps = [[Person alloc] init];
ps.tall = YES;
ps.rich = YES;
ps.handsome = NO;
NSLog(@"%d %d %d",ps.isTall,ps.isRich,ps.isHansome);
}
return 0;
}
上面的代码中Person有3个BOOL类型的属性,每个BOOL类型的属性,占用1个字节内存空间,3个BOOL类型也就是3个字节内存空间。
1个字节内存空间有8个二进制位,这3个BOOL类型开辟了24个二进制位!然而实际上,BOOL类型只需要1个二进制位0或1存储就足够了!也就是说这3个BOOL类型的属性浪费了21个二进制位的存储空间,这简直是一种极大的浪费!就我们平时编写代码来说,可能没什么,但是从操作系统层面来看,这可能积累起很大的浪费。
2.2 使用一个char类型来存储以上3个BOOL类型的值
char类型占据一个字节内存空间,即8个二进制位,我们可以添加一个char类型的成员变量,使用它的其中3个二进制位来存储3个BOOL类型的值。
@interface Person()
{
char _tallRichHandsome;
}
例如_tallRichHandsome的值为 0b 0000 0000,那么可以只使用8个二进制位中的最后3个,分别为其赋值0或1来代表tall、rich、handsome的值。如下图
问题是我们该如何对其进行取值赋值操作呢?
取值
假如char类型的成员变量中存储的二进制位 0b 0000 0010,如果想将倒数第二位也就是rich的值取出来,可以使用&(按位与)
运算,将它取出来。
&:按位与,同真为真,其他都为假。
// 示例
// 取出倒数第三位 tall
0000 0010
& 0000 0100
------------
0000 0000 // 取出倒数第三位的值为0,其他位都置为0
// 取出倒数第二位 rich
0000 0010
& 0000 0010
------------
0000 0010 // 取出倒数第二位的值为1,其他位都置为0
// 取出倒数第一位 handsome
0000 0010
& 0000 0001
------------
0000 0000 // 取出倒数第一位的值为0,其他位都置为0
按位与可以用来取出特定的位,想取出哪一位就将那一位置为1,其他位都置为0,然后同原数据进行按位与,即可取出特定的位。
那么我们就可以通过以下这种方式重写3个BOOL类型的getter方法来取值
#define TallMask 0b00000100 // 4
#define RichMask 0b00000010 // 2
#define HandsomeMask 0b00000001 // 1
- (BOOL)tall
{
return !!(_tallRichHandsome & TallMask);
}
- (BOOL)rich
{
return !!(_tallRichHandsome & RichMask);
}
- (BOOL)handsome
{
return !!(_tallRichHandsome & HandsomeMask);
}
上面的代码使用2个!!(非),是将按位与取出的值改为BOOL类型。一个!可以将按位与的值改为BOOL类型,但是取反了,所以再使用一个!取反,得到我们真正想要的结果。比如
// 取出倒数第二位 rich
0000 0010 // _tallRichHandsome
& 0000 0010 // RichMask
------------
0000 0010 // 取出rich的值为1,其他位都置为0
这里我们取出的值为 0000 0010,也就是十进制的2。但是我们需要返回的是一个BOOL类型的值0或1。这里_tallRichHandsome的倒数第2位为1,所以取出rich的值应为1,我们通过1个!将2转为0,再通过!将0转为1,即!!(_tallRichHandsome & TichMask)的结果为1,我们做到了获取正确的值。
掩码:上述代码中定义了3个宏,用来分别进行按位与运算而取出相应的值。一般的,用来进行按位与(&)运算的值我们称之为掩码。
为了可读性,上述3个宏的定义可以使用 <<(左移) 来优化。如 0000 0001 就是让1左移0位,用 1<<0 表示,0000 0010 就是让1左移1位,用 1<<1表示,0000 0100 就是让1左移2位,用 1<<2 表示。那么上述宏定义可以这样优化
#define TallMask (1<<2) // 0b00000100 4
#define RichMask (1<<1) // 0b00000010 2
#define HandsomeMask (1<<0) // 0b00000001 1
设值
设值就是将char类型8位中的某一位设值为0或1,可以使用|(按位或)
运算设值。
&:按位或,只要有一个为1即为1,否则为0。
如果要将某一位置为1的话,那么将原本的值与掩码进行按位或即可,比如我们将tall置为1
// 将倒数第三位 tall置为1
0000 0010 // _tallRichHandsome
| 0000 0100 // TallMask
------------
0000 0110 // 将tall置为1,其他位值都不变
如果要将某一位置为0的话,需要将掩码~(按位取反)
,然后再与原来的值进行按位与。
// 将倒数第二位 rich置为0
0000 0010 // _tallRichHandsome
& 1111 1101 // RichMask按位取反
------------
0000 0000 // 将rich置为0,其他位值都不变
所以对于之前的3个BOOL类型,setter方法可以如下实现
- (void)setTall:(BOOL)tall
{
if (tall) { // 如果需要将值置为1 // 按位或掩码
_tallRichHandsome |= TallMask;
}else{ // 如果需要将值置为0 // 按位与(按位取反的掩码)
_tallRichHandsome &= ~TallMask;
}
}
- (void)setRich:(BOOL)rich
{
if (rich) {
_tallRichHandsome |= RichMask;
}else{
_tallRichHandsome &= ~RichMask;
}
}
- (void)setHandsome:(BOOL)handsome
{
if (handsome) {
_tallRichHandsome |= HandsomeMask;
}else{
_tallRichHandsome &= ~HandsomeMask;
}
}
取值和赋值都完成了,我们来验证一下是否真的做到了我们想要实现的结果。
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *ps = [[Person alloc] init];
ps.tall = YES;
ps.rich = YES;
ps.handsome = NO;
NSLog(@"%d %d %d",ps.isTall,ps.isRich,ps.isHansome);
}
return 0;
}
2019-12-18 11:31:48.375172+0800 Runtime[53571:649155] 1 1 0
Program ended with exit code: 0
可以看到,经过验证,我们上述做法实现了使用char类型8个二进制位的后3位,实现3个BOOL类型的赋值取值。但是上述代码的可读性有待提高,维护效率也有待提高。接下来使用结构体的位域来优化上述代码。
位域
位域声明:位域名:位域长度
位域注意事项:
1.如果1个字节所剩空间不够存放另一位域时,应从下一单元起存放该位域。也可以直接使某位域从下一单元开始。
2.位域的长度不能大于数据类型本身的长度,比如int类型就不能超过32位二进制位。
3.位域可以无位域名,这时它只用来做填充或调整位置。无名的位域是不能使用的。
前面的代码使用结构体位域优化之后
@interface Person()
{
struct {
char handsome : 1; // 位域,代表占用一位空间
char rich : 1; // 按照顺序只占一位空间
char tall : 1;
}_tallRichHandsome;
}
setter、getter方法中可以直接通过结构体赋值和取值
- (void)setTall:(BOOL)tall
{
_tallRichHandsome.tall = tall;
}
- (void)setRich:(BOOL)rich
{
_tallRichHandsome.rich = rich;
}
- (void)setHandsome:(BOOL)handsome
{
_tallRichHandsome.handsome = handsome;
}
- (BOOL)tall
{
return _tallRichHandsome.tall;
}
- (BOOL)rich
{
return _tallRichHandsome.rich;
}
- (BOOL)handsome
{
return _tallRichHandsome.handsome;
}
下面我们进行验证一下
int main(int argc, const char * argv[]) {
@autoreleasepool {
Person *person = [[Person alloc] init];
person.tall = YES;
person.rich = NO;
person.handsome = YES;
NSLog(@"tall : %d, rich : %d, handsome : %d", person.tall,person.rich,person.handsome);
}
return 0;
}
在NSLog出打个断点,通过p/x
和x
查看_tallRichHandsome内存储的值
_tallRichHandsome占据一个内存空间,也就是8个二进制位,我们通过Mac电脑自带的计算器将05这个十六进制数转化为二进制数查看
可以看到,倒数第3位也就是tall的值为1,倒数第2位也就是rich的值为0,倒数第1位也是就handsome的值为1,和前面代码中我们设置的值是一样的。说明我们成功做到了。
但是打印的结果似乎有点问题,tall和handsome居然为-1。设值的时候,我们设置的是YES,应该打印1才对,为什么变成-1了呢?
2019-12-18 11:31:48.375172+0800 Runtime[53571:649155] tall:-1,rich:0,handsome:-1
Program ended with exit code: 0
来到getter方法内部,通过打印断点查看获取到的值。
- (BOOL)handsome
{
BOOL ret = _tallRichHandsome.handsome;
return ret;
}
打印ret的值
打印出来ret的值为255,也就是1111 1111,在一个字节时,有符号数则为-1,无符号数则为255。因此我们在打印的时候出现了-1。
我们通过结构体获取到handsome的值为一个字节8个二进制位中的一位,而BOOL类型占据一个字节8个二进制位,当仅有1位的值扩展为8位的时候,其余空位就会根据前面一位的值全部部位成1。所以当我们使用一个二进制位1
的handsome给BOOL赋值的时候,导致BOOL中的8个二进制位0000 0000
全被映射为了1,即1111 1111
。
为了解决这个问题,我们可以将tall、rich、handsome的值设置为占据2个二进制位。这样当我们使用2个二进制位01
的handsome给8个二进制位的BOOL类型赋值的时候,前面的空值就会自动根据前面一位补全为0,即 0000 0001
,因此这时打印出来的值为1。
同样的,上述问题也可以使用!!
来解决问题,达到我们想要的效果。
使用结构体位域优化之后的代码
@interface Person()
{
struct {
char tall : 1;
char rich : 1;
char handsome : 1;
}_tallRichHandsome;
}
@end
@implementation Person
- (void)setTall:(BOOL)tall
{
_tallRichHandsome.tall = tall;
}
- (void)setRich:(BOOL)rich
{
_tallRichHandsome.rich = rich;
}
- (void)setHandsome:(BOOL)handsome
{
_tallRichHandsome.handsome = handsome;
}
- (BOOL)tall
{
return !!_tallRichHandsome.tall;
}
- (BOOL)rich
{
return !!_tallRichHandsome.rich;
}
- (BOOL)handsome
{
return !!_tallRichHandsome.handsome;
}
上述代码中使用了结构体的位域,则不再需要使用掩码,使代码可读性增强了不少,但是效率相比使用位运算要低,如果想要高效率又想高可读性,那么就要使用到共用体了。
共用体
为了使代码存取高效率的同时,又有较强的可读性,可以使用共用体来增强代码的可读性,同时使用位运算来提高数据存取的效率
使用共用体优化的代码
#define TallMask (1<<2) // 0b00000100 4
#define RichMask (1<<1) // 0b00000010 2
#define HandsomeMask (1<<0) // 0b00000001 1
@interface Person()
{
union {
char bits;
// 结构体仅仅是为了增强代码可读性,无实质用处
struct {
char tall : 1;
char rich : 1;
char handsome : 1;
};
}_tallRichHandsome;
}
@end
@implementation Person
- (void)setTall:(BOOL)tall
{
if (tall) {
_tallRichHandsome.bits |= TallMask;
}else{
_tallRichHandsome.bits &= ~TallMask;
}
}
- (void)setRich:(BOOL)rich
{
if (rich) {
_tallRichHandsome.bits |= RichMask;
}else{
_tallRichHandsome.bits &= ~RichMask;
}
}
- (void)setHandsome:(BOOL)handsome
{
if (handsome) {
_tallRichHandsome.bits |= HandsomeMask;
}else{
_tallRichHandsome.bits &= ~HandsomeMask;
}
}
- (BOOL)tall
{
return !!(_tallRichHandsome.bits & TallMask);
}
- (BOOL)rich
{
return !!(_tallRichHandsome.bits & RichMask);
}
- (BOOL)handsome
{
return !!(_tallRichHandsome.bits & HandsomeMask);
}
上面的代码中使用位运算这种高效的方式存取值,使用union共用体来对数据进行存储。增加存取效率的同时提高了代码可读性。
其中_tallRichHandsome
共用体只占用一个字节,因为结构体中tall、rich、handsome都只占1个二进制位空间,所以结构体只占一个字节空间,而char类型的bits也只占用一个字节空间,它们都在共用体中,它们共用同一块内存空间,即共用一个字节的内存空间即可。
并且在getter、setter方法中并没有用到共用体,结构体仅仅为了增加代码可读性,指明共用体重存储了哪些值,以及这些值占多少个二进制位内存空间。同时存取值使用位运算来提高效率,存储使用共用体,存放的位置还是通过掩码进行位运算来控制。
至此,优化工作就完成了,优化后的代码不仅高效,可读性也高。这时,我们在回到isa_t共用体的源码中看看。
三、isa_t源码
精简过的isa_t源码
// 精简过的isa_t共用体
union isa_t
{
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
# if __arm64__
# define ISA_MASK 0x0000000ffffffff8ULL
# define ISA_MAGIC_MASK 0x000003f000000001ULL
# define ISA_MAGIC_VALUE 0x000001a000000001ULL
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;
# define RC_ONE (1ULL<<45)
# define RC_HALF (1ULL<<18)
};
#endif
};
经过上面对位运算、位域以及共用体的分析,现在再来看源码我们就可以很好的理解其中的内容。源码中通过共用体存储了64个二进制位的值,这些值在结构体中被展示出来,通过对bits进行位运算而取出相应位置的值。
这里主要关注一下shiftcls
,它里面存储着类对象、元类对象的内存地址,实例对象的isa指针需要和ISA_MASK
进行&(按位与)
运算才能得到真正的类对象在内存中的地址。
我们再来看看ISA_MASK
的值0x0000000ffffffff8ULL
,使用Mac电脑上的编程计算器将其转化为二进制数
可以看到ISA_MASK
的值转化为二进制后,其中有33位都为1,此时我们已经知道,用这个33个1通过&(按位与)
可以取出对应位的值。即通过ISA_MASK
可以取出类对象或元类对象在内存中的地址值。
值得注意的是,ISA_MASK
最后3位的值为0,所以任何数通过ISA_MASK
按位与得到的数,最后3位必定都为0,转化为十六进制末位必定为8或0。
四、isa中存储的信息及作用
isa中存储的信息及作用
struct {
// 0代表普通的指针,存储着Class,Meta-Class对象的内存地址。
// 1代表优化后的使用位域存储更多的信息。
uintptr_t nonpointer : 1;
// 是否有设置过关联对象,如果没有,释放时会更快
uintptr_t has_assoc : 1;
// 是否有C++析构函数,如果没有,释放时会更快
uintptr_t has_cxx_dtor : 1;
// 存储着Class、Meta-Class对象的内存地址信息
uintptr_t shiftcls : 33;
// 用于在调试时分辨对象是否未完成初始化
uintptr_t magic : 6;
// 是否有被弱引用指向过。
uintptr_t weakly_referenced : 1;
// 对象是否正在释放
uintptr_t deallocating : 1;
// 引用计数器是否过大无法存储在isa中
// 如果为1,那么引用计数会存储在一个叫SideTable的类的属性中
uintptr_t has_sidetable_rc : 1;
// 里面存储的值是引用计数器减1
uintptr_t extra_rc : 19;
};
验证
我们通过下面一段代码来验证一下上述注释,需要注意的是,应当在真机上运行,因为真机上才是__arm64__
架构,而模拟器上是x86架构。
- (void)viewDidLoad {
[super viewDidLoad];
Person *person = [[Person alloc] init];
NSLog(@"%p",[person class]);
NSLog(@"%@",person);
}
首先打印person类对象的地址,之后通过断点打印person对象的isa指针的地址。
将类对象的地址值转换为二进制
将person对象ps的isa指针地址转换为二进制
shiftcls:shiftcls
中存储类对象的地址,通过上面两张图对比可以发现存储类对象地址的33位二进制内容完全相同。
extra_rc:extra_rc
的19位中存储着的值为引用计数-1,因为此时person的引用计数为1,因此此时extra_rc
的19位二进制中存储的是0。
magic:magic
的6位用于在调试时标记对象是否未完成初始化,上述代码中person已经完成初始化,那么此时这6位二进制中存储的值011010
即为共用体重定义的宏# define ISA_MAGIC_VALUE 0x000001a000000001ULL
的值。
nonpointer:这里肯定是使用的优化后的isa,因此nonpointer
的值肯定为1。
因为此时person对象没有关联对象并且没有弱指针引用过,可以看出has_assoc
和weakly_referenced
的值都为0,接着我们person对象添加弱引用和关联对象,来观察一下has_assoc
和weakly_referenced
的变化。
- (void)viewDidLoad {
[super viewDidLoad];
Person *person = [[Person alloc] init];
NSLog(@"%p",[Person class]);
__weak Person *weakPerson = person;
objc_setAssociatedObject(person, @"name", @"jack", OBJC_ASSOCIATION_RETAIN_NONATOMIC);
NSLog(@"%@",person);
}
现在再重新打印person的isa指针地址,用Mac自带的计算器,将其转化为二进制可以看到has_assoc
和weakly_referenced
的值都变成了1。
值得注意的是:只要设置过关联对象或弱引用过对象,has_assoc
和weakly_referenced
的值就会变成1,无论之后是否将关联对象置为nil或不再弱引用对象。
如果没有设置过关联对象,对象释放时会更快,这是因为对象在销毁时会判断是否有关联对象,如果有,就要去释放关联对象。
对象销毁源码
void *objc_destructInstance(id obj)
{
if (obj) {
Class isa = obj->getIsa();
// 是否有c++析构函数
if (isa->hasCxxDtor()) {
object_cxxDestruct(obj);
}
// 是否有关联对象,如果有则移除
if (isa->instancesHaveAssociatedObjects()) {
_object_remove_assocations(obj);
}
objc_clear_deallocating(obj);
}
return obj;
}
希望到这里大家能对isa指针有一个新的认识,__arm64__
架构之后,isa指针不再只是只存储Class(类对象)
或Meta-Class(元类对象)
的地址。而是使用共用体的方式充分利用了8个字节,64个二进制位存储了更多信息,其中shiftcls
占用33个二进制位,存储了Class(类对象)
或Meta-Class(元类对象)
的地址,需要同ISA_MASK
进行&(按位与)
才可以取出其地址值。