对象原理探究(一)
内存对齐的原则
1,系统定义的数据成员的对齐规则:
结构体(struct)或者联合体(union)的数据成员,第一个数据成员会放在offset为0的地方,之后的每个数据成员存储的起始位置要从该成员大小(如果该成员有子成员,比如数组、结构体等,那么就从子成员大小)的整数倍开始。
2,如果一个结构体里面的成员又是一个结构体,那么该结构体成员要从其内部最大元素大小的整数倍开始存储。
3,收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍,不足的需要补齐。
代码语言:javascript复制struct NormanStruct1 {
char a;
double b;
int c;
short d;
} NormanStruct1;
struct NormanStruct2 {
double b;
int c;
char a;
short d;
} NormanStruct2;
NSLog(@"NormanStruct1***%lu", sizeof(NormanStruct1)); // NormanStruct1***24
NSLog(@"NormanStruct2***%lu", sizeof(NormanStruct2)); // NormanStruct2***16
NormanStruct1为什么是24:
a-1,b-8,c-4,d-2。a在第0个字节的位置;b的起始位置需要是8的整数倍,所以b的起始位置是8;c的起始位置需要是4的整数倍,所以c的起始位置是16;d的起始位置需要是2的整数倍,所以d的起始位置是20。最后按照内部最大成员大小(double,8字节)倍数补齐,因此NormanStruct1的大小是24。
NormanStruct2为什么是16:
b-8,c-4,a-1,d-2。b在第0个字节的位置;c的起始位置需要是4的整数倍,所以c的起始位置是8;a的起始位置需要是1的整数倍,所以a的起始位置是12;d的起始位置需要是2的整数倍,所以d的起始位置是14。最终正好是16,无需按照内部最大成员大小(double,8字节)倍数补齐,因此NormanStruct2的大小是16。
iOS中获取内存大小的三种方式
1,sizeof
传进来的是类型,用于计算这个类型占多大内存,它是在编译器编译阶段确定内存大小,因此不能用来返回动态分配的内存空间的大小。
它的功能是:获得能够容纳所建立的最大对象的内存大小。
2,class_getInstanceSize
获取对象申请的内存大小。在运行时分析该对象中的各个属性,然后计算出其所需要的内存大小,其具体是多少字节对齐,是由该对象内部最大成员决定的(最大是8字节对齐)。
3,malloc_size
系统给对象实际开辟的内存大小。其参考因素是整个对象,因此必须是16字节对齐。
也许你会有一个疑问,为什么参考因素是对象中的成员的时候是8字节对齐,而参考因素是对象的时候就是16字节对齐呢?
我们知道,系统中的内存空间是连续的,因此呢,对象与对象之间开辟的内存区域也是连续的,如果一个对象内存的尾部与另一个对象内存的首部是紧挨着而没有一丁点儿的缓冲余地的话,那么前面的对象遇到一些特殊情况需要处理的时候就会导致内存溢出(这里需要说明的是,只有中间有空隙而未完全填充的对象才会有内存溢出的风险,那些内存完全填充的对象是没有内存溢出风险的)。因此,对象内存分配的原则是,需要给未安全填充的对象在其内存段的最后留出8字节的缓冲区域。而在未完全填充的对象的内存中,那些间隙可能是在末尾,也可能是在中间,如果是在中间的话,那么按照8字节对齐的原则,有可能就不会在末尾预留充足的缓冲区域了(比如某对象现在是36字节,中间有4字节的间隙,如果按照8字节对齐,那么对象就会在其最后补4字节,而4字节是不够处理内存溢出的);而如果按照16字节对齐,那么就能确保缓冲区域是充足的。
通过二进制位移进行字节对齐
实际上,字节对齐的本质就是让这个数多加【一个对齐位减1】,然后抹零。
8(2^3)字节对齐:
(x (8 - 1)) >> 3 << 3
16(2^4)字节对齐:
(x (16 - 1)) >>4 << 4
2^n字节对齐
(x (2^n - 1)) >> n << n
等同于:(x WORD_MASK)& ~WORD_MASK
isa指针的结构
定义如下:
代码语言:javascript复制union isa_t {
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
Class cls;
uintptr_t bits;
#if defined(ISA_BITFIELD)
struct {
ISA_BITFIELD; // defined in isa.h
};
#endif
};
我们可以看到,isa是一个union,它有一个记录将其绑定到哪个类上面的属性cls。并且,isa_t这个联合体里面还有一个bits成员,说明还有位域的概念。
接下来介绍一下联合体和位域的概念
先来看一个例子:
现在有一个坦克类,它有4个属性:向前、向后、向左、向右,如下:
代码语言:javascript复制@interface NormanTank : NSObject
@property (nonatomic, assign)BOOL front;
@property (nonatomic, assign)BOOL back;
@property (nonatomic, assign)BOOL left;
@property (nonatomic, assign)BOOL right;
@end
然后我们在外界创建NormanTank的实例,并且打断点,如下:
在断点处使用x tank来打印tank的内存段,我们发现front、back、left、right这四个属性每个属性都占用了一个字节,这实际上是很耗内存的。
因为一个字节有8位,即00000000,如果我只需要存储一个布尔值,即非0即1,我们没有必要使用8位的,我只需要使用1位即可(0表示no ,1表示yes)。
因此,我就可以定义一个char类型(char是一个字节),一个char有8位,我们就可以使用这8位中的后4位来分别定义前后左右了。这样就能节省很多内存空间。
【联合体和位域的概念这里没有总结完,需要后期详细总结。这里只需要知道isa是一个联合体位域的结构即可】。
isa联合体中有定义位域,它是一个宏,之所以将它定义成宏,是因为这个位域是跟架构有关的,如下:
isa的结构是一个联合体,联合体里面的bits是一个uintptr_t类型,uintptr_t类型的定义如下:
代码语言:javascript复制typedef unsigned long uintptr_t;
因此,bits是一个无符号long类型,long类型是占8个字节的,因此isa指针占8个字节。
除了根据bits来知道isa指针占8个字节,根据位域ISA_BITFIELD也可以知道。位域ISA_BITFIELD是一个结构体,而结构体里面的内容算一下的话也是64位,即8个字节:
通过上图我们可以看出来,isa里面可以存储很多东西的,下面我将以arm64架构为例一一罗列。
第1位表示的是该isa是否是nonpointer,即是否对isa指针开启指针优化。
0表示不是nonpointer,即没有开启指针优化,即该isa是一个纯isa指针,不携带其他任何信息。
1表示是nonpointer,即开启了指针优化,也就是说该isa里面除了绑定了类对象的地址,还携带了其他的一些信息。
第二位has_assoc是关联对象标志位,0表示没有关联对象,1表示存在关联对象
第三位has_cxx_dtor表示该对象是否有C 或者Objc的析构器,如果有析构函数,则需要做析构逻辑,如果没有,则可以更快地释放对象。
第4位到第36位存储的是shiftcls,它是存储类指针的值。在开启指针优化的情况下,在arm64架构中有33位来存储类指针。
第37位到第42位是magic,它用于调试器判断当前对象是真的对象还是没有初始化的空间
第43位是weakly_referenced,它标志对象是否有弱引用,没有弱引用的对象可以更快被释放
第44位是deallocating,它标志对象是否正在释放内存
第45位是has_sidetable_rc,当对象的引用计数大于10 的时候,需要借助该变量存储进位。
第46到第64位是extra_rc,表示该对象的引用计数值减1。
现在我们可以更深刻地感知到,isa指针是可以存储很多信息的,而这些信息都是跟该对象有关的,如果我直接通过属性来存储这些信息,势必会浪费很多的内存空间。
isa联合体中,类结构的绑定
上面我们有提到,在nonpointer的isa指针中,会有一个shiftcls来存储类指针,即绑定对应类的地址。isa的初始化代码如下:
我们可以看到,如果该指针不是nonpointer类型,那么就直接给其cls赋值,如下:
代码语言:javascript复制isa.cls = cls;
如果该指针是nonpointer类型,则会进行相关信息的初始化,其中一个就是对应类信息的初始化:
代码语言:javascript复制newisa.shiftcls = (uintptr_t)cls >> 3;
接下来我们就来验证一下上面的这行初始化代码,看看是否真正绑定了对应的类。
首先,我在NormanTank的实例对象创建完成后打了个断点,然后在控制台执行lldb指令 x/4gx tank 来查看tank对象的前4段十六进制内存。
通过之前的讲解我们知道,对象的属性存在内存中的位置可能会因为内存优化而与声明的顺序不一致,因此我们可能会有疑问,isa指针的位置到底是固定的还是变化的呢?
正确答案是:所有实例对象的第一个属性必然都是isa,它在内存中的位置永远都是在最开始。
【题外话】
接下来我们进行二进制打印isa的地址:
代码语言:javascript复制(lldb) x/4gx tank
0x600003075910: 0x000000010e764ec8 0x0000000000000000
0x600003075920: 0x00007fcc52401650 0x0000000000000000
(lldb) p/t 0x000000010e764ec8
(long) $2 = 0b0000000000000000000000000000000100001110011101100100111011001000
- p/t是二进制打印
- p/o是八进制打印
- p/x是十六进制打印
- p/d是十进制打印
【题外话结束】
现在咱来想想,如何获取一个对象的的类呢?其中一个方式就是使用RuntimeAPI——object_getClass:
代码语言:javascript复制object_getClass(id _Nullable obj)
这个API的作用是通过一个对象获取一个类,通过对象找到对应的类,势必会使用到isa指针。现在我们来瞅瞅该API的源码:
代码语言:javascript复制Class object_getClass(id obj)
{
if (obj) return obj->getIsa();
else return Nil;
}
然后查看getIsa函数的源码:
代码语言:javascript复制inline Class
objc_object::ISA()
{
assert(!isTaggedPointer());
#if SUPPORT_INDEXED_ISA
if (isa.nonpointer) {
uintptr_t slot = isa.indexcls;
return classForIndex((unsigned)slot);
}
return (Class)isa.bits;
#else
return (Class)(isa.bits & ISA_MASK);
#endif
}
ISA_MASK是一个掩码,它的取值是跟架构有关的,定义如下:
现在我获取到了X86上isa的掩码,接下来我将isa的十六进制地址与isa的掩码做与操作,这次获取到的应该就是实例对象所对应的类的地址了:
此时获取到的0x000000010f7a6ec8应该就是NormanTank类的地址,接下来我们通过打印NormanTank类来验证一下:
我们发现,两者完全一致。
因此我们得出结论,对象中的第一个属性必然是isa指针,并且isa指针指向的就是该对象所对应的类的内存地址。
类在内存中只有一份
通过上面的分析我们知道,类的实例对象可以创建多个,并且每个实例对象内部第一个属性isa会指向该实例对象所对应的类,那么现在有个问题,指向的这个类的内存是固定的吗?或者说,类对象可以创建多份吗?
下面我们来验证一下:
代码语言:javascript复制Class class1 = NormanTank.class;
Class class2 = object_getClass([NormanTank alloc]);
Class class3 = [NormanTank alloc].class;
Class class4 = [NormanTank alloc].class;
NSLog(@"n%pn%pn%pn%pn", class1, class2, class3, class4);
打印如下:
2021-02-07 09:32:18.582448 0800 排序算法[1334:75838]
0x107357ee8
0x107357ee8
0x107357ee8
0x107357ee8
我们可以看到,打印结果是一致的,这说明类在内存中只会存在一份。
类的实例对象是由程序员对类进行实例化得来,而类对象是由系统创建的。
isa走位
我在isa指针中介绍过isa的走位,结论就是:
类的实例对象的isa指向类对象,类对象的isa指向元类对象,元类对象的isa指向根元类对象,根元类对象的isa指向其自身。
接下来进行验证:
第一步,我使用x/4gx tank来打印了实例对象tank的内存地址,第一段地址就是isa存储的内容,即对应类的地址:
(lldb) x/4gx tank
0x600003b64270: 0x0000000106b5eec8 0x0000000000000000
0x600003b64280: 0x000008facd634280 0x00007fcdc4400027
(lldb) po 0x0000000106b5eec8
NormanTank
第二步,打印对应类的内存,第一段地址就是isa存储的内容,即对应元类的地址:
(lldb) x/4gx 0x0000000106b5eec8
0x106b5eec8: 0x0000000106b5eef0 0x00000001074b1200
0x106b5eed8: 0x0000600001a48aa0 0x0001801800000003
(lldb) po 0x0000000106b5eef0
NormanTank
第三步,打印对应元类的内存,第一段地址就是isa存储的内容,即对应根元类的地址:
(lldb) x/4gx 0x0000000106b5eef0
0x106b5eef0: 0x00000001074b11d8 0x00000001074b11d8
0x106b5ef00: 0x0000600000b7e010 0x0004c03100000007
(lldb) po 0x00000001074b11d8
NSObject
第四步,打印对应根元类的内存,第一段地址就是isa存储的内容,细心的你不难发现,根元类的第一段地址就是根元类自身的地址,这说明根元类的isa指向的就是其本身。
(lldb) x/4gx 0x00000001074b11d8
0x1074b11d8: 0x00000001074b11d8 0x00000001074b1200
0x1074b11e8: 0x00007fcdc450e2c0 0x0008c0310000000f
(lldb) po 0x00000001074b11d8
NSObject
需要注意的是,此时打印出来的NSObject是根元类,不是根类,不信的话,你看下面第五步的验证:
(lldb) x/4gx NSObject.class
0x1074b1200: 0x00000001074b11d8 0x0000000000000000
0x1074b1210: 0x00007fcdc4504020 0x000480100000000f
这里我直接打印NSObject类的内存,发现第一段是0x00000001074b11d8,它是NSObject类的isa指针指向的元类,也就是根元类。而上面第四步中也是0x00000001074b11d8,所以说,上面第四步中的NSObject是根元类。
Clang编译
我们在研究的过程中,经常会需要将OC代码编译成C ,如何编译呢?
首先进入到需要编译的文件所在的文件夹,然后在终端执行如下命令(假设需要编译NormanTank.m):
clang -rewrite-objc NormanTank.m -o NormanTank.cpp
如果编译成功,那么在同一个文件目录下会多一个NormanTank.cpp文件,如下:
如果NormanTank.m中引入了UIKit框架,则会报下面的错误:
此时使用如下命令进行编译:
clang -x objective-c -rewrite-objc -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk NormanTank.m
或者使用如下命令(模拟器):
xcrun -sdk iphonesimulator clang -rewrite-objc NormanTank.m
或者(真机):
xcrun -sdk iphoneos clang -rewrite-objc NormanTank.m
以上。