1. isa
指针的本质
在学习Runtime
之前首先需要对isa的本质有一定的了解,这样之后学习Runtime
会更便于理解。
回顾一下之前学过的OC
对象的本质,每个OC
对象都含有一个isa
指针,__arm64__
之前,isa
仅仅是一个指针,保存着对象或类对象内存地址,在__arm64__
架构之后,Apple
对isa
进行了优化,变成了一个共用体(union
)结构,同时使用位域来存储更多的信息。
我们知道OC
对象的isa
指针并不是直接指向类对象或者元类对象,而是需要经过&ISA_MASK
位运算才能获取到类对象或者元类对象的地址。那么为什么需要&ISA_MASK
才能获取到类对象或者元类对象的地址?这样处理有什么好处?
在源码中找到isa
指针,看一下isa
指针的本质:
objc源码路径:https://opensource.apple.com/source/objc4/objc4-756.2/runtime/objc-private.h.auto.html
// 截取objc_object内部分代码
struct objc_object {
private:
isa_t isa;
...
...
...
}
isa
指针其实是一个isa_t
类型的共用体,来到isa_t
内部查看其结构:
// 精简过的isa_t共用体
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
语言中,被称作“共用体”类型结构,简称共用体。
接下来使用共用体的方式来深入的了解Apple
为什么要使用共用体,以及使用共用体的好处:
2. 探寻过程
创建一个Person
类并含有三个BOOL
类型的属性:
@interface Person : NSObject
@property (nonatomic, assign, getter = isTall) BOOL tall;
@property (nonatomic, assign, getter = isRich) BOOL rich;
@property (nonatomic, assign, getter = isHandsome) BOOL handsome;
@end
int main(int argc, char * argv[]) {
NSString * appDelegateClassName;
@autoreleasepool {
appDelegateClassName = NSStringFromClass([AppDelegate class]);
NSLog(@"%zd", class_getInstanceSize([Person class]));
// 输出:16
}
return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}
上述代码中Person
含有3个BOOL
类型的属性,打印Person
类对象占据内存空间为16
字节,也就是(isa指针 = 8
) + (BOOL tall = 1
) + (BOOL rich = 1
) + (BOOL handsome = 1
) = 13。因为内存对齐原则所以Person
类对象占据内存空间为16
。
那么我们是否可以不使用属性来存储上面的3个变量,因为每个属性都会生成成员变量,每个BOOL
类型的成员变量占用1个字节,我们可以使用更小的空间来存储以上变量,可以很大程度上节省内存空间。
2.1 使用1字节char
类型来存储
BOOL
值只有两种情况0或者 1,但是却占据了1个字节的内存空间,而一字节有8个二进制位,并且二进制只有0或者1 。那么是否可以使用1个二进制位来表示一个BOOL
值,也就是说3个BOOL
值最终只使用3个二进制位,也就是1字节的内存空间即可呢?如何实现这种方式?
首先如果使用这种方式需要自己写方法声明与实现,不可以写属性,因为一旦写属性,系统会自动帮我们添加成员变量。
另外想要将三个BOOL
值存放在一个字节中,我们可以添加一个char
类型的成员变量,char
类型占据1个字节内存空间,也就是8个二进制位。可以使用其中最后三个二进制位来存储3个BOOL
值。
@interface Person()
{
char _tallRichHandsome;
}
例如_tallRichHansome
的值为0b 0000 0010
,那么只使用8个二进制位中的最后3个,分别为其赋值0或者1来代表tall、rich、handsome
的值。如下图所示
那么现在面临的问题就是如何取出8个二进制位中的某一位的值,或者为某一位赋值呢?
2.1.1 取值
首先来看一下取值,假如char
类型的成员变量中存储的二进制为0b 0000 0010
如果想将倒数第2位的值也就是rich
的值取出来,可以使用&
进行按位与运算进而取出相应位置的值。
&
:按位与运算,同真为真,其他都为假
// 示例
// 取出倒数第三位 tall
0000 0010
& 0000 0100
------------
0000 0000 // 取出倒数第三位的值为0,其他位都置为0
// 取出倒数第二位 rich
0000 0010
& 0000 0010
------------
0000 0010 // 取出倒数第二位的值为1,其他位都置为0
说白了,按位与可以用来取出特定的位,想取出哪一位就将那一位置为1
,其他为都置为0
,然后同原数据进行按位与计算,即可取出特定的位。
那么此时可以将get
方法写成如下方式:
#define TallMask 0b00000100 // 4,第3位
#define RichMask 0b00000010 // 2,第2位
#define HandsomeMask 0b00000001 // 1, 第1位
- (BOOL)isTall
{
return !!(_tallRichHandsome & TallMask);
}
- (BOOL)isRich
{
return !!(_tallRichHandsome & RichMask);
}
- (BOOL)isHandsome
{
return !!(_tallRichHandsome & HandsomeMask);
}
上述代码中使用两个!!
(非)来将值改为BOOL
类型,加!
是为了强转,加了!!
两个是为了获得原来的值。
同样使用上面的例子
// 取出倒数第二位 rich
0000 0010 // _tallRichHandsome
& 0000 0010 // RichMask
------------
0000 0010 // 取出rich的值为1,其他位都置为0
上述代码中(_tallRichHandsome & TallMask
)的值为0000 0010
也就是2,但是我们需要的是一个BOOL类型的值0或者 1 ,那么!!2
就将2先转化为0 ,之后又转化为 1。相反如果按位与取得的值为0时,!!0将0先转化为1 之后又转化为 0。
因此使用!!
两个非操作将值转化为0或者1来表示相应的值。
掩码 : 上述代码中定义了三个宏,用来分别进行按位与运算而取出相应的值,一般用来按位与(&
)运算的值称之为掩码。
为了能更清晰的表明掩码是为了取出哪一位的二进制值,上述三个宏的定义可以使用<<
(左移)来优化
<<
:表示左移一位,下图为例。
<<
:左移运算
那么上述宏定义可以使用<<
(左移)优化成如下代码:
#define TallMask (1<<2) // 0b00000100 4
#define RichMask (1<<1) // 0b00000010 2
#define HandsomeMask (1<<0) // 0b00000001 1
我们发现,使用<<
左移之后,掩码本身的值没有发生变化。
2.1.2 设值
设值即是将某一位设值为0或者1,可以使用|
(按位或)操作符。
|
: 按位或,只要有一个1即为1,否则为0
如果想将某一位置为1的话,那么将原本的值与掩码进行按位或|
操作即可。
例如我们想将tall
置为1:
// 将倒数第三位tall的置为1
0000 0010 // _tallRichHandsome
| 0000 0100 // TallMask
------------
0000 0110 // 将tall置为1,其他位值都不变
如果想将某一位置为0的话,需要将掩码按位取反(~
: 按位取反符),之后在与原本的值进行按位与&
操作即可。
例如我们将rich
置为0:
// 将倒数第二位 rich置为0
0000 0010 // _tallRichHandsome
& 1111 1101 // RichMask按位取反
------------
0000 0000 // 将rich置为0,其他位值都不变
此时set
方法内部实现如下:
- (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;
}
}
写完set、get
方法之后通过代码来查看一下是否可以设值、取值成功:
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;
}
输出:
2020-01-16 17:27:14.117626+0800 Runtime的本质1[48482:6405132] tall : 1, rich : 0, handsome : 1
可以看出上述代码可以正常赋值和取值。但是代码还是有一定的局限性,当需要添加新属性的时候,需要重复上述工作,并且代码可读性比较差。接下来使用结构体的位域特性来优化上述代码。
2.2 使用1字节的结构体位域来存储
将上述代码进行优化,使用结构体位域,可以使代码可读性更高。
位域声明格式:位域名 : 位域占用大小(bit位)
使用位域需要注意以下3点:
- 如果一个字节所剩空间不够存放另一位域时,应从下一单元起存放该位域。也可以有意使某位域从下一单元开始。
- 位域的长度不能大于数据类型本身的长度,比如int类型就不能超过32位二进位。
- 位域可以无位域名,这时它只用来作填充或调整位置。无名的位域是不能使用的。
上述代码使用结构体位域优化之后。
@interface Person()
{
struct {
char handsome : 1; // 代表占用一个bit,倒是第一位
char rich : 1; // 按照顺序只占一个bit, 倒数第二位
char tall : 1; // 倒是第三位
}_tallRichHandsome;
}
@end
上述位域结构体会按照顺序依次存储带二进制位中。
set、get
方法中可以直接通过结构体赋值和取值:
- (BOOL)isTall
{
return _tallRichHandsome.tall;
}
- (BOOL)isRich
{
return _tallRichHandsome.rich;
}
- (BOOL)isHandsome
{
return _tallRichHandsome.handsome;
}
- (void)setTall:(BOOL)tall
{
_tallRichHandsome.tall = tall;
}
- (void)setRich:(BOOL)rich
{
_tallRichHandsome.rich = rich;
}
- (void)setHandsome:(BOOL)handsome
{
_tallRichHandsome.handsome = handsome;
}
通过上面的代码验证一下是否可以赋值或取值正确
首先在log
处打个断点,查看结构体位域_tallRichHandsome
,里面存储的值是5
因为_tallRichHandsome
占据1字节内存空间,也就是8个二进制位,取出这8个二进制位00 00 00 00 00 00 00 05
,因为我们打印的地址都是16进制的,所以我们将00 00 00 00 00 00 00 05
16进制转化为2进制查看里面的位值:
我们来读取一下2进制位,倒数第三位也就是tall
值为1,倒数第二位也就是rich
值为0,倒数第一位也就是handsome
值为1,如此看来和上述代码中我们设置的值一样。可以成功赋值。
接着继续打印内容:
2020-01-16 18:03:25.050703+0800 Runtime的本质1[50045:6438500] tall : 1, rich : 0, handsome : 1
上述代码中使用结构体位域则不在需要使用掩码,使代码可读性增强了很多,但是效率相比直接使用位运算的方式来说差很多,如果想要高效率的进行数据的读取与存储同时又有较强的可读性就需要使用到共用体了。
2.3 使用共用体来存储
为了使代码存储数据高效率的同时,有较强的可读性,可以使用共用体来增强代码可读性,同时使用位运算来提高数据存取的效率。
使用共用体优化的代码
上述代码中使用位运算这种比较高效的方式存取值,使用union
共用体来对数据进行存储。增加读取效率的同时增强代码可读性。
@interface Person()
{
union {
char bits;
// 结构体仅仅是为了增强代码可读性,无实质用处
struct {
char tall : 1; // 倒数第一位
char rich : 1; // 倒数第二位
char handsome : 1; // 倒数第三位
};
}_tallRichHandsome;
}
@end
- (BOOL)isTall
{
return !!(_tallRichHandsome.bits & TallMask);
}
- (BOOL)isRich
{
return !!(_tallRichHandsome.bits & RichMask);
}
- (BOOL)isHandsome
{
return !!(_tallRichHandsome.bits & HandsomeMask);
}
- (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;
}
}
上述代码中使用位运算这种比较高效的方式存取值,使用union
共用体来对数据进行存储。增加读取效率的同时增强代码可读性。
其中_tallRichHandsome
共用体只占用1字节,因为结构体中tall、rich、handsome
都只占一1字节空间,所以结构体只占1字节,而char
类型的bits
也只占1字节,他们都在共用体中,因此共用1字节的内存即可。
并且在get、set
方法中并没有使用到结构体,结构体仅仅为了增加代码可读性,指明共用体中存储了哪些值,以及这些值各占多少位空间。同时存值取值还使用位运算来增加效率,存储使用共用体,存放的位置依然通过与掩码进行位运算来控制。
此时代码已经算是优化完成了,高效的同时可读性高,那么此时再回头看isa_t
共用体的源码。
3. 回到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; // 倒数第1位
uintptr_t has_assoc : 1; // 倒数第2位
uintptr_t has_cxx_dtor : 1; // 倒数第3位
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位的值(8字节),这些值在结构体中被展示出来,通过对bits
进行位运算而取出相应位置的值。
这里主要关注一下shiftcls
,,从倒数第3位开始,使用了33位来存储,shiftcls
中存储着Class
、Meta-Class
对象的内存地址信息。
我们之前在OC
对象的本质中提到过,对象的isa
指针需要同ISA_MASK
经过一次&
(按位与)运算才能得出真正的Class
对象地址。
那么此时我们重新来看ISA_MASK
的值0x0000000ffffffff8ULL
,我们将其ffffffff8
转化为二进制数
可以看出ISA_MASK
的值转化为二进制中,从倒数第3位开始有33位都为1。
上面提到过,想要取出相对应位的二进制值,那就把这些位置1,其他位置0,然后按位与(&
)运算,按位与(&
)的作用是可以取出这33位中的值。那么此时很明显了,同ISA_MASK
进行按位与运算即可以取出Class
或Meta-Class
的值。
同时可以看出ISA_MASK
最后三位的值为0,那么任何数同ISA_MASK
按位与运算之后,得到的最后三位必定都为0,因此任何类对象或元类对象的内存地址最后三位必定为0,转化为十六进制的时候末位必定为8或者0(1个16进制位 等于 4个2进制位)。
4. 位运算在枚举中的应用
我们在平常写OC
的代码的时候,经常会把多个枚举值进行|
按位或运算当做一个参数,例如:
view.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight | UIViewAutoresizingFlexibleTopMargin;
NSKeyValueObservingOptions options = NSKeyValueObservingOptionNew | NSKeyValueObservingOptionOld;
其实这背后的原理就是使用了位运算符或|
:
typedef NS_OPTIONS(NSUInteger, UIViewAutoresizing) {
UIViewAutoresizingNone = 0, // 二进制 0000 0000,十进制 0
UIViewAutoresizingFlexibleLeftMargin = 1 << 0, // 二进制 0000 0001,十进制 1
UIViewAutoresizingFlexibleWidth = 1 << 1, // 二进制 0000 0010,十进制 2
UIViewAutoresizingFlexibleRightMargin = 1 << 2, // 二进制 0000 0100,十进制 4
UIViewAutoresizingFlexibleTopMargin = 1 << 3, // 二进制 0000 1000,十进制 8
UIViewAutoresizingFlexibleHeight = 1 << 4, // 二进制 0001 0000,十进制 16
UIViewAutoresizingFlexibleBottomMargin = 1 << 5 // 二进制 0010 0000,十进制 32
};
我们发现以上枚举的值是使用位运算符号左移<<
来表示的,并且遵循一定的规律,即2的n+1
次幂。
那么怎么获取这样多个枚举的值呢UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleRightMargin
?
先将UIViewAutoresizingFlexibleLeftMargin | UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleRightMargin
这3个枚举值叠加按位或|
运算:
0000 0001
| 0000 0010
| 0000 0100
-----------
0000 0111
再将结果0000 0111
分别与每个枚举值进行按位与&
:
0000 0111
& 0000 0001
-----------
0000 0001 UIViewAutoresizingFlexibleLeftMargin
0000 0111
& 0000 0010
-----------
0001 0010 UIViewAutoresizingFlexibleWidth
0000 0111
& 0000 0100
-----------
0000 0100 UIViewAutoresizingFlexibleRightMargin
0000 0111
& 0000 1000
-----------
0000 0000
0000 0111
& 0001 0000
-----------
0000 0000
0000 0111
& 0010 0000
-----------
0000 0000
我们发现经过2步位运算之后,会得到相应的传进来的参数的每一个枚举值,其他的枚举值都为0,这样就可以知道参数传的是哪几个枚举值了。
5. isa
中存储的信息及作用
将结构体取出来标记一下这些信息的作用。
struct {
// 0代表普通的指针,存储着Class,Meta-Class对象的内存地址。
// 1代表优化后的使用位域存储更多的信息。
uintptr_t nonpointer : 1;
// 是否有设置过关联对象,如果没有,释放时会更快
uintptr_t has_assoc : 1;
// 是否有C++析构函数(类似于dealloc函数),如果没有,释放时会更快
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;
};
5.1 验证
通过下面一段代码验证上述信息存储的位置及作用
以下代码需要在真机中运行,因为真机中才是__arm64__
架构
Person *person = [[Person alloc] init];
NSLog(@"%p",[person class]);
NSLog(@"%@",person);
首先打印person
类对象的地址,之后通过断点打印一下person
对象的isa
指针地址。
首先来看一下打印的内容:
2020-01-17 11:52:07.910849+0800 Runtime的本质1[1150:491939] 0x10429d678
(lldb) p/x person->isa
(Class) $0 = 0x000001a10429d679 Person
(lldb)
将类对象地址0x10429d678
转化为二进制
将person
的isa
指针地址0x000001a10429d679
转化为二进制
shiftcls : shiftcls
中存储类对象地址,通过上面两张图对比可以发现存储类对象地址的33位二进制内容完全相同。
extra_rc : extra_rc
的19位中存储着的值为引用计数减一,因为此时person
的引用计数为1,因此此时extra_rc
的19位二进制中存储的是0。
magic : magic
的6位用于在调试时分辨对象是否未完成初始化,上述代码中person
已经完成初始化,那么此时这6位二进制中存储的值000000
即为共用体中定义的宏# define ISA_MAGIC_VALUE 0x000001a000000001ULL
的值。
nonpointer : 这里肯定是使用的优化后的isa
,因此nonpointer
的值肯定为1。
因为此时person
对象没有关联对象并且没有弱指针引用过,可以看出has_assoc
和weakly_referenced
值都为0。
接着我们为person
对象添加弱引用和关联对象,来观察一下has_assoc
和weakly_referenced
的变化。
Person *person = [[Person alloc] init];
NSLog(@"%p",[person class]);
// 为person添加弱引用
__weak Person *weakPerson = person;
// 为person添加关联对象
objc_setAssociatedObject(person, @"name", @"hjr", OBJC_ASSOCIATION_RETAIN_NONATOMIC);
NSLog(@"%@",person);
重新打印person
的isa
指针地址将其转化为二进制可以看到has_assoc
和weakly_referenced
的值都变成了1
(lldb) p/x person->isa
(Class) $1 = 0x000005a104f016e3 Person
(lldb)
注意:只要设置过关联对象或者弱引用引用过对象has_assoc
和weakly_referenced
的值就会变成1,不论之后是否将关联对象置为nil或断开弱引用。
如果没有设置过关联对象,对象释放时会更快,这是因为对象在销毁时会判断是否有关联对象进而对关联对象释放。来看一下对象销毁的源码
objc源码路径:https://opensource.apple.com/source/objc4/objc4-756.2/runtime/objc-runtime-new.mm.auto.html
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
元类对象的地址,而是使用共用体的方式存储了更多信息。
其中shiftcls
存储了Class
类对象或元类对象Meta-Class
的地址,需要同ISA_MASK
进行按位&
运算才可以取出其内存地址值。