iOS底层学习 - OC对象前世今生

2,049 阅读13分钟

在平时的开发中,如果说你没有对象,我是不信的。那么本着了解对象,呵护对象,关心对象的原则,我们一定要清楚的知道什是事对象,以及对象是怎么来的和对象内部的构成。so,这篇文章就来剖析一下对象的前世今生

准备工作

传送门☞iOS底层探索-准备工作

对象的本质

首先,我们要知道对象OC的底层中,究竟是以什么方式在存在的

1.首先创建对象后,我们运行clang -rewrite-objc main.m -o test.c++命令,将main文件转换为C++代码,看代码编译后的对象是什么样子的

2.通过查看源码,我们可以发现,一个对象在经过编译之后,在底层的是一个结构体的形式存在的,如下图

3.其中我们可以发现有一个struct NSObject_IMPL NSObject_IVARS结构体,这个结构体实际上是所有继承自NSobject的类均有的一个属性,它是一个指向类对象的isa,如图

属性和成员变量

我们给类添加了一个属性name和一个成员变量name,那么这两者有何区别呢,通过编译后的源码,我们可以发现

1.成员变量和属性都是对象struct结构体中的一个变量,但是属性会自动变成带下划线的变量,如_name,而成员变量不会
2.属性会自动生成get和set方法,而成员变量不会
get和set源码分析

底层get和set源码如下

可以看到系统自动给方法增加了两个参数LGPerson * self, SEL _cmd 在类的方法列表中,我么可以看到相关绑定实现

  • name 为上层get方法名
  • @16@0:8 为符号方法签名
  • _I_LGPerson_name为底层方法名

关于方法签名:

第一个符号@为返回值类型

第二个16为方法所占用的偏移量,即为总长度

第三个@为系统方法生成的id类型参数

第四个0为@的偏移量,即从0位开始,id类型占有8个字节(0-7)

第五个:为SEL参数

第六个8为SEL方法的偏移量,即SEL参数从第8位开始(8-15) 下图为整理的符号表

alloc原理

对象的本质有了初步了解后,我们需要知道对象是怎么开辟控件,怎么获取到的,常用的[LGPerson alloc]init]到底是怎么工作的呢

根据准备工作,配置到objc的源码之后,我们开始探索

alloc流程

通过断点逐步跟踪

_objc_rootAlloc(self)

发现alloc的底层调用了 _objc_rootAlloc(self)

但是一个需要注意的点:系统动态库真实函数地址在共享缓存取的,dyld加载的时候绑定一次符号,以后就直接找真实的函数地址,所以,第一次alloc的时候,会调用objc_alloc(Class cls)方法,后续才走_objc_rootAlloc(Class cls)

callAlloc(cls, false/checkNil/, true/allocWithZone/)

callAlloc分析

1.hasDefaultAWZ

hasDefaultAWZ( )方法是用来判断当前class是否有默认的allocWithZone。

bool hasCustomAWZ() {
    return ! bits.hasDefaultAWZ();
}
bool hasDefaultAWZ() {
    return data()->flags & RW_HAS_DEFAULT_AWZ;
}

在对象的数据段data中,class_rw_t中有一个flags,RW_HAS_DEFAULT_AWZ 这个是用来标示当前的class或者是superclass是否有默认的alloc/allocWithZone:。值得注意的是,这个值会存储在metaclass 中。

如果cls->ISA()->hasCustomAWZ()返回YES,意味着有默认的allocWithZone方法,那么就直接对class进行allocWithZone,申请内存空间。

+ (id)allocWithZone:(struct _NSZone *)zone {
    return _objc_rootAllocWithZone(self, (malloc_zone_t *)zone);
}

2.canAllocFast是否可以快速创建,如果可以,直接调用calloc函数,申请1块bits.fastInstanceSize()大小的内存空间,如果创建失败,会调用callBadAllocHandler函数,返回错误信息。新版本中canAllocFast默认返回false,因为宏定义FAST_ALLOC没有进行定义

3.在新版本代码中,会直接走else的class_createInstance方法

class_createInstance

如果没有快速创建等方法,会走到class_createInstance中, 这个方法是产生对象的关键步骤

1.hasCxxCtor 判断当前class或者superclass 是否有.cxx_construct构造方法的实现

2.instanceSize(extraBytes)这个方法是计算对象中属性内存对齐的主要方法,其实属性以8字节内存对齐,对象以16字节内存对齐,相关更详细的分下会在内存对齐模块讲述

3.(id)calloc(1, size)方法是对象进行申请内存的主要方法,以16字节对齐,后续malloc原理详细讲述

4.initInstanceIsainitIsa两个方式是给创建isa并关联到对象,后续isa模块详细讲述

流程图分析

init和new的原理

通过上述流程,一个对象就已经申请内存,并创建了isa指向该类,完成了从无到有的孕育过程,那么initnew方法是用来干什么的呢。

init源码

通过源码可知,底层调用了_objc_rootInit方法 通过_objc_rootInit方法可以看到,init方法直接返回了self,并没有做其他任何的操作

new源码

通过源码可以看到,new方法只是调用了callAllocinit方法

所以,init和new方法只是返回了alloc之后就返回了对象本身,没有做其他操作,是方便开发者重写自己的逻辑的一种工厂模式

malloc和内存对齐

上一节我们对对象的孕育有了一个大体的了解,但是有些概念还是不太熟悉,比如对象是如何开辟内存的,到底开辟多少的内存才合适?,对象的属性和对象本身是如何进行二进制对齐的?这一节主要解决这个问题,并探寻原理

例子解读

通过对象的本质我们知道,对象在底层是以结构体的形式存在的,那么要计算结构的大小,就需要知道结构体中所包含的所有属性的大小,但是进行计算并不是单纯想加各元素所占字节大小,编译器会进行优化

struct LGStruct1 {
    char a;     // 1 [0] 
    double b;   // 8 [8,15]
    int c;      // 4 [16,19]
    short d;    // 2 [20,21]
} MyStruct1;

struct LGStruct2 {
    double b;   // 8 [0,7]
    int c;      // 4 [8,11]
    char a;     // 1 [12]
    short d;    // 2 [14,15]
} MyStruct2;

NSLog(@"%lu---%lu---%lu",sizeof(MyStruct1),sizeof(MyStruct2));

上述代码的打印结果为 24-16,其内部遵循以下原则 初始位置为0,之后每个元素的位置遵循min(当前开始的位置m n)

LGStruct1char a [0]; // [1,7]为空,因为都不是double字节8的倍数
double b [8,15] // 8位double字节8,直接存放
int c [16,19] // 16为int字节4 的倍数,直接存放
short d [20,21] // 20 为short 2 的倍数,直接存放

LGStruct1一共占有了22字节,但是总大小一定要为元素最大字节数的倍数,里面最大为8字节,所以总字节数应为8的倍数,所以共申请24字节,以此类推,可以得到LGStruct2共占有了16字节

内存对齐

通过以上的例子,我们基本可以总结内存对齐的三原则为:

  • 结构体(struct)或联合体(union)的数据成员,第一个数据成员放在offset为0的地方,以后每个数据成员存储的起始位置要从该成员大小或者成员的子成员大小(只要该成员有子成员,比如数组、结构体等)的整数倍开始。 eg: int为4字节,则要从4的整数倍地址开始存储

  • 结构体作为成员:如果一个结构体内部包含其他结构体成员,则结构体成员要从其内部最大元素大小的整数倍地址开始存储。 eg: struct a里包含struct b,b中包含其他char、int、double等元素,那么b应该从8(double的元素大小)的整数倍开始存储

  • 收尾工作:结构体的总大小,也就是sizeof的结果,必须是其内部最大成员的整数倍,不足的需要补齐。

1. 属性对齐

通过上一节instanceSize(extraBytes)方法的源码我们来首先进行分析

  • 通过判断我们可知,一个对象所申请的空间,最低为16字节
  • alignedInstanceSize方法:

  • word_align函数是一个进行对齐的算法

该算法和上述例子对齐方式是一个道理,其中WORD_MASK为7,通过二进制的& ~ 运算,即代表该算法为8字节对齐,即所计算出的内存为8的倍数,代表对象实际根据属性数来申请内存的话,其实是以8的倍数来进行申请的

2.对象对齐和malloc流程

    LGTeacher  *p = [LGTeacher alloc];
    p.name = @"LG_Cooci";   // 8
    p.age  = 18;            // 4
    p.height = 185;         // 8
    p.hobby  = @"女";       // 8
    NSLog(@"%lu - %lu",class_getInstanceSize([p class]),malloc_size((__bridge const void *)(p)));

上述例子打印的结果为 40,48

根据内存原则,我们可以知道,类实际有class_getInstanceSize大小为8倍数40自家,但是为什么malloc_size有48呢,我们通过追踪源码,发现instanceSize(id)calloc(1, size)的size均为40,那么calloc函数到底做了什么操作

我们可以通过libmalloc源码进行分析 首先创建一个下图所示代码

找到calloc的底层实现,由于返回retval,所以调用malloc_zone_calloc方法 由于返回ptr,所以要寻找zone-calloc

直接点击的话,会找不到方法,所以要使用LLDB命令来寻找,发现对象的调用为default_zone_calloc

(lldb) p zone->calloc
(void *(*)(_malloc_zone_t *, size_t, size_t)) $0 = 0x000000010031cd14 (.dylib`default_zone_calloc at malloc.c:249)

依旧点击不了,LLDB走起,发现调用为nano_calloc

(lldb) p zone->calloc
(void *(*)(_malloc_zone_t *, size_t, size_t)) $0 = 0x000000010031e33f (.dylib`nano_calloc at nano_malloc.c:878)

通过代码可知,如果在最大值只下,会return p,如果大于才会走向helper_zone,并递归,所以跟踪_nano_malloc_check_clear 通过方法可以发现segregated_size_to_fit是用来计算的大小的主要方法,并且返回了48,所以对齐方法在此 跟踪slot_bytes,此时NANO_REGIME_QUANTA_SIZE为15,SHIFT_NANO_QUANTUM为4,所以slot_bytes为[目标值 > > 4 < < 4]的位运算,是16字节对齐的,所以申请为40,16字节对齐后为48

通过上述mallco的流程我们可以知道,对象是以16字节对齐的,所以在属性对齐时,才会要求最小16字节

流程图和总结

malloc的流程图如下 calloc流程.jpg

n字节对齐方式算法(x为初始值):

1.(x + (2^n - 1))& ~(2^n - 1)

2.(x + (2^n - 1) 位运算:>>n <<n

总结:根据内存原则,对象在申请内存空间时,首先会进行属性对齐,此时会已8字节进行对齐,最后会对象对齐,此时已16字节进行对齐,所以对象最小为16字节,并已16字节对齐

isa的原理和走向

通过上面的小结,我们已经知道对象是如何创建,内存时如何申请的了,那么我们都知道,每个集成字NSObject的类的默认属性isa,那么isa本质到底是个什么,它是怎么绑定的类,以及作用到底是啥?

isa本质和类绑定

通过alloc流程分析,我们找到initIsa源码中堆isa的定义

我们可以看到isa实际是一个isa_t结构,看源码可知,isa是一个联合体,联合体中各元素共享内存,并互斥,且isa总共占有8字节,64位,在类中以Class 对象存在,是用来指向类的地址

那么ISA_BITFIELD中存有那些元素么,这个会根据系统架构的不同,有不同的元素,我们以arm64结构来解读,根据各元素站的位数可得,一共为64位8字节

  • 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

isa类绑定

通过源码发现isa有cls和shiftcls属性是与类相关的 如果时候纯isa指针,那么直接绑定cls即可

如果为1,因为是结构体,且前面占有3位,则需要对类指针进行 >>3的位运算,来存储类的信息,对cls的地址右移动3位的目的是为了减少内存的消耗,因为类的指针需要按照8字节对齐,也就是说类的指针的大小必定是8的倍数,其二进制后三位为0,右移三位抹除后面的3位0并不会产生影响。

isa的指向

通过上面我们知道了isa是绑定类的,那么我们可以通过object_getClass方法来通过对象是怎么获类的。源码如下

Class object_getClass(id obj)
{
    if (obj) return obj->getIsa();
    else return Nil;
}

objc_object::getIsa() 
{
    // 一般都不是TaggedPointer,这是特殊指针
    if (!isTaggedPointer()) return ISA();
}

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
}
// 64位架构下 ISA_MASK的值为
#   define ISA_MASK        0x00007ffffffffff8ULL

通过以上源码可以发现获取对象的类就是获取对象的isa,而isa通过位域&上一个mask(isa.bits & ISA_MASK),就可以获取类。

那么让我通过打印LGPerson *p = [LGPerson alloc];对象的地址来看,首先对象的第一个属性为isa,即0x10200c480第一个值为isa的值,然后获取到LGPerson.class类的地址。

1.通过验证我们可以得到,对象的isa是指向对象的类

那么类对象的isa又指向什么呢,我们可以通过上述命令继续验证

2.通过验证可得,LGPerson类的isa指向了一个地址完全不同,但是也名为LGPerson的类,我们一般叫这个类为元类,这是由系统创建的,无法操作

那么元类又指向什么呢,继续验证

3.通过验证可得LGPerson元类的isa指向了NSObject类,我们一般叫此为根元类

那么根元类又指向什么呢,走起

4.通过验证可得,根源类的isa就指向了自己,一个流程就此结束

总结一下:

  • 实例对象的isa指向的是类;
  • 类的isa指向的元类;
  • 元类指向根元类;
  • 根元类指向自己;
  • NSObject的父类是nil,根元类的父类是NSObject。

流程图

通过上述验证,可以得到经典流程图

总结

至此一个对象的申请内存并创建,且与类之间的关系已经全部探索完毕,一个章节将要进行类的探究