iOS 面试题:内存管理

22 阅读9分钟

一、NSTimer 和 CADisplayLink 有什么注意点?

NSTimer 本质上是注册到 RunLoop 上的定时器,只有对应的 RunLoop 正常运行时,定时器才会触发。

使用时需要注意:

  1. NSTimer 会被 RunLoop 持有。
  2. NSTimer 通常会强引用 target,容易和控制器形成循环引用。
  3. 页面销毁时要调用 invalidate。
  4. 主线程被耗时任务阻塞时,定时器会延迟执行。
  5. 定时器会受到 RunLoop Mode 影响,滚动 UIScrollView 时,默认 Mode 下的 timer 可能暂时不触发。
self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
                                               target:self
                                             selector:@selector(timerAction)
                                             userInfo:nil
                                              repeats:YES];

页面销毁时:

[self.timer invalidate];
self.timer = nil;

如果希望滚动时也继续触发,可以加入 common modes:

[[NSRunLoop mainRunLoop] addTimer:timer
                           forMode:NSRunLoopCommonModes];

NSTimer 从 iOS 10 开始支持 block API,但 block 可能强引用 self:

__weak typeof(self) weakSelf = self;

self.timer = [NSTimer scheduledTimerWithTimeInterval:1.0
                                             repeats:YES
                                               block:^(NSTimer *timer) {
    [weakSelf timerAction];
}];

如果直接在 block 中使用 self,可能形成:

self -> timer -> block -> self

CADisplayLink 是和屏幕刷新同步的定时器,适合:

  • 动画
  • 游戏循环
  • 进度更新
  • 实时绘制
self.displayLink = [CADisplayLink displayLinkWithTarget:self
                                               selector:@selector(tick:)];

[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop]
                       forMode:NSRunLoopCommonModes];

CADisplayLink 的 target 也可能被强引用,因此需要调用:

[self.displayLink invalidate];
self.displayLink = nil;

CADisplayLink 没有官方的 block 初始化 API。通常可以使用弱引用代理来避免强引用控制器。

总结:

NSTimer 是基于 RunLoop 的普通定时器,CADisplayLink 是与屏幕刷新同步的定时器。两者都要注意 RunLoop Mode、主线程阻塞、循环引用和销毁时 invalidate。


二、iOS 内存的主要区域

iOS 程序常见的内存区域包括:

1. 代码区

存放程序编译后的机器指令,例如函数和方法实现。

通常是只读、可执行的。

2. 常量区

存放常量数据,例如字符串常量:

NSString *name = @"Tom";

3. 全局区和静态区

存放全局变量和静态变量:

int globalValue = 10;
static int count = 0;

它们的生命周期通常和进程一致。

4. 栈区

主要存放:

  • 局部变量
  • 函数参数
  • 返回地址
  • 临时数据
- (void)test {
    int number = 10;
}

栈由系统自动管理,函数结束后对应的栈空间会被回收。

5. 堆区

堆主要存放动态创建的对象:

NSObject *object = [[NSObject alloc] init];

对象的生命周期由引用计数、ARC 或手动内存管理决定。

6. 映射区

用于存放:

  • 动态库
  • Framework
  • mmap 映射的文件
  • 系统共享内存

可以简单记忆:

栈:局部变量
堆:动态对象
全局区:全局和静态变量
代码区:程序指令
常量区:常量
映射区:动态库和文件映射

三、对 iOS 内存管理的理解

iOS 对象内存管理的核心是引用计数。

当一个对象被强引用时,引用计数增加;当强引用消失时,引用计数减少。当引用计数降为零时,对象会执行 dealloc。

MRC 下常见方法:

retain      // 引用计数加一
release     // 引用计数减一
autorelease // 稍后 release
dealloc     // 对象真正销毁

例如:

NSObject *object = [[NSObject alloc] init];

在 MRC 下,alloc 让当前代码获得了对象所有权,因此需要:

[object release];

在 ARC 下,编译器会自动插入相关的 retain、release 和 autorelease 操作。

实际开发中还要注意:

循环引用

A strong 持有 B
B strong 持有 A

两个对象都无法释放。

通常需要将其中一条关系改为 weak。

自动释放池

@autoreleasepool {
    // 临时对象
}

自动释放对象会在自动释放池销毁时统一处理。

内存警告和 Jetsam

iOS 内存不足时,系统可能发送内存警告。如果内存占用继续过高,系统可能通过 Jetsam 机制直接终止 App。

常见优化方式:

  • 避免一次性加载大量图片
  • 控制缓存大小
  • 及时释放不需要的大对象
  • 避免循环引用
  • 使用 Instruments 检查内存问题

四、ARC 都做了什么?

ARC 是 Automatic Reference Counting,也就是自动引用计数。

ARC 不是垃圾回收机制,它仍然是引用计数,只是把内存管理工作交给编译器完成。

NSObject *object = [[NSObject alloc] init];

在 ARC 下不需要手动:

[object release];

ARC 主要做了以下事情:

  1. 自动插入 retain、release、autorelease。
  2. 自动管理局部强引用。
  3. 自动管理 strong 属性。
  4. 支持 weak 引用清零。
  5. 对对象返回过程进行优化。
  6. 在合适的时机释放对象。

ARC 主要由两部分配合:

LLVM 编译器

负责在编译阶段:

  • 分析对象所有权
  • 判断对象什么时候需要持有
  • 判断对象什么时候可以释放
  • 插入引用计数相关代码

Objective-C Runtime

负责运行时:

  • 执行引用计数
  • 管理 weak 引用
  • 清零 weak 指针
  • 执行 autorelease
  • 销毁对象

面试中可以总结为:

ARC 不是运行时垃圾回收,而是 LLVM 在编译阶段根据所有权规则自动插入引用计数代码,再由 Objective-C Runtime 在运行时完成引用计数、weak 清零和对象销毁。


五、weak 指针的实现原理

weak 是一种不拥有对象的弱引用。

@property (nonatomic, weak) id delegate;

它有三个特点:

  • 不增加对象引用计数
  • 不拥有对象
  • 对象销毁后,weak 指针自动变为 nil

Runtime 内部会维护弱引用表,记录哪些指针指向某个对象。

可以理解为:

对象 A
└── weak 引用列表
    ├── controller.delegate
    ├── view.delegate
    └── ...

当对象 A 即将销毁时,Runtime 会:

  1. 找到所有指向 A 的 weak 指针。
  2. 把这些指针置为 nil。
  3. 再完成对象销毁。

因此:

id object = ...;
self.delegate = object;

// object 被释放后
self.delegate == nil;

weak 和 unsafe_unretained 的区别是:

weak

对象销毁后会自动变成 nil。

unsafe_unretained

对象销毁后不会自动清空,可能变成悬空指针,继续访问可能崩溃。

weak 不能单独解决循环引用,必须把循环引用中的一条 strong 关系改成 weak:

A strong 持有 B
B weak 持有 A

六、autorelease 对象什么时候调用 release?

autorelease 不会立即调用 release,而是把对象放入当前线程的自动释放池。

NSObject *object = [[[NSObject alloc] init] autorelease];

当对应的 autorelease pool 被销毁时,系统会对这个对象执行:

[object release];

流程是:

autorelease
    ↓
对象进入自动释放池
    ↓
自动释放池销毁
    ↓
发送 release
    ↓
引用计数减一
    ↓
如果引用计数为 0
    ↓
执行 dealloc

常见释放时机包括:

  • @autoreleasepool 作用域结束
  • 主线程 RunLoop 一轮事件结束
  • 手动创建的自动释放池结束
  • 外层自动释放池销毁

需要注意:

release 只会让引用计数减一,只有引用计数降为零时,才会真正执行 dealloc。

在 ARC 下不能手动调用 release 和 autorelease,但底层仍然存在自动释放池机制。


七、main.m 中的 @autoreleasepool 有什么作用?

典型的 iOS 入口代码:

int main(int argc, char * argv[]) {
    @autoreleasepool {
        return UIApplicationMain(
            argc,
            argv,
            nil,
            NSStringFromClass([AppDelegate class])
        );
    }
}

这里的 @autoreleasepool 是给整个应用启动过程和应用生命周期提供一个最外层的自动释放池。

虽然 main.m 中没有显式创建对象,但是:

UIApplicationMain(...)

内部会创建很多对象,例如:

  • UIApplication
  • AppDelegate
  • UIWindow
  • 控制器
  • 视图
  • 事件处理过程中的临时对象

真正启动 RunLoop 的是 UIApplicationMain,而不是 @autoreleasepool。

可以理解为:

@autoreleasepool
└── UIApplicationMain
    └── 主线程 RunLoop
        ├── 处理事件
        ├── 执行回调
        ├── 清理临时对象
        └── 继续下一轮循环

@autoreleasepool 不负责创建 RunLoop,也不会自己循环。

UIKit 在主线程 RunLoop 的每轮事件处理过程中,还会创建和清理更细粒度的自动释放池。


八、viewDidLoad 和 RunLoop 有什么关系?

viewDidLoad 不是由 RunLoop 直接调用的。

viewDidLoad 的触发原因是:

当前控制器的 view 第一次被加载。

例如:

UIViewController *vc = [[UIViewController alloc] init];

vc.view;

第一次访问 vc.view 时,如果视图还没有加载,就会触发 viewDidLoad。

当执行:

[self.navigationController pushViewController:vc animated:YES];

UIKit 通常会访问控制器的 view,因此可能发生:

pushViewController
    ↓
UIKit 请求 vc.view
    ↓
加载 view
    ↓
调用 viewDidLoad
    ↓
后续进行布局、绘制和动画

RunLoop 主要负责让主线程持续处理:

  • 用户事件
  • 主线程任务
  • 定时器
  • 布局
  • 绘制
  • 动画
  • 屏幕刷新

因此两者的关系是:

viewDidLoad:视图第一次加载时调用
RunLoop:驱动主线程不断处理事件、布局和绘制

不能简单说成“RunLoop 调用了 viewDidLoad”。


九、方法中的局部对象,出了方法后会立即释放吗?

不一定。

- (void)test {
    NSObject *object = [[NSObject alloc] init];
}

在 ARC 下,object 是一个局部强引用。离开生命周期后,ARC 会释放这个强引用。

如果对象没有其他强引用,那么它通常会被销毁。

但需要区分:

局部变量失效
≠
对象立即销毁

如果对象还有其他强引用:

self.object = object;

那么局部变量失效后,对象仍然存在,因为 self.object 还在持有它。

另外,ARC 不保证一定在右花括号结束的瞬间释放对象。编译器可能根据优化策略,在对象最后一次使用之后提前释放。

在 MRC 下:

NSObject *object = [[NSObject alloc] init];

不会自动释放,必须手动:

[object release];

否则就会造成内存泄漏。

如果确实需要保证对象活到作用域结束,可以使用:

objc_precise_lifetime NSObject *object =
    [[NSObject alloc] init];

不过普通业务代码一般不需要这样写。

最终可以这样总结:

ARC 下,局部变量离开生命周期后,其强引用会被释放;对象是否真正销毁,取决于是否还有其他强引用。MRC 下则必须手动 release。ARC 也不保证释放一定发生在右花括号的准确时刻。


十、面试总结

iOS 内存管理本质上是引用计数。MRC 时代需要手动管理 retain、release 和 autorelease,ARC 则由 LLVM 在编译阶段自动插入相关代码,再由 Objective-C Runtime 在运行时执行引用计数、weak 清零和对象销毁。RunLoop 负责主线程的事件、布局、绘制和定时器处理;viewDidLoad 则是在控制器的 view 第一次加载时调用。实际开发中需要重点注意循环引用、Timer 生命周期、自动释放池、局部变量生命周期、大图片缓存以及内存警告。