iOS之武功秘籍 文章汇总
写在前面
在iOS之武功秘籍②:OC对象原理-中(内存对齐和malloc源码分析)一文中讲了对象中的属性在内存中的排列 -- 内存对齐 和malloc
源码分析,那么接下我们就来分析一下isa
的初始化和指向分析与对象的本质
本节可能用到的秘籍Demo
一、对象的本质
① Clang的了解
Clang
是⼀个由Apple
主导编写,基于LLVM
的C/C++/Objective-C
轻量级编译器.源代码发布于LLVM BSD
协议下.Clang
将⽀持其普通lambda
表达式、返回类型的简化处理以及更好的处理constexpr
关键字。它与
GNU C语⾔
规范⼏乎完全兼容
(当然,也有部分不兼容的内容,
包括编译命令选项也会有点差异),并在此基础上增加了额外的语法特性,⽐如C函数重载
(通过__attribute__((overloadable))
来修饰函数),其⽬标(之⼀)就是超越GCC
.它主要是用于
底层编译
,将一些OC
文件输出成C++
文件,例如main.m
输出成main.cpp
,其目的是为了更好的观察底层的一些结构
及实现
的逻辑,方便理解底层原理
② Clang操作指令
// 把⽬标⽂件编译成c++⽂件 -- 将 main.m 编译成 main.cpp
clang -rewrite-objc main.m -o main.cpp
// UIKit报错问题 -- 将 ViewController.m 编译成 ViewController.cpp
clang -rewrite-objc -fobjc-arc -fobjc-runtime=ios-13.0.0 -isysroot /
Applications/Xcode.app/Contents/Developer/Platforms/
iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator14.0.sdk ViewController.m
// `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 main�arm64.cpp (⼿机)
③ 探索对象本质
- 构建测试代码
-
通过终端,利用
clang
将main.m
编译成main.cpp
,在终端输入以下命令- xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp
-
打开编译好的
main-arm64.cpp
,找TCJPerson
的定义,发TCJPerson
在底层会被编译成struct
结构体
通过编译好的main-arm64.cpp
我们可以看到:
-
NSObject
的底层实现其实就是一个包含一个isa
指针的结构体. -
Class
其实就是一个指针,指向了objc_class
类型的结构体. -
TCJPerson_IMPL
结构体内有三个成员变量:-
isa
继承自父类NSObject
helloName
_name
-
- 对于属性
name
:底层编译会生成相应的setter
(_I_TCJPerson_setName_
,setter
方法内调用objc_setProperty
方法)、getter
(_I_TCJPerson_name
)方法,且帮我们转化为_name
- 对于成员变量
helloName
:底层编译不会生成相应的setter
、getter
方法,且没有转化为_helloName
通过上述分析,理解了OC
对象的本质 -- 结构体,但是看到NSObject
的定义,会产生一个疑问:为什么isa
的类型是Class
?
- 在iOS之武功秘籍①:OC对象原理-上(alloc & init & new)文章中,提及过
alloc
方法的核心之一的initInstanceIsa
方法,通过查看这个方法的源码实现,我们发现,在初始化isa
指针时,是通过isa_t
类型初始化的 - 而在
NSObject
定义中isa
的类型是Class
,其根本原因是由于isa
对外反馈的是类信息,为了让开发人员更加清晰明确,需要在isa
返回时做了一个类型强制转换,类似于swift
中的as
的强转.源码中isa
的强转如下图所示
④ 探究属性get、set方法
通过上文的分析我们知道:对于属性name
:底层编译会生成相应的setter
和getter
方法,且帮我们转化为_name
成员变量,而对于成员变量helloName
:底层编译不会生成相应的setter
、getter
方法,且没有转化为_helloName
.这其中的setter
方法的实现依赖于runtime
中的objc_setProperty
.
接下来我们来看看objc_setProperty
的底层实现
-
在
objc4
源码中全局搜索objc_setProperty
,找到objc_setProperty
的源码实现 -
进入
reallySetProperty
的源码实现,其方法的原理就是新值retain
,旧值release
总结:
通过对objc_setProperty
的底层源码探索,有以下几点说明:
objc_setProperty
方法的目的适用于关联上层
的set
方法以及底层
的set
方法,其本质就是一个接口这么设计的原因是,上层的
set
方法有很多,如果直接调用底层set
方法,会产生很多的临时变量,当你想查找一个sel
时,会非常麻烦基于上述原因,苹果采用了
适配器设计模式
(即将底层接口适配为客户端需要的接口),对外提供一个接口,供上层的set
方法使用,对内调用底层的set
方法,使其相互不受影响,即无论上层怎么变,下层都是不变的,或者下层的变化也无法影响上层,主要是达到上下层接口隔离的目的.
下图是上层、隔离层、底层之间的关系
- 外部
set
方法: 上层 - 个性化定制层(例如setName、setAge
等) -
objc_setProperty
:接口隔离层 (将外界信息转化为对内存地址和值的操作) -
reallySetProperty
:底层实现层 (赋值和内存管理)
二、isa底层原理
在iOS之武功秘籍①:OC对象原理-上(alloc & init & new)与iOS之武功秘籍②:OC对象原理-中(内存对齐和malloc源码分析)中分别分析了alloc
中3核心的前两个,今天来探索initInstanceIsa
是如何将cls
与isa
关联的.
在此之前,需要先了解什么是联合体,为什么isa
的类型isa_t
是使用联合体定义的.那么什么是联合体?什么又是位域?
①. 位域
①.1 定义
有些信息在存储时,并不需要占用一个完整的字节,而只需占一个或几个二进制位.例如在存放一个开关量时,只有0和1两种状态,用1位二进位即可.为了节省存储空间并使处理简便,C语言
提供了一种数据结构
,称为位域
或位段
.
所谓位域
就是把一个字节中的二进位划分为几个不同的区域,并说明每个区域的位数.每个域有一个域名,允许在程序中按域名进行操作——这样就可以把几个不同的对象用一个字节的二进制位域来表示.
①.2 与结构体比较
位域的使用与结构体相仿,它本身也是结构体的一种.
// 结构体
struct TCJStruct {
// (类型说明符 元素);
char a;
int b;
} TCJStr;
// 位域
struct TCJBitArea {
// (类型说明符 位域名: 位域长度);
char a: 1;
int b: 3;
} TCJBit;
int main(int argc, const char * argv[]) {
@autoreleasepool {
NSLog(@"Struct:%lu——BitArea:%lu", sizeof(TCJStr), sizeof(TCJBit));
}
return 0;
}
输出Struct:8——BitArea:4
.
②. 联合体
②.1 定义
当多个数据需要共享内存或者多个数据每次只取其一时,可以利用联合体(union)
- 联合体是一个结构
- 它的所有成员相对于基地址的偏移量都为0
- 此结构空间要大到足够容纳最"宽"的成员
- 各变量是“互斥”的——共用一个内存首地址,联合变量可被赋予任一成员值,但每次只能赋一种值, 赋入新值则冲去旧值.
②.2 与结构体比较
结构体每个成员依次存储,联合体中所有成员的偏移地址都是0,也就是所有成员是叠在一起的,所以在联合体中在某一时刻,只有一个成员有效——结构体内存大小取决于所有元素,联合体取决于最大那个
②.3 补充知识--位运算符
在计算机语言中,除了加、减、乘、除等这样的算术运算符之外还有很多运算符,这里只为大家简单讲解一下位运算符.
位运算符用来对二进制位进行操作,当然,操作数只能为整型和字符型数据。C
语言中六种位运算符:&
按位与、|
按位或、^
按位异或、~
非、<<
左移和>>
右移。
我们依旧引用上面的电灯开关论,只不过现在我们有两个开关:开关A和开关B,1
代表开,0
代表关.
1)按位与&
有0出0,全1出1.
A | B | & |
---|---|---|
0 | 0 | 0 |
1 | 0 | 0 |
0 | 1 | 0 |
1 | 1 | 1 |
我们可以理解为在按位与运算中,两个开关是串联的,如果我们想要灯亮,需要两个开关都打开灯才会亮,所以是1 & 1 = 1. 如果任意一个开关没有打开,灯都不会亮,所以其他运算都是0.
2)按位或 |
有1出1,全0出0.
A | B | I |
---|---|---|
0 | 0 | 0 |
1 | 0 | 1 |
0 | 1 | 1 |
1 | 1 | 1 |
在按位或运算中,我们可以理解为两个开关是并联的,即一个开关开,灯就会亮.只有当两个开关都是关的.灯才不会亮.
3)按位异或^
相同为0,不同为1.
A | B | ^ |
---|---|---|
0 | 0 | 0 |
1 | 0 | 1 |
0 | 1 | 1 |
1 | 1 | 0 |
4)非 ~
非运算即取反运算,在二进制中 1 变 0 ,0 变 1。例如110101
进行非运算后为001010
,即1010
.
5)左移 <<
左移运算就是把<<
左边的运算数的各二进位全部左移若干位,移动的位数即<<
右边的数的数值,高位丢弃,低位补0.
左移n位就是乘以2的n次方.例如:a<<4
是指把a的各二进位向左移动4位.如a=00000011(十进制3),左移4位后为00110000(十进制48).
6)右移 >>
右移运算就是把>>
左边的运算数的各二进位全部右移若干位,>>
右边的数指定移动的位数.例如:设 a=15,a>>2 表示把00001111右移为00000011(十进制3).
②.4 位运算符的运用
1)取值
可以利用按位与 &
运算取出指定位的值,具体操作是想取出哪一位的值就将那一位置为1,其它位都为0,然后同原数据进行按位与计算,即可取出特定的位.
例:
0000 0011
取出倒数第三位的值
// 想取出倒数第三位的值,就将倒数第三位的值置为1,其它位为0,跟原数据按位与运算
0000 0011
& 0000 0100
------------
0000 0000 // 得出按位与运算后的结果,即可拿到原数据中倒数第三位的值为0
上面的例子中,我们从0000 0011
中取值,则有0000 0011
被称之为源码.进行按位与操作设定的0000 0100
称之为掩码.
2)设值
可以通过按位或 |
运算符将某一位的值设为1或0.具体操作是:
想将某一位的值置为1的话,那么就将掩码中对应位的值设为1,掩码其它位为0,将源码与掩码进行按位或操作即可.
例: 将
0000 0011
倒数第三位的值改为1
// 改变倒数第三位的值,就将掩码倒数第三位的值置为1,其它位为0,跟源码按位或运算
0000 0011
| 0000 0100
------------
0000 0111 // 即可将源码中倒数第三位的值改为1
想将某一位的值置为0的话,那么就将掩码中对应位的值设为0,掩码其它位为1,将源码与掩码进行按位或操作即可.
例: 将
0000 0011
倒数第二位的值改为0
// 改变倒数第二位的值,就将掩码倒数第二位的值置为0,其它位为1,跟源码按位或运算
0000 0011
| 1111 1101
------------
0000 0001 // 即可将源码中倒数第二位的值改为0
到这里相信大家对位运算符有了一定的了解.
③. 结构体位域与联合体的使用
我们来看下面的:我们声明一个TCJCar
类,类中有四个BOOL
类型的属性,分别为front
、back
、left
、right
,通过这四个属性来判断这辆小车的行驶方向.
然后我们来查看一下这个TCJCar
类对象所占据的内存大小:
我们看到,一个TCJCar
类的对象占据16个字节.其中包括一个isa
指针和四个BOOL
类型的属性,8+1+1+1+1=12,根据内存对齐原则,所以一个TCJCar
类的对象占16个字节.
我们知道,BOOL
值只有两种情况:0
或1
,占据一个字节的内存空间.而一个字节的内存空间中又有8
个二进制位,并且二进制同样只有0
或1
,那么我们完全可以使用1个二进制位来表示一个BOOL
值.也就是说我们上面声明的四个BOOL
值最终只使用4个二进制位就可以,这样就节省了内存空间.那我们如何实现呢?
想要实现四个BOOL
值存放在一个字节中,我们可以通过char
类型的成员变量来实现.char
类型占一个字节内存空间,也就是8个二进制位.可以使用其中最后四个二进制位来存储4个BOOL
值.
当然我们不能把char
类型写成属性,因为一旦写成属性,系统会自动帮我们添加成员变量,自动实现set
和get
方法.
@interface TCJCar(){
char _frontBackLeftRight;
}
如果我们赋值_frontBackLeftRight
为1
,即0b 0000 0001
,只使用8个二进制位中的最后4个分别用0
或者1
来代表front
、back
、left
、right
的值.那么此时front
、back
、left
、right
的状态为:
我们可以分别声明front
、back
、left
、right
的掩码,来方便我们进行下一步的位运算取值和赋值:
#define TCJDirectionFrontMask 0b00001000 //此二进制数对应十进制数为 8
#define TCJDirectionBackMask 0b00000100 //此二进制数对应十进制数为 4
#define TCJDirectionLeftMask 0b00000010 //此二进制数对应十进制数为 2
#define TCJDirectionRightMask 0b00000001 //此二进制数对应十进制数为 1
通过对位运算符的左移<<
和右移>>
的了解,我们可以将上面的代码优化成:
#define TCJDirectionFrontMask (1 << 3)
#define TCJDirectionBackMask (1 << 2)
#define TCJDirectionLeftMask (1 << 1)
#define TCJDirectionRightMask (1 << 0)
自定义的set
方法如下:
- (void)setFront:(BOOL)front
{
if (front) {// 如果需要将值置为1,将源码和掩码进行按位或运算
_frontBackLeftRight |= TCJDirectionFrontMask;
} else {// 如果需要将值置为0 // 将源码和按位取反后的掩码进行按位与运算
_frontBackLeftRight &= ~TCJDirectionFrontMask;
}
}
- (void)setBack:(BOOL)back
{
if (back) {
_frontBackLeftRight |= TCJDirectionBackMask;
} else {
_frontBackLeftRight &= ~TCJDirectionBackMask;
}
}
- (void)setLeft:(BOOL)left
{
if (left) {
_frontBackLeftRight |= TCJDirectionLeftMask;
} else {
_frontBackLeftRight &= ~TCJDirectionLeftMask;
}
}
- (void)setRight:(BOOL)right
{
if (right) {
_frontBackLeftRight |= TCJDirectionRightMask;
} else {
_frontBackLeftRight &= ~TCJDirectionRightMask;
}
}
自定义的get
方法如下:
- (BOOL)isFront
{
return !!(_frontBackLeftRight & TCJDirectionFrontMask);
}
- (BOOL)isBack
{
return !!(_frontBackLeftRight & TCJDirectionBackMask);
}
- (BOOL)isLeft
{
return !!(_frontBackLeftRight & TCJDirectionLeftMask);
}
- (BOOL)isRight
{
return !!(_frontBackLeftRight & TCJDirectionRightMask);
}
此处需要注意的是,代码中!
为逻辑运算符非,因为_frontBackLeftRight & TCJDirectionFrontMask
代码执行后,返回的肯定是一个整型数,如当front
为YES
时,说明二进制数为0b 0000 1000
,对应的十进制数为8,那么进行一次逻辑非运算后,!(8)
的值为0
,对0
再进行一次逻辑非运算!(0)
,结果就成了1
,那么正好跟front
为YES
对应.所以此处进行两次逻辑非运算,!!
.
当然,还要实现初始化方法:
- (instancetype)init
{
self = [super init];
if (self) {
_frontBackLeftRight = 0b00001000;
}
return self;
}
通过测试验证,我们完成了取值和赋值:
③.1 使用结构体位域优化代码
我们在上文讲到了位域
的概念,那么我们就可以使用结构体位域
来优化一下我们的代码.这样就不用再额外声明上面代码中的掩码部分了.位域声明格式是位域名: 位域长度
.
在使用位域
的过程中需要注意以下几点:
- 如果一个字节所剩空间不够存放另一位域时,应从下一单元起存放该位域.
- 位域的长度不能大于数据类型本身的长度,比如
int
类型就不能超过32位二进位. - 位域可以无位域名,这时它只用来作填充或调整位置.无名的位域是不能使用的.
来测试看一下是否正确,这次我们将front
设为YES
、back
设为NO
、left
设为NO
、right
设为YES
:
依旧能完成赋值和取值.
但是代码这样优化后我们去掉了掩码和初始化的代码,可读性很差,我们继续使用联合体进行优化:
③.2 使用联合体优化代码
我们可以使用比较高效的位运算来进行赋值和取值,使用union
联合体来对数据进行存储。这样不仅可以增加读取效率,还可以增强代码可读性.
#import "TCJCar.h"
//#define TCJDirectionFrontMask 0b00001000 //此二进制数对应十进制数为 8
//#define TCJDirectionBackMask 0b00000100 //此二进制数对应十进制数为 4
//#define TCJDirectionLeftMask 0b00000010 //此二进制数对应十进制数为 2
//#define TCJDirectionRightMask 0b00000001 //此二进制数对应十进制数为 1
#define TCJDirectionFrontMask (1 << 3)
#define TCJDirectionBackMask (1 << 2)
#define TCJDirectionLeftMask (1 << 1)
#define TCJDirectionRightMask (1 << 0)
@interface TCJCar()
{
union{
char bits;
// 结构体仅仅是为了增强代码可读性
struct {
char front : 1;
char back : 1;
char left : 1;
char right : 1;
};
}_frontBackLeftRight;
}
@end
@implementation TCJCar
- (instancetype)init
{
self = [super init];
if (self) {
_frontBackLeftRight.bits = 0b00001000;
}
return self;
}
- (void)setFront:(BOOL)front
{
if (front) {
_frontBackLeftRight.bits |= TCJDirectionFrontMask;
} else {
_frontBackLeftRight.bits &= ~TCJDirectionFrontMask;
}
}
- (BOOL)isFront
{
return !!(_frontBackLeftRight.bits & TCJDirectionFrontMask);
}
- (void)setBack:(BOOL)back
{
if (back) {
_frontBackLeftRight.bits |= TCJDirectionBackMask;
} else {
_frontBackLeftRight.bits &= ~TCJDirectionBackMask;
}
}
- (BOOL)isBack
{
return !!(_frontBackLeftRight.bits & TCJDirectionBackMask);
}
- (void)setLeft:(BOOL)left
{
if (left) {
_frontBackLeftRight.bits |= TCJDirectionLeftMask;
} else {
_frontBackLeftRight.bits &= ~TCJDirectionLeftMask;
}
}
- (BOOL)isLeft
{
return !!(_frontBackLeftRight.bits & TCJDirectionLeftMask);
}
- (void)setRight:(BOOL)right
{
if (right) {
_frontBackLeftRight.bits |= TCJDirectionRightMask;
} else {
_frontBackLeftRight.bits &= ~TCJDirectionRightMask;
}
}
- (BOOL)isRight
{
return !!(_frontBackLeftRight.bits & TCJDirectionRightMask);
}
@end
来我们测试看一下是否正确,这次我们依旧将front
设为YES
、back
设为NO
、left
设为NO
、right
设为YES
:
通过结果我们看到依旧能完成赋值和取值.
这其中_frontBackLeftRight
联合体只占用一个字节,因为结构体中front
、back
、left
、right
都只占一位二进制空间,所以结构体只占一个字节,而char
类型的bits
也只占一个字节.他们都在联合体中,因此共用一个字节的内存即可.
而且我们在set
、get
方法中的赋值和取值通过使用掩码进行位运算来增加效率,整体逻辑也就很清晰了.但是如果我们在日常开发中这样写代码的话,很可能会被同事打死.虽然代码已经很清晰了,但是整体阅读起来还是很吃力的.我们在这里学习了位运算以及联合体这些知识,更多的是为了方便我们阅读OC
底层的代码.下面我们来回到本文主题,查看一下isa_t
联合体的源码.
④. isa_t联合体
通过源码我们发现isa
它是一个联合体,联合体是一个结构占8个字节,它的特性就是共用内存,或者说是互斥,比如说如果cls
赋值了就不在对bits
进行赋值.在isa_t
联合体内使用宏ISA_BITFIELD
定义了位域,我们进入位域内查看源码:
我们看到,在内部分别定义了arm64
位架构和x86_64
架构的掩码和位域.我们只分析arm64
为架构下的部分内容(真机环境下).
可以清楚的看到ISA_BITFIELD
位域的内容以及掩码ISA_MASK
的值:0x0000000ffffffff8ULL
.我们重点看一下uintptr_t shiftcls : 33;
,在shiftcls
中存储着类对象和元类对象的内存地址信息,我们上文讲到,对象的isa
指针需要同ISA_MASK
经过一次按位与运算才能得出真正的类对象地址.那么我们将ISA_MASK
的值0x0000000ffffffff8ULL
转化为二进制数分析一下:
从图中可以看到ISA_MASK
的值转化为二进制中有33位都为1,上文讲到按位与运算是可以取出这33位中的值.那么就说明同ISA_MASK
进行按位与运算就可以取出类对象和元类对象的内存地址信息了.
不同架构下isa
所占内存均为8字节
——64位
,但内部分布有所不同,arm64
架构isa
内部成员分布如下图
nonpointer
:表示是否对isa
指针开启指针优化 ——0
:纯isa指针
;1
:不止是类对象地址
,isa
中包含了类信息
、对象的引用计数
等has_assoc
:关联对象标志位
,0
没有,1
存在has_cxx_dtor
:该对象是否有C++
或者Objc
的析构器,如果有析构函数,则需要做析构逻辑, 如果没有,则可以更快的释放对象shiftcls
:存储类指针的值(类的地址),即类、元类对象的内存地址信息.在开启指针优化的情况下,在 arm64 架构中有 33 位用来存储类指针magic
:用于调试器判断当前对象是真的对象
还是没有初始化的空间
weakly_referenced
:对象是否被指向
或者曾经指向一个 ARC 的弱变量
,没有弱引用的对象可以更快释放deallocating
:标志对象是否正在释放
内存has_sidetable_rc
:当对象引用技术大于 10
时,则需要借用该变量存储进位
extra_rc
:当表示该对象的引用计数值
,实际上是引用计数值减 1, 例如,如果对象的引用计数为 10
,那么extra_rc
为 9。如果引用计数大于 10
, 则需要使用到下面的has_sidetable_rc
上面所说的当对象
引用技术大于 10
时,那是一个例如, 不是具体的10.
至此我们已经对isa
指针有了新的认识,arm64
架构之后,isa
指针不单单只存储了类对象和元类对象的内存地址,而是使用联合体的方式存储了更多信息,其中shiftcls
存储了类对象和元类对象的内存地址,需要同ISA_MASK
进行按位与 &
运算才可以取出其内存地址值.
⑤. isa原理探索
⑤.1 isa初始化
在之前的iOS之武功秘籍①:OC对象原理-上(alloc & init & new)一文中轻描淡写的提了一句obj->initInstanceIsa(cls, hasCxxDtor)
—— 只知道内部调用initIsa(cls, true, hasCxxDtor)
初始化isa
,并没有对isa
进行细说.
⑤.2 initIsa分析
-
isa_t newisa(0)
相当于初始化isa
这个东西,newisa.
相当于给isa
赋值属性. -
SUPPORT_INDEXED_ISA
适用于WatchOS
,isa
作为联合体具有互斥性,而cls
、bits
是isa
的元素,所以当!nonpointer=true
时对cls
进行赋值操作,为false
是对bits
进行赋值操作(反正都是一家人,共用一块内存地址).
⑤.3 验证isa指针 位域(0-64)
根据前文提及的0-64
位域,可以在这里通过initIsa
方法证明isa
指针中有这些位域(目前是处于macOS,所以使用的是x86_64).
- 首先通过
main
中的TCJPerson
断点 -->initInstanceIsa
-->initIsa
-->isa_t newisa(0)
完成isa
初始化. - 执行
LLDB
指令:p newisa
,得到newisa
的详细信息 - 继续往下执行,走到
newisa.bits = ISA_MAGIC_VALUE;
下一行,表示为isa
的bits
成员赋值,重新执行LLDB
命令p newisa
,得到的结果如下
通过与前一个newsize
的信息对比,发现isa
指针中有一些变化,如下图所示
- 其中
magic
是59是由于将isa
指针地址转换为二进制,从47(因为前面有4个位域,共占用47位,地址是从0开始)位开始读取6位,再转换为十进制,如下图所示
⑥. isa与类的关联
cls
与 isa
关联原理
就是isa
指针中的shiftcls
位域中存储了类信息
,其中initInstanceIsa
的过程是将 calloc
返回的指针 和当前的 类cls
关联起来,有以下几种验证方式:
- 【方式一】通过
initIsa
方法中的newisa.setClass(cls, this);
方法里面的shiftcls = (uintptr_t)newCls >> 3
验证 - 【方式二】通过
isa
指针地址与ISA_MSAK
的值&
来验证 - 【方式三】通过
runtime
的方法object_getClass
验证 - 【方式四】通过
位运算
验证
方式一:通过 initIsa 方法
-
运行至
newisa.setClass(cls, this);
方法中shiftcls = (uintptr_t)newCls >> 3;
前一步,其中shiftcls
存储当前类的值信息
- 此时查看
cls
,是TCJPerson
类 -
shiftcls
赋值的逻辑是将TCJPerson
进行编码后,右移3位
- 此时查看
-
执行
LLDB
命令p (uintptr_t)cls
,结果为(uintptr_t) $2 = 4295000336
,再右移三位
,有以下两种方式(任选其一),将得到536875042
存储到newisa
的shiftcls
中p (uintptr_t)cls >> 3
- 通过上一步的结果
$2
,执行LLDB
命令p $2 >> 3
-
继续执行程序到
isa = newisa;
部分,此时执行p newisa
与bits
赋值结果的对比,bits
的位域中有两处变化
-
cls
由默认值,变成了TCJPerson
,将isa
与cls
完美关联 -
shiftcls
由0
变成了536875042
所以isa
中通过初始化后的成员的值变化过程,如下图所示
为什么在shiftcls赋值时需要类型强转?
因为内存
的存储不能存储字符串
,机器码
只能识别 0 、1
这两种数字,所以需要将其转换为uintptr_t
数据类型,这样shiftcls
中存储的类信息
才能被机器码理解
, 其中uintptr_t
是long
类型.
为什么需要右移3位?
主要是由于shiftcls
处于isa
指针地址的中间
部分,前面还有3个
位域,为了不影响前面的3个
位域的数据,需要右移
将其抹零
.
方式二:通过 isa & ISA_MSAK
方式三:通过 object_getClass
通过查看object_getClass
的源码实现,同样可以验证isa
与类关联的原理,有以下几步:
-
main.m
中导入#import
- 通过
runtime
的api
,即object_getClass
函数获取类信息
object_getClass(<#id _Nullable obj#>)
-
查看
object_getClass
函数 源码的实现 -
点击进入
object_getClass
底层实现 -
进入
getIsa
的源码实现 -
点击
ISA()
,进入源码,在点击getDecodedClass
-
接着点击
getClass
这与方式二中的原理是一致的,获得当前的类信息,从这里也可以得出 cls 与 isa 已经完美关联
方式四:通过位运算
-
回到
_class_createInstanceFromZone
方法.通过x/4gx obj
得到obj
的存储信息,当前类的信息存储在isa
指针中,且isa
中的shiftcls
此时占44位
(因为处于macOS
环境) -
想要读取中间的
44位
类信息,就需要经过位运算
,将右边3位
,和左边除去44位以外的部分
都抹零
,其相对位置是不变的.其位运算过程如图所示,其中shiftcls即为需要读取的类信息- 将
isa
地址右移3
位:p/x 0x011d800100008111 >> 3
,得到0x0023b00020001022
- 在将得到的
0x0023b00020001022``左移20
位:p/x 0x0023b00020001022 << 20
,得到0x0002000102200000
- 为什么是
左移20
位?因为先右移了3位
,相当于向右偏移了3位
,而左边
需要抹零
的位数有17位
,所以一共需要移动20位
- 将得到的
0x0002000041d00000
再右移17位
:p/x 0x0002000102200000 >> 17
得到新的0x0000000100008110
- 将
-
获取
cls
的地址 与 上面的进行验证 :p/x cls
也得出0x0000000100008110
,所以由此可以证明cls
与isa
是关联的.
三、isa走位分析
③.1 类在内存中只会存在一份
我们都知道对象可以创建多个,那么类是否也可以创建多个呢? 答案是一个.怎么验证它呢? 来我们看下面代码及打印结果:通过运行结果证明了类在内存中只会存在一份.
③.2.1 通过对象/类查看isa走向
类
其实和实例对象
一样,都是由上级实例化出来的——类的上级叫做元类
.
我们先用p/x
打印类的内存地址,再用x/4gx
打印内存结构取到对应的isa
,再用& ISA_MASK
进行偏移得到isa
指向的上级(等同于object_getClass
)依次循环.
①打印TCJPerson类
取得isa
②由TCJPerson类
进行偏移得到TCJPerson元类
指针,打印TCJPerson元类
取得isa
③由TCJPerson元类
进行偏移得到NSObject根元类
指针,打印NSObject根元类
取得isa
④由NSObject根元类
进行偏移得到NSObject根元类
本身指针
⑤打印NSObject根类
取得isa
⑥由NSObject根类
进行偏移得到NSObject根元类
指针
结论:
①实例对象-> 类对象 -> 元类 -> 根元类 -> 根元类(本身)
②NSObject(根类) -> 根元类 -> 根元类(本身)
③指向根元类的isa都是一样的
③.2.2 通过NSObject查看isa走向
因为是NSObject
(根类)它的元类就是根元类
——输出可得根元类指向自己
③.2.3 证明类、元类是系统创建的
①运行时伪证法
在main
之前TCJPerson类
和TCJPerson元类
已经存在在内存中,不过此时程序已经在运行了,并没有什么说服力.
②查看MachO文件法
编译项目后,使用MachoView打开程序二进制可执行文件查看:
结论:
- 对象是程序员(猿)根据类实例化来的
- 类是代码编写的,内存中只有一份,是系统创建的
- 元类是系统编译时,系统编译器创建的,便于方法的编译
③.3 isa走位图
我们对上图进行总结一波:图中实线是 super_class
指针,它代表着继承链的关系.虚线是isa
指针.
isa走位(虚线):实例对象-> 类对象 -> 元类 -> 根元类 -> 根元类(本身)
继承关系(实线):NSObject父类为nil,根元类的父类为NSObject
1.Root class (class)
其实就是NSObject
,NSObject
是没有超类的,所以Root class(class)
的superclass
指向nil
(NSObject
父类是nil
).
2.每个Class
都有一个isa指针指向唯一的Meta class
.
3.Root class(meta)
的superclass
指向Root class(class)
,也就是NSObject
,形成一个回路.这说明Root class(meta)
是继承至Root class(class)
(根元类的父类是NSObject
).
4.每个Meta class
的isa
指针都指向Root class (meta)
-
instance
对象的isa
指向class
对象 -
class
对象的isa
指向meta-class
对象 -
meta-class
对象的isa
指向基类的meta-class
对象
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.