对象的本质
探讨对象的本质前,我们先了解一下clang编译器:clang是一个c++编写、基于llvm、发布于llvm bsd许可证下的c/c++/objective-c/objective-c++的轻量级编译器。
来一段main.m代码如下:
#import <Foundation/Foundation.h>
@interface OCPeople : NSObject{
NSString *nickName;
}
@property (nonatomic,copy) NSString *name;
@property (nonatomic,assign) int age;
@end
@implementation OCPeople
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...
NSLog(@"Hello, World!");
}
return 0;
}
这里介绍一个clang命令:clang -rewrite-objc main.m -o main.cpp,这条命令是将main.m文件转化为main.cpp文件。
在终端输入上面的命令,main.m所在的文件夹內就会生成一个main.cpp文件,转化后的文件部分代码截图如下:
对此底层代码进行分析:发现对象
OCPeople在底层被编译成了结构体。结构体OCPeople_IMPL内部还嵌套了一个结构体NSObject_IMPL NSObject_IVARS,是因为OCPeople继承自NSObject。
NSObject_IVARS是成员变量isa,如下图:
值得注意这里有一句:typedef struct objc_object OCPeople;,为什么OCPeople类本质的类型是objc_object这个类型呢?是因为OCPeople继承自NSObject,NSObject在底层的本质是objc_object。那class是什么类型呢?查找发现它是objc_class *,它是结构体类型的指针,如下图:
为啥我们常用到的
id可以指向任意对象且不用带*,是因为id本来就是objc_object *类型的结构体指针,在oc开发中,基本上所有的对象都是继承自NSObject,而它的本质是objc_object结构体类型。
底层代码main.cpp中有这样一段代码,如下图:
这段代码是属性
name和age的get方法和set方法。从底层代码中可以看到系统自动给这两个属性添加了get方法和set方法。而我们定义的变量nickName,系统却没有自动添加get方法和set方法,这个要注意区分。OCPeople * self, SEL _cmd是底层的两个隐藏参数。((char *)self + OBJC_IVAR_$_OCPeople$_name)可以这样理解:OCPeople对象在堆区开辟内存空间,堆区空间存放isa,name,age等等一些变量,对象的首地址加上OBJC_IVAR_$_OCPeople$_name所在内存空间的平移量才能获得name的地址,拿到地址才能获取name里面的值。
总结:对象的本质是结构体objc_object,对象都包含一个class类型的isa是继承自NSObject,class的本质是结构体类型指针objc_class *。
位域、联合体
了解位域之前先来看一段代码:
struct OCBike1 {
BOOL front;
BOOL back;
BOOL left;
BOOL right;
};
通过这个结构体实例化一个bike1,打印它的size大小为4,如下图:
bike1的大小为4字节,一个字节8位,4字节就是32位,结构体OCBike1中每个成员都是布尔类型,布尔类型不是0就是1,那真正的存储这个结构体OCBike1中每个成员,其实只需要4位就可以了,相当于半个字节,但没有半个字节还是要用到1个字节存储,剩下3个字节其实都是浪费空间了。
对上面的结构体OCBike1我们进行一点修改变成结构体OCBike2,代码如下:
struct OCBike1 {
BOOL front;
BOOL back;
BOOL left;
BOOL right;
};
struct OCBike2 {
BOOL front: 1;
BOOL back : 1;
BOOL left :1 ;
BOOL right : 1;
};
结构体OCBike2相对于结构体OCBike1,只是为每个成员设置了位域,都设置成1,代表1位,占用一个位置,设置位域值不能大于8,否则系统提示出错。我们再实例化结构体OCBike2,然后观察这两个结构体实例化后的size大小,打印输出如下:
bike1占用4字节,而bike2占用1字节,bike2大大的减少了占用空间,这也是写代码时的一种优化方式。代码里面我们设计一辆车,同一时刻只有一个方向,向前就不能向后,向左就不会向右,这种设计就是互斥。
接下来我们来看另外一个结构体,代码如下:
struct OCProfessor1 {
char *name;
int age;
double height;
};
实例化结构体OCProfessor1,给professor1的成员赋值,然后边运行边打印输出professor1对象,如下图:
刚开始
professor1对象里面的成员都为空,后面所有成员变量都赋值成功。这里我们引入一个词“联合体”,联合体和结构体有什么区别?我们先来看下联合体OCProfessor2的代码,如下:
union OCProfessor2 {
char *name;
int age;
double height;
};
联合体OCProfessor2的成员和结构体OCProfessor1的成员设计成一样,看起来一样,但它们还是有区别的,结构体里面的成员是可以共存的,但联合体里面的成员是互斥的。我们用联合体OCProfessor2实例化一个professor2,给professor2的成员赋值,边运行边打印输出professor2,看看和结构体professor1的输出是否还一样,如下图:
代码运行到line77时,
professor2中的成员还没赋值,我们打印输出p professor2,name、age和height都赋值为空显示正常。继续运行到line78之后,我们再打印输出,name显示的是我们当前赋的值Jones,但是age和height的值发生了变化,从原来的0变成了16296和2.12一长串的数值。之所以发生这样的变化,是因为变量age和height所在的内存区域是脏内存,显示的是脏数据。继续运行到line79,我们再打印输出,name的值没有了,age为13,在这一时刻,name和age只能有一个被使用,共用了内存。
- 结构体
struct中所有变量都是共存的,缺点是结构体内存空间的分配是粗放的,不管用不用都会分配。 - 联合体
union中各变量是互斥的,优点是内存使用更精细灵活,节省了内存空间。一般联合体搭配位域一起使用。
联合体(也叫共用体)中的所有成员是共享一段内存的,因此每个成员的存放首地址相对于联合体变量的基地址的偏移量为0,即所有成员的首地址都是一样的。为了使得所有成员能够共享一段内存,因此该空间必须足够容纳这些成员中最宽的成员。
使用位域的主要目的是压缩存储,大致规则:如果相邻位域字段的类型相同,且其位宽之和小于类型的sizeof大小,则后面的字段将紧邻前一个字段存储,直到不能容纳为止;如果相邻位域字段的类型相同,但其位宽之和大于类型的sizeof大小,则后面的字段将从新的存储单元开始,其偏移量为其类型大小的整数倍。
isa
我们在探索alloc的流程的流程时,在_class_createInstanceFromZone函数中有用到initIsa,进入initIsa代码如下:
_class_createInstanceFromZone函数,堆内存申请的结构体指针和cls绑定在一起:
_class_createInstanceFromZone(Class cls, size_t extraBytes, void *zone,
int construct_flags = OBJECT_CONSTRUCT_NONE,
bool cxxConstruct = true,
size_t *outAllocatedSize = nil)
{
ASSERT(cls->isRealized());
// Read class's info bits all at once for performance
bool hasCxxCtor = cxxConstruct && cls->hasCxxCtor();
bool hasCxxDtor = cls->hasCxxDtor();
bool fast = cls->canAllocNonpointer();
size_t size;
size = cls->instanceSize(extraBytes);
if (outAllocatedSize) *outAllocatedSize = size;
...
...
...
if (!zone && fast) {
obj->initInstanceIsa(cls, hasCxxDtor);
} else {
// Use raw pointer isa on the assumption that they might be
// doing something weird with the zone or RR.
obj->initIsa(cls);
}
...
...
...
}
进入到initIsa函数如下:
inline void
objc_object::initIsa(Class cls)
{
initIsa(cls, false, false);
}
继续进入到下层的initIsa,如下图:
上面的函数有一个isa_t,我们看下这个isa_t,如下图:
我们发现这个
isa_t就是一个联合体,line81和line82就是isa_t的构造方法,bits是属性。
在表现类的地址过程中,经常会出现一个名词:nonPointerIsa,nonPointerIsa是什么呢?它不是一个简简单单的指针。类是一个对象,也是一个指针,类里面有很多的信息是可以存储的, 当前指针是8字节,8 * 8 = 64 位,这64位如果只是存一个指针,那这个空间就大大的浪费了,我们可以优化一些空间,因为每个类几乎都有一个isa,导致它很浪费,所以苹果就想优化一下这些空间。在isa中,我们经常关注的类,和类相关的变量等一些东西我们可以写在里面。比如是否正在释放、引用计数、weak、关联对象、析构函数(oc下层是c/c++,oc层的释放不是真正的释放,而是下层c/c++的释放,下层的函数不释放它就不会释放。)等等。这些信息都和类有关,可以把这些信息存到这64位里面,所以就出现了nonPointerIsa。
要看这个联合体isa_t里面存了什么东西,重点要看位域,接下来看一下ISA_BITFIELD,如下图:
这是x86_64架构下(macOS)的
ISA_BITFIELD结构,也有arm64位架构下(iOS)的ISA_BITFIELD结构。
nonpointer是1位,表示是否对isa指针开启指针优化,0:表示纯isa指针,1:表示不止是类对象地址,isa中包含了类信息、对象的引用计数等。has_assoc是关联对象标志位,0表示没有关联,1表示存在关联。has_cxx_dtor该对象是否有c++或者objc的析构器,如果有析构函数,则需要做析构逻辑,如果没有,则可以更快的释放对象。shiftcls是类的指针地址,存储类指针的值。开启指针优化的情况下,在x86_64架构中有44位来存储类指针,在arm64架构中真机是有33位来存储类指针。magic用于调试器判断当前对象是真的对象还是没有初始化的空间。weakly_referenced对象是否被指向或者曾经指向一个arc的弱变量,没有弱引用的对象可以更快释放。unused是否没使用。has_sidetable_rc散列表,当对象引用计数大于10时,则需要借用该变量存储进位。extra_rc表示该对象的引用计数值,实际上是引用计数值减1。如果对象的引用计数为10,那么extra_rc为9。如果引用计数大于10,则需要用到has_sidetable_rc。 我们画了一个x86_64架构下的ISA_BITFIELD存储分布图,如下:
总共是64位。第0位存储nonpointer,第1位存储has_assoc,第2位存储has_cxx_dtor,第3位到第46位存储shiftcls,第47位到第52位存储magic,第53位存储weakly_referenced,第54位存储unused,第55位存储has_sidetable_rc,第56位到63位存储extra_rc。纯poniterIsa是只存储类的指针地址,默认创建的都是nonPointerIsa。通过nonPointerIsa我们最主要的是拿到shiftcls。
从实际代码中来分析下这个isa,创建一个demo1,main.m文件中代码如下:
int main(int argc, const char * argv[]) {
@autoreleasepool {
// insert code here...
OCPeople *p = [OCPeople alloc];
NSLog(@"%@",p);
}
return 0;
}
断点运行起来,打印输出一下p,如图:
把p的内存地址中的第一条16进制数据(isa)以二进制输出,以及打印输出OCPeople类的内存地址,如下图:
0x011d8001000080e9以二进制打印出来,可以看到它的64位是没有全部存满的,还有7位没有用到,浪费很多。当前对象p关联到了类OCPeople,那对象的地址是怎么和类的地址0x00000001000080e8关联的呢?这里就需要引入ISA_MASK了。
ISA_MASK
刚刚讲的x86_64架构下(macOS)的ISA_BITFIELD结构时,截图里面有个宏定义# define ISA_MASK 0x00007ffffffffff8ULL,ISA_MASK是面具,针对类的面具,将0x011d8001000080e9和ISA_MASK进行与操作,就能得出我们想要的信息(类的地址0x00000001000080e8),如下图:
对象
p通过isa,这个isa是不纯的,通过掩码操作一下,得到了类cls。为什么可以这样操作呢?因为原来存就是这样存的,现在是反过来去取。正常的是通过类关联到对象,现在是反过来从对象到类。
接下来我们回到上面提到的initIsa函数,把objc的源码跑起来,其中main.m中的代码如下图:
int main(int argc, const char * argv[]) {
@autoreleasepool {
OCPeople *ocp = [OCPeople alloc];
NSLog(@"%@",ocp);
}
return 0;
}
进入到initIsa函数:
此时的nonpointer为true,我们打印一下newisa,如下:
newisa是通过联合体isa_t声明的,此时newisa里面所有的变量都是0或者nil,代码继续运行,运行到line363,打印newisa,如图:
此时的
newisa.bits是有值了,ISA_MAGIC_VALUE是宏定义,为 0x001d800000000001ULL,打印出来bits的值是8303511812964353,这个值等于ISA_MAGIC_VALUE,只不过一个是10进制一个是16进制。
给bits赋值后,cls的值也变化了,bits的值也赋值给了cls。
p/t 0x001d800000000001打印出0x001d800000000001的二进制为0b0000 0000 0001 1101 1000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0001,是64位的,其中的第0位1赋值给了nonpointer,表示不是纯的isa指针,它不仅包括类对象的地址信息,还包括了类信息和引用计数等信息。第47位到52位是111011,p 0b111011刚好是59,赋值给了magic。
代码继续运行,如图:
从这句newisa.setClass(cls, this);我们断点进入到setClass函数:
这里的if...else比较多,我采用的是最笨的方法,在分支语句处打断点,发现这个函数进来后,直接进入了line213这句。我们通过
p/x newCls命令以16进制打印输出newCls的地址,再打印此地址右移3位的结果为536875229,shiftcls就等于536875229。
代码继续运行到line367,打印输出newisa,如图:
shiftcls打印出来的值就是我们上面计算的536875229,一模一样。OCPeople类的地址,右移3位就是shiftcls的值。此时的newisa与类OCPeople已经关联,打印出来的cls等于了OCPeople。
继续往下运行,到line376,打印输出newisa。此时的extra_rc也有了值为1。bits的值也发生了变化,bits = 80361110145894121。但shiftcls的值没变,在上一步的探索中已经关联成功。如下图:
总结一下:cls与isa关联就是isa指针中的shiftcls存储了类信息。
接下来,我们再回到demo1中,断点运行,x/4gx p打印输出p对象:
对象
p的isa是0x011d8001000080e9,0x011d8001000080e9右移3位是0x0023b0002000101d,0x0023b0002000101d左移20位是0x0002000101d00000,0x0002000101d00000右移17位是0x00000001000080e8。打印出类OCPeople的地址是0x00000001000080e8,和isa进行位移运算后最终的结果是一样的。可以看到对象p的isa已经关联了类OCPeople。
isa位移运算过程图解,如下:
总结一下isa与类关联:
- 类的地址右移3位就是
shiftcls的值,shiftcls是isa中的ISA_BITFIELD下的字段。 - isa进行位移运算就是类的地址。
- isa跟
ISA_MASK进行与(&)操作也能得到类的地址。
关于对象的本质和isa就探究到这里,本文的创作参考了逻辑cooci大神的讲解和学员的博客,感谢他们的分享!文章如有错误,欢迎指正