iOS之武功秘籍③:OC对象原理-下(isa的初始化和指向分析与

2021-02-19  本文已影响0人  長茳

iOS之武功秘籍 文章汇总

写在前面

iOS之武功秘籍②:OC对象原理-中(内存对齐和malloc源码分析)一文中讲了对象中的属性在内存中的排列 -- 内存对齐 和malloc源码分析,那么接下我们就来分析一下isa的初始化和指向分析与对象的本质

本节可能用到的秘籍Demo

一、对象的本质

① Clang的了解

② 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 (⼿机) 

③ 探索对象本质

通过编译好的main-arm64.cpp我们可以看到:

通过上述分析,理解了OC对象的本质 -- 结构体,但是看到NSObject的定义,会产生一个疑问:为什么isa的类型是Class?

④ 探究属性get、set方法

通过上文的分析我们知道:对于属性name:底层编译会生成相应的settergetter方法,且帮我们转化为_name成员变量,而对于成员变量helloName:底层编译不会生成相应的settergetter方法,且没有转化为_helloName.这其中的setter方法的实现依赖于runtime中的objc_setProperty.

接下来我们来看看objc_setProperty的底层实现

总结:
通过对objc_setProperty的底层源码探索,有以下几点说明:

下图是上层、隔离层、底层之间的关系

二、isa底层原理

iOS之武功秘籍①:OC对象原理-上(alloc & init & new)iOS之武功秘籍②:OC对象原理-中(内存对齐和malloc源码分析)中分别分析了alloc中3核心的前两个,今天来探索initInstanceIsa是如何将clsisa关联的.

在此之前,需要先了解什么是联合体,为什么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)

②.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类型的属性,分别为frontbackleftright,通过这四个属性来判断这辆小车的行驶方向.

然后我们来查看一下这个TCJCar类对象所占据的内存大小:

我们看到,一个TCJCar类的对象占据16个字节.其中包括一个isa指针和四个BOOL类型的属性,8+1+1+1+1=12,根据内存对齐原则,所以一个TCJCar类的对象占16个字节.

我们知道,BOOL值只有两种情况:01,占据一个字节的内存空间.而一个字节的内存空间中又有8个二进制位,并且二进制同样只有01,那么我们完全可以使用1个二进制位来表示一个BOOL值.也就是说我们上面声明的四个BOOL值最终只使用4个二进制位就可以,这样就节省了内存空间.那我们如何实现呢?
想要实现四个BOOL值存放在一个字节中,我们可以通过char类型的成员变量来实现.char类型占一个字节内存空间,也就是8个二进制位.可以使用其中最后四个二进制位来存储4个BOOL值.
当然我们不能把char类型写成属性,因为一旦写成属性,系统会自动帮我们添加成员变量,自动实现setget方法.

@interface TCJCar(){
    char _frontBackLeftRight;
}

如果我们赋值_frontBackLeftRight1,即0b 0000 0001,只使用8个二进制位中的最后4个分别用0或者1来代表frontbackleftright的值.那么此时frontbackleftright的状态为:

我们可以分别声明frontbackleftright的掩码,来方便我们进行下一步的位运算取值和赋值:

#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代码执行后,返回的肯定是一个整型数,如当frontYES时,说明二进制数为0b 0000 1000,对应的十进制数为8,那么进行一次逻辑非运算后,!(8)的值为0,对0再进行一次逻辑非运算!(0),结果就成了1,那么正好跟frontYES对应.所以此处进行两次逻辑非运算,!!.
当然,还要实现初始化方法:

- (instancetype)init
{
    self = [super init];
    if (self) {
        _frontBackLeftRight = 0b00001000;
    }
    return self;
}
通过测试验证,我们完成了取值和赋值:

③.1 使用结构体位域优化代码

我们在上文讲到了位域的概念,那么我们就可以使用结构体位域来优化一下我们的代码.这样就不用再额外声明上面代码中的掩码部分了.位域声明格式是位域名: 位域长度.
在使用位域的过程中需要注意以下几点:

  1. 如果一个字节所剩空间不够存放另一位域时,应从下一单元起存放该位域.
  2. 位域的长度不能大于数据类型本身的长度,比如int类型就不能超过32位二进位.
  3. 位域可以无位域名,这时它只用来作填充或调整位置.无名的位域是不能使用的.
使用位域优化后的代码:

来测试看一下是否正确,这次我们将front设为YESback设为NOleft设为NOright设为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设为YESback设为NOleft设为NOright设为YES:

通过结果我们看到依旧能完成赋值和取值.
这其中_frontBackLeftRight联合体只占用一个字节,因为结构体中frontbackleftright都只占一位二进制空间,所以结构体只占一个字节,而char类型的bits也只占一个字节.他们都在联合体中,因此共用一个字节的内存即可.
而且我们在setget方法中的赋值和取值通过使用掩码进行位运算来增加效率,整体逻辑也就很清晰了.但是如果我们在日常开发中这样写代码的话,很可能会被同事打死.虽然代码已经很清晰了,但是整体阅读起来还是很吃力的.我们在这里学习了位运算以及联合体这些知识,更多的是为了方便我们阅读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内部成员分布如下图

上面所说的当对象引用技术大于 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分析

⑤.3 验证isa指针 位域(0-64)

根据前文提及的0-64位域,可以在这里通过initIsa方法证明isa指针中有这些位域(目前是处于macOS,所以使用的是x86_64).

通过与前一个newsize的信息对比,发现isa指针中有一些变化,如下图所示

⑥. isa与类的关联

clsisa 关联原理就是isa指针中的shiftcls位域中存储了类信息,其中initInstanceIsa的过程是将 calloc返回的指针 和当前的 类cls 关联起来,有以下几种验证方式:

方式一:通过 initIsa 方法

bits赋值结果的对比,bits的位域中有两处变化

所以isa中通过初始化后的成员的值变化过程,如下图所示

为什么在shiftcls赋值时需要类型强转?
因为内存的存储不能存储字符串机器码只能识别 0 、1这两种数字,所以需要将其转换为uintptr_t数据类型,这样shiftcls中存储的类信息才能被机器码理解, 其中uintptr_tlong类型.

为什么需要右移3位?
主要是由于shiftcls处于isa指针地址的中间部分,前面还有3个位域,为了不影响前面的3个位域的数据,需要右移将其抹零.

方式二:通过 isa & ISA_MSAK

方式三:通过 object_getClass

通过查看object_getClass的源码实现,同样可以验证isa与类关联的原理,有以下几步:

object_getClass(<#id  _Nullable obj#>)

方式四:通过位运算

三、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)其实就是NSObjectNSObject是没有超类的,所以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 classisa指针都指向Root class (meta)

写在后面

和谐学习,不急不躁.我还是我,颜色不一样的烟火.

上一篇 下一篇

猜你喜欢

热点阅读