写在前面
在iOS之武功秘籍②:OC对象原理-中(内存对齐和malloc源码分析)一文中讲了对象中的属性在内存中的排列 -- 内存对齐 和malloc源码分析,那么接下我们就来分析一下isa的初始化和指向分析与对象的本质
一、对象的本质
① 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 <objc/runtime.h> - 通过
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对象
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.























