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

iOS之武功秘籍 文章汇总

写在前面

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

本节可能用到的秘籍Demo

一、对象的本质

① Clang的了解

  • Clang是⼀个由Apple主导编写,基于LLVMC/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 (⼿机) 

③ 探索对象本质

  • 构建测试代码
  • 通过终端,利用clangmain.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:底层编译不会生成相应的settergetter方法,且没有转化为_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:底层编译会生成相应的settergetter方法,且帮我们转化为_name成员变量,而对于成员变量helloName:底层编译不会生成相应的settergetter方法,且没有转化为_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是如何将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)

  • 联合体是一个结构
  • 它的所有成员相对于基地址的偏移量都为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类型的属性,分别为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内部成员分布如下图

  • 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作为联合体具有互斥性,而clsbitsisa的元素,所以当!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;下一行,表示为isabits成员赋值,重新执行LLDB命令p newisa,得到的结果如下

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

  • 其中magic是59是由于将isa指针地址转换为二进制,从47(因为前面有4个位域,共占用47位,地址是从0开始)位开始读取6位,再转换为十进制,如下图所示

⑥. isa与类的关联

clsisa 关联原理就是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存储到newisashiftcls

    • p (uintptr_t)cls >> 3
    • 通过上一步的结果$2,执行LLDB命令p $2 >> 3
  • 继续执行程序到isa = newisa;部分,此时执行p newisa

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

  • cls 由默认值,变成了TCJPerson,将isacls完美关联
  • shiftcls0变成了536875042

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

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

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

方式二:通过 isa & ISA_MSAK

方式三:通过 object_getClass

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

  • main.m中导入#import <objc/runtime.h>
  • 通过runtimeapi,即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,所以由此可以证明 clsisa 是关联的.

三、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)

  • instance对象的isa指向class对象
  • class对象的isa指向meta-class对象
  • meta-class对象的isa指向基类的meta-class对象

写在后面

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容