内存管理
相关面试题
- 使用 CADisplayLink、NSTimer 有什么注意点?
- 强引用控制器导致控制器,定时器均无法正常释放的问题
- 依赖 RunLoop,如果 RunLoop 任务繁重可能出现定时器不准时的问题
- 介绍下内存的几大区域
- 讲一下你对 iOS 内存管理的理解
- autorelease 对象在什么时机会被释放
- 方法里有局部对象,出了方法后会立即释放吗?
- ARC 都帮我们做了什么?
- weak 指针的实现原理?
定时器
在使用 CADisplayLink 或 NSTimer 这类需要添加到 RunLoop 的定时器的时候,需要特别注意的是应用程序的主线程 RunLoop 生命周期是和应用同步的,它是常驻内存的。在这种情况下,如果定时器是重复执行的,就会出现 RunLoop 永远强引用着定时器,如果此时定时器还强引用着控制器,那么不论是否存在循环引用控制器都永远无法释放,即内存泄露。
⚠️ 观察定时器是否正确释放方法:
与一般的类不同,NSTimer 被设计为类簇,它是一个对外的接口类,底层实际创建的并不是 NSTimer 实例,而是 __CFNSTimer 等其他底层类,即使我们新建一个类继承 NSTimer 并重写 dealloc 方法,也无法按预期执行。在运行时会报如下错误:*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** initialization method -initWithFireDate:interval:target:selector:userInfo:repeats: cannot be sent to an abstract object of class MKTimer: Create a concrete instance!'
在这种情况下,我们就需要另辟蹊径找到其他能够观察定时器释放的方法了。方法不止有一种,但我这里仅推荐一种方法:使用关联对象,给定时器添加一个关联对象,在关联对象的 dealloc 方法打印信息。这样在定时器释放的时候,我们的关联对象也会被释放,调用它的 dealloc 方法从而可以看到释放的时机。CADisplayLink 虽然子类化之后运行时创建不会报错,但是调用
+ (CADisplayLink *)displayLinkWithTarget:(id)target selector:(SEL)sel
实际返回的实例对象也不是你自定义子类的实例,所以你的 dealloc 方法也不会被调用。依旧需要使用关联对象的方法来观察释放时间。
控制器无法释放的本质
以下案例是一个新创建的 iOS 项目,仅对 ViewController 嵌入了一个导航控制器,正是为了演示 ViewController 从导航控制器栈中移除后能不能正常释放。这个案例对于有经验的 iOS 开发者来说应该不需要多说了吧。
然后 ViewController 中的代码如下:
代码1:
1 | @interface TimerDeallocWatcher : NSObject |
以上代码中,ViewController 对 NSTimer 存在一个强引用,同时 NSTimer 对 target 参数也存在着一个强引用。不过这个强引用隐藏的很深,没有一点的技术深度还真无法验证这个强引用在哪。。。我也在考虑要不要研究并发布出来,不过暂时还是算了,你记住 NSTimer 底层的确强引用着 target 就好了(其实可以通过 Xcode 的内存图看到)。。。但是造成 ViewController 无法释放的真正原因并不是它和定时器循环引用了。循环引用并不是一定会造成内存泄露。
很多人可能对刚刚那句话不完全相信,那这样吧,我们看代码2,和代码3,看它们能不能解决循环引用的内存泄露?
代码2:
1 | @interface TimerDeallocWatcher : NSObject |
代码2这种情况并不能改变 NSTimer 内部对 target 的指针的强弱性质,NSTimer 内部对 target 的引用默认就是强的,__weak 根本无法修改 NSTimer 内部的代码实现。__weak 只有在搭配 Block 的时候,才能改变 Block 捕获外部变量时的强弱性质。所以这种写法也根本不可能解决 ViewController 无法释放的问题。
代码3:
1 | @interface TimerDeallocWatcher : NSObject |
如果按照循环引用一定产生内存泄露无法释放的说法的话,那么代码3就一定可以解决 ViewController 无法释放的问题了吧,ViewController 对 NSTimer 是弱引用,NSTimer 对 ViewController 是强引用,并没有产生循环引用啊,但是为什么 ViewController 还是无法释放呢?再看以下代码4:
代码4:
1 | @interface TimerDeallocWatcher : NSObject |
代码 4 中,ViewController 明明强引用着 NSTimer,而 NSTimer 的 target 也是强引用的。这不是循环引用了吗?怎么这个时候没有导致内存泄露,ViewController 退出之后也能正常释放呢?
其实根本原因是,NSTimer 和 CADisplayLink 这两种定时器都需要添加到 RunLoop 中运行,而且大多数情况下,这些定时器都被添加到了主线程的 RunLoop 中运行,而主线程的 RunLoop 在程序运行过程中一直存在,如果你看过我之前的 RunLoop 文章就会知道,RunLoop 有多种模式,每种模式都有一个定时器数组强引用着定时器,而这些定时器又强引用着控制器。在这种情况下,不论你定时器和控制器循环不循环引用都会导致 ViewController 无法释放。因为始终存在 RunLoop -> 定时器 -> 控制器。
而为什么代码 4 即使循环引用了,却还是能正常释放 ViewController 和 NSTimer 呢?关键在于代码 4 中的定时器不是永远重复的,它只执行一次。这样在定时器执行一次它的方法之后,RunLoop 就会将这个定时器从数组中移除,此时定时器依旧被 ViewController 强引用着,所以引用计数并不会归 0,就不会被释放。但是 RunLoop 移除定时器的时候,定时器必然也会对它强引用着的 ViewController 对象进行一次 release 调用。这个时候,ViewController 释放不释放同样取决于 ViewController 的引用计数是否为 0,如果退出了当前页面,ViewController 没有导航控制器等其他对象强引用着,引用计数归 0 的话,就会被释放内存,ViewController 在释放时也必然对它强引用着的 NSTimer 调用一次 release 操作,这样 NSTimer 也能顺利释放了。如果没有退出当前页面,也没有任何问题,定时器执行完,RunLoop 移除定时器时,定时器也会对它强引用的 ViewController 进行一个 release 操作,这样定时器对 ViewController 的强引用进行的引用计数加 1 操作也减回去了,退出 ViewController 页面的时候,引用着 ViewController 的对象正常内存管理就会导致 ViewController 引用计数减为 0 从而释放 ViewController。
所谓的强引用,弱引用本质并不会对引用计数产生变化,而是强引用的指针,在对它进行赋值的时候,会让该对象的引用计数加 1,而弱引用的指针就不会让指向的对象引用计数加 1。额就这样吧,也不知道有没有说清楚。。。
回到 ViewController 能否正常释放的问题来,在使用 NSTimer、CADisplayLink 这种需要添加到 RunLoop 的定时器时,对于不重复的定时器,还不不太需要操心内存管理问题的。但是对于需要重复的定时器而言,就需要特别注意内存管理问题了,不仅仅是 ViewController 的内存管理,还有定时器的内存管理,首先要保证 ViewController 能正常退出释放,其次 ViewController 在 dealloc 的时候要将定时器 invalidate,否则即使 ViewController 释放了定时器也无法释放。以下是一些解决方法:
解决方法
使用 __weak 和 block API 解决
直接看代码吧。。。
1 | @interface TimerDeallocWatcher : NSObject |
使用带有 block 的定时器 API 的时候,配合 __weak 的使用,这样 NSTimer 就对 ViewController 不会产生强引用。ViewController 页面退出的时候就正常释放内存了,但是这个时候定时器还在 RunLoop 中,控制器都不存在了,定时器也没有必要继续存在了,就需要在 ViewController 的 dealloc 方法中对定时器调用 invalidate 方法,这个方法会让定时器从 RunLoop 中移除从而让定时器也能接着释放内存。
使用中间层对象解决
中间层这种思想在软件开发领域好像是一个解决问题的规律,不知道是谁说的,但是的确蛮有道理的。通过使用中间层对象,让定时器强引用中间层对象,中间层弱引用控制器,同时中间层对象将定时器需要调用的方法转发给它弱引用的控制器,这样也能完美解决控制器的释放问题,和定时器的释放问题。
1 | @interface Proxy : NSObject |
除了使用 NSObject 子类的方式,在 iOS 中,这种中间层对象的解决方式还有一个更加推荐的对象,即 NSProxy。对于简单的场景,使用 NSObject 没有什么问题,但是对于更加复杂的场景,以及从性能和安全角度考虑使用 NSProxy 子类会比使用 NSObject 子类更加合适。这种方式不仅仅是对 NSTimer 有效,同样也能解决 CADisplayLink 带来的问题。
在解决 CADisplayLink 和 NSTimer 重复定时器导致的内存问题时,推荐使用 NSProxy 子类而非 NSObject 子类,主要原因如下:
高效的消息转发机制
- NSProxy 的纯粹性:NSProxy 是专门为消息转发设计的抽象基类,它本身不实现任何方法(除极少数必要方法外)。所有发送给 NSProxy 对象的消息都会直接进入消息转发流程(forwardInvocation: 和 methodSignatureForSelector:),无需像 NSObject 子类那样先逐级查找方法实现。
- NSObject 的冗余步骤:NSObject 子类在消息转发前会经历动态方法解析(resolveInstanceMethod:)和快速转发(forwardingTargetForSelector:)等步骤,导致额外的性能开销。对于高频触发的定时器(如 CADisplayLink 每秒 60 次回调),NSProxy 的短路径转发更高效。
避免方法冲突与副作用
- 无方法实现干扰:NSProxy 默认没有实现常见方法(如 respondsToSelector: 或 description),所有消息均被转发到目标对象。而 NSObject 子类可能因自身方法(如 class、isEqual:)导致意外行为,需额外处理这些方法的转发逻辑。
- 干净的代理角色:NSProxy 作为纯粹的代理,不会因继承 NSObject 的复杂方法体系而产生歧义,确保所有调用都正确传递给目标对象。
设计意图的契合性
- 代理模式的天然选择:NSProxy 的设计初衷即为代理对象提供轻量级、专注的转发能力,符合通过中间对象打破循环引用的场景需求。而 NSObject 作为通用基类,承担了更多与对象生命周期、键值观察等无关职责,逻辑上不够契合。
减少代码复杂性
- 简化实现:使用 NSProxy 子类通常只需实现 methodSignatureForSelector: 和 forwardInvocation: 即可完成消息转发。而 NSObject 子类可能需要覆盖更多方法(如 respondsToSelector:)以确保行为正确,增加代码复杂度。
使用 NSProxy 子类解决内存问题的示例代码:
1 | @interface WeakProxy : NSProxy |
使用 GCD 的定时器
除了以上两种方式,我们知道问题就在于这两种定时器依赖 RunLoop 才能运行。如果使用不依赖 RunLoop 的 GCD 定时器,也可以解决控制器无法释放的问题。而且 RunLoop 本身如果任务过于繁重的话,就可能导致基于 RunLoop 的定时器执行间隔不准确,所以在一些特定的情况下,可以使用 GCD 定时器替代 NSTimer 定时器。但是也并不是说使用 GCD 的定时器就没有内存管理的问题了。该注意的地方还是要注意。
直接使用 GCD 定时器
完全自己创建 GCD 的定时器需要不少代码,好在 Xcode 的代码块功能已经自带了 GCD 定时器的代码块。输入 dispatch_source
应该会有提示,回车就可以,不过系统自带的这个代码块几个参数不好填。可以复制修改一下为自己的代码块,以下是我修改后的 GCD 定时器代码块。
1 | __weak typeof(self) weakself = self; |
以下是填完参数之后的代码:
1 | @interface ViewController () |
启动 APP,进入 ViewController 所在页面只看到了打印 -[ViewController viewDidLoad]
,这是因为 timer 作为一个局部变量如果没有强引用持有它,ARC 可能在对象超出作用域后释放它,导致定时器提取释放。同时在不需要定时器的时候需要手动调用 dispatch_source_cancel()
取消定时器。所以正确的用法应该如下:
1 | @interface ViewController () |
进入页面 2 秒后启动定时器,每间隔 1 秒执行定时器方法,退出页面后控制器能正常释放,定时器也能正常销毁。
封装 GCD 定时器
直接使用 GCD 可能看起来代码较多,可以自己封装一个小型的定时器工具类,使用 GCD 实现。方便以后使用。以下是源码:
1 | @interface MKTimer : NSObject |
在 ViewController 中的使用方式如下:
1 | @interface ViewController () |
iOS 应用内存布局
如下图:
可以在 iOS 项目中打印不同类型的变量进行验证。
1 |
|
实际打印的结果如下:
1 | &a=0x100481250 |
从打印的结果来看,有些结果会让人有点意外。首先a,b,c,d,str 的地址都能够理解,不论是全局变量,静态变量,还是字符串常量都在一块应该都是在数据区。但是局部变量 e,f 的地址在栈上是应该大于对象 obj1,obj2 所在的堆区地址的。但是这里却出现了堆区地址大于栈区地址的现象很是奇怪。抛开这点不谈,通过两个同区域变量的地址对比,如 e 的地址大于 f 的地址,说明栈的生长方向随着使用在变小是没错的。obj1 的地址小于 obj2 的地址,说明堆的生长方向随着使用在变大也是没错的。
最奇怪的是为什么栈的地址比堆的地址小了?
这里的原因是,iOS 中的一种叫 ASLR 的安全技术导致。
什么是 ASLR ?
ASLR(Address Space Layout Randomization,地址空间布局随机化) 是一种操作系统级别的安全技术,核心目的是 让程序的内存布局变得不可预测,从而增加攻击者利用内存漏洞(如缓冲区溢出)的难度。它通过 随机化程序内存区域的起始地址 来实现这一点。
ASLR 的作用原理
- 传统内存布局的问题
在没有 ASLR 时,程序的代码段、数据段、堆、栈等内存区域的起始地址是固定的。例如:- 代码段总是从 0x100000000 开始。
- 栈总是从 0x7FFF00000000 开始。
攻击者可以提前知道这些地址,从而精准构造攻击代码。
- ASLR 的解决方案
ASLR 在程序启动时,为每个内存区域分配一个 随机的基址偏移量。例如:- 代码段可能从 0x12345000000 开始。
- 栈可能从 0x7F12F0000000 开始。
每次运行程序时,这些地址都会变化,攻击者无法提前预测。
静态变量的作用域
先看以下代码:
1 |
|
然后是 Person 的头文件和实现文件代码:
Person.h
1 |
|
Person.m
1 |
|
在 ViewController 的 viewDidLoad 方法中,实际的打印结果如下:
1 | demoiOSApp[26641:23464211] 0x104715fc0 1000 |
可以看到在 ViewController 中的确可以访问并修改全局静态变量 count。但是在 ViewController 内的修改并不会影响 Person 类中的 count。这是因为 static 全局变量在每个包含它的编译单元(实现文件)中生成独立副本。作用域仅限于当前编译单元(文件),对其他文件不可见。
而在头文件声明 static 变量的用法看似“全局”,实际是每个文件独立持有。虽然合法但不推荐使用(容易导致代码冗余和误解)。推荐在实现文件中定义 static 变量,头文件中声明为 extern(若需跨文件访问)。
Tagged Pointer
什么是 Tagged Pointer
1.基本概念
- 指针与标记结合:Tagged Pointer 在存储内存地址的同时,利用未使用的位存储额外信息(如类型标签或小型数据)。
- 目的:减少小对象的内存分配开销,加速类型检查或数据访问。
2.实现原理
- 位利用:在 64 位系统中,指针通常占用 8 字节(即 64 位),但实际地址可能未使用全部高位。这些空闲位用于存储标记。
- 标记位识别:通常通过特定位(如最低有效位)标识是否为 Tagged Pointer。例如,最低位为 1 表示标记指针,为 0 则为普通指针。
3.常见应用场景
- 小对象优化:如 Objective-C 的 NSNumber 或 NSString,直接将小数值存储在指针中,避免堆内存分配。
- 类型快速判断:通过标签位区分数据类型,减少动态类型检查的开销。
- 垃圾回收:辅助垃圾回收器识别指针类型,提升效率。
4.计数细节
- 位分配策略:不同系统/语言实现不同。例如,某些系统使用高位存储标签,某些使用低位。
- 数据存储:若标签指示数据直接存储,剩余位存储实际值(如整数、浮点数)。
- 内存对齐:确保普通指针地址对齐,使空闲位可预测(如地址总是 4/8 字节对齐)。
5.优点
- 内存节省:避免小对象的堆分配,减少内存碎片。
- 性能提升:减少间接访问(如解引用指针),加速类型检查和数据操作。
- 缓存友好:数据直接存储在指针中,提高缓存局部性。
6.限制与挑战
- 存储限制:可用位数限制直接存储数据的大小(如 64 位中可能仅存 60 位有效数据)。
- 兼容性:需确保标记位不与实际地址冲突,依赖运行时或操作系统的内存管理。
- 调试复杂性:指针值包含元数据,需工具解析以方便调试。
7.示例与类比
- Objective-C/Swift:使用 Tagged Pointer 优化 NSNumber、NSDate 等对象。
- NaN Boxing:类似技术,利用浮点数的 NaN 空间存储类型信息(如JavaScript引擎)。
总结
Tagged Pointer 是一种高效利用指针空间的技术,通过在地址中嵌入元数据或数据,优化内存和性能。其实现需精细设计位布局,并确保与系统内存管理的兼容性,广泛应用于动态语言运行时和高效内存管理的场景。
iOS 中的 Tagged Pointer
iOS 的 Tagged Pointer 是在 iOS 7(2013年)中引入的,与 iPhone 5s 的 64 位 A7 芯片的发布同步。以下是详细背景和关键点:
1.引入背景
- 64 位架构的普及:
iPhone 5s(2013年)首次搭载 64 位 A7 芯片,iOS 7 开始全面支持 64 位应用。64 位指针的地址空间远超出实际物理内存需求,高位空闲的指针位为 Tagged Pointer 提供了存储额外数据的空间。 - 性能优化需求:
Apple 希望通过减少小对象(如 NSNumber、NSDate、短 NSString)的堆内存分配和访问开销,提升运行效率。
2.技术实现的核心
- 指针位复用:
在 64 位系统中,指针占用 8 字节(64 位),但实际地址通常仅使用低 48 位。高位空闲的 16 位被用于存储:- 标记位(Tag):标识指针是否为 Tagged Pointer,在 arm64 架构中最高位 1 代表是 Tagged Pointer,非 arm64 架构中最低位 1 代表是 Tagged Pointer。可以在 libobjc.A.dylib 源码中看到。
- 直接数据:将小对象的值(如整数、短字符串)直接编码到指针中,避免分配堆内存。
- 示例:
NSNumber 存储一个小于 2^60 的整数时,直接将数值编码到指针的高 60 位,最低 4 位作为类型标记。
3.主要优势
- 内存效率:
避免频繁分配和释放小对象的内存碎片,减少内存占用。 - 性能提升:
省去堆内存访问(无需解引用指针),提升数据存取速度。 - 类型判断加速:
通过指针的标记位快速识别对象类型(如 NSNumber 或 NSString),减少动态类型检查开销。
4.应用场景
- 基础类优化:
NSNumber、NSString(短字符串)、NSDate、NSIndexPath 等小对象默认使用 Tagged Pointer。 - 条件限制:
对象的值需满足直接编码到指针中的大小限制(例如 NSNumber 的数值范围受剩余位数约束)。
5. 开发者注意事项
- 透明实现:
Tagged Pointer 对开发者完全透明,无需修改代码即可享受优化。 - 调试工具识别:
在 Xcode 中,Tagged Pointer 的地址通常显示为特殊格式(如 0xb000000000000013,末尾的 3 表示 NSNumber 类型)。 - 兼容性:
仅限 64 位设备,32 位架构因指针位不足无法使用。
6. 扩展知识
- 与 Compact Strings 的关系:
iOS 15 引入的字符串压缩技术(Compact Strings)进一步优化 NSString,但 Tagged Pointer 是其底层基础之一。 - 安全影响:
Tagged Pointer 的标记位设计需避免与有效地址冲突,否则可能引发内存错误(如早期 iOS 版本曾因混淆标记位导致漏洞)。
总结
iOS 7 通过引入 Tagged Pointer,在 64 位设备上实现了小对象的高效存储,显著提升了内存和性能表现。这一技术至今仍是 iOS 运行时优化的核心机制之一。
面试题
以下两段代码有什么区别?为什么会产生这种这种区别?
代码1:
1 |
|
代码2:
1 |
|
代码 1 会导致崩溃而代码 2 不会崩溃的原因在于字符串的内存管理方式以及多线程环境下的竞争条件:
- 字符串类型差异
- 代码 1 的字符串 @”helloWorld,areyouOK?” 较长,会创建普通的 NSString 对象(堆内存分配),涉及引用计数管理。
- 代码 2 的字符串 @”abc” 较短,会被优化为 NSTaggedPointerString(标签指针),其值直接存储在指针中,无需引用计数。
- 多线程竞争条件:
- 对于普通 NSString 对象(代码1):每次赋值会触发 retain 新值和 release 旧值的操作。多线程环境下,多个线程同时执行这些非原子操作可能导致:
- 旧值被多次 release(过度释放)。
- 对象引用计数混乱,引发野指针访问(EXC_BAD_ACCESS)。
- 对于 NSTaggedPointerString(代码2):赋值仅是指针的原子写入,不涉及引用计数操作,因此无竞争风险。
- 对于普通 NSString 对象(代码1):每次赋值会触发 retain 新值和 release 旧值的操作。多线程环境下,多个线程同时执行这些非原子操作可能导致:
- 属性原子性:
- name 属性声明为 nonatomic,缺乏锁保护,允许多线程直接访问,加剧了普通对象的内存管理问题。
代码 1 因涉及非原子的引用计数操作导致多线程崩溃,而代码 2 的标签指针赋值是原子且无内存管理的,因此安全。解决方法包括使用 atomic 属性、串行队列或同步机制(如 @synchronized)保护属性访问。
MRC
什么是 MRC
在编写 C 语言程序时,我们需要手动对 malloc 创建出来的数据进行内存管理,在合适的时机需要手动 free 释放堆区内存。同样在 iOS 开发中,对象的创建就是在堆区分配的内存,那么当然也需要在合适的时机对对象进行内存的释放。MRC(Manual Reference Counting,手动引用计数)是 iOS 早期手动管理对象内存的核心机制,开发者需要显式控制对象的生命周期,通过以下规则和方法实现:
1. 引用计数的核心原则
- 每个对象都有一个引用计数(retainCount),初始值为 1(通过 alloc、new、copy 等方法创建的对象)。
- 当引用计数 retainCount = 0 时,对象会被系统立即释放内存(调用 dealloc 方法)。
- 开发者需要手动调用 retain 增加计数和 release 减少技术来管理所有权。
2. 关键方法
- retain
- 作用:增加对象的引用计数 retainCount +1。
- 场景:当需要持有(拥有)一个对象时(如将对象赋值给实例变量或添加到集合中)。
- release
- 作用:减少对象的引用计数 retainCount -1。
- 场景:当不再需要对象时调用。若计数减到 0,对象内存被释放。
- autorelease
- 作用:将对象加入自动释放池(Autorelease Pool),延迟释放(通常在当前 RunLoop 进入休眠前统一调用 release)。
- 场景:方法返回对象时,避免立即释放(如工厂方法)。
3. 所有权规则
- 谁创建,谁释放:通过 alloc、new、copy、mutableCopy 创建的对象,需由创建者调用 release。
- 谁持有,谁释放:通过 retain 或强引用的对象,需调用 release 放弃所有权。
- 方法命名约定:
- 方法名以 alloc、new、copy 开头,返回的对象由调用者负责释放。
- 其他方法返回的对象默认是 autorelease 的(如 [NSString stringWithFormat:])。
4. 常见错误
- 内存泄露:未调用 release,导致对象没有释放,之后也无法再次使用对象。
- 悬垂指针:过早调用 release 后继续访问对象(导致 BAD_ACCESS 崩溃)。
- 过度释放:对一个对象多次调用 release(直接崩溃)。
5. 代码示范
代码1:手动释放不再需要使用的对象
1 |
|
在 MRC 环境下,ViewDidLoad 方法中创建了一个 Person 对象,如果不手动调用 release 方法,那么 ViewDidLoad 执行完后这个对象就再也无法访问到了,这就是内存泄露。如果不注意任这样的代码泄露内存,很快就会发现应用内存占用过大而被系统杀死。所以非常有必要将不再需要使用的对象内存正确释放掉,方法就是手动调用 release 方法使其引用计数变为 0。或者再对象创建的时候调用 autorelease 方法加入自动释放池,那么在合适的时机,自动释放池清空的时候,会对池内的对象发送一个 release 消息。
代码2:对象持有其他对象的内存管理
当 Person 对象持有 Dog 对象的时候,setter 方法改如何实现?
在使用 @property 声明属性时候,编译器自动帮我们生成了 setter、getter 和成员变量。那么编译器生成的 setter 方法内是如何管理新旧两个对象内存的?
在 MRC 时代,@property 声明属性并不会自动生成 setter、getter 和成员变量,还需要配合 @synthesize 才能生成。但是目前 ARC 已经不再需要使用 @synthesize 了。除此之外,还有两种情况需要使用 @synthesize,一是在协议和分类中的属性,需要使用 @synthesize 显式生成。二是有自定义成员变量名的需求时,因为默认生成的成员变量名是属性名前面加下划线。
不管37二十一,直接将参数新对象赋值给成员变量???
1
2
3- (void)setDog:(Dog *)dog {
_dog = dog;
}- 这样肯定会出问题的,旧对象没有调用 release,那必然会造成内存泄露。
- 新对象没有被 retain,那么在这个对象在外部 release 的时候,Person 的 dog 也同步被释放了。应该做到只要 Person 还在内存中,它所拥有的对象就都应该还在内存中。Person 释放的时候,也需要释放它拥有的对象,也就是在 Person 的 dealloc 方法中调用它持有对象的 release 方法。
所以 setter 方法至少需要做的两个操作,对旧的对象调用一次 release,对新的对象调用一次 retain。
经过上面的讨论,那么此时的 setter 方法应该是
1
2
3
4- (void)setDog:(Dog *)dog {
[_dog release];
_dog = [dog retain];
}这样子就足够完美了吗?还是不行。如果外部多次调用 setDog: 方法,且参数时候同一个对象,那么这个对象有可能被释放了,然后还在继续使用。需要做的是对参数和成员变量进行判断
1
2
3
4
5
6- (void)setDog:(Dog *)dog {
if (_dog != dog) {
[_dog release];
_dog = [dog retain];
}
}
总结
MRC 要求开发者像“管家”一样精准控制对象生命周期,每个 retain 必须对应一个 release。虽然灵活,但极易出错。2011 年推出的 ARC(自动引用计数) 通过编译器自动插入 retain/release 代码,彻底解放了开发者,成为现代 Objective-C/Swift 开发的主流选择。但在维护旧项目或特定场景下,仍需理解 MRC 的原理。ARC 出现之后虽然说不再需要程序员手动管理内存,但是仍然需要注意循环引用可能引起的内存泄露问题。
copy
copy 就是为了复制一个新的对象出来,新对象的属性和值都和原来对象一模一样,但又是两个独立的对象,修改其中一个对象的属性不会影响到另一个对象。在 iOS 中,实现了 NSCopying 协议的类就拥有了 copy 的能力,它返回一个不可变的副本,如 NSURL、NSCachedURLResponse、NSDate 等等。还有一个 NSMutableCopying 协议,实现了这个协议的类就拥有了 mutableCopy 的能力,它返回一个可变类型的副本,如 NSString、NSArray、NSDictionary、NSData 等等。
调用了 copy、mutableCopy 方法返回的对象,同样需要内存管理。这个在刚刚的 MRC 中已经提到过了。在 MRC 环境下需要手动释放,在 ARC 环境下由编译器插入合适的 release 方法。
- 对于不可变的对象,如 NSArray,NSDictionary,NSData
- 如果调用 copy 方法返回的是对象本身,引用计数加 1,这种称之为浅拷贝。
- 如果调用 mutableCopy 方法返回的是新的可变类型的对象,如 NSMutableArray、NSMutableDictionary、NSMutableData,这种称之为深拷贝。
- 对与可变类型的对象,如 NSMutableArray、NSMutableDictionary、NSMutableData
- 如果调用 copy 方法返回的是新的不可变类型的对象,如 NSArray、NSDictionary、NSData。这种也是深拷贝。
- 如果调用 mutableCopy 方法返回同样类型的新对象。这种也是深拷贝。
总结,浅拷贝并没有创建新的对象,而深拷贝创建了新的对象。不可变对象的不可变拷贝是浅拷贝,其余都是深拷贝。
自定义对象的拷贝
如果一个自己写的类,想要拥有 copy 能力,就需要采用 NSCopying 协议,实现 - (id)copyWithZone:(nullable NSZone *)zone;
方法:
如以下代码:
1 | @interface Person : NSObject <NSCopying> |
面试题
以下代码会有什么问题?
1 | @interface Person : NSObject |
这段代码存在一个关键问题:将 NSMutableArray 类型的属性声明为 copy,这会导致不可预知的运行时崩溃。以下是具体分析:
- 问题根源:copy 修饰符的副作用
- copy 修饰符的特性:当给属性赋值时,系统会自动对传入的对象调用 copy 方法,生成一个不可变的副本,即使参数对象的确是个可变的对象。
- NSMutableArray 的 copy 行为:NSMutableArray 的 copy 方法返回的是 不可变的 NSArray,而非 NSMutableArray。因此,虽然属性声明为 NSMutableArray,但实际存储的是 NSArray。
- 导致崩溃的原因
- 尝试修改不可变数组:在 main 函数中,调用 addObject: 方法时,实际是向一个 NSArray 对象发送 addObject: 消息,而 NSArray 没有 addObject: 方法。这会引发如下运行时错误: 程序会因此崩溃。
1
-[__NSArrayI addObject:]: unrecognized selector sent to instance
- 尝试修改不可变数组:在 main 函数中,调用 addObject: 方法时,实际是向一个 NSArray 对象发送 addObject: 消息,而 NSArray 没有 addObject: 方法。这会引发如下运行时错误:
- 解决方案
- 使用 strong 修饰符:若属性需要保存可变性,应使用 strong(ARC)或 retain(MRC)修饰符:
1
@property (nonatomic, strong) NSMutableArray *array;
- 若改用 NSArray:如果确实需要 copy 语义,应将属性类型改为 NSArray,并避免修改数组:
1
@property (nonatomic, copy) NSArray *array;
- 使用 strong 修饰符:若属性需要保存可变性,应使用 strong(ARC)或 retain(MRC)修饰符:
- 验证问题的示例
1
2
3
4
5
6Person *person = [Person new];
NSMutableArray *mutableArray = [NSMutableArray array];
person.array = mutableArray;
// 实际类型是 NSArray,而非 NSMutableArray
NSLog(@"%@", [person.array class]); // 输出 __NSArrayI(不可变数组)
声明 NSMutableArray 属性时使用 copy 修饰符,会导致实际存储的是不可变数组,后续修改操作会崩溃。应根据需求选择正确的修饰符(strong 或 retain)或调整属性类型(改为 NSArray)。
引用计数
在 iOS 中,对象的引用计数存储位置从历史的发展角度来看,可以分为以下两个阶段:
1. Non-pointer isa 出现前的机制
- 传统 isa 指针的作用
在 32 位系统或早期的 64 位系统中,对象的 isa 指针仅用于指向类对象的内存地址,不包含其他信息。 - 引用计数的存储方式
对象的引用计数(retainCount)并不直接存储在对象内存中,而是通过一个全局的 SideTable 结构来维护。这个 SideTable 中的一个成员 RefcountMap refcnts 是一个哈希表,键是对象的地址,值则是对象的引用计数,至于什么是哈希表可以理解为 Objective-C 中的字典,但是比字典更底层更高效。SideTable 中还存在一个成员 spinlock_t slock,这是个自旋锁用来保证对 refcnts 的原子操作。 - 性能与设计考量:
- 优点:避免了为每个对象单独分配存储引用计数的空间(节省内存,尤其是对象未被频繁操作时)。
- 缺点:每次 retain/release 操作都需要访问散列表,可能引发锁竞争和性能损耗。
2. Non-pointer isa 的优化
在 64 位系统下,苹果对 isa 指针进行了优化(称为 non-pointer isa),将部分内存管理信息直接存储在 isa 指针的冗余比特位中:
- extra_rc 字段:isa 指针中保留了 19 个比特位(不同架构可能略有差异)用于存储额外的引用计数值(extra retain count)。
- 之所以叫额外的引用计数,这是因为在 objc4 某个版本之前是通过 extra_rc + 1 的形式返回对象的实际引用计数,即对象默认隐含了 1 个引用计数。
- 而在最近的版本中直接通过 extra_rc 表示实际引用计数
- 如果 extra_rc 溢出(即引用计数超过 2^19 -1),则会将溢出的部分转移到 SideTable。
这种方式减少了全局散列表的访问频率,降低了锁竞争,提高了内存操作效率。
总结
- non-pointer isa 出现前:引用计数完全依赖全局散列表(Side Tables)管理。
- non-pointer isa 出现后:引用计数优先内联到 isa 的冗余位,仅在必要时使用 Side Tables。
这种设计使得 iOS 的内存管理在绝大多数场景下(引用计数较小)无需访问全局表,从而显著提升性能。这一优化显著提升了内存访问效率和并发性能,是苹果针对 64 位系统的重要底层优化之一。
weak 指针
weak 指针功能演示
有以下代码:
1 |
|
输出结果如下:
1 | 1.person:<Person: 0x2821c0f10> |
从打印的结果可以看到,__weak
修饰的 person1 所指向的对象在释放之后 person1 指向了 nil,所以打印的结果是 null。而 __unsafe_unretained
修饰的 person2 所指向的对象在释放之后依然指向着原来的地方,访问已经被释放的对象内存空间导致了 EXC_BAD_ACCESS
崩溃。
__unsafe_unretained
和 __weak
的异同
在 Objective-C 中,__weak
和 __unsafe_unretained
都是用于避免对象强引用的修饰符,但它们在安全性和底层行为上有本质区别。以下是它们的异同点:
相同点
- 不增加引用计数
两者都不会增加对象的引用计数(即不持有对象所有权),因此不会阻止对象被释放。 - 用于打破循环引用
都可以用于解决对象间的循环引用问题(例如在 block 或 delegate 中)。
核心区别
内存管理机制
__weak
__unsafe_unretained
安全性 自动置空(对象释放后指针变 nil) 不自动置空(对象释放后指针变为野指针) 底层实现 依赖运行时库中建立的弱引用表(weak_table) 仅简单存储指针地址,无运行时介入 使用场景
__weak
__unsafe_unretained
需要自动安全的指针(如 delegate) 性能敏感场景,可以避免运行时开销 常规开发中的弱引用 兼容旧代码(iOS 4之前无 __weak) 需要避免野指针崩溃的场合 明确知道对象生命周期时的优化手段 性能差异
__weak
需要运行时通过弱引用表动态管理指针,有额外的性能开销(注册、清理等)。__weak_unretained
直接存储指针地址,无运行时开销,性能更高。
何时使用 __unsafe_unretained ?
- 兼容旧系统
iOS 4 或更早系统不支持__weak
时,必须使用__unsafe_unretainde
。 - 性能优化
在需要极致性能的代码中(例如高频调用的循环),避免弱引用表的运行时开销。 - 与非 Objective-C 对象交互
例如与 CoreFoundation 对象,如 CFArrayRef 交互时,可能直接使用指针地址。 - 明确对象生命周期
当开发者能严格包装被引用对象的声明周期时(例如单例对象),可安全使用。
总结
特性 | __weak | __unsafe_unretained |
---|---|---|
安全性 | ✅自动置空 | ❌野指针风险 |
运行时介入 | ✅依赖 weak_table | ❌无介入 |
性能开销 | 较高(注册、清理操作) | 无 |
适用场景 | 常规开发 | 旧系统兼容、性能优化、明确生命周期 |
在 iOS 5+ 开发中,优先使用 __weak;仅在必要时(如性能优化或旧代码维护)使用 __unsafe_unretained,并确保完全理解其风险。
weak 底层原理
Objective-C 中的 weak 实现是编译器的代码生成与运行时的内存管理机制协同工作的结果。以下是完整的协作流程:
1. 编译器与运行时的协作机制
- 编译器的作用:
- 插入关键函数:在 ARC 环境下,编译器会自动为 __weak 变量生成代码,插入 objc_initWeak(注册弱引用)和 objc_destroyWeak(移除弱引用)等函数调用。
- 管理作用域:根据变量的生命周期(如超出作用域、被重新赋值),编译器决定何时调用这些函数。
- 运行时的作用:
- 维护弱引用表:动态管理全局的弱引用表(Weak Table),记录对象与弱指针的映射关系。
- 对象释放时的清理:在对象 dealloc 时,遍历弱引用表,将关联的弱指针置 nil。
2. 完整协作流程示例
代码示例:
1 | { |
编译器生成的伪代码:
1 | // 进入作用域 |
3. 编译器的关键操作
autorelease 底层原理
autorelease 对象何时释放?
@autoreleasepool {} 这是一个编译器提供的语法糖。
分情况,
- 你手动创建了 Autoreleasepool 的话,当然是在你自己创建的自动释放池释放的时候。
- 否则就是系统在 RunLoop 的几个时机创建的自动释放池了。
通过 Clang rewrite-objc 查看 @Autoreleasepool{} 的底层表示,是个结构体。
220
然后是查看 objc_aureleasePoolPush() 函数和 objc_aureleasePoolPop() 函数的实现。在 objc4 源码里。
里面调用了 AutoreleasePoolPage 的方法,这是个 C++ 的类。
每个 AutoreleasePoolPage 实例对象占用 4KB 字节。除了自己的成员需要占用的几十个字节的空间,剩下的空间都用于存放加入了自动释放池的对象。
所有 AutoreleasePoolPage 实例对象采用双向链表的结构链接在一起。
@autoreleasepool{} 嵌套是个什么情况?每次都创建新池吗?
有一个私有函数可以查看自动释放池的情况 _objc_autoreleasePoolPrint()
RunLoop 与 AutoreleasePool,有一个细节,究竟是在哪个库的哪个函数里面对 RunLoop 添加观察者实现自动释放池的创建和释放的,需要逆向分析出来~是 libobjc.A.dylib 库吗?还是 Foundation 库?
进入 RunLoop 之前创建一个新池 push
休眠之前,调用一个 pop,再创建一个新池 push
退出 RunLoop 的时候调用一次 pop。
现在的问题是,第一次进入 RunLoop 前 push 的池,是休眠前 pop 掉的,还是退出 RunLoop 的时候 pop 掉的?
打印 RunLoop 能看到自动释放池相关的 Observers
方法里面有局部对象,出了方法后会立即释放吗?不一定会,主要还是看对象的引用计数是否变为0,比如局部对象被Block捕获了的话出了方法体也不会被释放。只有等Block释放的时候,Block对它拥有的对象进行 release 的时候可能被释放。也还是要看对象的引用计数。