2万字 长文对 iOS底层知识大总结

356 阅读27分钟

iOS-底层原理 20:OC底层面试解析

developer.aliyun.com/article/928…

【面试-1】Runtime Asssociate方法关联的对象,需要在dealloc中释放?


当我们对象释放时,会调用dealloc

  • 1、C++函数释放 :objc_cxxDestruct

  • 2、移除关联属性:_object_remove_assocations

  • 3、将弱引用自动设置nil:weak_clear_no_lock(&table.weak_table, (id)this);

  • 4、引用计数处理:table.refcnts.erase(this)

  • 5、销毁对象:free(obj)

所以,关联对象不需要我们手动移除,会在对象析构即dealloc时释放

【面试-2】方法的调用顺序

类的方法 和 分类方法 重名,如果调用,是什么情况?

  • 如果同名方法是普通方法,包括initialize -- 先调用分类方法
    • 因为分类的方法是在类realize之后 attach进去的,插在类的方法的前面,所以优先调用分类的方法(注意:不是分类覆盖主类!!)

    • initialize方法什么时候调用? initialize方法也是主动调用,即第一次消息时调用,为了不影响整个load,可以将需要提前加载的数据写到initialize

  • 如果同名方法是load方法 -- 先 主类load,后分类load(分类之间,看编译的顺序)

1、category 类别、分类


  • 专门用来给类添加新的方法

  • 不能给类添加成员属性,添加了成员属性,也无法取到

  • 注意:其实可以通过runtime 给分类添加属性,即属性关联,重写setter、getter方法

  • 分类中用@property 定义变量,只会生成变量的setter、getter方法的声明不能生成方法实现 和 带下划线的成员变量

2、extension 类扩展


  • 可以说成是特殊的分类 ,也可称作 匿名分类

  • 可以给类添加成员属性,但是是私有变量

  • 可以给类添加方法,也是私有方法

【面试-4】方法的本质,sel是什么?IMP是什么?两者之间的关系又是什么?


  • 方法的本质:发送消息,消息会有以下几个流程
    • 快速查找(objc_msgSend) - cache_t缓存消息中查找

    • 慢速查找 - 递归自己|父类 - lookUpImpOrForward

    • 查找不到消息:动态方法解析 - resolveInstanceMethod

    • 消息快速转发 - forwardingTargetForSelector

    • 消息慢速转发 - methodSignatureForSelector & forwardInvocation

  • sel方法编号 - 在read_images期间就编译进了内存

  • imp函数实现指针找imp就是找函数的过程

  • sel 相当于 一本书的目录title

  • imp 相当于 书本的页码

  • 查找具体的函数就是想看这本书具体篇章的内容

    • 1、首先知道想看什么,即目录 title - sel

    • 2、根据目录找到对应的页码 - imp

    • 3、通过页码去翻到具体的内容

【面试-5】能否向编译后得到的类中增加实例变量?能否向运行时创建的类中添加实例变量


  • 1、不能向编译后的得到的类中增加实例变量

  • 2、只要类没有注册到内存还是可以添加的

  • 3、可以添加属性+方法

【原因】 :编译好的实例变量存储的位置是ro,一旦编译完成,内存结构就完全确定了

【经典面试-6】 [self class]和[super class]的区别以及原理分析


  • [self class]就是发送消息 objc_msgSend,消息接收者是self,方法编号 class

  • [super class] 本质就是objc_msgSendSuper,消息的接收者还是 self,方法编号 class,在运行时,底层调用的是_objc_msgSendSuper2【重点!!!】

  • 只是 objc_msgSendSuper2 会更快,直接跳过self的查找

完整回答

**
**

所以,最完整的回答如下

  • [self class]方法调用的本质是 发送消息,调用class的消息流程,拿到元类的类型,在这里是因为类已经加载到内存,所以在读取时是一个字符串类型,这个字符串类型是在map_imagesreadClass时已经加入表中,所以打印为LGTeacher

  • [super class]打印的是LGTeacher,原因是当前的super是一个关键字,在这里只调用objc_msgSendSuper2,其实他的消息接收者和[self class]是一模一样的,所以返回的是LGTeacher

【面试-7】内存平移问题


Class cls = [LGPerson class];
void  *kc = &cls;  //
[(__bridge id)kc saySomething];

LGPerson中有一个属性 kc_name 和一个实例方法saySomething,通过上面代码这种方式,能否调用实例方法?为什么?

哪些东西在栈里 哪些在堆里


  • alloc的对象 都在

  • 指针、对象中,例如person指向的空间中,person所在的空间在栈中

  • 临时变量

  • 属性值,属性随对象是在

注意:

  • 是从小到大,即低地址->高地址

  • 栈是从大到小,即从高地址->低地址分配

    • 函数隐藏参数会从前往后一直压,即 从高地址->低地址 开始入栈

    • 结构体内部的成员是从低地址->高地址

  • 一般情况下,内存地址有如下规则
    • 0x60 开头表示在

    • 0x70 开头的地址表示在

    • 0x10 开头的地址表示在全局区域

【面试-8】 Runtime是如何实现weak的,为什么可以自动置nil


  • 1、通过SideTable 找到我们的 weak_table

  • 2、weak_table 根据 referent找到或者创建 weak_entry_t

  • 3、然后append_referrer(entry,referrer)将我的新弱引用的对象加进去entry

  • 4、最后 weak_entry_insert,把entry加入到我们的weak_table

底层源码调用流程如下图所示

image.png

内存对齐的原理

developer.aliyun.com/article/921…

总结


  • sizeof:计算类型占用的内存大小,其中可以放 基本数据类型、对象、指针

    • 对于类似于int这样的基本数据而言,sizeof获取的就是数据类型占用的内存大小,不同的数据类型所占用的内存大小是不一样的

    • 而对于类似于NSObject定义的实例对象而言,其对象类型的本质就是一个结构体(即 struct objc_object)的指针,所以sizeof(objc)打印的是对象objc的指针大小,我们知道一个指针的内存大小是8,所以sizeof(objc) 打印是 8。注意:这里的8字节与isa指针一点关系都没有!!!)

    • 对于指针而言,sizeof打印的就是8,因为一个指针的内存大小是8,

  • class_getInstanceSize:计算对象实际占用的内存大小,这个需要依据类的属性而变化,如果自定义类没有自定义属性,仅仅只是继承自NSObject,则类的实例对象实际占用的内存大小是8,可以简单理解为8字节对齐

  • malloc_size:计算对象实际分配的内存大小,这个是由系统完成的,可以从上面的打印结果看出,实际分配的和实际占用的内存大小并不相等,这个问题可以通过iOS-底层原理 02:alloc & init & new 源码分析中的16字节对齐算法来解释这个问题 在iOS开发中,内存对齐是一种优化技术,它可以提高内存访问的效率。iOS中的内存对齐原理与底层硬件体系结构和编译器的工作方式有关。

在iOS中,内存对齐是指将变量在内存中的起始地址调整为其数据类型大小的整数倍。这样做的目的是为了优化内存访问的速度和效率。

当变量在内存中的起始地址是其数据类型大小的整数倍时,读取或写入该变量的操作可以通过一次内存访问来完成。如果变量的起始地址不是对齐的,那么读取或写入该变量的操作可能需要多次内存访问,从而降低了效率。

iOS中的内存对齐是由编译器自动完成的,开发者无需显式地进行操作。编译器会根据变量的数据类型和目标硬件架构的要求,自动调整变量在内存中的对齐方式。

一般情况下,iOS中的数据类型都有默认的对齐方式,比如int类型的对齐方式通常是4字节对齐,double类型的对齐方式通常是8字节对齐。但是,有些特殊情况下,我们可能需要手动指定变量的对齐方式,这可以通过编译器提供的一些特殊的对齐指令来实现。

总结一下,iOS中的内存对齐原理是将变量在内存中的起始地址调整为其数据类型大小的整数倍,以提高内存访问的效率。这一过程由编译器自动完成,开发者通常无需手动操作。

总结

iOS-底层原理 14:消息流程分析之 动态方法决议 & 消息转发

developer.aliyun.com/article/924…


到目前为止,objc_msgSend发送消息的流程就分析完成了,在这里简单总结下

  • 【快速查找流程】首先,在类的缓存cache中查找指定方法的实现

  • 【慢速查找流程】如果缓存中没有找到,则在类的方法列表中查找,如果还是没找到,则去父类链的缓存和方法列表中查找

  • 【动态方法决议】如果慢速查找还是没有找到时,第一次补救机会就是尝试一次动态方法决议,即重写resolveInstanceMethod/resolveClassMethod 方法

  • 【消息转发】如果动态方法决议还是没有找到,则进行消息转发,消息转发中有两次补救机会:快速转发+慢速转发

  • 如果转发之后也没有,则程序直接报错崩溃unrecognized selector sent to instance

iOS-底层原理 15:dyld加载流程

developer.aliyun.com/article/924…

编译过程


其中编译过程如下图所示,主要分为以下几步:

  • 源文件:载入.h、.m、.cpp等文件
  • 预处理:替换宏,删除注释,展开头文件,产生.i文件
  • 编译:将.i文件转换为汇编语言,产生.s文件
  • 汇编:将汇编文件转换为机器码文件,产生.o文件
  • 链接:对.o文件中引用其他库的地方进行引用,生成最后的可执行文件

image.png

静态库 和 动态库


  • 静态库:在链接阶段,会将可汇编生成的目标程序与引用的库一起链接打包到可执行文件当中。此时的静态库就不会在改变了,因为它是编译时被直接拷贝一份,复制到目标程序里的

    • 好处:编译完成后,库文件实际上就没有作用了,目标程序没有外部依赖,直接就可以运行

    • 缺点:由于静态库会有两份,所以会导致目标程序的体积增大,对内存、性能、速度消耗很大

  • 动态库:程序编译时并不会链接到目标程序中,目标程序只会存储指向动态库的引用,在程序运行时才被载入

    • 优势
      • 减少打包之后app的大小:因为不需要拷贝至目标程序中,所以不会影响目标程序的体积,与静态库相比,减少了app的体积大小

      • 共享内存,节约资源:同一份库可以被多个程序使用

      • 通过更新动态库,达到更新程序的目的:由于运行时才载入的特性,可以随时对库进行替换,而不需要重新编译代码

    • 缺点:动态载入会带来一部分性能损失,使用动态库也会使得程序依赖于外部环境,如果环境缺少了动态库,或者库的版本不正确,就会导致程序无法运行

iOS-底层原理 15:dyld加载流程

developer.aliyun.com/article/926… 在上一篇iOS-底层原理 16:dyld与objc的关联文章中,我们理解了dyldobjc是如何关联的,本文的主要目的是理解类的相关信息是如何加载内存的,其中重点关注

map_imagesload_images

  • map_images:主要是管理文件中和动态库中的所有符号,即class、protocol、selector、category

  • load_images:加载执行load方法

其中代码通过编译,读取到Mach-O可执行文件中,再从Mach-O中读取到内存,如下图所示

image.png

iOS-底层原理 21:Method-Swizzling 方法交换

developer.aliyun.com/article/928…

method-swizzling 是什么?


  • method-swizzling的含义是方法交换,其主要作用是在运行时将一个方法的实现替换成另一个方法的实现,这就是我们常说的iOS黑魔法

  • 在OC中就是利用method-swizzling实现AOP,其中AOP(Aspect Oriented Programming,面向切面编程)是一种编程的思想,区别于OOP(面向对象编程)

    • OOP和AOP都是一种编程的思想

    • OOP编程思想更加倾向于对业务模块的封装,划分出更加清晰的逻辑单元;

    • AOP面向切面进行提取封装,提取各个模块中的公共部分,提高模块的复用率,降低业务之间的耦合性

  • 每个类都维护着一个方法列表,即methodListmethodList中有不同的方法Method,每个方法中包含了方法的selIMP,方法交换就是将sel和imp原本的对应断开,并将sel和新的IMP生成对应关系

如下图所示,交换前后的sel和IMP的对应关系

image.png

iOS-底层原理 27:GCD 之 NSThread & GCD & NSOperation

developer.aliyun.com/article/929…

iOS-底层原理 28:GCD 之 底层原理分析

developer.aliyun.com/article/928…

总共有4个任务,其中前2个任务有依赖关系,即任务1执行完,执行任务2,此时可以使用栅栏函数

  • 异步栅栏函数 不会阻塞主线程 ,异步 堵塞 的是队列

image.png

同步栅栏函数 会堵塞主线程,同步 堵塞 是当前的线程

总结

**
**

  • 异步栅栏函数阻塞的是队列,而且必须是自定义的并发队列,不影响主线程任务的执行

  • 同步栅栏函数阻塞的是线程,且是主线程,会影响主线程其他任务的执行

设目前有两个任务,需要等待这两个任务都执行完毕,才会更新UI,可以使用调度组

image.png

会崩溃,因为enter-leave不成对,崩溃在里面是因为async有延迟


  • enter-leave只要成对就可以,不管远近

  • dispatch_group_enter在底层是通过C++函数,对group的value进行--操作(即0 -> -1)

  • dispatch_group_leave在底层是通过C++函数,对group的value进行++操作(即-1 -> 0)

  • dispatch_group_notify在底层主要是判断group的state是否等于0,当等于0时,就通知

  • block任务的唤醒,可以通过dispatch_group_leave,也可以通过dispatch_group_notify

  • dispatch_group_async 等同于enter - leave,其底层的实现就是enter-leave

iOS-底层原理 29:锁的原理

developer.aliyun.com/article/928…

性能总结


  • OSSpinLock自旋锁由于安全性问题,在iOS10之后已经被废弃,其底层的实现用os_unfair_lock替代
    • 使用OSSpinLock及所示,会处于忙等待状态

    • os_unfair_lock是处于休眠状态

  • atomic原子锁自带一把自旋锁,只能保证setter、getter时的线程安全,在日常开发中使用更多的还是nonatomic修饰属性
    • atomic:当属性在调用setter、getter方法时,会加上自旋锁osspinlock,用于保证同一时刻只能有一个线程调用属性的读或写,避免了属性读写不同步的问题。由于是底层编译器自动生成的互斥锁代码,会导致效率相对较低

    • nonatomic:当属性在调用setter、getter方法时,不会加上自旋锁,即线程不安全。由于编译器不会自动生成互斥锁代码,可以提高效率

  • @synchronized在底层维护了一个哈希表进行线程data的存储,通过链表表示可重入(即嵌套)的特性,虽然性能较低,但由于简单好用,使用频率很高

  • NSLockNSRecursiveLock底层是对pthread_mutex的封装

  • NSConditionNSConditionLock是条件锁,底层都是对pthread_mutex的封装,当满足某一个条件时才能进行操作,和信号量dispatch_semaphore类似

锁的使用场景


  • 如果只是简单的使用,例如涉及线程安全,使用NSLock即可

  • 如果是循环嵌套,推荐使用@synchronized,主要是因为使用递归锁的 性能 不如 使用@synchronized的性能(因为在synchronized中无论怎么重入,都没有关系,而NSRecursiveLock可能会出现崩溃现象)

  • 循环嵌套中,如果对递归锁掌握的很好,则建议使用递归锁,因为性能好

  • 如果是循环嵌套,并且还有多线程影响时,例如有等待、死锁现象时,建议使用@synchronized

iOS-底层原理 30:Block底层原理

developer.aliyun.com/article/928…

iOS-底层原理 32:启动优化(三)二进制重排

developer.aliyun.com/article/928…

iOS-底层原理 33:内存管理(一)TaggedPointer/retain/release/dealloc/retainCount 底层分析

developer.aliyun.com/article/929…

内存管理方案

developer.aliyun.com/article/929…


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

  • Tagged Pointer:专门用来处理小对象,例如NSNumber、NSDate、小NSString等

  • Nonpointer_isa:非指针类型的isa,主要是用来优化64位地址,这个在iOS-底层原理 07:isa与类关联的原理一文中,已经介绍了

  • SideTables散列表,在散列表中主要有两个表,分别是引用计数表弱引用表

这里主要着重介绍Tagged PointerSideTables,我们通过一个面试题来引入Tagged Pointer

进入objc_retainobjc_release源码,在这里都判断是否是小对象,如果是小对象,则不会进行retain或者release,会直接返回。因此可以得出一个结论:如果对象是小对象,不会进行retain 和 release

//****************objc_retain****************
__attribute__((aligned(16), flatten, noinline))
id 
objc_retain(id obj)
{
    if (!obj) return obj;
    //判断是否是小对象,如果是,则直接返回对象
    if (obj->isTaggedPointer()) return obj;
    //如果不是小对象,则retain
    return obj->retain();
}

//****************objc_release****************
__attribute__((aligned(16), flatten, noinline))
void 
objc_release(id obj)
{
    if (!obj) return;
    //如果是小对象,则直接返回
    if (obj->isTaggedPointer()) return;
    //如果不是小对象,则release
    return obj->release();
}

优化内存建议:对于NSString来说,当字符串较小时,建议直接通过@""初始化,因为存储在常量区,可以直接进行读取。会比WithFormat初始化方式更加快速

SideTables 散列表


引用计数存储到一定值是,并不会再存储到Nonpointer_isa的位域的extra_rc中,而是会存储到SideTables 散列表中

下面我们就来继续探索引用计数retain的底层实现

问题1:散列表为什么在内存有多张?最多能够多少张?

**
**

  • 如果散列表只有一张表,意味着全局所有的对象都会存储在一张表中,都会进行开锁解锁(锁是锁整个表的读写)。当开锁时,由于所有数据都在一张表,则意味着数据不安全

  • 如果每个对象都开一个表,会耗费性能,所以也不能有无数个表

  • 散列表的类型是SideTable,有如下定义

struct SideTable {
    spinlock_t slock;//开/解锁
    RefcountMap refcnts;//引用计数表
    weak_table_t weak_table;//弱引用表
    
    ....
}

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

**
**

  • 数组:特点在于查询方便(即通过下标访问),增删比较麻烦(类似于之前讲过的methodList,通过memcopy、memmove增删,非常麻烦),所以数据的特性是读取快,存储不方便

  • 链表:特点在于增删方便,查询慢(需要从头节点开始遍历查询),所以链表的特性是存储快,读取慢

  • 散列表本质就是一张哈希表,哈希表集合了数组和链表的长处增删改查都比较方便,例如拉链哈希表(在之前锁的文章中,讲过的tls的存储结构就是拉链形式的),是最常用的,如下所示

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

**
**

总结

**
**

  • alloc创建的对象没有retain和release

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

MRC


  • MRC时代,系统是通过对象的引用计数来判断一个是否销毁,有以下规则

    • 对象被创建时引用计数都为1

    • 当对象被其他指针引用时,需要手动调用[objc retain],使对象的引用计数+1

    • 当指针变量不再使用对象时,需要手动调用[objc release]释放对象,使对象的引用计数-1

    • 当一个对象的引用计数为0时,系统就会销毁这个对象

  • 所以,在MRC模式下,必须遵守:谁创建,谁释放,谁引用,谁管理
    ARC

**
**

  • ARC模式是在WWDC2011和iOS5引入的自动管理机制,即自动引用计数。是编译器的一种特性。其规则与MRC一致,区别在于,ARC模式下不需要手动retain、release、autorelease。编译器会在适当的位置插入release和autorelease

总结


通过上面的分析,针对自动释放池的push和pop,总结如下

  • 在自动释放池的压栈(即push)操作中

    • 当没有pool,即只有空占位符(存储在tls中)时,则创建页,压栈哨兵对象

    • 在页中压栈普通对象主要是通过next指针递增进行的,

    • 页满了时,需要设置页的child对象为新建页

所以,综上所述,autoreleaseobjc_autoreleasePush的整体底层的流程如下图所示

内存布局相关面试题


面试题1:全局变量和局部变量在内存中是否有区别?如果有,是什么区别?

**
**

  • 有区别

  • 全局变量保存在内存的全局存储区(即bss+data段),占用静态的存储单元

  • 局部变量保存在中,只有在所在函数被调用时才动态的为变量分配存储单元

**
**

面试题2:Block中可以修改全局变量,全局静态变量,局部静态变量,局部变量吗?

**
**

  • 可以修改全局变量,全局静态变量,因为全局变量 和 静态全局变量是全局的,作用域很广

  • 可以修改局部静态变量,不可以修改局部斌量

    • 局部静态变量(static修饰的) 和 局部变量,被block从外面捕获,成为 __main_block_impl_0这个结构体的成员变量

    • 局部变量是以值方式传递到block的构造函数中的,只会捕获block中会用到的变量,由于只捕获了变量的值,并非内存地址,所以在block内部不能改变局部变量的值

    • 局部静态变量是以指针形式,被block捕获的,由于捕获的是指针,所以可以修改局部静态变量的值

  • ARC环境下,一旦使用__block修饰并在block中修改,就会触发copy,block就会从栈区copy到堆区,此时的block是堆区block

  • ARC模式下,Block中引用id类型的数据,无论有没有__block修饰,都会retain,对于基础数据类型没有__block就无法修改变量值;如果有__block修饰,也是在底层修改__Block_byref_a_0结构体,将其内部的forwarding指针指向copy后的地址,来达到值的修改

内存管理方案


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

  • Tagged Pointer:专门用来处理小对象,例如NSNumber、NSDate、小NSString等

  • Nonpointer_isa:非指针类型的isa,主要是用来优化64位地址,这个在iOS-底层原理 07:isa与类关联的原理一文中,已经介绍了

  • SideTables散列表,在散列表中主要有两个表,分别是引用计数表弱引用表

这里主要着重介绍Tagged PointerSideTables,我们通过一个面试题来引入Tagged Pointer

面试题

**
**

以下代码会有什么问题?

//*********代码1*********
- (void)taggedPointerDemo {
  self.queue = dispatch_queue_create("com.cjl.cn", DISPATCH_QUEUE_CONCURRENT);
    
    for (int i = 0; i<10000; i++) {
        dispatch_async(self.queue, ^{
            self.nameStr = [NSString stringWithFormat:@"CJL"];  // alloc 堆 iOS优化 - taggedpointer
             NSLog(@"%@",self.nameStr);
        });
    }
}

//*********代码2*********
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
    NSLog(@"来了");
    for (int i = 0; i<10000; i++) {
        dispatch_async(self.queue, ^{
            self.nameStr = [NSString stringWithFormat:@"CJL_越努力,越幸运!!!"];
            NSLog(@"%@",self.nameStr);
        });
    }
}

运行以上代码,发现taggedPointerDemo单独运行没有问题,当触发touchesBegan方法后。程序会崩溃,崩溃的原因是多条线程同时对一个对象进行释放,导致了 过渡释放所以崩溃。其根本原因是因为nameStr在底层的类型不一致导致的,我们可以通过调试看出

image.png

调试NSString

  • taggedPointerDemo方法中的nameStr类型是 NSTaggedPointerString,存储在常量区。因为nameStralloc分配时在堆区,由于较小,所以经过xcode中iOS的优化,成了NSTaggedPointerString类型,存储在常量区

  • touchesBegan方法中的nameStr类型是 NSCFString类型,存储在堆上

**
**

NSString的内存管理

**
**

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

  • 通过 WithString + @""方式初始化

  • 通过 WithFormat方式初始化

#define KLog(_c) NSLog(@"%@ -- %p -- %@",_c,_c,[_c class]);

- (void)testNSString{
    //初始化方式一:通过 WithString + @""方式
    NSString *s1 = @"1";
    NSString *s2 = [[NSString alloc] initWithString:@"222"];
    NSString *s3 = [NSString stringWithString:@"33"];
    
    KLog(s1);
    KLog(s2);
    KLog(s3);
    
    //初始化方式二:通过 WithFormat
    //字符串长度在9以内
    NSString *s4 = [NSString stringWithFormat:@"123456789"];
    NSString *s5 = [[NSString alloc] initWithFormat:@"123456789"];
    
    //字符串长度大于9
    NSString *s6 = [NSString stringWithFormat:@"1234567890"];
    NSString *s7 = [[NSString alloc] initWithFormat:@"1234567890"];
    
    KLog(s4);
    KLog(s5);
    KLog(s6);
    KLog(s7);
}

以下是运行的结果

image.png

所以,从上面可以总结出,NSString的内存管理主要分为3种

  • __NSCFConstantString:字符串常量,是一种编译时常量,retainCount值很大,对其操作,不会引起引用计数变化,存储在字符串常量区

  • __NSCFString:是在运行时创建的NSString子类,创建后引用计数会加1,存储在堆上

  • NSTaggedPointerString:标签指针,是苹果在64位环境下对NSString、NSNumber等对象做的优化。对于NSString对象来说

    • 字符串是由数字、英文字母组合且长度小于等于9时,会自动成为NSTaggedPointerString类型,存储在常量区

    • 当有中文或者其他特殊符号时,会直接成为__NSCFString类型,存储在堆区

Tagged Pointer 小对象

相关面试题


AutoreleasePool 相关


面试题1:临时变量什么时候释放?

**
**

  • 如果在正常情况下,一般是超出其作用域就会立即释放

  • 如果将临时变量加入了自动释放池,会延迟释放,即在runloop休眠或者autoreleasepool作用域之后释放

面试题2:AutoreleasePool原理

**
**

  • 自动释放池的本质是一个AutoreleasePoolPage结构体对象,是一个栈结构存储的页,每一个AutoreleasePoolPage都是以双向链表的形式连接

  • 自动释放池的压栈出栈主要是通过结构体的构造函数和析构函数调用底层的objc_autoreleasePoolPushobjc_autoreleasePoolPop,实际上是调用AutoreleasePoolPagepushpop两个方法

  • 每次调用push操作其实就是创建一个新的AutoreleasePoolPage,而AutoreleasePoolPage的具体操作就是插入一个POOL_BOUNDARY,并返回插入POOL_BOUNDARY的内存地址。而push内部调用autoreleaseFast方法处理,主要有以下三种情况

    • page存在,且不满时,调用add方法将对象添加至page的next指针处,并next递增

    • page存在,且已满时,调用autoreleaseFullPage初始化一个新的page,然后调用add方法将对象添加至page栈中

    • page不存在时,调用autoreleaseNoPage创建一个hotPage,然后调用add方法将对象添加至page栈中

  • 当执行pop操作时,会传入一个值,这个值就是push操作的返回值,即POOL_BOUNDARY的内存地址token。所以pop内部的实现就是根据token找到哨兵对象所处的page中,然后使用 objc_release 释放 token之前的对象,并把next 指针到正确位置

面试题3:AutoreleasePool能否嵌套使用?

**
**

  • 可以嵌套使用,其目的是可以控制应用程序的内存峰值,使其不要太高

  • 可以嵌套的原因是因为自动释放池是以栈为节点,通过双向链表的形式连接的,且是和线程一一对应的

  • 自动释放池的多层嵌套其实就是不停的pushs哨兵对象,在pop时,会先释放里面的,在释放外面的

面试题4:哪些对象可以加入AutoreleasePool?alloc创建可以吗?

**
**

  • 使用new、alloc、copy关键字生成的对象和retain了的对象需要手动释放,不会被添加到自动释放池中

  • 设置为autorelease的对象不需要手动释放,会直接进入自动释放池

  • 所有 autorelease 的对象,在出了作用域之后,会被自动添加到最近创建的自动释放池中

面试题5:AutoreleasePool的释放时机是什么时候?

**
**

  • App 启动后,苹果在主线程 RunLoop 里注册了两个 Observer,其回调都是_wrapRunLoopWithAutoreleasePoolHandler()

  • 第一个 Observer 监视的事件是 Entry(即将进入 Loop),其回调内会调用 _objc_autoreleasePoolPush() 创建自动释放池。其 order 是 -2147483647,优先级最高,保证创 建释放池发生在其他所有回调之前。

  • 第二个 Observer 监视了两个事件: BeforeWaiting(准备进入休眠) 时调用 _objc_autoreleasePoolPop() 和 _objc_autoreleasePoolPush() 释放旧的池并创建新池;Exit(即 将退出 Loop) 时调用 _objc_autoreleasePoolPop() 来释放自动释放池。这个 Observer 的 order 是 2147483647,优先级最低,保证其释放池子发生在其他所有回调之后。

面试题6:thread 和 AutoreleasePool的关系

**
**

官方文档中,找到如下说明

Each thread (including the main thread) maintains its own stack of NSAutoreleasePool objects (see Threads). As new pools are created, they get added to the top of the stack. When pools are deallocated, they are removed from the stack. Autoreleased objects are placed into the top autorelease pool for the current thread. When a thread terminates, it automatically drains all of the autorelease pools associated with itself.

大致意思如下:

  • 主程序的RunLoop在每次事件循环之前之前,会自动创建一个 autoreleasePool

  • 并且会在事件循环结束时,执行drain操作,释放其中的对象

RunLoop相关


面试题1

**
**

当前有个子线程,子线程中有个timer。timer是否能够执行 并进行持续的打印?

 CJLThread *thread = [[CJLThread alloc] initWithBlock:^{

        // thread.name = nil 因为这个变量只是捕捉
        // CJLThread *thread = nil
        // thread = 初始化 捕捉一个nil进来
        NSLog(@"%@---%@",[NSThread currentThread],[[NSThread currentThread] name]);
        [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
            NSLog(@"hello word");            // 退出线程--结果runloop也停止了
            if (self.isStopping) {
                [NSThread exit];
            }
        }];
    }];

    thread.name = @"lgcode.com";
    [thread start];
  • 不可以,因为子线程的runloop默认不启动, 需要runloop run启动,需要将上述代码改成下面这样:
//改成
 CJLThread *thread = [[CJLThread alloc] initWithBlock:^{

    // thread.name = nil 因为这个变量只是捕捉
    // CJLThread *thread = nil
    // thread = 初始化 捕捉一个nil进来
    NSLog(@"%@---%@",[NSThread currentThread],[[NSThread currentThread] name]);
    [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer * _Nonnull timer) {
        NSLog(@"hello word");            // 退出线程--结果runloop也停止了
        if (self.isStopping) {
            [NSThread exit];
        }
    }];
     [[NSRunLoop currentRunLoop] run];
}];

thread.name = @"lgcode.com";
[thread start];

面试题2:RunLoop和线程的关系

**
**

  • 每个线程都有一个与之对应的RunLoop,所以RunLoop与线程是一一对应的,其绑定关系通过一个全局的DIctionary存储,线程为key,runloop为value。

  • 线程中的RunLoop主要是用来管理线程的,当线程的RunLoop开启后,会在执行完任务后进行休眠状态,当有事件触发唤醒时,又开始工作,即有活时干活,没活就休息

  • 主线程RunLoop默认开启的,在程序启动之后,会一直运行,不会退出

  • 其他线程的RunLoop默认是不开启的,如果需要,则手动开启

面试3:NSRunLoop 和 CFRunLoopRef 区别

**
**

  • NSRunLoop是基于CFRunLoopRef面向对象的API,是不安全

  • CFRunLoopRef是基于C语言,是线程安全

面试4:Runloop的mode作用是什么?

**
**

mode主要是用于指定RunLoop中事件优先级的

面试5:以+scheduledTimerWithTimeInterval:的方式触发的timer,在滑动页面上的列表时,timer会暂停回调, 为什么?如何解决?

**
**

  • timer停止的原因是因为滑动scrollView时,主线程的RunLoop会从NSDefaultRunLoopMode切换到UITrackingRunLoopMode,而timer是添加在NSDefaultRunLoopMode。所以timer不会执行

  • timer放入NSRunLoopCommonModes中执行

以上解释,均为个人理解,如果不足,请留言补充,谢谢