OC底层知识点之 - 内存管理(上)

2,253 阅读19分钟

系列文章:OC底层原理系列OC基础知识系列

前言

之前在使用clang将.m文件转成.cpp文件,查看里面的内容,发现属性上的编译也很有意思,所以本篇探究下iOS内存管理顺便探究下属性,成员变量,实例变量

ARC & MRC

iOS中的内存管理方案,大致可以分为两类:MRC(手动内存管理)和ARC(自动内存管理)

  • MRC
    • MRC时代,系统是通过对象的引用计数来判断一个是否销毁,有以下规则
    • 对象被创建时引用计数都为1
    • 当对象被其他指针引用时,需要手动调用[objc retain],使对象的引用计数+1
    • 当指针变量不再使用对象时,需要手动调用[objc release]释放对象,使对象的引用计数-1
    • 当一个对象的引用计数为0时,系统就会销毁这个对象 【总结】:在MRC模式下,必须遵守:谁创建谁释放谁引用谁管理
  • ARC
    • ARC模式是在WWDC2011和iOS5引入的自动管理机制,即自动引用计数,是编译器的一种特性
    • 规则与MRC一致,区别在于,ARC模式下不需要手动retain、release、autorelease。编译器会在适当的位置插入release和autorelease

内存布局

之前在OC基础知识点之-内存管理初识(内存分区)介绍了内存的五大区,其实除了五大区还有内核区保留区,以4G手机为例,如下图所示:系统将其中的3GB给了五大区+保留区,剩余的1GB给内核区使用

  • 内核区:系统用来进行内核处理操作的区域
  • 保留区:预留给系统处理nil等 【说明】:之所以最后的内存地址是从0x00400000开始的,是因为0x00000000表示nil,不能直接用nil表示一个段,所以单独给一段内存用于处理nil等情况

内存管理方案

内存管理方案除了前文提及的MRCARC,还有以下三种

  • 1.Tagged Pointer:专门用来处理小对象,例如NSNumber、NSDate、小NSString等
  • 2.Nonpointer_isa:非指针类型的isa,主要是用来优化64位地址。这个在OC底层原理之-OC对象(下)isa指针结构分析对isa进行了介绍
  • 3.SideTables:散列表,在散列表中主要有两个表,分别是引用计数表弱引用表 这里主要介绍Tagged PointerSideTables

Tagged Pointer

我们通过一个面试题来引入Tagged Pointer 上面代码运行有没有问题,为什么?

运行上面的代码我们发现taggedPointerDemo正常的,但是在touchesBegan方法出现了崩溃(taggedPointerDemo执行在viewDidLoad方法中)

崩溃原因是多条线程同一个对象进行释放,导致对象过度释放,所以才会崩溃。 【思考】:taggedPointerDemo和touchesBegan内部实现基本一样,唯一的区别就是nameStr不一样,但是一个没有任何问题,一个却崩溃了,是不是因为nameStr造成的呢?

验证

我们先看下nameStr有什么不一样的地方,在NSLog处打断点,运行代码,分别打印nameStr 我们发现他们的类型不同,taggedPointerDemo中的nameStr的类型是NSTaggedPointerString类型,而touchesBegan中的类型是__NSCFString类型。

  • 1.NSTaggedPointerString类型小对象,存储在常量区,因为nameStr在alloc原本分配是在堆区,但是由于taggedPointerDemo的nameStr较小,经过iOS优化,就成了NSTaggedPointerString类型,存在常量区
  • 2.touchesBegan方法中的nameStr类型是NSCFString类型,存储在堆区

NSString的内存管理

我们可以通过NSString初始化的两种方式,来测试NSString的内存管理

  • 1.通过withString+@""方式初始化
  • 2.通过WithFormat方式初始化 运行结果: 通过打印我们可以看到NSString的`内存管理``主要分为3种
  • 1.NSTaggedPointerString:标签指针,是苹果在64位环境下对NSString、NSNumber等对象做的优化。对于NSString对象来说
    • 字符串是由数字、英文字母组合且长度小于等于9时,会自动成为NSTaggedPointerString类型,存储在常量区
    • 当有中文或者其他特殊符号时,会直接成为__NSCFString类型,存储在堆区
  • 2.__NSCFString:是在运行时创建的NSString子类,创建后引用计数会加1存储在堆上
  • 3.__NSCFConstantString字符串常量,是一种编译时常量retainCount值很大,对其操作,不会引起引用计数变化,存储在字符串常量区

agged Pointer 小对象底层原理

上面我们通过面试题引出了Tagged Pointer,那么我下面就来探究下Tagged Pointer底层实现,看看为什么Tagged Pointer类型不会存在过度释放问题,我们进入objc源码中查看

小对象的引用计数处理分析

查看reallySetProperty源码,后面我们会仔细将reallySetProperty方法

看到不过不是copy修饰就会通过objc_retain赋新值objc_release释放旧值,再看objc_retain,objc_release底层实现 通过源码我们可以看到,在objc_retainobjc_release中都对agged Pointer进行了判断,如果小对象,就直接返回。由此我们可以得出一个结论:小对象是不会进行retain和release操作的

小对象的地址分析

我们继续以NSString为例,对于NSString来说

  • 一般的NSString对象指针,都是string值 + 指针地址两者是分开
  • 对于Tagged Pointer指针,其指针+值,都能在小对象中体现。所以Tagged Pointer 既包含指针,也包含值 在之前的文章OC底层原理之-类的加载过程-上( _objc_init实现原理)中讲类加载时,其中_read_images源码有一个方法对小对象进行了处理,即initializeTaggedPointerObfuscator方法,下面我们查看下initializeTaggedPointerObfuscator方法实现 在iOS12后,Tagged Pointer采用了混淆处理,我们可以设置OBJC_DISABLE_TAG_OBFUSCATION为YES来关闭Tagged Pointer的混淆 我们可以通过源码中objc_debug_taggedpointer_obfuscator查找taggedPointer的编码和解码,来查看底层是如何混淆处理的 上面我们知道编码_objc_encodeTaggedPointer是通过objc_debug_taggedpointer_obfuscator异或传入值,解码_objc_decodeTaggedPointer也是通过objc_debug_taggedpointer_obfuscator异或传入值,相当于是两层异或。下面我们举例说明:传入值1010 0100,mask值0101 0010
 1010 0100
^0101 0010 mask (编码)
 1111 0110
^0101 0010 mask (解码)
 1010 0100

我们看下解码后的小对象地址,其中61表示a的ASCII码63表示c的ASCII码,我们再以NSNumber为例 我们看到地址确实存储值了。但是小对象后面的0xa,0xb又是什么含义呢?最后我们在判断是否为小对象的判断里找到了答案 所以0xa、0xb主要是用于判断是否是小对象TaggedPointer,判断第64位上是否有为1taggedpointer指针地址即表示指针地址,也表示值)

  • 0xa转换成二进制为1 010(64为为1,63~61后三位表示tagType类型-2),表示NSString类型
  • 0xb转换为二进制为1 011(64为为1,63~61后三位表示tagType类型-3),表示NSNumber类型,这里需要注意一点,如果NSNumber的值是-1,其地址中的值是用补码表示的 这里可以通过_objc_makeTaggedPointer方法的参数tag类型objc_tag_index_t进入其枚举,其中2表示NSString3表示NSNumber 验证下:我们可以定义一个NSDate对象,来验证其tagType是否为6 通过打印结果,其地址高位是0xe,转换为二进制为1 110,排除64位的1,剩余的3位正好转换为十进制是6符合上面的枚举值

Tagged Pointer 总结

  • 1.Tagged Pointer小对象类型(用于存储NSNumber、NSDate、小NSString),小对象指针不再是简单的地址,而是地址 + 值,即真正的值,所以,实际上它不再是一个对象了,它只是一个披着对象外衣的普通变量而以。所以可以直接进行读取。优点是占用空间小,节省内存
  • 2.Tagged Pointer小对象,不会进入retain和release,而是直接返回了,意味着不需要ARC进行管理,所以可以直接被系统自主的释放和回收
  • 3.Tagged Pointer内存并不存储在堆区中,而是在常量区中,也不需要malloc和free,所以可以直接读取,相比存储在堆区的数据读取,效率上快了3倍左右。创建效率相比堆区了近100倍左右。taggedPointer的内存管理方案,比常规的内存管理,要快很多
  • 4.Tagged Pointer的64位地址中,前4位代表类型后4位主要适用于系统做一些处理中间56位用于存储值
  • 5.优化内存建议:对于NSString来说,当字符串较小时,建议直接通过@""初始化,因为存储在常量区,可以直接进行读取。会比WithFormat初始化方式更加快速

探究strong和copy的内存管理

准备代码

我们在ViewController.h文件写的有如下属性

@interface ViewController ()
{
    Man *man;
    NSString *workTime;
    NSInteger times;
}
@property (nonatomic, strong)Student *student;
@property (nonatomic, copy)NSString *schoolName;
@property (nonatomic, copy)NSString *name;
@property (nonatomic, assign) NSInteger age;
@property (nonatomic, strong)NSMutableArray *houses;
@end

名词解释

属性:有前缀 @property修饰的变量

成员变量:就是{}内的变量,上面我们写的man,workTime,times都是成员变量

实例变量:如果成员变量的数据类型是个类,能被实例化,那它就是实例变量,上面我们写的man就是实例变量

通过clang转成.cpp文件。我们在它的.cpp文件发现属性他们在c++底层是如图(截取部分) 这是属性的get和set方法,发现name的set方法和age,houses有区别(区别用下划线标记出来了)原因是name1使用的是copy,二age用的assign,houses用的strong

探究copy

我们探究下为什么会使用objc_setProperty,我们去先取LLVM源码中找一下objc_setProperty,我们找到了getOptimizedSetPropertyFn放发,看下图: 通过图我们看到对属性使用不同的修饰词,对象的set方法修饰也不同

  • 如果使用natomic和copy修饰:objc_setProperty_atomic_copy
  • 如果使用natomic和非copy修饰:objc_setProperty_atomic
  • 如果使用非natomic和copy修饰:objc_setProperty_nonatomic_copy
  • 如果使用非natomic和非copy修饰:objc_setProperty_nonatomic

我们的name是非natomic和copy修饰,所以应该是objc_setProperty_nonatomic_copy 下面我们再去源码中查看下objc_setProperty_nonatomic_copy如何实现。 上图就是我们找到的源码实现,我们注意到方法reallySetProperty,去这个方法看看 上图我们知道如果是copy就是调用copyWithZone方法,由于不是atomic,所以会走90,91这里就是取出之前的值,将新值赋给*slot,最后将旧值释放。 下面我们代码验证下

@interface Person : NSObject
@property (nonatomic, copy) NSString *name;
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *p  = [Person alloc];
        p.name = @"小明";
        p.name = @"小张";
        NSLog(@"--->");
        NSString *nameStr = [p.name copy];
    }
    return 0;
}

运行代码,打断点 打印因为小明是第一次赋值,所以不存在旧值。我们继续 这次再给name赋值小张旧值就存在了。最后在100行会将旧值释放掉。 我们再看下NSString *nameStr = [p.name copy];我们发现并没有走我们打断点的reallySetProperty方法,那这个方法会走哪呢?我们打断点,在断点停留处看看汇编(截取关键部分) 我们发现后面会调用objc_storeStrong,下面我们在源码中找一下该方法 打断点,不再看汇编,继续下一步 但此时的obj是nil,因为之前不存在nameStrobjc_retain(id obj)方法里进行判断,如果存在就返回不存在就进行创建(通过objc_msgSend的方式发送retain方法)。我们发现调用objc_retain方法,下面我们探究下strong属性(objc_retain后面讲)

探究strong

我们准备下代码:

@interface Person : NSObject
@property (nonatomic, strong) NSString *name;
@end
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Person *p  = [Person alloc];
        p.name = @"aaa";
    }
    return 0;
}

打断点,当来到这里是,我们查看汇编 我们发现这个方法和上面讲的nameStr一样,都会走objc_storeStrong 此时我们打印,发现此时·obj为我们对name的赋值。接着对obj进行objc_retain

探究objc_retain

上面我们知道对属性进行copy,以及属性使用strong修饰,都会走objc_retain,我们看看objc_retain究竟做了些什么属性使用copy修饰是不走objc_retain。 我们全局搜objc_retain,发现如下代码

  • 1586:如果obj不存在,就直接返回
  • 1587:如果obj是Tagged Pointer对象也直接返回
  • 1588:上面都不满足调用retain()

下面看下retain()方法

  • 455:判断不是Tagged Pointer对象,这个方法不希望处理Tagged Pointer
  • 457:通过hasCustomRR函数检查类(包括其父类)中是否含有默认的retain方法
  • 458:如果没有调用rootRetain()
  • 461:如果有就进行消息转发,去调用自定义的retain方法

走下来我们发现只会走461行,不会走458行。这是因为hasCustomRR再检查的时候,会通过isa指针一直向上查找,直到找到NSObject,在NSObject中重写了retain方法 _objc_rootRetain会调用rootRetain()方法。 下面我们看下rootRetain()方法 发现调用了rootRetain(false, false)注意传值:false,false,下面我们看下rootRetain方法。 rootRetain的方法比较多,我们会挑比较重要的地方进行解释说明

  • 489:如果是Tagged Pointer直接返回
  • 492:transcribeToSideTable用于表示extra_rc是否溢出,默认为false(不抄写到SideTable)
  • 499:通过atomic(原子性)获取isa
  • 501:isa是不是被优化过nonpointer就是isa结构体中的nonpointer,具体看OC底层原理之-OC对象(下)isa指针结构分析
  • 502:如果不是,就清空对象的isa.bits
  • 503:如果是元类,说明是类对象,就直接返回
  • 504:如果tryRetainfalse且sideTable被锁,就打开锁retain必须要为true,如果当前线程锁住了sideTable对象,则需要解锁)。
  • 505:通过判断tryRetain是否为true来确定retain是否成功,如果成功,就判断sidetable_retain()是否存在,如果存在就返回this,如果不存在就返回nil。如果失败继续往下走调用sidetable_retain()
  • 515:将newisa的bits进行处理,进行addc操作(注释说对引用计数进行+1RC_ONE1想左偏移56位,而isa指针的extra_rc:引用计数,在63-56之间。注:在x86下)
  • 519:有进位,说明溢出了,这时候表示extra_rc已经不能存储在isa指针中了。
  • 520-521:如果不处理溢出情况,在520行对bits进行清空,在521行再次调用rootRetain,此时的handleOverflow的会被rootRetain_overflow置为true。从而直接走下
  • 525-529:525行就是对sideTable进行加锁,528行是将引用计数减半(RC_HALF为1左移7为,extra_rc满为8位,少了一位就是减半),继续存在isa中,529行将has_sidetable_rc设置为true,表明借用了sideTable存储
  • 533:因为上面讲如果溢出了,就会将transcribeToSideTable置为true。所以如果溢出了就会进来
  • 535:将上面说的引用计数溢出,一半放在isa指针中,另一半就存在sideTable中
  • 538:如果tryRetain为falseSideTable锁了,那就解锁
  • 539:返回

通过上面的解释我们可以确定几个问题:1.调用rootRetain会让引用计数+1. 2.当引用计数过大溢出时,会将引用计数一半存在isa的extra_rc中,另一半存在sideTable中 我们上面说了,属性直接copy以及strong修饰属性赋值时都会调用rootRetain,说明对对象copy以及strong修饰属性赋值时都会导致引用计数+1因为strong是强引用,所以+1,而copy修饰属性,只是调用copyWithZone,而不可变对象进行copy时是对内存地址进行copy,也就是此处也指向该内存,所以需要+1 可看下图 我们看到内存地址是一样的。

SideTables 散列表

上面我们说了引用计数存储到一定的值时,就不会存在isa指针中的extra_rc中,而是将一半存到SideTables散列表中,为什么是将一半存在SideTables而不是全部呢?

原因:如果都存储在散列表中,每次对散列表操作都需要开解锁,操作耗时,消耗性能大,所以对半分的操作目的是为了提高性能 我们看下sidetable_addExtraRC_nolock源码 发现获取SideTable是从SideTables取的,说明SideTable是有多张的

问题1.为什么在内存中有多张?最多能够多少张?**

  • 如果散列表只有一张表,意味着全局所有的对象都会存储在一张表中,操作任意一个对象,都会进行解锁(锁是锁整个表的读写)。当开锁时,其它对象可能也操作这张表,则意味着数据不安全
  • 如果每个对象都开一个表,会耗费性能,所以也不能有无数个表 我们看下SideTable结构,SideTables的底层实现

我们发现sideTable包含互斥锁slock,引用计数表refcnts,以及一个弱引用表weak_table,而SideTables通过SideTablesMap的get方法获取,而SideTablesMap是通过StripedMap<SideTable>定义的。我们再看下StripedMap源码 从这里可以看到,同一时间,真机中的散列表最多只能有8张

问题2.为什么在用散列表,而不用数组、链表?

  • 数组:特点在于查询方便(即通过下标访问)增删比较麻烦(类似于之前讲过的methodList通过memcopy、memmove增删,非常麻烦),所以数组的特性是读取快,但存储不方便
  • 链表:特点在于增删方便查询慢(需要从头节点开始遍历查询),所以链表的特性是存储快,但读取慢
  • 散列表:其本质就是一张哈希表,哈希表集合了数组和链表的长处增删改查都比较方便,例如拉链哈希表(在之前锁的文章中,讲过的tls的存储结构就是拉链形式的),是最常用的链表 上面对对象的copy,strong(retain是一样的,都会调用retain()方法,结合上面的小对象。我们总结下retain()作了什么操作

总结retain作了什么

  • 1.retain在底层首先会判断是否是Nonpointer isa,如果不是,则直接操作散列表 进行+1操作
  • 2.如果是Nonpointer isa,还需要判断是否正在释放,如果正在释放,则执行dealloc流程,释放弱引用表和引用计数表,最后free释放对象内存
  • 3.如果不是正在释放,则对Nonpointer isa进行常规的引用计数+1。这里需要注意一点的是,extra_rc在真机上只有8位用于存储引用计数的值,当存储满了时,需要借助散列表用于存储。需要将满了的extra_rc对半分一半(即2^7)存储在散列表中另一半还是存储在extra_rc中,用于常规的引用计数的+1或者-1操作,然后再返回

release 源码分析

上面分析了+1操作,下面分析下-1操作:release,看下release底层实现 上面的reallySetProperty最后对旧值进行objc_release,我们就从objc_release开始,objc_release->release()->rootRelease()->rootRelease()

大致上和上面的rootRetain方法类似,只不过是相反的操作。下面简要分析一下

  • 1.判断是否是Nonpointer isa,如果不是,则直接对散列表进行-1操作
  • 2.如果是Nonpointer isa,则对extra_rc中的引用计数值进行-1操作,并存储此时的extra_rc状态到carry
  • 3.如果此时的状态carray为0,则走到underflow流程
  • 4.underflow流程有以下几步:
    • 判断散列表是否存储了一半的引用计数
    • 如果是,则从散列表取出存储的一半引用计数,进行-1操作,然后存储到extra_rc中
    • 如果此时extra_rc没有值散列表中也是空的,则直接进行析构,即dealloc操作,属于自动触发

dealloc 源码分析

在retain和release的底层实现中,都提及了dealloc析构函数,下面来分析dealloc的底层的实现,通过delloc->_objc_rootDealloc->rootDealloc

  • 1.根据条件判断是否有isa、cxx、关联对象、弱引用表、引用计数表,如果没有,则直接free释放内存
  • 2.如果,则进入object_dispose方法 通过上面可以看到,object_dispose方法的目的有一下几个
  • 1.销毁实例,主要有以下操作
    • 1.调用c++析构函数
    • 2.删除关联引用
    • 3.释放散列表
    • 4.清空弱引用表
  • 2.free释放内存 到现在为止,retain -> release -> dealloc就全部串联起来了

retainCount 源码分析

上面提到了retainCount,我们来看下retainCount底层是如何操作的,我们先看个面试题 问:打印结果是多少?答案:1,为什么?如果回答因为NSObject被alloc了,所以引用计数+1,那么你是说对了结果,但是不知道原因

我们在文章对alloc理解从来没说过allock会对引用计数+1。那为什么答案会是1呢,下面我们来分析下 上面就是retainCount源码,我们在rootRetainCount打断点,进行调试

答案:alloc创建的对象实际的引用计数为0,其引用计数打印结果为1,是因为在底层rootRetainCount方法中,引用计数默认+1了,但是这里只有对引用计数的读取操作,是没有写入操作的,简单来说就是:为了防止alloc创建的对象被释放(引用计数为0会被释放),所以在编译阶段,程序底层默认进行了+1操作。实际上在extra_rc中的引用计数仍然为0

总结

  • 1.alloc创建的对象没有retain和release
  • 2.alloc创建对象的引用计数为0,会在编译时期,程序默认加1,所以读取引用计数时为1

扩展

我们再看上面的.cpp文件发现如下: 我们看到每个属性都有一个get方法和set方法

我们解释下红框部分的意思,红框是签名,以@16@0:8为例,

  • 第一个@是返回值为id类型
  • 16表示的返回值为16字节
  • 第二个@表示第一个参数
  • 0表示从0开始,到8(0-8)
  • :是指sel方法编号
  • 8是指8-16 "v24@0:8@16",v指无返回值

为了方便理解,我附上一张官方解释的图,以及官方连接,大家可以去官方看更详细的解释。 附:Type Encoding-官方文档Property Type String-官方文档