iOS / Objective-C / Swift 八股文全解析(全面 · 深入 · 底层原理)

1 阅读38分钟

iOS / Objective-C / Swift 八股文全解析(全面 · 深入 · 底层原理)

说明:本次生成时联网搜索工具后端持续故障(多次报错),未能核对最新 Xcode/Swift/iOS SDK 具体版本号细节。文档内容聚焦跨版本长期稳定的核心底层原理(runtime 消息机制、内存管理、Swift 值/引用语义、并发模型、Runloop 等),这部分是面试的绝对重点,可放心使用;具体版本号、API 可用性建议以 Apple 官方文档为准做最终核对。


目录

  1. Objective-C Runtime 底层原理

  2. 内存管理:引用计数与 ARC 底层机制

  3. Objective-C 消息机制与 Method Swizzling

  4. Category / Extension / Associated Object 原理

  5. KVC / KVO 底层实现

  6. Block 的底层结构与内存管理

  7. Swift 值类型 vs 引用类型底层原理

  8. Swift ARC、写时复制(COW)与内存布局

  9. Swift 协议(Protocol)与存在类型底层原理

  10. Swift 并发模型:GCD / Operation / async-await / Actor

  11. RunLoop 底层原理

  12. App 启动流程与优化

  13. 多线程与锁的底层原理

  14. 常见崩溃与内存问题分析方法

  15. 常见高频面试题 Q&A 速查


1. Objective-C Runtime 底层原理

1.1 Runtime 是什么

Objective-C 是一门运行时语言:绝大多数决策(方法调用、类型判断)不是在编译期确定的,而是推迟到程序运行时,由一套用 C/C++ 实现的 **Runtime 库(libobjc) ** 在运行时动态完成。这也是 OC 能实现 Method Swizzling、动态创建类、消息转发等"黑魔法"的根本原因。

1.2 类的底层结构


struct objc_class {

    Class isa;             // 指向元类(metaclass)

    Class superclass;      // 父类指针

    cache_t cache;         // 方法缓存(imp 缓存,加速查找)

    class_data_bits_t bits; // 存放 class_rw_t 指针,内含方法列表/属性列表/协议列表等

};

  • 每个对象的第一个成员都是 **isa** 指针,指向它的类;类对象的 isa 指向它的****元类(Metaclass)** **;元类的 isa 最终指向根元类,根元类的 isa 指向自己(形成闭环)。

  • isa 走位口诀:实例对象 isa → 类对象;类对象 isa → 元类对象;元类对象 isa → 根元类(自身闭环)。

  • superclass 链:类对象的 superclass 指向父类对象,一路向上到 NSObject 再到 nil;元类的 superclass 链最终也汇聚到根元类。这两条链共同构成了方法查找时"向上遍历"的路径。

1.3 objc_msgSend —— OC 方法调用的本质

OC 里 [obj method] 这种方法调用,在编译期被转换成:


objc_msgSend(obj, @selector(method));

objc_msgSend 是纯汇编实现(为了极限性能),核心逻辑:

  1. 判断 obj 是否为 nil(OC 中给 nil 发消息是安全的,直接返回 0/nil,不会崩溃——这也是常见面试题)。

  2. 取 obj->isa 得到类对象,先查这个类的 **方法缓存(cache_t) **,命中直接调用对应的 IMP(函数指针),这是一次哈希查找,性能很高。

  3. 缓存未命中,则查类自己的方法列表(class_rw_t 里的 methods)。

  4. 本类没找到,沿着 superclass 链逐级向上查找,直到 NSObject。

  5. 如果一路找到根类都没找到,进入消息转发流程(见 1.4)。

  6. 找到后会把结果写入缓存,下次同一个 selector 调用可以直接命中缓存,避免重复遍历。

1.4 消息转发机制(Message Forwarding)—— 三级救援

当 objc_msgSend 找不到对应方法实现时,Runtime 给了三次"补救"机会,这也是面试高频考点:

**第一步:动态方法解析(Dynamic Method Resolution) **


+ (BOOL)resolveInstanceMethod:(SEL)sel {

    if (sel == @selector(xxx)) {

        class_addMethod([self class], sel, (IMP)xxxIMP, "v@:");

        return YES;

    }

    return [super resolveInstanceMethod:sel];

}

可以在这一步动态添加一个方法实现(常用于 @dynamic 属性 + Core Data 场景)。

**第二步:快速转发(Fast Forwarding) **


- (id)forwardingTargetForSelector:(SEL)aSelector {

    if (aSelector == @selector(xxx)) {

        return self.otherObject; // 把消息转给另一个能处理的对象

    }

    return [super forwardingTargetForSelector:aSelector];

}

把消息完整转发给另一个对象处理,性能开销小(不涉及 NSInvocation 的构造),常用于多重继承的模拟(一个类不能继承多个父类,但可以把不同职责委托给多个内部对象)。

**第三步:标准转发(Normal/Invocation Forwarding) **


- (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector {

    // 返回方法签名,缺失会直接抛异常

}

- (void)forwardInvocation:(NSInvocation *)anInvocation {

    // 可以在这里改参数、换调用目标、甚至聚合多个调用结果

}

这一步会构造一个完整的 NSInvocation 对象(保留了 selector、参数、目标),比前两步开销大,但能力最强——可以在真正调用前修改参数、调用多个对象、组装返回值。

三步都没处理,最终会走到 doesNotRecognizeSelector:,抛出经典的 unrecognized selector sent to instance 崩溃。

1.5 元类与 isa 的意义

为什么需要元类?因为 OC 中"类方法"本质上也是通过 objc_msgSend 发消息实现的,接收者是类对象本身。类对象要"响应消息",就必须也有一个 isa 指向"存放类方法的容器"——这个容器就是元类。这套设计让 类方法和实例方法在查找机制上完全统一,都是"顺着 isa 找方法列表,再顺着 superclass 链向上找",没有制造出第二套调用体系。


2. 内存管理:引用计数与 ARC 底层机制

2.1 引用计数存储位置

  • 有 Tagged Pointer 优化的场景(见 2.4)不需要真正的引用计数存储。

  • 普通对象的引用计数不是存在对象内存的某个字段里(早期确实有过这种实现,现代 Runtime 已改),而是维护在一个全局的 ****** **SideTable** (散列表) ** 中,用对象指针地址作哈希 key,映射到一个引用计数值。这样设计的好处是对象本身内存布局更紧凑,代价是查表有哈希开销(配合 Tagged Pointer 大量规避了这个开销)。

2.2 ARC 的本质:编译期插入 retain/release/autorelease

ARC(Automatic Reference Counting)**不是运行时的垃圾回收(GC) **,而是编译器(Clang)在编译期,根据一套明确的内存管理规则(谁创建谁释放/命名约定),自动在代码里插入 **retain** / **release** / **autorelease** 调用。这意味着:

  • ARC 是编译期特性,运行时的开销和 MRC 手动写的 retain/release 本质相同,只是不需要人写。

  • 编译器还会插入 objc_retainAutoreleasedReturnValue/objc_autoreleaseReturnValue 这类特殊符号,用于方法返回自动释放对象时的调用者-被调用者之间的握手优化(避免多余的 retain/release 抖动,这一优化机制被称为 TAILCALL 优化 / ARC 快速路径,具体表现为:调用者和被调用者约定,如果紧接着 return 就 retain,调用者紧接着就用,可以省掉一组 retain+release)。

2.3 strong/weak/unowned(Swift)与循环引用

  • OC 的 strong/weak/assign/copy 对应 Swift 的 strong(默认)/weak/unowned。

  • weak 引用的底层实现:Runtime 维护一张 ****** **weak_table_t** **(弱引用表),记录"哪个对象被哪些弱引用变量指向"。当对象被释放(dealloc)时,Runtime 会遍历这张表,把所有指向该对象的 weak 变量自动置为 nil(这就是为什么访问一个已释放对象的 weak 属性不会崩溃,而是拿到 nil)。这个"清零"操作发生在对象释放流程中的 objc_destructInstance 之前的特定阶段。

  • 循环引用的经典场景:闭包/Block 捕获 self(strong),同时 self 又强持有这个闭包/Block(比如存进属性),形成 self → block → self 的强引用环,双方引用计数都无法降到 0,造成内存泄漏。解决方式是用 weak self/__weak typeof(self) weakSelf。

  • weak 比 unowned 慢:weak 涉及查 weak_table_t 和运行时的零权重检查(每次访问都要判断是否已被置 nil),而 unowned(Swift)编译后是****裸指针访问,没有额外运行时检查(unowned 非 safe 模式下)** **,性能更高但如果引用的对象已被释放会直接野指针崩溃(Swift 的 unowned 默认是"safe"的,会在访问时做一次有效性检查并 trap,而 unowned(unsafe) 才是完全不检查的裸指针模式)。

2.4 Tagged Pointer

  • 对于 NSNumber、短 NSString(≤ 一定长度)等小对象,系统直接把值编码进指针本身(利用指针的高位做类型标记 tag,低位存实际数据),这样的"对象"根本没有真正在堆上分配内存,也不需要引用计数(Tagged Pointer 的 retain/release 是空操作,直接返回)。

  • 判断方法:指针最低有效位(在 64 位系统上通常是最高位或某几个特定位,具体位布局是内部实现细节)被置 1,Runtime 通过检测这个标记位区分 Tagged Pointer 和普通堆对象指针。

  • 好处:内存零分配 + 引用计数零开销,大量小整数/短字符串场景性能显著提升,这也是苹果在 64 位架构下引入的关键优化。


3. Objective-C 消息机制与 Method Swizzling

3.1 Method Swizzling 原理

本质是****替换类的方法列表中某个 selector 对应的 IMP(函数指针)** **,不改变调用方的代码,就能让同一个方法调用触发不同的实现:


Method originalMethod = class_getInstanceMethod(cls, originalSelector);

Method swizzledMethod = class_getInstanceMethod(cls, swizzledSelector);

method_exchangeImplementations(originalMethod, swizzledMethod);

  • 常用于 AOP(面向切面编程)场景:统一埋点、崩溃防护(比如 hook 数组越界方法改为返回 nil 而不崩溃)、性能监控(hook viewDidAppear 统计页面停留时长)。

  • 必须在 **+load** 方法里做 Swizzling(而不是 +initialize),因为 +load 在类被加载进 Runtime 时就会调用,且只调用一次,时机足够早、可靠性高;+initialize 是"该类第一次接收到消息时才调用",且子类如果没有重写会继承调用父类实现,可能被意外多次触发(同一个 +initialize 实现可能因为子类没实现而被 Runtime 针对每个子类各调用一次)。

  • Swizzling 一定要调用原始实现(保存 originalMethod 的 IMP 后在新实现里调用),否则容易破坏原有功能链条。

3.2 关联对象(Associated Object)

用于给已经编译好的类(比如系统类 NSObject,或者通过 Category 扩展的类)"动态挂"一个属性,因为 **Category 不能新增实例变量(ivar) **——类的内存布局在编译期已经固定,Category 是运行时合并方法列表,无法改变对象的内存大小。


objc_setAssociatedObject(self, &kAssociatedKey, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);

objc_getAssociatedObject(self, &kAssociatedKey);

底层原理:Runtime 维护一个全局的 ****** **AssociationsManager** **,内部是一个从对象指针到 "该对象所有关联对象的小字典" 的映射表(类似一个全局的 weak/strong side table),跟对象本身内存完全独立,对象释放时(dealloc)会触发清理相关联对象的逻辑(_object_remove_associations)。


4. Category / Extension / Associated Object 原理

4.1 Category 的本质与加载时机

Category 编译后对应一个 category_t 结构体(含方法列表、属性列表、协议列表),在 App 启动、Runtime 初始化阶段( **objc_init** → **map_images** → **load_categories** ) ,会把所有 Category 的方法列表、属性、协议****合并(append)进宿主类原有的 **class_rw_t** 结构,而不是替换。

Category 与本类同名方法的覆盖规则:合并时 Category 的方法被放在方法列表的前面,而 Runtime 查找方法时是"顺序遍历列表,找到第一个匹配就返回",所以Category 的实现会覆盖本类的原始实现(且这是"覆盖"而不是"替换删除"——原实现依然在方法列表里,只是排在后面永远查不到,如果用 Swizzling 手动跳过 Category 的实现,理论上还能拿到本类原始实现)。

  • 如果多个 Category 都实现了同名方法,最终生效的是编译顺序(Compile Sources 里的顺序)中最后一个参与编译的 Category,这是不确定/依赖构建配置的行为,实际工程中要避免多个 Category 重写同名方法。

4.2 Extension(类扩展)与 Category 的本质区别

  • Extension 是编译期特性,通常写在 .m 文件顶部(@interface ClassName ()),可以新增实例变量(ivar)和 readwrite 属性,因为它在类主体编译时就参与了类的内存布局计算,本质上是这个类的"私有延伸声明",不是独立的运行时结构。

  • Category 是运行时特性,独立编译,不能新增 ivar(会导致内存布局在不同编译单元里不一致,无法安全实现),只能新增方法/属性声明(属性只生成 getter/setter 声明,具体存储需要靠 Associated Object 补足实现)。


5. KVC / KVO 底层实现

5.1 KVC(Key-Value Coding)查找顺序

setValue:forKey: / valueForKey: 的底层查找顺序(以 valueForKey: 为例):

  1. 优先查找 getKey/key/isKey 这几种命名模式的 accessor 方法。

  2. 没找到,检查类方法 + (BOOL)accessInstanceVariablesDirectly(默认 YES),允许时直接按 _key/_isKey/key/isKey 顺序查找实例变量直接读写(绕过 setter/getter,也因此可能绕过自定义 setter 里的校验/KVO 触发逻辑,是常见 bug 点)。

  3. 都没找到,调用 valueForUndefinedKey:(默认抛异常),可以重写它做兜底/防崩溃处理。

5.2 KVO 的本质:运行时动态生成子类

KVO(Key-Value Observing)的底层实现是isa-swizzling(不要和 Method Swizzling 混淆,机制类似但对象是 isa 指针本身):

  1. 当对某个对象的某个属性 addObserver:forKeyPath: 时,Runtime 动态创建一个该对象原类的子类(类名形如 NSKVONotifying_OriginalClassName)。

  2. 把这个被观察对象的 isa 指针偷偷改指向这个新创建的子类。

  3. 在这个子类里重写被观察属性对应的 setter 方法,新的 setter 实现在真正赋值前后调用 willChangeValueForKey:/didChangeValueForKey:,从而触发 observeValueForKeyPath:ofObject:change:context: 回调。

  4. 该子类还重写了 class 方法,让 [obj class] 依然返回原类(伪装成"看起来还是原来的类",这是为什么直接用 object_getClass(obj) 才能看到真实的 KVO 生成子类,而 [obj class] 看不出区别)。

关键推论:KVO 依赖的是属性变化经过标准 setter 方法触发(因为 hook 的就是 setter)。如果直接修改底层实例变量(比如通过 KVC 绕过 setter,或者是非 @property 生成的 setter 场景),KVO 不会被触发,这是经典的面试坑点和实际 bug 来源。


6. Block 的底层结构与内存管理

6.1 Block 的本质

Block 在底层被编译成一个 结构体实例,本质是"带数据的匿名函数"(类似闭包):


struct Block_layout {

    void *isa;             // Block 也是"对象",有 isa 指针

    int flags;

    int reserved;

    void (*invoke)(void *, ...);  // 真正的函数指针

    struct Block_descriptor *descriptor;

    // 后面跟着捕获的外部变量(按值拷贝的部分)

};

6.2 三种 Block 类型

| 类型 | 存储位置 | 触发条件 |

|---|---|---|

| NSGlobalBlock | 全局数据区(类似字符串常量) | Block 内部不捕获任何外部变量(或只用了全局/静态变量) |

| NSStackBlock | 栈上 | 捕获了外部局部变量,且没有被 copy |

| NSMallocBlock | 堆上 | Stack Block 被系统或手动 copy 后转换而来 |

ARC 下的自动 copy:现代 ARC 环境中,Block 赋值给 strong 类型的属性、作为参数传递给方法(大多数系统 API 内部会 copy)等场景,编译器会自动插入 copy 操作,把 Stack Block 提升为 Malloc Block,这也是为什么 MRC 时代经典的"Block 用完就跑飞,因为栈帧已经销毁"的问题在 ARC 下大幅减少(但跨 __block 变量、手动管理内存的场景仍需注意)。

6.3 __block 修饰符的原理

普通局部变量被 Block 捕获时是值拷贝(捕获时刻的值,之后 Block 内外互不影响);用 __block 修饰后,变量被包装进一个结构体(__Block_byref_xxx),Block 捕获的是这个包装结构体的指针,因此 Block 内部对该变量的修改能够同步反映到外部(本质是"捕获了地址而不是值")。同样在 ARC 下,__block 变量在从栈迁移到堆时也会经历类似的 copy 流程。

6.4 Block 与循环引用

self.block = ^{ [self doSomething]; }; —— self 强持有 block(因为是 strong 属性),block 又默认对 self 做了一次隐式 retain(值捕获对象类型时会 retain),形成循环引用。标准解法:__weak typeof(self) weakSelf = self; 在 Block 内部使用 weakSelf,或者搭配 __strong typeof(weakSelf) strongSelf = weakSelf;(在 Block 内部再持有一次强引用,防止执行过程中 self 被提前释放导致 Block 内部逻辑访问到 nil 中途出现不一致行为)。


7. Swift 值类型 vs 引用类型底层原理

7.1 内存布局的根本差异

  • **引用类型(class) **:实例数据存储在堆上,变量(局部变量/属性)里存的是一个指向堆内存的指针。赋值/传参时拷贝的是指针(引用计数 +1),多个变量可以指向同一块堆内存,修改会互相影响。

  • **值类型(struct/enum/tuple) **:变量本身直接内联存储实际数据(在栈上,或者作为其他结构体/类的一部分内联存在堆里),赋值/传参在语义上是"整份数据的拷贝"(实际实现见 7.2 的写时复制优化),多个变量各自独立,互不影响。

7.2 写时复制(Copy-On-Write, COW)

Swift 标准库的 Array/Dictionary/Set/String 等看起来是"值类型",但底层实际存储在堆上的一个引用类型缓冲区(Buffer)中,值类型本身只是一个包裹了指向该 Buffer 指针的小结构体。赋值 let b = a 时并不真正拷贝底层数据,只是让 b 和 a 指向同一个 Buffer,并让该 Buffer 的引用计数 +1。

真正的拷贝发生在"写"操作时:当对 b 执行任何可能修改内容的操作(如 append)时,标准库内部会先检查该 Buffer 的引用计数:

  • 如果引用计数 > 1(说明还有别的变量共享这份数据),才真正分配一块新内存、拷贝一份数据,再在新内存上执行修改(isKnownUniquelyReferenced 是这套机制暴露给开发者自定义值类型实现 COW 的关键 API)。

  • 如果引用计数 == 1(独占),直接原地修改,不需要拷贝。

这个设计让"值类型赋值/传参看起来总是深拷贝"的语义能够以接近引用类型的性能运作——大多数场景下根本不会触发真正的内存拷贝。

7.3 struct 中包含 class 属性的语义

如果一个 struct 里持有一个 class 类型的属性,"值拷贝"只拷贝了指向该 class 实例的指针(引用计数 +1),底层这个 class 实例本身是被两个 struct 副本共享的——这意味着通过其中一个 struct 修改这个 class 属性的内部状态,会影响到另一个 struct 副本看到的结果(因为共享的是同一个堆对象)。这是"值类型不是绝对隔离"的经典陷阱,也是设计自定义值类型时需要考虑是否要给内部 class 属性也做深拷贝的原因。

7.4 Swift 对象的内存布局与 isa

  • Swift 的 class 实例内存布局第一个字(word)是一个指向类型元数据(Type Metadata)的指针,其结构和 OC 的 isa 有一定的兼容映射(Swift 的类如果继承自 NSObject,其类型元数据指针会兼容 OC Runtime 的 isa 语义,这也是纯 Swift 的 NSObject 子类依然能被 OC Runtime 消息机制识别、能用 respondsToSelector: 等 API 的底层原因)。

  • 纯 Swift 类型(不继承 NSObject)的方法调用默认走的是虚函数表(Witness Table / V-Table)机制,而不是 OC 那种运行时消息查找,因此纯 Swift 代码的方法调用性能通常更高(编译期能确定更多信息),但也失去了 Method Swizzling 那种运行时 hook 的能力(除非该方法标记了 dynamic 且类继承 NSObject,才会强制走 OC 的消息机制,可以被 hook)。


8. Swift ARC、写时复制(COW)与内存布局

8.1 Swift 的 ARC 与 OC 本质相同

Swift 的自动内存管理同样是编译期插入 retain(** **swift_retain** )/release( **swift_release** )调用**,原理和 OC ARC 一致,只是 Swift 编译器(在 SIL 中间表示层)能做更激进的引用计数优化****(比如相邻的 retain/release 配对,如果中间没有可能导致对象被释放的操作,编译器可以直接消除这一组 retain/release,减少运行时开销)。

8.2 Swift 的 weak/unowned 与 OC 的差异

  • Swift 的 weak 引用要求被引用类型必须是 Optional(weak var delegate: SomeProtocol?),底层依然依赖类似 OC 的"弱引用表 + 对象释放时自动置 nil"机制。

  • unowned(safe 模式,默认):对象释放后访问会触发运行时检查并直接 fatalError/trap,不是"野指针",是刻意设计的"快速失败";unowned(unsafe):完全不做检查,性能最高但真的是裸指针,访问已释放对象是未定义行为(UB),生产代码基本不推荐用。

8.3 Copyable / ~Copyable(Swift 较新语法,noncopyable types)

现代 Swift 引入了****不可拷贝类型(~Copyable** ) **的概念,允许开发者定义"只能移动(move-only),不能被隐式拷贝"的值类型,用于表达"独占所有权"的资源管理语义(类似 Rust 的 move 语义)。这是值类型体系在资源安全管理方向的进一步扩展,面试中如果被问到"Swift 的所有权模型是否借鉴了 Rust",可以从这个角度回答。


9. Swift 协议(Protocol)与存在类型底层原理

9.1 静态派发 vs 动态派发

Swift 方法调用有三种派发方式,性能和灵活性此消彼长:

| 派发方式 | 触发场景 | 性能 | 特点 |

|---|---|---|---|

| **直接调用(静态派发) ** | struct/enum 的方法、final class 的方法、全局函数 | 最快,编译期直接确定地址,可内联 | 无法被子类重写/动态替换 |

| **虚函数表派发(V-Table) ** | 非 final 的 class 方法(默认) | 较快,一次数组下标查表 | 支持子类重写(多态),但比直接调用多一次查表开销 |

| 消息机制派发 | 标记 @objc dynamic 的方法 | 最慢,走 OC Runtime 的 objc_msgSend | 支持运行时 Method Swizzling、KVO 等动态特性 |

9.2 协议的两种实现路径:静态 vs 存在类型(Existential)

  • **静态多态(泛型约束 <T: Protocol> ) **:编译器在编译期为每个具体类型生成/特化一份代码(泛型特化,Specialization),调用是直接静态派发,性能最高,但会增加二进制体积(每种具体类型一份代码)。

  • **存在类型( **any Protocol** / 早期写法直接写 **Protocol** 作类型) **:当类型在编译期不能完全确定(比如一个数组要装"任意实现了某协议的对象"),Swift 会用一种叫 Existential Container 的机制封装:

  - 一个固定大小的内联缓冲区(通常 3 个 word,够装大多数小值类型,装不下则自动在堆上分配并存指针,这个内联优化机制类似小对象优化 SSO);

  - 一个指向 **Value Witness Table(VWT) ** 的指针,记录如何拷贝/销毁/移动这个具体类型的值;

  - 一个指向 **Protocol Witness Table(PWT) ** 的指针,记录该具体类型对协议里每个方法的具体实现地址。

  • 调用存在类型上的协议方法时,实际上是通过 PWT 做一次间接查表,类似 V-Table 派发,但多了一层"类型抹去后重新恢复具体实现"的开销,因此存在类型的调用性能通常低于泛型静态派发,这也是近年 Swift 引入 any/some 关键字****显式区分存在类型和不透明类型(Opaque Type)** **、鼓励开发者关注这层性能差异的原因。

9.3 some 关键字(Opaque Type)

some Protocol 表示"返回一个遵循该协议的具体、固定的类型,但调用方不需要知道具体是什么类型"——区别于存在类型的"类型可以在运行时变化",Opaque Type 在编译期就锁定了具体类型,因此能享受静态派发的性能,同时对调用方隐藏了具体实现细节,是 SwiftUI 里 some View 返回值类型设计的核心依据。


10. Swift 并发模型:GCD / Operation / async-await / Actor

10.1 GCD(Grand Central Dispatch)底层原理

  • GCD 的核心抽象是 **Dispatch Queue(队列,FIFO) ** + **Dispatch Block(任务) **,底层由内核线程池(基于 libdispatch,最终落到 pthread/内核调度)管理线程的创建、复用和销毁,开发者不直接管理线程,只声明"往哪个队列扔什么任务"。

  • **串行队列(Serial Queue) **:任务按顺序一个个执行,前一个没完成,下一个不会开始,但不代表一定是同一个物理线程在跑。

  • **并发队列(Concurrent Queue) **:多个任务可以被派发到不同线程同时执行,具体开几个线程由系统根据当前负载和 CPU 核心数动态决定(GCD 内部有一套线程池管理和"线程爆炸保护"机制,避免无限制创建线程)。

  • **同步(sync)vs 异步(async) ** 描述的是"当前线程是否等待任务执行完再往下走",与"串行/并发队列"是两个独立维度,四种组合都合法(但主队列 + sync 会导致经典的死锁:主线程本身在等 sync 任务完成,而 sync 任务被派发到主队列却要排队等当前主线程空闲,互相等待)。

10.2 Operation / OperationQueue

基于 GCD 封装的更高层抽象,核心优势:

  • 支持任务依赖(addDependency:,A 依赖 B 时 B 必须先执行完)。

  • 支持取消(cancel,需要任务内部自行检查 isCancelled 配合实现,系统不会强行打断正在执行的代码)。

  • 支持优先级、最大并发数(maxConcurrentOperationCount)、KVO 观察任务状态变化。

  • 底层最终仍是通过 GCD(或者说更底层的线程调度)执行,Operation 只是加了一层更丰富的状态管理和调度策略。

10.3 async/await 与结构化并发

Swift 现代并发模型(async/await、Task、TaskGroup)的底层设计要点:

  • 不是基于线程池简单封装,而是引入了协作式调度(Cooperative Scheduling)** **的概念:Swift Runtime 维护一个全局的协作线程池****(默认线程数量接近 CPU 核心数,而不是像 GCD 那样在高并发下可能线性增长线程数),await 挂起时当前线程不会被阻塞,而是把执行状态保存下来(类似协程的"栈切换"或"状态机重写"),线程被释放去执行别的任务,等异步操作完成后重新调度一个线程(不一定是原来那个线程)继续执行挂起点之后的代码。

  • 这套模型比 GCD 更适合大量并发的异步任务场景,因为不会因为大量任务同时等待 I/O 而创建大量线程消耗系统资源(GCD 在极端场景下确实有"线程爆炸"的风险,尤其是同步等待场景层层嵌套)。

  • ****** **Task** 是最小的并发执行单元**,Task { } 创建一个新的异步任务,继承调用者的优先级和(在 Actor 隔离场景下)执行上下文;TaskGroup 支持动态数量的子任务并发执行并统一收集结果,是"结构化并发"思想的核心体现——父任务必须等待所有子任务完成(或被显式取消)才能真正结束,避免了传统回调地狱里"任务泄漏、无法追踪生命周期"的问题。

10.4 Actor —— 数据竞争的编译期防护

actor 是 Swift 并发模型中专门解决****数据竞争(Data Race)** **问题的语言特性:

  • 每个 actor 实例的可变状态(属性)只能通过该 actor 自身的方法访问,外部代码想读写这些状态必须通过 await 异步调用(哪怕看起来是"同步的属性访问",编译器也会强制插入 await),本质是串行化对该 actor 内部状态的所有访问(类似给这个对象自带了一个专属的串行队列,但是通过编译期类型系统强制检查,而不是靠开发者自觉记得加锁)。

  • 底层实现类似"每个 actor 拥有一个专属的顺序执行上下文(Executor)",多个外部调用者对同一 actor 的方法调用会被自动排队,同一时刻只有一个任务真正在执行该 actor 内部逻辑,从根本上避免了传统"忘记加锁导致的多线程读写同一变量"问题。

  • ****** **@MainActor** ** 是一个特殊的全局 Actor,专门代表主线程(UI 线程)的隔离上下文,标记为 @MainActor 的类型/方法保证只在主线程执行,编译器会在编译期静态检查是否有代码试图在非主线程上下文直接访问它(这是替代过去"手动 dispatch 到主线程 + 运行时才能发现的错误"的编译期解决方案)。


11. RunLoop 底层原理

11.1 RunLoop 是什么

RunLoop 本质是一个****基于 mach 端口和事件循环模型实现的"任务调度中心"** **,核心作用是:让线程在没有任务时休眠(不占用 CPU),有事件到来时被唤醒处理,处理完再休眠,从而让主线程能够一直"活着"响应用户交互而不是执行完 main() 就退出。

11.2 RunLoop 与线程的关系

  • 一个线程对应最多一个 RunLoop(CFRunLoopGetCurrent() 首次调用时才会为当前线程创建,惰性创建,不用不创建)。

  • 主线程的 RunLoop 在 App 启动时被系统自动创建并启动(UIApplicationMain 内部调起),这是 App 能一直保持响应的根本原因。

  • 子线程默认没有运行中的 RunLoop(默认执行完任务就退出),需要手动调用 run(或搭配 NSTimer/Port 添加事件源)才会进入循环保活。

11.3 RunLoop 内部结构:Mode(运行模式)

RunLoop 内部维护若干个 Mode,每个 Mode 内包含一组 **Source(输入源) **、**Timer(定时器) **、**Observer(观察者) **。RunLoop 每次运行只能指定跑在一个 Mode 下,不同 Mode 之间的事件是隔离的:

  • ****** **kCFRunLoopDefaultMode** **:App 平常大部分时间所在的默认模式。

  • ****** **UITrackingRunLoopMode** **:当用户拖动 UIScrollView(如滚动列表)时,主线程 RunLoop 会切换到这个模式,只处理和滚动相关的事件源,这也是为什么"默认 Mode 下注册的 NSTimer 在滚动列表时会暂停触发"这个经典面试题/坑点的根本原因——需要把 Timer 加进 NSRunLoopCommonModes 这个特殊的"Mode 集合标记",让它同时存在于多个 Mode 里才不受影响。

11.4 一次 RunLoop 循环的关键步骤(Observer 通知的六个时间点)


1. 即将进入 Loop(kCFRunLoopEntry)

2. 即将处理 Timer(kCFRunLoopBeforeTimers)

3. 即将处理 Source(kCFRunLoopBeforeSources)

4. 即将进入休眠(kCFRunLoopBeforeWaiting)  ← 大量框架在这里做延迟任务

5. 刚从休眠中被唤醒(kCFRunLoopAfterWaiting)

6. 即将退出 Loop(kCFRunLoopExit)

核心机制:RunLoop 在没有事件时会调用 mach_msg 让线程进入内核级别的休眠(真正的休眠,不占用 CPU 时间片),当有 Source0(需要手动触发的自定义事件,如 performSelector:onThread:)、Source1(基于 mach port,可以主动唤醒线程的系统事件,如触摸事件、网络回调)或 Timer 到期时,内核通过 mach port 消息唤醒该线程,RunLoop 恢复运行处理对应事件,处理完毕后再次尝试进入休眠。

11.5 RunLoop 的实际应用场景

  • ****** **NSTimer** / **CADisplayLink** 的实现基础**:都依赖 RunLoop 的 Timer 机制驱动周期性回调。

  • ****** **performSelector:withObject:afterDelay:** **:底层就是往当前线程 RunLoop 加一个 Timer,因此如果当前线程没有开启的 RunLoop(比如手动创建的子线程不 run),这个方法完全不会被触发。

  • ****** **AutoreleasePool** 的自动释放时机**:主线程的 @autoreleasepool 是由系统在 RunLoop 的每次循环(进入休眠前 kCFRunLoopBeforeWaiting、循环结束前)自动 push/pop 管理的,这也是为什么"哪怕不手动写 autoreleasepool,主线程的临时对象最终也会被释放"的原理,但在紧密循环里手动创建大量临时对象(比如 for 循环里反复创建图片)时,依然建议手动包一层 @autoreleasepool 提前释放,避免等到 RunLoop 下一次循环才释放导致的瞬时内存峰值。

  • 卡顿监测原理:很多性能监控 SDK 通过监听 RunLoop 的 kCFRunLoopBeforeSources/kCFRunLoopBeforeWaiting 等状态切换的耗时,如果某个状态之间的间隔超过阈值(比如 400ms 没有正常完成一次事件处理循环),推断主线程被长时间阻塞,从而实现"卡顿监控"。


12. App 启动流程与优化

12.1 启动流程分段


1. dyld 加载阶段(pre-main,进程创建到 main() 执行前)

   - 加载可执行文件本身

   - 加载所有依赖的动态库(Dylib),解析符号(Rebase/Bind)

   - 调用所有 Objective-C 类的 +load 方法

   - 调用 C++ 静态构造函数、attribute((constructor)) 标记的 C 函数

2. main() 函数执行

3. UIApplicationMain 内部初始化 RunLoop、AppDelegate、window

4. application:didFinishLaunchingWithOptions: 回调

5. 首屏渲染完成(真正对用户"可交互"的时间点)

12.2 pre-main 阶段耗时的关键因素

  • 动态库数量:每多加载一个动态库都要经历"打开文件 → 解析 Mach-O 头 → 符号绑定"的过程,第三方 SDK 集成越多(尤其是很多小的动态库而不是合并成静态库),pre-main 耗时越长,这也是很多性能优化文档建议尽量把内部多个 Framework 合并/尽量用静态库的原因之一。

  • ****** **+load** 方法数量与耗时**:所有类的 +load 会在 dyld 阶段被同步调用,如果某个 +load 里做了重活(比如 Swizzling 全量方法、初始化大对象),会直接拖慢启动。优化建议:尽量把初始化逻辑挪到 **+initialize** 或 **application:didFinishLaunchingWithOptions:** 里做懒加载,+load 只做最必要的注册。

  • Objective-C 类/Category 数量:dyld 需要在启动时做类的"修复(rebase/bind)"和 Category 合并,类数量越多这一步耗时越长(业内常用 Xcode 的 DYLD_PRINT_STATISTICS 环境变量测量 pre-main 各阶段耗时)。

12.3 首屏优化常见手段

  • 把非首屏必须的初始化逻辑(第三方 SDK 初始化、非核心数据预加载)延后到首屏渲染完成之后(比如用 dispatch_async 扔到主线程下一个 RunLoop 周期,或专门监听首屏渲染完成的信号后再触发)。

  • **二进制重排(Binary Reordering / Order File) **:dyld 加载可执行文件是按页(Page,通常 16KB)从磁盘读取,如果启动路径上会用到的函数在二进制文件里物理上分散在很多页,会触发更多次不必要的页面调入(Page Fault/IO)。通过 Link Map + 启动期符号调用顺序采集,生成 .order 文件指导链接器把"启动时会用到的函数"物理排列在一起,减少启动期需要读取的页数,是较高阶的启动优化手段。


13. 多线程与锁的底层原理

13.1 常见锁的性能与语义对比

| 锁类型 | 底层实现 | 是否可重入 | 特点 |

|---|---|---|---|

| os_unfair_lock | 内核级轻量互斥锁 | 否 | 替代已废弃的 OSSpinLock(自旋锁在优先级反转场景下有致命缺陷),当前苹果推荐的高性能锁 |

| pthread_mutex | POSIX 标准互斥锁 | 可配置 | 跨平台通用,功能全面(可配置递归、超时等) |

| NSLock | 对 pthread_mutex 的 OC 封装 | 否 | API 友好,性能接近底层 mutex |

| NSRecursiveLock | 递归版 pthread_mutex(PTHREAD_MUTEX_RECURSIVE) | 是 | 同一线程可重复加锁不死锁,适合递归函数场景 |

| @synchronized(obj) | 基于传入对象地址的一个全局哈希表管理的递归锁,运行时开销较大(要维护哈希表、支持异常安全的 try/finally 语义) | 是 | 写法最简单,性能相对最差,不推荐高频调用场景使用 |

| NSCondition/dispatch_semaphore | 条件变量/信号量,用于线程间协作等待而不仅是互斥 | - | dispatch_semaphore_wait/signal 常被用来把异步 API 强行"同步等待化",但要小心在主线程使用可能造成的卡顿/死锁 |

13.2 自旋锁 vs 互斥锁的本质区别

  • **自旋锁(Spin Lock) **:抢锁失败时不释放 CPU,原地"忙等"循环检查锁状态,适合"预期等待时间极短"的场景(避免线程切换的上下文切换开销),但如果等待时间较长会白白空转浪费 CPU。

  • **互斥锁(Mutex) **:抢锁失败时线程让出 CPU,进入休眠等待队列,被唤醒的开销(上下文切换)比自旋锁大,但不会无意义地空转 CPU。

  • OSSpinLock 被废弃的根本原因:在优先级反转场景下(低优先级线程持有锁自旋等待,系统认为它在"忙碌工作"给予较多 CPU 时间片,而真正需要锁的高优先级线程却因为自旋锁的实现方式无法被系统正确识别为"应该被优先调度"),可能导致高优先级线程长时间无法获得锁,苹果推荐迁移到 os_unfair_lock(等待方是真正休眠,能被系统优先级调度正确识别和处理)。

13.3 死锁的典型场景

  1. 主队列 sync 死锁(见 10.1)——最常见。

  2. 两个锁交叉加锁顺序不一致:线程 A 先锁 lock1 再锁 lock2,线程 B 先锁 lock2 再锁 lock1,两个线程各自持有一个锁等待对方释放另一个锁,永久互相等待。

  3. ****** **@synchronized** 嵌套但传入不同的对象却期望是同一把锁**(@synchronized 锁的粒度是"传入对象的地址",如果不小心传了不同对象,实际上没有起到互斥作用;如果传了同一个对象但在没有意识到是同一把锁的情况下重复进入且不支持重入逻辑处理不当,也可能造成逻辑混乱)。


14. 常见崩溃与内存问题分析方法

14.1 常见崩溃类型

| 崩溃类型 | 典型原因 | 排查思路 |

|---|---|---|

| EXC_BAD_ACCESS (SIGSEGV) | 访问已释放对象(野指针)、访问越界内存 | 开启 Address Sanitizer / Zombie Objects 调试,定位具体访问的野指针来源 |

| EXC_BAD_INSTRUCTION (SIGILL/SIGTRAP) | 强制解包 nil 的 Optional、数组下标越界(Swift 运行时主动 trap) | 直接看崩溃堆栈通常能精确定位到具体代码行 |

| unrecognized selector sent to instance | 消息转发三步都没处理成功,通常是拼写错误的 selector、对象类型被意外替换 | 检查 doesNotRecognizeSelector 前的调用栈,确认实际的 self 类型是否符合预期 |

| EXC_CRASH(SIGABRT,比如强制 unwrap 失败/断言失败) | Swift 的 ! 强制解包、try!、precondition/assert 失败 | 优先检查触发行附近的可选值来源逻辑 |

14.2 野生对象(Zombie)调试

开启 Xcode Scheme 的 ** "Malloc Scribble" / "Zombie Objects" ** 选项后,对象被 release 到引用计数为 0 时不会真的释放内存,而是把该内存转换为一个"僵尸对象"标记,继续访问已释放对象会明确抛出 message sent to deallocated instance 的可读崩溃信息(而不是难以定位的 EXC_BAD_ACCESS),是排查"野指针/悬垂指针访问"问题的第一选择。

14.3 内存泄漏排查思路

  • Xcode Memory Graph Debugger:运行时直接查看对象引用关系图,能直观发现"本应该释放却因为循环引用还活着"的对象,图中会用紫色感叹号提示疑似的循环引用/泄漏对象。

  • Instruments 的 Leaks / Allocations 工具:Leaks 侦测"标准意义上的内存泄漏"(完全没有任何指针能再访问到、但没被释放的内存,纯 ARC 循环引用往往查不出这一类,因为循环引用的对象理论上依然"可达");Allocations 更适合排查"内存持续增长但没到传统泄漏定义"的场景(比如缓存无限增长、大对象没有及时释放)。

  • 常见泄漏根因:闭包/Block 循环引用、NSTimer/CADisplayLink 强引用 target 未在合适时机 invalidate、NotificationCenter 添加了 observer 却没有 removeObserver(虽然现代系统 API 在 observer 被释放时通常也会自动清理,但显式 viewDidLoad 添加 + deinit 移除依然是更安全的习惯)、委托属性用了 strong 而不是 weak。


15. 常见高频面试题 Q&A 速查

**Q1:为什么给 nil 发消息不会崩溃? **

A:objc_msgSend 在真正查找方法之前会先判断接收者是否为 nil,如果是则直接返回一个"零值"(指针类型返回 nil,数值类型返回 0,结构体返回内存置零的结构体),不会走到方法查找/调用逻辑。

**Q2:ARC 是编译期特性还是运行时特性? **

A:编译期特性。ARC 由 Clang 在编译阶段依据一套确定的内存管理规则自动插入 retain/release/autorelease 调用,运行时的内存管理机制和 MRC 手写没有本质区别,只是开发者不需要手写。

**Q3:Category 能不能添加实例变量?为什么? **

A:不能。类的内存布局在编译期已经固定,Category 是运行时把方法/属性/协议合并进已有类的方法列表,不能改变对象实例的内存大小。如果需要给已有类"动态挂"数据,要用 Associated Object。

**Q4:KVO 的底层实现原理是什么? **

A:Runtime 在运行时动态创建一个原类的子类(NSKVONotifying_XXX),把被观察对象的 isa 指针指向这个新子类,并在子类里重写被观察属性的 setter 方法,在真正赋值前后调用 willChangeValueForKey:/didChangeValueForKey: 触发通知回调。

**Q5:Swift 的 Array/Dictionary 是值类型,为什么赋值不会立刻发生大量数据拷贝? **

A:因为它们底层用写时复制(COW)实现——赋值只是让新变量指向同一份堆上的底层存储 Buffer(引用计数+1),只有当真正尝试修改内容且发现该 Buffer 被多方共享(引用计数>1)时,才会触发真正的数据拷贝,否则直接原地修改。

**Q6:async/await 和 GCD 在底层调度上有什么本质区别? **

A:GCD 依赖系统线程池,高并发下有可能出现"线程数量随任务数量增长"的风险;Swift 并发模型采用协作式调度,维护一个数量相对固定的协作线程池,await 挂起时不阻塞线程,而是保存执行状态、释放线程去做别的任务,完成后重新调度继续执行,更适合大规模并发异步任务场景。

**Q7:actor 是如何在编译期防止数据竞争的? **

A:actor 内部的可变状态只能被自身方法访问,外部代码对其状态的访问被编译器强制要求走 await 异步路径,实际上等价于把所有外部访问自动串行化排队,同一时刻只有一个任务真正执行该 actor 的内部逻辑,从根本上避免了多线程同时读写同一状态的竞争问题,且这个约束是在编译期通过类型系统静态检查的。

**Q8:为什么在 UIScrollView 滚动时,主线程 NSTimer 会暂停触发? **

A:因为滚动时主线程 RunLoop 切换到了 UITrackingRunLoopMode,而普通 NSTimer 默认只加入了 kCFRunLoopDefaultMode,两个 Mode 互相隔离,滚动期间 RunLoop 根本没有运行在 Default Mode 下,Timer 自然不会被触发;需要把 Timer 加入 NSRunLoopCommonModes 才能不受 Mode 切换影响。

**Q9:为什么建议把很多逻辑挪出 **+load** 方法? **

A:所有类的 +load 是在 App 启动阶段的 dyld pre-main 阶段被同步调用的,早于 main() 函数执行,如果里面做了耗时操作会直接拖慢应用启动时间;建议只在 +load 里做最必要的注册(比如 Method Swizzling 的注册),重逻辑挪到懒加载或 application:didFinishLaunchingWithOptions: 之后执行。

**Q10: **weak** 和 **unowned** (Swift)该怎么选? **

A:如果被引用对象的生命周期可能先于当前对象结束、且访问时需要判断是否还存在,用 weak(自动置 nil,Optional 类型,访问安全但有一定运行时开销);如果业务逻辑上能保证被引用对象一定与当前对象同生共死(比如互相持有、生命周期强绑定),用 unowned(非 Optional,访问性能更高,如果对象已释放会直接崩溃,是刻意的"快速失败"设计)。


附:学习与验证建议

  • 想验证 Runtime 消息机制/内存布局细节,直接用 class-dump、MachOView 反编译系统库/自己的二进制,或者用 LLDB 打印 po object_getClass(obj)、p/x *(long*)obj(打印对象内存首字,观察 isa/类型元数据指针)做实测。

  • 想直观看 COW/引用计数变化,可以在 Swift 里用 withUnsafePointer/CFGetRetainCount 或者简单打印 Unmanaged.passUnretained(obj).toOpaque() 观察地址是否变化来验证是否真的发生了拷贝。

  • 想验证 RunLoop Mode 切换,用 CFRunLoopObserver 打日志观察真实设备/模拟器在滚动列表时 Mode 的切换情况。

  • 版本相关的具体语法(~Copyable、any/some 引入的确切 Swift 版本号等)建议以 Apple 官方 Swift Evolution 提案和 developer.apple.com 发布说明为准做最终核对。