iOS 底层原理 内存管理 定时器 Tagged Pointer 深浅拷贝 autoreleasepool

22 阅读12分钟

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. 关键总结

  1. block版Timer:循环引用来自block捕获self,block内weakSelf即可解决。
  2. target-selector版Timer/CADisplayLink:timer内部retain target,传weakSelf无效,要用NSProxy弱转发。
  3. invalidate:移除RunLoop对timer的强引用,必须在dismiss前调用;如果卡在循环引用,dealloc永远不会执行,dealloc里写invalidate毫无意义!
  4. scheduledTimer:自动进RunLoop;timerWithTimeInterval:手动addTimer。
  5. 引用链梳理: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. 核心特性(面试高频)

  1. 没有堆内存,没有 isa,retain / release / autorelease是空操作,不维护引用计数。
  2. 访问更快:不用访问堆内存;创建销毁开销极低。
  3. 伪装成 OC 对象,可以正常调用NSObject方法([num intValue]),runtime 内部做特殊判断。
  4. 判断源码(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(最高位)

  1. 混淆:新版本 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. 坑点总结(写笔记)

  1. 短字符串 NSTaggedPointerString 属于 TaggedPointer,长字符串是__NSCFString堆对象。
  2. TaggedPointer 没有引用计数,retain/release 是空操作。
  3. 并发 setter 场景:堆对象容易多线程过度释放 crash;TaggedPointer 不会 crash,但依然不是线程安全。
  4. 只有 64 位设备支持。
  5. 存储容量有限,数据超出限制自动降级为普通堆对象。

一句话面试口述版(背这个)

Tagged Pointer 是 64 位 Runtime 的小对象优化技术。对于 NSNumber、短 NSString 这类小对象,不再在堆上分配内存,而是把类型标记和数据直接编码存放在指针本身。它没有 isa,不维护引用计数,retain/release 是空操作,内存访问更快。最经典面试场景:多线程并发给属性赋值,堆对象会并发 release 导致野指针崩溃,而 TaggedPointer 不会发生内存崩溃,但仍然不具备线程安全。

# 深浅拷贝(面试笔记精简版)

核心一句话:拷贝的本质看:新对象的指针,指向的是同一个内存,还是新开一块内存。

  • 浅拷贝:只拷贝指针,不拷贝底层数据,新旧指针指向同一块内存。
  • 深拷贝:开辟新内存,拷贝底层数据;新旧对象指向两块独立内存。

两个方法:copy / mutableCopy 底层协议:NSCopying、NSMutableCopying。 ⚠️ 只有遵守协议,copy/mutableCopy才可用;自定义类要手动实现协议。

一、基础规则(必背)

表格

对象类型copymutableCopy
不可变对象 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 同样变化!

真正完全递归深拷贝(全部元素都新建对象)两种方式

  1. initWithArray:copyItems:
NSArray *deepCopyArray = [[NSArray alloc] initWithArray:arr1 copyItems:YES];

copyItems=YES,会对每一个元素执行copy,但依然要看元素是否支持 copy。

  1. 归档反归档(终极全深拷贝)
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 不是立刻减引用计数,只是“登记延迟释放” :

  1. 对象加入当前autoreleasepool 链表
  2. 方法正常返回,外部拿到有效对象
  3. 等到 RunLoop 一次循环迭代结束(beforeWaiting),统一对池内所有对象执行一次 release

核心逻辑:延长对象生命周期,保证跨函数调用安全,又不造成内存泄漏。


三、RunLoop 与 AutoreleasePool 绑定关系(必考)

主线程 RunLoop 每一次循环迭代固定流程:

  1. Loop 开始前:objc_autoreleasePoolPush(创建池子、插入哨兵边界)
  2. 处理事件、回调、代码执行(产生大量 autorelease 临时对象)
  3. Loop 即将休眠:objc_autoreleasePoolPop(批量 release、清空本轮所有临时对象)

底层存储结构:AutoreleasePoolPage 双向链表,支持嵌套池子。


四、实战:循环大量 autorelease 对象导致内存暴涨

直接开启Profile,设置为debug模式,选择Instrument的Allocations。其中蓝色表示没有被释放的内存。

image.png

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,内存断崖式回落

image.png

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 开销

image.png

可以看到,所有的临时对象都及时被释放了。