NSTimer / CADisplayLink 循环引用
核心根源:Timer/CADisplayLink 加入 RunLoop 后,RunLoop 强持有 timer;timer 强持有 target;如果 target 又强持有 timer,形成循环引用。
invalidate:唯一解除 RunLoop 对 timer 强引用的方法,调用后 timer 失效,RunLoop 释放 timer。
1. block 形式 NSTimer(scheduledTimerWithTimeInterval)
// ❌ 循环引用:block捕获self,timer持有block,self持有timer
self.myTimer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer *timer) {
NSLog(@"%@", self);
}];
✅ 方案1:提前 invalidate(dismiss前手动销毁timer)
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event{
[self.myTimer invalidate]; // 断开RunLoop -> timer引用
[self dismissViewControllerAnimated:YES completion:nil];
}
✅ 方案2:block内使用弱引用(最常用)
__weak typeof(self) weakSelf = self;
self.myTimer = [NSTimer scheduledTimerWithTimeInterval:1 repeats:YES block:^(NSTimer *timer) {
NSLog(@"%@", weakSelf);
}];
补充:
scheduledTimerWithTimeInterval自动加入当前RunLoop;timerWithTimeInterval创建出来不会自动加入RunLoop,需要手动addTimer:forMode:。
2. target-selector 形式 NSTimer(重点坑!)
// ❌ 就算传weakSelf当target依然循环引用!
__weak typeof(self) weakSelf = self;
self.myTimer = [NSTimer timerWithTimeInterval:1 target:weakSelf selector:@selector(print) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.myTimer forMode:NSDefaultRunLoopMode];
坑点:NSTimer内部会强持有target,传入weakSelf也会被timer retain,弱引用失效!
✅ 解决方案:NSProxy 中间代理(虚代理)
- 关系:self强持有timer → timer强持有proxy → proxy弱持有self,无环
// MyProxy.h
@interface MyProxy : NSProxy
+ (instancetype)proxyWithTarget:(id)target;
@property (nonatomic,weak) id target;
@end
// MyProxy.m
@implementation MyProxy
+ (instancetype)proxyWithTarget:(id)target{
MyProxy *p = [MyProxy alloc];
p.target = target;
return p;
}
- (NSMethodSignature *)methodSignatureForSelector:(SEL)sel{
return [self.target methodSignatureForSelector:sel];
}
- (void)forwardInvocation:(NSInvocation *)invocation{
[invocation invokeWithTarget:self.target];
}
@end
// 使用
self.myTimer = [NSTimer timerWithTimeInterval:1 target:[MyProxy proxyWithTarget:self] selector:@selector(print) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:self.myTimer forMode:NSDefaultRunLoopMode];
3. CADisplayLink(原理同NSTimer)
CADisplayLink 同样被RunLoop强持有,内部强持有target,直接传self会循环引用。
// ❌ 循环引用
self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(print)];
[self.link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSDefaultRunLoopMode];
// ✅ 使用Proxy代理,打破循环
self.link = [CADisplayLink displayLinkWithTarget:[MyProxy proxyWithTarget:self] selector:@selector(print)];
4. 关键总结
- block版Timer:循环引用来自block捕获self,block内weakSelf即可解决。
- target-selector版Timer/CADisplayLink:timer内部retain target,传weakSelf无效,要用NSProxy弱转发。
invalidate:移除RunLoop对timer的强引用,必须在dismiss前调用;如果卡在循环引用,dealloc永远不会执行,dealloc里写invalidate毫无意义!- scheduledTimer:自动进RunLoop;timerWithTimeInterval:手动addTimer。
- 引用链梳理:RunLoop → Timer → target;target → Timer,构成环。
NSTimer和GCDTimer
因为NSTimer是依赖于runloop的,如果runloop任务繁重,就算时间到了也不会执行。
而GCDTimer是独立线程。不会被阻塞。
@property (nonatomic, strong) dispatch_source_t gcdTimer;
// 创建GCD定时器
dispatch_queue_t queue = dispatch_queue_create("timerQueue", DISPATCH_QUEUE_SERIAL);
self.gcdTimer = dispatch_source_create(DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue);
// 参数:timer, 起始时间, 间隔, 允许误差leeway
dispatch_source_set_timer(self.gcdTimer, DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC, 100 * NSEC_PER_MSEC);
dispatch_source_set_event_handler(self.gcdTimer, ^{
NSLog(@"GCD timer 回调");
});
dispatch_resume(self.gcdTimer);
// 销毁
dispatch_source_cancel(self.gcdTimer);
开一条单独常驻线程跑 RunLoop+Timer,可以隔绝主线程和其他 GCD 任务带来的阻塞,稳定性比 GCDTimer 更高;但如果定时器回调内部写耗时代码,照样阻塞 RunLoop 产生延迟,同时 NSTimer 本身依然存在 RunLoop 轮询带来的微小时间误差。
Tagged Pointer
一句话:64 位下的小对象优化,把类型 + 数据直接编码在指针变量本身,不分配堆内存,不是真正指向堆对象的指针。 32 位系统没有 Tagged Pointer。
1. 原理(ARM64 iOS)
ARM64 指针占 8 字节(64bit):
- 最高第 63 位:标记位 = 1 → Tagged Pointer;0 → 普通堆对象指针
- 剩下 bit:一部分是
tag类型标记,剩下的空间存放真实数据(payload)
普通对象:栈指针 → 指向堆,堆里有 isa、引用计数、数据。 Tagged Pointer:指针本身就是对象 + 数据,没有堆内存,没有 isa,不需要 malloc/free。
支持的类型:
NSNumber:小数字NSString:短 ASCII 字符串(NSTaggedPointerString,一般≤7 个字符)NSDate、NSIndexPath等小对象
超出存储空间 → 自动退化成普通堆对象。
2. 核心特性(面试高频)
- 没有堆内存,没有 isa,
retain/release/autorelease是空操作,不维护引用计数。 - 访问更快:不用访问堆内存;创建销毁开销极低。
- 伪装成 OC 对象,可以正常调用
NSObject方法([num intValue]),runtime 内部做特殊判断。 - 判断源码(objc-internal.h)
static inline bool _objc_isTaggedPointer(const void *ptr) {
return ((uintptr_t)ptr & _OBJC_TAG_MASK) == _OBJC_TAG_MASK;
}
ARM64:_OBJC_TAG_MASK = 1UL << 63(最高位)
- 混淆:新版本 runtime 有tagged pointer obfuscator,指针值会异或混淆,不能直接打印二进制看原始数据。
3. 经典面试题
Q1:下面两段并发代码,一段崩溃一段正常,为什么?
// 代码A:长字符串(普通堆对象)
for(int i=0;i<1000;i++){
dispatch_async(globalQueue, ^{
self.name = [NSString stringWithFormat:@"12345678901"];
});
}
// 代码B:短字符串(TaggedPointer NSTaggedPointerString)
for(int i=0;i<1000;i++){
dispatch_async(globalQueue, ^{
self.name = [NSString stringWithFormat:@"1234567"];
});
}
✅ 答案: self.name 属性 setter 底层伪代码:
if (_name != newVal) {
[_name release];
_name = [newVal retain];
}
- A:普通堆对象。多线程并发进入 setter,多个线程同时执行
[_name release],过度释放,野指针 crash。 - B:TaggedPointer。
release是空操作,不会执行内存释放,没有过度释放问题,所以不会崩溃。
⚠️ 但这不代表线程安全!只是不会内存崩溃,赋值依然存在竞态,值可能错乱。
Q2:TaggedPointer 会触发 retain/release 吗?
✅ 不会。runtime 在objc_retain/objc_release内部先判断是否是 TaggedPointer,如果是直接 return,不做引用计数操作。
Q3:NSNumber *a = @10; 是 TaggedPointer 吗?@1000000000000呢?
✅ 小数字:是 TaggedPointer;数值太大放不下 payload → 变成普通堆 NSObject 对象。
Q4:TaggedPointer 是对象吗?
✅ 从 OC 语法层面,可以调用对象方法,表现像对象;底层没有堆对象,没有 isa,不是真正的堆实例,是伪装成对象的编码值。
Q5:TaggedPointer 可以 weak 引用吗?
✅ 可以。weak 只是对对象的弱引用;runtime 识别 TaggedPointer,不会去 SideTable 注册弱引用记录。
Q6:为什么 32 位没有 TaggedPointer?
✅ 32 位指针只有 32bit,地址空间紧张,没有多余 bit 用来存放类型 + 数据。
4. 坑点总结(写笔记)
- 短字符串
NSTaggedPointerString属于 TaggedPointer,长字符串是__NSCFString堆对象。 - TaggedPointer 没有引用计数,retain/release 是空操作。
- 并发 setter 场景:堆对象容易多线程过度释放 crash;TaggedPointer 不会 crash,但依然不是线程安全。
- 只有 64 位设备支持。
- 存储容量有限,数据超出限制自动降级为普通堆对象。
一句话面试口述版(背这个)
Tagged Pointer 是 64 位 Runtime 的小对象优化技术。对于 NSNumber、短 NSString 这类小对象,不再在堆上分配内存,而是把类型标记和数据直接编码存放在指针本身。它没有 isa,不维护引用计数,retain/release 是空操作,内存访问更快。最经典面试场景:多线程并发给属性赋值,堆对象会并发 release 导致野指针崩溃,而 TaggedPointer 不会发生内存崩溃,但仍然不具备线程安全。
# 深浅拷贝(面试笔记精简版)
核心一句话:拷贝的本质看:新对象的指针,指向的是同一个内存,还是新开一块内存。
- 浅拷贝:只拷贝指针,不拷贝底层数据,新旧指针指向同一块内存。
- 深拷贝:开辟新内存,拷贝底层数据;新旧对象指向两块独立内存。
两个方法:
copy/mutableCopy底层协议:NSCopying、NSMutableCopying。 ⚠️ 只有遵守协议,copy/mutableCopy才可用;自定义类要手动实现协议。
一、基础规则(必背)
表格
| 对象类型 | copy | mutableCopy |
|---|---|---|
| 不可变对象 NSString / NSArray / NSDictionary | 浅拷贝,返回不可变对象 | 深拷贝,返回可变对象 |
| 可变对象 NSMutableString / NSMutableArray / NSMutableDictionary | 深拷贝,返回不可变对象 | 深拷贝,返回可变对象 |
简单记忆:
copy:永远返回不可变副本mutableCopy:永远返回可变副本
二、逐个举例
1. 不可变字符串 NSString
NSString *str1 = @"abc";
NSString *str2 = [str1 copy]; // 浅拷贝,同一块内存,地址相同
NSMutableString *str3 = [str1 mutableCopy]; // 深拷贝,新内存,地址不同
原因:NSString 本身不可变,copy 没必要新开内存,直接返回原对象指针(浅拷贝)。
2. 可变字符串 NSMutableString
NSMutableString *str1 = [NSMutableString stringWithString:@"abc"];
NSString *str2 = [str1 copy]; // 深拷贝,新建不可变NSString
NSMutableString *str3 = [str1 mutableCopy]; // 深拷贝,新建可变字符串
可变对象内容能修改,拷贝必须开辟新内存,全部深拷贝。
三、集合类 NSArray / NSDictionary【面试重点:集合的浅拷贝 & 单层深拷贝】
❗ 集合的深拷贝 ≠ 递归全深拷贝!
[array mutableCopy]只是单层深拷贝:集合容器本身新建内存;容器里面存放的元素,依旧只是指针拷贝(浅拷贝) 。
NSArray *arr1 = @[objA, objB];
NSArray *arr2 = [arr1 copy]; // 浅拷贝,容器指针相同
NSMutableArray *arr3 = [arr1 mutableCopy]; // 单层深拷贝:容器是新内存;里面objA、objB仍然共用
现象:修改 arr3 容器本身(增删元素)不会影响 arr1;但是修改 arr3 内部元素 objA 的属性,arr1 里面的 objA 同样变化!
真正完全递归深拷贝(全部元素都新建对象)两种方式
initWithArray:copyItems:
NSArray *deepCopyArray = [[NSArray alloc] initWithArray:arr1 copyItems:YES];
copyItems=YES,会对每一个元素执行
copy,但依然要看元素是否支持 copy。
- 归档反归档(终极全深拷贝)
NSArray *trueDeepCopy = [NSKeyedUnarchiver unarchivedObjectOfClass:[NSArray class] fromData:[NSKeyedArchiver archivedDataWithRootObject:arr1 requiringSecureCoding:NO error:nil] error:nil];
归档方式:递归完整深拷贝,所有层级全部新建对象;缺点开销大,对象需要支持 NSCoding。
四、自定义对象的 copy(高频面试)
默认自定义类不遵守 NSCopying,直接调用copy会崩溃。 需要手动遵守协议,实现 - (id)copyWithZone:(NSZone *)zone
@interface Person : NSObject <NSCopying>
@property (nonatomic, copy) NSString *name;
@end
@implementation Person
- (id)copyWithZone:(NSZone *)zone {
Person *p = [[Person allocWithZone:zone] init];
p.name = self.name; // 这里name是NSString,copy属性,浅拷贝没问题
return p;
}
@end
在
copyWithZone里面:
- 如果直接赋值:浅拷贝
- 如果给成员也重新创建对象:深拷贝
五、属性关键字 copy(面试常问,和深浅拷贝联动)
@property (nonatomic, copy) NSString *name;
setter 伪代码:
- (void)setName:(NSString *)name {
_name = [name copy];
}
✅ 使用场景:防止传入可变字符串,外部修改影响内部变量。
NSMutableString *mStr = [NSMutableString stringWithString:@"test"];
person.name = mStr;
[mStr appendString:@"123"];
如果属性是strong:外部修改 mStr,person.name 跟着变。 如果属性是copy:setter 执行[mStr copy]生成新不可变字符串,不受外部影响。
坑:
@property (nonatomic, copy) NSMutableString *mStr;赋值时[mStr copy]返回不可变 NSString;后续调用可变方法appendString直接 crash!
六、面试题汇总
Q1:NSString 的 copy 是深拷贝还是浅拷贝?为什么?
A:NSString 不可变,copy 是浅拷贝。因为内容无法修改,拷贝一份新内存没有意义,直接返回原对象指针,优化性能。mutableCopy 才会深拷贝。
Q2:NSArray mutableCopy 是完全深拷贝吗?
A:不是,只是容器单层深拷贝。容器开辟新内存,但数组内元素依旧是指针浅拷贝。修改元素的属性,新旧数组都会看到变化。想要完整递归深拷贝要用归档,或者 initWithArray:copyItems:
Q3:属性用 copy 修饰 NSMutableString 会有什么问题?
A:setter 调用copy返回不可变 NSString,赋值给 NSMutableString 变量;后续调用可变方法会发生 unrecognized selector 崩溃。
Q4:浅拷贝一定会带来循环引用吗?
A:不一定。浅拷贝只是共用内存;只有互相持有对方才会循环引用。
七、一句话背诵总结
浅拷贝只拷贝指针,共用同一块内存;深拷贝开辟新内存,拷贝数据。 copy返回不可变对象;mutableCopy返回可变对象。 不可变对象 copy 浅拷贝;可变对象 copy/mutableCopy 都是深拷贝。集合 mutableCopy 只是单层深拷贝,内部元素仍然浅拷贝。自定义对象 copy 需要实现 NSCopying 协议。
autoreleasepool 原理 + 内存暴涨实战
一、核心问题:为什么方法返回值不能立刻 release?
这是 autorelease 机制的设计初衷。
假设我们在方法内部创建对象、外部使用对象:
- (NSString *)getFullName {
// 引用计数 = 1
NSString *fullName = [[NSString alloc] initWithFormat:@"%@ %@", @"Hello", @"World"];
// ❌ 绝对不能在这里 release
// [fullName release];
return fullName;
}
如果方法内部直接 release:
- 对象立即销毁
- 返回给外部调用者的是 野指针
如果不 release:
- 调用者不知道需要释放对象(违背 MRC 谁创建谁释放原则)
- 造成内存泄漏
二、autorelease 完美解决矛盾(延迟释放)
- (NSString *)getFullName {
NSString *fullName = [[NSString alloc] initWithFormat:@"%@ %@", @"Hello", @"World"];
return [fullName autorelease];
}
autorelease 不是立刻减引用计数,只是“登记延迟释放” :
- 对象加入当前autoreleasepool 链表
- 方法正常返回,外部拿到有效对象
- 等到 RunLoop 一次循环迭代结束(beforeWaiting),统一对池内所有对象执行一次
release
核心逻辑:延长对象生命周期,保证跨函数调用安全,又不造成内存泄漏。
三、RunLoop 与 AutoreleasePool 绑定关系(必考)
主线程 RunLoop 每一次循环迭代固定流程:
- Loop 开始前:objc_autoreleasePoolPush(创建池子、插入哨兵边界)
- 处理事件、回调、代码执行(产生大量 autorelease 临时对象)
- Loop 即将休眠:objc_autoreleasePoolPop(批量 release、清空本轮所有临时对象)
底层存储结构:AutoreleasePoolPage 双向链表,支持嵌套池子。
四、实战:循环大量 autorelease 对象导致内存暴涨
直接开启Profile,设置为debug模式,选择Instrument的Allocations。其中蓝色表示没有被释放的内存。
1. 问题代码(无手动 autoreleasepool,内存堆积)
整个 for 循环是同步卡死主线程,RunLoop 无法进入下一次迭代、无法 pop 池子,对象持续堆积。
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"start");
for (NSInteger i = 0; i < 200000; i++) {
NSString *s = [self genString];
if (i == 50000) NSLog(@"i=50000");
if (i == 100000) NSLog(@"i=100000");
}
NSLog(@"end");
}
- (NSString *)genString {
NSString *base = @"abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz";
return [NSString stringWithFormat:@"%lld %@", arc4random(), base];
}
内存现象:
- 循环执行中:内存持续飙升、大量临时字符串堆积
- 循环结束、viewDidLoad 走完:RunLoop 迭代结束,池子瞬间 pop,内存断崖式回落
2. 优化方案:循环内手动添加 @autoreleasepool
每一轮循环单独创建、销毁池子,无需等待 RunLoop 结束,即时清理临时 autorelease 对象,彻底解决内存峰值、内存抖动问题。
- (void)viewDidLoad {
[super viewDidLoad];
NSLog(@"start");
for (NSInteger i = 0; i < 200000; i++) {
@autoreleasepool {
NSString *s = [self genString];
if (i == 50000) NSLog(@"i=50000");
if (i == 100000) NSLog(@"i=100000");
}
}
NSLog(@"end");
}
优化后现象:
-
内存全程平稳,无暴涨峰值
-
大幅减少 Transient 临时对象数量,降低内存抖动、CPU 开销
可以看到,所有的临时对象都及时被释放了。