苹果官方资源opensource
本章节研究对象的底层探索:
1.alloc init在底层的调用、new的调用实质
2.关于编译器的优化
3.对象的本质
4.对象的内存对齐方式
5.结构体的内存对齐方式
6.对象的内存分布
7.影响对象内存的因素
8.认识位域和联合体
9.实例对象的nonPointerIsa
10.通过isa位运算后得到类对象
一、alloc在底层的调用流程
一个class的实例是通过这行代码: Person *p = [[Person alloc] init];
或者 Person *p = [Person new];
来创建的。
那我们的 alloc
和 init
分别做了什么事,内存的分配到底是何时分配的呢?
来看看这段代码:
- (void)viewDidLoad {
[super viewDidLoad];
// MyPerson是继承NSObject的类
MyPerson *p = [MyPerson alloc];
MyPerson *p1 = [p init];
MyPerson *p2 = [p init];
NSLog(@"p = %@, p1 = %@, p2 = %@", p, p1, p2);
}
注意:p、p1和p2是同一个对象,因为他们都指向同一个内存地址。
2022-04-16 14:08:43.058890+0800 AllocProcess[27584:5000030] p = , p1 = , p2 =
结论就是 init
方法不会去开辟内存空间。
1.通过 objc4-838可编译联调源码 进行解读 alloc
的调用过程:
注意:断点调试要先把源码里的断点关闭掉,等运行流程走到 main 函数的断点才把源码的断点打开,这样是防止断在系统的类创建实例。
注意:[Person alloc] 在调用的时候第一次进源码调试,并没有走到 + (id)alloc
,而是直接走在 callAlloc
函数,通过objc_msgSend
的方式去调用
到 + (id)alloc
再一次调用callAlloc
_class_createInstanceFromZone
才是分配内存,创建对象的实质逻辑。
2.通过汇编的方式验证源码中分析的 alloc
调用流程
首先创建一个工程,并运行到断点位置
开启汇编模式
开启汇编模式后,就会看到汇编调试的代码
注意汇编找到 objc_alloc
函数并调用的逻辑是在fixupMessageRef
声明的
在我调用 [MyPerson alloc]
的时候在汇编看到它是调用 objc_alloc
和源码里分析的调用 callAlloc
不一样。于是我在源码里搜索找到 objc_alloc
看看是个什么逻辑:
可以看到 objc_alloc
依旧是 callAlloc
问题不大,于是继续看看汇编底层怎么个调用法
此时读取寄存器的值:
register read x0
确认的确是MyPerson,也就是调用方法的第一个隐藏参数self
register read x1
打印地址,po这个地址,确认的确是alloc,也就是调用方法的第二个隐藏参数 cmd
通过objc_msgSend
发送一个 alloc
消息,于是我添加一个 [NSObject alloc]符号断点
继续往下执行
汇编流程继续往下走,就找不到源码里会调用_class_createInstanceFromZone
函数了。
这是因为我们的编译器给我们做了优化的缘故,这个问题第二部分讨论。
汇编流程和源码逻辑分析出来的alloc
调用流程是一样的。
总结 alloc
的调用流程
3._class_createInstanceFromZone
才是分配内存,创建对象的实质逻辑
/***********************************************************************
* class_createInstance
* fixme
* Locking: none
*
* Note: this function has been carefully written so that the fastpath
* takes no branch.
**********************************************************************/
static ALWAYS_INLINE id
_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;
// 创建obj的逻辑
id obj;
if (zone) {
obj = (id)malloc_zone_calloc((malloc_zone_t *)zone, 1, size);
} else {
// 向系统申请开辟内存,返回地址指针
obj = (id)calloc(1, size);
}
if (slowpath(!obj)) {
if (construct_flags & OBJECT_CONSTRUCT_CALL_BADALLOC) {
return _objc_callBadAllocHandler(cls);
}
return nil;
}
// 此时obj还是id类型
// 关联到类
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);
}
// 最终会返回这个obj
if (fastpath(!hasCxxCtor)) {
return obj;
}
construct_flags |= OBJECT_CONSTRUCT_FREE_ONFAILURE;
return object_cxxConstructFromClass(obj, cls, construct_flags);
}
总结 alloc
的核心方法
a、来看看 size = cls->instanceSize(extraBytes);
计算对象内存空间大小
inline size_t instanceSize(size_t extraBytes) const {
if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
return cache.fastInstanceSize(extraBytes);
}
// 这里是关于内存对齐的计算
// 在为实例开辟内存空间是以8字节作为内存对齐的
size_t size = alignedInstanceSize() + extraBytes;
// CF requires all objects be at least 16 bytes.
// CF要求所有对象至少为16字节。
if (size < 16) size = 16;
return size;
}
在计算对象的内存对齐方式是以8字节作为内存对齐的(在第四部分还有关于对象的内存对齐的分析)
在64位的iOS操作系统下,是以8字节为内存对齐的
所有创建出来的对象大小最少是16个字节
b、来看看 obj = (id)calloc(1, size);
实际分配内存的逻辑
objc4的源码里面没有calloc
的实现,它的实现是另一份libmalloc源码才有。
在malloc.c
找到calloc
void *
calloc(size_t num_items, size_t size)
{
return _malloc_zone_calloc(default_zone, num_items, size, MZ_POSIX);
}
它底层找得比较深,这里就不粘贴了,直接看目标函数吧
static MALLOC_INLINE size_t
segregated_size_to_fit(nanozone_t *nanozone, size_t size, size_t *pKey)
{
size_t k, slot_bytes;
if (0 == size) {
size = NANO_REGIME_QUANTA_SIZE; // Historical behavior
}
// k = (size + 16-1) >> 4
k = (size + NANO_REGIME_QUANTA_SIZE - 1) >> SHIFT_NANO_QUANTUM; // round up and shift for number of quanta
// slot_bytes = k << 4
slot_bytes = k << SHIFT_NANO_QUANTUM; // multiply by power of two quanta size
*pKey = k - 1; // Zero-based!
return slot_bytes;
}
在分配对象内存空间对齐方式是以16字节作为内存对齐的。
为什么要在分配对象内存空间以16字节对齐呢?
这里涉及到空间换时间
的概念,这是因为cpu在读取内存的时候是不是以字节为单位,而是以内存块为单位,它可以每次读2个字节,但是会造成大量计算,cpu效率会很低很低,也可以每次读32字节,无疑可能造成空间浪费,在大量试验读取的方式,最终选用以16字节对齐的方式。
结论:
a.调用[MyPerson alloc]
就能返回类的实例对象了。
b.alloc
对象分配内存最终底层会走malloc
。(ps: swift底层也是malloc)
c.在计算对象的内存对齐方式是以8字节作为内存对齐的,
在分配对象内存空间对齐方式是以16字节作为内存对齐的。
4.init
做了什么逻辑
运动断点到init
调用的位置,开启汇编模式
添加一个 [NSObject init]符号断点
,继续看看init到底做了什么事
调用init
方法之后就直接返回了
于是我找到objc4源码
看看init
方法的逻辑
啥也没干,直接返回了对象了。所以苹果设计这个init
方法有什么作用呢?
苹果设计init
方法是以工厂模式
思想,给子类重写init
,以提供子类的成员变量赋值操作。
5.new
的调用实质
在objc4源码中找到new
方法,它的底层调用和alloc底层调用是一样的,另外它还调用了init方法
通过汇编模式调试看看调用new方法是否和源码的逻辑一样:
在汇编中看到调用new方法会调用objc_opt_new
于是我在源码中找到这个objc_opt_new
符号,是一样的。(这里做了OBJC2和之前版本的适配)
调用new
的实质其实就是调用了alloc
、init
。
二、关于编译器的优化
我们的Xcode中是可以配置编译器的优化等级的
TARGETS
-> Build Settings
-> Optimization Level
设置这个编译器的优化等级有什么用呢?
新建一个工程 macOS -> Command Line 取名为Test
将Optimization Level
的debug设置和release一样的等级,开启汇编模式运行
运行可以看到,这个赋值并没有看到3和4
然后再Optimization Level
的debug设置调回来(没有任何优化等级的情况下),开启汇编模式运行
可以看到 0x3 和 0x4
这就是编译器帮我们做了优化的部分,因为对于 a 和 b 这两个变量我们根本没有去使用它,并且对于一些简单的计算(比如声明一个 sum函数 去a+b),它一样会被优化。
这样做的话整个系统能更加地快速,另外在实际开发过程中不需要去改动默认的Optimization Level
。
而上面所说的 _class_createInstanceFromZone
就是编译器优化掉的部分。
三、对象的本质
创建一个main.m
#import
@interface Person : NSObject
@property (nonatomic, assign) int age;
@property (nonatomic, copy) NSString *name;
- (void)test;
@end
@implementation Person
- (void)test {
}
@end
int main(int argc, char * argv[]) {
@autoreleasepool {
// Setup code that might create autoreleased objects goes here.
}
return 0;
}
通过clang
指令编译一下main.m
,得到main.cpp
当我们的类被编译了之后,底层会类编译成 isa + 成员变量
,所以在给类的实例分配内存的话这个内存块存储的就是 isa + 成员变量的值
四、对象的内存对齐方式
上面提到过:对象的内存对齐方式是以8字节作为内存对齐的
inline size_t instanceSize(size_t extraBytes) const {
if (fastpath(cache.hasFastInstanceSize(extraBytes))) {
return cache.fastInstanceSize(extraBytes);
}
size_t size = alignedInstanceSize() + extraBytes;
// CF requires all objects be at least 16 bytes.
if (size < 16) size = 16;
return size;
}
// Class's ivar size rounded up to a pointer-size boundary.
uint32_t alignedInstanceSize() const {
return word_align(unalignedInstanceSize());
}
(x + 7) & ~7
这是苹果设计的8字节对齐算法,先留下这个公式。
思考:如果让我们设计一个8字节对齐算法应该怎么做?
首先8字节对齐一定是8的倍数
int align8Bit(int x) {
return (x+7)/8*8;
}
// x+7 避免x的值小于8,否则做除以8都是0了
// 然后除以8 再乘以8 得到的一定是8的倍数
但是这样写除法和乘法会比位运算的效率要低,于是又可以把乘除换成位运算
int align8Bit(int x) {
return (x+7) >> 3 << 3;
}
// 除以8就是右移3位
// 乘以8就是左移3位
注意:
进行右移3位再左移3位,它的低3位永远是0
所有的数只要是8的倍数,它的低3位永远是0
再回来看看苹果的8字节对齐公式:
(x + 7) & ~7
7的二进制是 0111,~7就是对7的二进制取反即1000,最后做 & 操作这样就保证了低3位永远是0了。
虽然我们设计的8字节对齐算法和苹果设计的对齐算法是一样的,但是苹果用一个函数就适配了对64位和32位的适配,真大佬呀。
五.对象的内存分布、影响对象内存的因素
在第三部分就总结: 对象的本质 = isa + 成员变量的值。
计算下面这个MyPerson
类分配的对象占用多大的内存
#import
@interface MyPerson : NSObject
// isa 8字节
@property (nonatomic, copy) NSString *name; // 8字节
@property (nonatomic, copy) NSString *hobby; // 8字节
@property (nonatomic, assign) int age; // 4字节
@property (nonatomic, assign) double height; // 8字节
@property (nonatomic, assign) short number; // 2字节
@end
得到的对象实际占用38字节,但是我们的在创建对象的时候系统会以16字节对齐的方式去给对象分配内存,即系统会为MyPerson
的实例分配48字节的内存大小。
#import
#import
#import "MyPerson.h"
int main(int argc, const char * argv[]) {
@autoreleasepool {
MyPerson *p = [MyPerson new];
p.name = @"安安";
p.hobby = @"吃吃睡睡喝喝";
p.height = 1.80;
p.age = 26;
p.number = 123;
// 48
NSLog(@"%lu", malloc_size((__bridge const void *)(p)));
}
return 0;
}
给MyPerson类添加一个实例方法和类方法,系统给MyPerson的实例分配内存大小是没有影响的。
猜想:如果把MyPerson
的属性的顺序打乱是否会影响该类的实例分配的内存大小呢?(影响对象内存的因素)
请自行随意打乱MyPerson
的属性顺序,然后再控制台通过lldb
指令调试:
lldb指令:
p 输出10进制
p/x 输出16进制
p/0 输出8进制
p/t 输出2进制
p/f 输出浮点数
x 输出地址
x/4gx 输出4个字节地址
x/6gx 输出6个字节地址
...
尽管我如何去打乱属性的顺序,发现age和number属性的值存在第二个8字节里,这是个什么机制呢?因为age只占用4字节 number只占用2字节,如果让他们都单独占据一个8字节的内存,无疑造成了内存浪费,于是苹果会对这个问题做了一系列的优化。
系统如何优化内存分配的?
解答影响对象内存的因素:
在编译器时,编译器会自动重排属性的顺序,以达到节约内存空间目的。
举例重排属性顺序:
#import
@interface OSTestObject : NSObject
@property (nonatomic, strong) NSObject *n1;
@property (nonatomic, assign) int count1;
//@property (nonatomic, assign) int count2;
@property (nonatomic, strong) NSObject *n2;
@end
@interface OSTestSubObject : OSTestObject
@property (nonatomic, assign) int count3;
@end
int main(int argc, const char * argv[]) {
@autoreleasepool {
OSTestSubObject *objc = [[OSTestSubObject alloc] init];
objc.count1 = 10;
// objc.count2 = 11;
objc.count3 = 12;
NSLog(@"objc实际占用内存的空间为%zd",class_getInstanceSize([OSTestSubObject class]));
NSLog(@"系统为objc开辟内存的空间为%zd", malloc_size((__bridge void *)objc));
NSLog(@"------------");
}
return 0;
}
此时我的父类属性count在中间,按理说应该在isa后面的第二个位置,但是重排后的效果却是把它安排在了第一的位置了。
接着我把count2的注释打开,继续打印,发现父类的count1和count2属性合并在一个8字节了
总结:类的属性重排不仅只针对当前类,还有父类,但是合并属性在同一个内存只针对当前类。
特别注意:类的成员变量是不能重排的!
举例父类书写成员变量的顺序对子类实例分配内存的影响:
@interface OSTestObject : NSObject
{
@public
int count;// 1.若打开这个注释,为OSTestSubObject实例实际需要40字节,系统为实例分配48字节
NSObject *obj1;
// int count; // 2.若打开这个注释,为OSTestSubObject实例实际需要40字节,系统为实例分配48字节
NSObject *obj2;
// int count; // 3.若打开这个注释,为OSTestSubObject实例实际需要32字节,系统为实例分配32字节
}
@end
@interface OSTestSubObject : OSTestObject
{
@public
int count2;
}
@end
这三个count的位置,在分别用x/6gx输出OSTestSubObject对象的时候,顺序永远不会变的,但是书写顺序会影响内存分配。
为什么对类的属性顺序进行重排能够优化内存空间?
因为内存对齐,在计算对象所需内存的时候是以8字节对齐的。
六、结构体的内存对齐方式
首先了解:结构体和数组一样都是一块连续的内存空间。
结构体内存对齐方式规则:
a.结构体的第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小的整数倍开始(比如int为4字节,则要从4的整数倍地址开始存储)b.如果一个结构体包含了某些结构体成员,则结构体成员要从其内部最大的元素大小的整数倍地址开始存储。
c.结构体的总大小:sizeof的结果必须是其内部最大成员的整数倍,不足的要补齐。
//案例一:
struct MyStruct1 {
double a; // [0-7]
char b; // [8]
int c; // 9 10 11不行 [12-15]
short d; // [16-17]
}struct1;
// 需要24字节
//案例二:
struct MyStruct2 {
double a; // [0-7]
int b; // [8-11]
char c; // [12]
short d; // 13不行 [14-15]
}struct2;
// 需要16字节
//案例三:
struct MyStruct1 {
double a; // [24-31]
char b; // [32]
int c; // [36-39]
short d; // [40-41]
}struct1;
struct MyStruct3 {
double a; // [0-7]
int b; // [8-11]
char c; // [12]
short d; // 13不行 [14-15]
int e; // [16-19]
struct MyStruct1 stru; // 24开始,因为里边double类型最大 是8
}struct3; // [0-41]
// MyStruct3需要48字节
七、认识位域和联合体
1.位域
struct MyStruct1 {
char a;
char b;
char c;
char d;
}struct1; // 4字节
// 位域,注意:数字的大小不能小于类型的长度!
struct MyStruct2 {
char a : 1;
char b : 1;
char c : 1;
char d : 1;
}struct2; // 1字节
NSLog(@"%lu, %lu", sizeof(struct1), sizeof(struct2)); // 4, 1
注意:数字的大小不能小于类型的长度!
char a : 1;
表示a用1个比特位来存储,所以abcd总共需要4个比特位,只需要分配1个字节足够。
如果改成char a : 7;
和 char b : 2;
其中a就单独占一个字节,因为a占用7位,而b占用2位,一个字节8位,不能把ab同塞一个字节。于是sizeof(struct2)
大小就是2。
这里知识仅当学习作用方便看源码,我们不建议平时开发这样去做,因为一般都是系统级别的才会这样处理,这样做的节省内存空间极为有限。
2.联合体
struct Teacher1 {
char *name;
int age;
double height;
}t1;
NSLog(@"name=%s, age=%d, height=%f", t1.name, t1.age, t1.height); // name=(null), age=0, height=0.000000
t1.name = "安安老师";
NSLog(@"name=%s, age=%d, height=%f", t1.name, t1.age, t1.height); // name=安安老师, age=0, height=0.000000
t1.age = 18;
NSLog(@"name=%s, age=%d, height=%f", t1.name, t1.age, t1.height); // name=安安老师, age=18, height=0.000000
t1.height = 1.80;
NSLog(@"name=%s, age=%d, height=%f", t1.name, t1.age, t1.height); // name=安安老师, age=18, height=1.800000
// 0x100008508: 0x100008508 -- 0x100008510 -- 0x100008518
NSLog(@"%p: %p -- %p -- %p", &t1, &t1.name, &t1.age, &t1.height);
// 联合体
union Teacher2 {
char *name; // 8字节
int age;
double height;
}t2;
t2.name = "安安老师";
t2.age = 18;
t2.height = 1.80;
// 0x100008508: 0x100008508 -- 0x100008508 -- 0x100008508
NSLog(@"%p: %p -- %p -- %p", &t2, &t2.name, &t2.age, &t2.height);
联合体的所有成员变量共用同一个内存地址,赋值了一个成员会影响别的不同类型成员的取值。
联合体的大小决定于最大成员(基本数据类型的整数倍,数组不是基本数据类型)(t2最大的是char * 相当于是对象8个字节)。
union Teahcer3 {
char a[7]; // 占7字节
int b; // 占4字节
}t3; // 4字节的整数倍,至少需要8字节
结构体与联合体的区别:
struct中所有变量是“共存”的——优点是“有容乃大”,全面;缺点是struct内存空间的分配是粗放的,不管用不用,全分配;
联合体(union)中是各变量是“互斥”的——缺点就是不够“包容”;但优点是内存使用更为精细灵活,也节省了内存空间。
八、使用isa通过位运算后得到类对象、nonPointerIsa是什么
上面讲述到alloc
的底层是开辟内存空间,它底层是调用_class_createInstanceFromZone
函数处理内存分配逻辑的,实际上它还处理了isa
!
而obj->initInstanceIsa(cls, hasCxxDtor);
底层就是调用了obj->initIsa(cls);
,所以obj->initIsa(cls);
就是处理isa
的底层逻辑
inline void
objc_object::initIsa(Class cls, bool nonpointer, UNUSED_WITHOUT_INDEXED_ISA_AND_DTOR_BIT bool hasCxxDtor)
{
ASSERT(!isTaggedPointer()); // taggedPointer 指针优化
isa_t newisa(0); // isa_t 是联合体
// nonpointer:表示是否对 isa 指针开启指针优化 0:纯isa指针,1:不止是类对象地址,isa 中包含了类信息、对象的引用计数等
if (!nonpointer) {
newisa.setClass(cls, this); // isa里保存了类对象
} else {
ASSERT(!DisableNonpointerIsa);
ASSERT(!cls->instancesRequireRawIsa());
#if SUPPORT_INDEXED_ISA
ASSERT(cls->classArrayIndex() > 0);
newisa.bits = ISA_INDEX_MAGIC_VALUE;
// isa.magic is part of ISA_MAGIC_VALUE
// isa.nonpointer is part of ISA_MAGIC_VALUE
newisa.has_cxx_dtor = hasCxxDtor;
newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
newisa.bits = ISA_MAGIC_VALUE;
// isa.magic is part of ISA_MAGIC_VALUE
// isa.nonpointer is part of ISA_MAGIC_VALUE
# if ISA_HAS_CXX_DTOR_BIT
newisa.has_cxx_dtor = hasCxxDtor;
# endif
newisa.setClass(cls, this); // isa里保存了类对象
#endif
newisa.extra_rc = 1; // isa里保存了 引用计数的值1
}
// This write must be performed in a single store in some cases
// (for example when realizing a class because other threads
// may simultaneously try to use the class).
// fixme use atomics here to guarantee single-store and to
// guarantee memory order w.r.t. the class index table
// ...but not too atomic because we don't want to hurt instantiation
isa = newisa;
}
可以看到往isa
里保存了好多信息,比如类对象、引用计数等等。
其次,来看看isa_t
的声明,它是一个联合体:
#include "isa.h"
union isa_t {
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }
uintptr_t bits;
private:
// Accessing the class requires custom ptrauth operations, so
// force clients to go through setClass/getClass by making this
// private.
Class cls;
public:
#if defined(ISA_BITFIELD)
struct {
ISA_BITFIELD; // defined in isa.h
};
bool isDeallocating() {
return extra_rc == 0 && has_sidetable_rc == 0;
}
void setDeallocating() {
extra_rc = 0;
has_sidetable_rc = 0;
}
#endif
void setClass(Class cls, objc_object *obj);
Class getClass(bool authenticated);
Class getDecodedClass(bool authenticated);
};
我们知道isa指针
它是一个Class
类型的结构体指针,主要用来存储内存地址的的,它占用8个字节(64位),但是我们的类对象的存储用不完这64的位域,于是苹果就把一些和对象息息相关的东西,一起保存到这64位域信息里。
这些存放的东西都在 ISA_BITFIELD
这个宏定义里,它是区分平台的(arm64、x86_64等等)。
苹果设计使用联合体isa_t
的目的是去兼容老版本的isa,因为老版本的isa里只有Class cls
,没有别的信息,而相关的信息又会需要另外内存空间,无疑造成内存浪费。nonPointerIsa
可以理解成是新版本的isa。
nonPointerIsa
里64位域里的内容:
如何使用isa
通过位运算后得到类对象?
我是通过模拟器调试的并且电脑芯片是Intel Core i5的,所以我直接看x86_64
的ISA_BITFIELD
声明:
我要得到中间的44位要如何得到?
就是把低3位和高17位清0,再复位即得到中间的44位。x >> 3 << (17+3) >> 17
而苹果给的方案就是:isa地址 & ISA_MASK = 类对象地址
想要得到引用计数extra_rc
的值:x >> (64-8)
注意验证的时候,要看清楚机型对应的架构。
最后附上objc_object
总结图:
引用计数位extra_rc
存储的值超了,会存储在has_sidetable_rc
,在内存管理章节会讲。