整理自 忆江南的博客(CSDN),并结合 objc4 源码、WWDC 资料做了补充与校对。 适合用作 iOS 中高级面试复习提纲:每个题目先给"一句话答案",再展开底层原理。
目录
- 一、内存管理:weak 原理 / 关联对象 / atomic / AutoreleasePool
- 二、App 启动与性能优化:启动流程 / dyld / Page Fault 与二进制重排 / +load
- 三、Runtime 与语言特性:Block / Category / 消息转发 / attribute
- 四、UI 与事件:渲染与卡顿 / 事件传递与响应链
- 五、多线程:RunLoop 常驻线程 / GCD 经验
- 六、架构与设计模式:组件化路由 / 依赖倒置与 AOP / AppDelegate 瘦身
- 七、网络:HTTPS / 请求取消
- 八、数据结构:HashMap
一、内存管理
1. weak 的实现原理
一句话答案:Runtime 用一张全局的 weak 哈希表记录"对象 → 所有指向它的 weak 指针",对象 dealloc 时把这些指针统一置 nil,从而保证 weak 指针不会变成野指针。
底层数据结构(objc4):
SideTables(StripedMap,全局 64 张,按对象地址 hash 分桶)
└── SideTable
├── spinlock_t slock // 自旋锁(分离锁 / lock striping)
├── RefcountMap refcnts // 引用计数表(isa 溢出时使用)
└── weak_table_t weak_table // 弱引用表
└── weak_entry_t // key=对象地址,value=指向该对象的所有 weak 指针的地址
SideTables是一个StripedMap,内部有 64 个SideTable,用对象地址做 hash 取桶。这样设计是为了 分离锁(lock striping):操作 bucket 1 不会阻塞 bucket 2,提高并发性能。weak_entry_t内部存的是指向该对象的所有 weak 指针的"指针的指针"(objc_object **)。当指针数量 ≤ 4 时用定长数组,超过后升级为 hash 表,避免遍历开销。
三个关键阶段:
- 初始化:
__weak id obj = value;编译器转为objc_initWeak(&obj, value)。 - 添加引用:
objc_initWeak→objc_storeWeak()→weak_register_no_lock(),把 weak 指针的地址登记到对象对应的weak_entry_t。 - 释放:对象 dealloc 时最终调用
objc_clear_deallocating→weak_clear_no_lock,取出该对象所有 weak 指针地址,逐个置nil,再把entry从weak_table删除。
高频追问:
- 为什么能自动置 nil? 表里存的是"指针的指针",dealloc 时遍历并
*referrer = nil。 - 读 weak 有性能损耗吗? 有。读取走
objc_loadWeakRetained,需要加锁查表,热点路径频繁读 weak 比读 strong 慢。 - weak vs assign?
assign不置 nil,释放后是野指针,可修饰基本类型;weak自动置 nil,只能修饰对象。
2. 关联对象的存储结构与线程安全
一句话答案:关联对象不存在对象本身的内存里,而是存在全局单例 AssociationsManager 持有的 AssociationsHashMap 中,因此是线程安全的。
存储结构(objc-references.mm):
AssociationsManager(全局锁 + 唯一 hashmap)
└── AssociationsHashMap // key = DISGUISE(对象指针)
└── ObjectAssociationMap // key = 关联的 key(void *)
└── ObjcAssociation // policy(内存策略) + value
要点:
obj.name = nil等价于"移除该 key 的关联对象"(value 为 nil 时执行 erase)。- 内存管理策略(
OBJC_ASSOCIATION_RETAIN_NONATOMIC等)由policy字段决定。 - 线程安全:是。读写都经过
AssociationsManager内同一把锁串行化。 - 随对象销毁释放吗:会。对象 dealloc 时若
isa标记了has_assoc,调用_object_remove_assocations清理。 - 典型用途:给 Category 添加"属性"(Category 不能直接添加实例变量,因为类的内存布局编译期已定)。
3. atomic / nonatomic 与原子性
一句话答案:atomic 只保证单次 setter/getter 的原子性(内部加锁),不保证整体线程安全;nonatomic 不加锁、性能更高,是日常默认选择。
原子性的本质:
- 原子性 = 操作在 CPU 执行中不被中断。问题源头是 线程切换,而线程切换依赖 CPU 中断。
- 只要读写的内存长度 ≤ 地址总线宽度,读写本身就是原子的(一次总线事务完成)。
bool占 1 字节,读写天然原子。long(64 位)在 32 位 CPU 上,写会被拆成"写高 32 位 + 写低 32 位"两次,可能被打断,非原子;64 位机器无此问题。
为什么大家都写 nonatomic:
atomic只保护单次 set/get,对"先读再改再写"复合操作仍不安全,业务层该加锁还得加锁,保证"鸡肋"。atomic每次访问都加解锁,属性访问极频繁,开销大,故默认nonatomic。
4. AutoreleasePool 原理及与 RunLoop 的关系
一句话答案:@autoreleasepool 由 AutoreleasePoolPage(双向链表 + 栈)实现;主线程通过在 RunLoop 注册 Observer,在循环开始 push、休眠/结束时 pop,自动管理 autorelease 对象。
实现:
@autoreleasepool {}编译为objc_autoreleasePoolPush()与objc_autoreleasePoolPop()。- 底层是以页(
AutoreleasePoolPage,约 4KB)为节点的双向链表,对象autorelease即压入当前 page 栈。push插入哨兵POOL_BOUNDARY,pop一路向上release到上一个哨兵。
与 RunLoop 的关系(主线程):
- 主线程 RunLoop 注册了两个相关 Observer:
- 优先级最高的监听
Entry(即将进入 RunLoop),调用 push 创建释放池。 - 优先级最低的监听
BeforeWaiting(准备休眠)与Exit,休眠前 pop 旧池并 push 新池,退出时 pop。
- 优先级最高的监听
- 因此一次迭代中产生的 autorelease 对象,会在本次循环休眠前被释放。
高频追问:
- 何时手动加 @autoreleasepool?
for循环里创建大量临时对象时,手动包一层及时释放、降低内存峰值。 - 子线程有释放池吗? 默认没开 RunLoop,但首次 autorelease 时系统自动创建 pool,线程退出时销毁。
二、App 启动与性能优化
5. App 启动流程(main 之前发生了什么)
- 解析 Info.plist:加载闪屏、建立沙箱、权限检查。
- 加载 Mach-O:内核
fork进程并映射可执行文件,识别为动态链接,加载dyld并交出控制权。 - dyld 装载(pre-main):
- 加载动态库:递归加载依赖;一个 App 通常依赖 100~400 个库,多数是系统库,已被
dyld shared cache缓存。 - Rebase(偏移修正):ASLR 随机化了基地址,需把镜像内部指针都加上随机偏移。数据取自
__LINKEDIT,修正后写入__DATA。解决"内部指针"。 - Binding(符号绑定):把引用外部 dylib 的符号(如
NSLog)指向真实地址。解决"外部指针"。一句话:Binding 就是给符号赋值。 - ObjC setup:注册类、把 Category 方法插入方法列表、selector 去重等。
- Initializers:执行
+load、C++ 静态构造(__attribute__((constructor)))、全局对象初始化。
- 加载动态库:递归加载依赖;一个 App 通常依赖 100~400 个库,多数是系统库,已被
main()→UIApplicationMain→didFinishLaunching。
优化方向:减少/合并动态库;减少 +load,能延后的初始化挪到首屏后或用 +initialize 懒加载;减少 C++ 静态初始化;二进制重排减少 Page Fault;首屏只做必要工作,三方 SDK 延迟初始化。
6. dyld、dyld2 与 dyld3
dyld 是什么:动态链接器,是 in-process 的辅助程序——启动时被加载进进程地址空间,再接管后续启动流程。
- dyld2(iOS 3.1 ~ iOS 12):纯 in-process,每次启动都要解析 header、找依赖、map、rebase/binding、符号查找、运行 initializer。
- 关键优化 dyld shared cache:把 UIKit 等系统库合并成一个预链接的大文件,映射进所有进程地址空间,提升加载性能、节省内存。
- dyld3(iOS 13 起默认):把可缓存的耗时阶段(解析 Mach-O、找依赖、符号查找)挪到 out-of-process,在安装/系统升级时执行,结果以 启动闭包(launch closure) 写入磁盘。运行时只需小型 in-process 引擎:校验闭包 → map dylib → fixup → 运行 initializer → 跳
main(),显著加快启动。系统 App 的闭包内建于 shared cache。
7. Page Fault 与二进制重排
Page Fault:虚拟内存机制下,App 启动不会一次性把数据载入内存,而是 以页为单位按需从磁盘加载。CPU 访问的内容不在物理内存时,触发一次 page fault 去磁盘加载新页(还伴随签名校验开销)。
为什么要二进制重排:
- 启动具有 局部性特征:只有少部分函数在启动时被调用,但它们在 Mach-O 中的位置编译期确定且零散分布。
- 若启动用到的 10 个方法恰好分散在 10 个不同页,就要约 10 次 page fault,Page In 数据利用率低。
做法:把启动用到的符号集中排布到二进制连续区间。链接器 ld 提供 -order_file,按 .order 文件中符号顺序排列。启动用到的函数集中在少数几页,page fault 次数大降。
抖音用"基于二进制文件重排"把启动速度提升超 15%。获取启动符号顺序常用 Clang 插桩
__sanitizer_cov_trace_pc_guard收集调用顺序。
8. +load 与 +initialize、constructor 的区别
调用时机(由早到晚):+load → C++ constructor → main() → +initialize(首次用到该类时)。
- +load:
- 在
main()之前、由 dyld 加载 image 时调用:每加载一个类就调用其+load,全部类加载完后才调用该 image 的constructor,所以 +load 比 constructor 更早。 - 不走
objc_msgSend,直接取函数指针调用;父类 → 子类 → 分类 都会各自调用,不存在覆盖。 - 因为"所有类都已加载完成",是做 Method Swizzle、埋点 hook 等"干坏事"的绝佳时机,但会拖慢启动,应尽量少用。
- 在
- +initialize:
- 懒加载,第一次给该类发消息时才调用,走
objc_msgSend。 - 若子类未实现,会调用父类的
+initialize(可能被调用多次),需用if (self == [XXX class])保护。
- 懒加载,第一次给该类发消息时才调用,走
- constructor:C/C++ 的
__attribute__((constructor))函数,在 dyld 完成 image 的所有+load后、main()前执行。
三、Runtime 与语言特性
9. Block 的本质与变量捕获
一句话答案:Block 本质是一个带有 isa 指针的结构体对象(__block_impl + 捕获变量),可以捕获自动变量;用 __block 修饰可在 block 内修改外部变量。
捕获规则:
- 普通局部变量(值捕获):捕获的是值的副本。block 内部重新定义了一个同名变量存这个值,因此 block 内外是两个不同变量,外部修改不影响内部,内部也改不了外部(编译报错)。
int a = 10; void (^blk)(void) = ^{ NSLog(@"%d", a); }; // 捕获的是 10 这个值 a = 20; blk(); // 仍输出 10 __block变量(地址捕获):变量被包装成结构体__Block_byref_x,block 捕获的是这个结构体的 地址,内部通过地址读写,因此 内外是同一份,互相影响。- 静态变量 / 全局变量:以地址访问,可直接修改。
Block 的三种类型:
__NSGlobalBlock__:未捕获自动变量,存全局区。__NSStackBlock__:捕获了自动变量,存栈区(ARC 下常被自动 copy 到堆)。__NSMallocBlock__:栈 block 被 copy 到堆区,可跨作用域持有。
循环引用:block 被对象持有、block 又捕获 self(强引用)→ 循环引用。用 __weak(必要时配 __strong 防提前释放)打破。
10. Category 的实现原理
一句话答案:Category 在编译期生成 category_t 结构体(含方法、属性、协议列表),运行期由 dyld 调用 attachCategories 把这些内容 追加合并 到宿主类的 class_rw_t 中。
要点:
- 加载时机:在
+load之前,运行时通过realizeClass→methodizeClass→attachLists把分类方法插入类的方法列表。 - 方法"覆盖"的本质:分类方法被插到原方法列表 前面,
objc_msgSend查找时先命中分类方法,造成"覆盖"假象,原方法仍在。多个分类间的覆盖顺序取决于编译顺序(后编译的在更前)。 - 不能添加实例变量:类的
instanceSize/ivar 布局在编译期就已确定,运行期 attach 无法改变内存布局;需要"属性"时只能借助 关联对象。 - Category vs Extension(类扩展):Extension 在编译期就是类的一部分,可加 ivar、私有方法;Category 是运行期追加。
11. 消息转发与"多继承"
一句话答案:OC 不支持多继承,但可通过 消息转发 把无法处理的消息转交给其他对象,间接达到"多继承"效果。
消息发送 & 三次补救机会:
- 消息发送:
objc_msgSend(receiver, selector, ...)——receiver是消息接收者,selector是要调用的方法。沿isa→ superclass 链查 IMP,找到就调用。 - 找不到则进入转发,依次给三次机会:
- 动态方法解析
+resolveInstanceMethod::运行期用class_addMethod动态加方法。 - 快速转发
-forwardingTarget:for::返回一个备援接收者,把消息整体转给它(开销小,常用于"组合实现多继承")。 - 完整转发
-methodSignatureForSelector:+-forwardInvocation::把消息打包成NSInvocation,可任意修改 target/selector/参数,灵活但开销大。
- 动态方法解析
- 都不处理 →
doesNotRecognizeSelector:抛unrecognized selectorcrash。
间接实现多继承的手段:消息转发、delegate/protocol、Category、组合。
12. attribute 的理解
一句话答案:__attribute__ 是编译器给符号关联的属性标记,编译器/链接器读到标记后产生特定行为——本质是"让编译器帮你生成代码或改变链接行为"。
常见用法:
__attribute__((constructor))/(destructor):链接器把函数加入mod_init_funcs,使其在main()前/后自动执行。+load用的就是 constructor 机制。availability:编译器据此给出版本兼容警告。always_inline/noinline:控制内联。cleanup:变量离开作用域时自动调用清理函数(@weakify/@strongify、defer 类库的实现基础)。objc_subclassing_restricted:禁止被继承。unavailable、deprecated、overloadable等。
执行顺序上
+load优先于 attribute 指定的 constructor(同一 image 内,类的+load在 image 所有 constructor 之前由 dyld 触发)。
四、UI 与事件
13. UI 渲染原理与卡顿成因
渲染流程:
- VSync 信号 到来后,系统图形服务通过 CADisplayLink 等机制通知 App。
- CPU 阶段:主线程计算显示内容——视图创建、布局计算(Layout)、图片解码、文本绘制(Draw)。
- GPU 阶段:CPU 把内容提交给 GPU,GPU 做变换、合成、渲染。
- GPU 把渲染结果写入 帧缓冲区(frame buffer),等下一个 VSync 到来时显示到屏幕。
卡顿成因(掉帧):由于垂直同步机制,若在一个 VSync 周期内 CPU 或 GPU 没能完成内容提交,这一帧就会被丢弃、等待下一次机会,表现为掉帧/卡顿。
优化方向:
- CPU 侧:减少视图层级与离屏计算;提前在子线程做文本/图片的预排版、预解码;用
CALayer轻量绘制;缓存 cell 高度;避免主线程阻塞。 - GPU 侧:减少离屏渲染(圆角 +
masksToBounds、阴影、shouldRasterize、mask);减少透明视图混合(blending);控制纹理尺寸不超过 GPU 上限;避免大量 layer 同时动画。
14. 事件传递与响应链(hitTest / pointInside)
两个阶段:
- 寻找第一响应者(hit-test,自上而下):
- 触摸事件由
UIApplication→UIWindow开始,递归调用hitTest:withEvent:。 hitTest:内部先用pointInside:withEvent:判断触点是否在自身范围内:- 不在范围、
hidden、alpha < 0.01、userInteractionEnabled == NO→ 返回 nil,整棵子树被跳过。 - 在范围内 → 倒序遍历 subviews(后添加的在上层,优先命中),对每个子视图递归
hitTest:;子视图返回非 nil 则用它,否则返回自己。
- 不在范围、
- 最终返回的 view 即为 first responder。
- 触摸事件由
- 事件响应(响应链,自下而上):
- 若 first responder 不处理(未重写
touchesBegan:等或调用super),事件沿响应链向上传递:view → superview → ... → ViewController → window → application → 丢弃。
- 若 first responder 不处理(未重写
常见应用:
- 扩大按钮点击区域 / 让超出父视图的子视图可点击:重写
pointInside:或hitTest:。 - 事件穿透:让某层视图不拦截事件,传给下层。
pointInside:withEvent:返回触点是否在视图内部;hitTest:withEvent:返回最终响应触摸的视图。
五、多线程
15. RunLoop 常驻线程与 GCD global 队列的坑
为什么需要常驻线程:线程执行完任务默认会退出。若想让一个子线程长期存活以处理零散任务(如 AFNetworking 2.x 的网络回调线程),需要给它开启 RunLoop 并加入 Source/Timer 维持运行。
避免用 GCD global 队列创建常驻线程:
- GCD 的全局并发队列由系统线程池管理,线程的创建/复用不可控;在其上跑
[[NSRunLoop currentRunLoop] run]这种"永不返回"的耗时操作,会 长期占用线程池线程,导致线程池被耗尽(线程爆炸),其它dispatch_async(global)的任务被饿死或延迟。 - 正确做法:用
NSThread/pthread显式创建线程,自己控制其生命周期与 RunLoop,而不是借用全局队列。
单一 RunLoop 常驻线程:用一个专门的 NSThread + 自定义 RunLoop,按需 perform...onThread: 派发任务,是较稳妥的方案。
16. GCD 使用经验
dispatch_once_t必须是全局或 static 变量:非全局/非 static 的onceToken会导致单例失效或诡异、难排查的 bug(每次进函数 token 都是新的,block 会重复执行)。- 死锁:在串行队列里同步派发到当前队列(
dispatch_sync(当前串行队列))必然死锁;主线程上dispatch_sync(dispatch_get_main_queue())同理。 - 栅栏
dispatch_barrier_async:在自定义并发队列上实现"多读单写"(读并发、写独占)。 dispatch_group:聚合多个异步任务,全部完成后notify回调。dispatch_semaphore:控制并发数 / 把异步转同步 / 加锁。- QoS:合理设置队列优先级,避免优先级反转。
六、架构与设计模式
17. 组件化路由:CTMediator / Class-Protocol Router
为什么要路由:组件化后各业务模块要解耦,不能互相 #import 对方的类。路由层让模块间"无依赖通信"。常见三类方案:URL 注册表(如 MGJRouter)、Protocol-Class 映射(如 BeeHive)、Target-Action(CTMediator)。
CTMediator(Target-Action,中介者模式):
- 核心思想:A 不直接依赖 B,而是 A ↔ CTMediator ↔ B。
- 实现关键:CTMediator 用
NSInvocation+ Runtime 完成消息触发/转发。流程:- 调用方把"目标名 + Action 名 + 参数字典"传给 CTMediator。
- CTMediator 用
NSClassFromString拼出Target_xxx类、NSSelectorFromString拼出Action_xxx:方法。 - 通过
NSInvocation动态调用并取返回值。
- 每个业务模块提供一个
Target_xxx+ 一个对外的CTMediator+xxx分类(封装拼字符串细节),调用方只依赖分类,不依赖具体业务类,从而 零耦合、无依赖引入。 - 优点:完全解耦、可处理模块间通信不止页面跳转。缺点:硬编码字符串、参数用字典弱类型、不易在编译期发现错误。
Class-Protocol Router(协议-类映射):
- 定义协议
ProtocolB(声明 B 创建所需参数,如setBViewControllerName:age:),让BViewController实现它。 - 用一张
protocol → class的注册表,调用方面向协议拿到实例并调用,避免直接依赖具体类。 - 优点:参数 强类型,编译期有提示;缺点:需要维护协议与注册关系。
18. 依赖倒置与 AOP
依赖倒置原则(DIP):
- 定义:高层模块不应依赖底层模块,两者都应依赖抽象;抽象不应依赖细节,细节应依赖抽象。
- 痛点:分层开发中上层依赖下层,下层一变上层就得改,复用性差、成本高。
- 做法:让上下层都依赖接口/协议(抽象一般很少变),通过接口注入具体实现,降低类间耦合。是路由、依赖注入的理论基础。
面向切面编程(AOP):
- 面向对象(继承、多态、封装)要求把功能分散到各对象中;但 日志、埋点、鉴权、性能监控 这类横切关注点会散落在各处、重复且耦合。
- AOP 把这些横切逻辑抽到"切面",在不修改原有业务代码的前提下,于方法调用前后"织入"逻辑。
- iOS 实现:Method Swizzling(如 Aspects 库基于消息转发
forwardInvocation:实现 hook)。典型场景:无侵入埋点统计。
19. AppDelegate 瘦身
问题:didFinishLaunchingWithOptions: 里堆满了各模块的初始化代码,臃肿且耦合。
思路:
- 模块自注册 + 事件分发:把 AppDelegate 的生命周期方法(
didFinishLaunching、applicationDidEnterBackground等)作为"事件",让各模块声明自己关心哪些事件并自行注册,由一个分发器在对应时机遍历调用。各模块的初始化代码搬回各自模块内部,AppDelegate 只剩分发逻辑。 - 实现方式:协议 + 模块列表(plist/注册表),或基于
+load自动注册(如 BeeHive 的 ModuleManager)。 - 收益:AppDelegate 解耦、各模块初始化内聚、便于按需裁剪与维护。
七、网络
20. HTTPS 加解密原理
HTTPS 解决什么:HTTP 明文传输,存在窃听、篡改、冒充三大风险。HTTPS = HTTP + TLS/SSL,保证 机密性、完整性、身份认证。
对称 vs 非对称加密:
- 对称加密(AES):加解密同一把密钥,速度快,但密钥如何安全分发是难题。
- 非对称加密(RSA/ECC):公钥加密、私钥解密,解决密钥分发,但运算慢。
- HTTPS 的组合策略:用非对称加密在握手阶段 安全协商出一把对称密钥,之后通信用对称加密——兼顾安全与性能。
CA 与根证书解决什么:防中间人冒充。
- 服务器把公钥交给 CA 机构,CA 用自己的私钥对"服务器公钥 + 域名等信息"做数字签名,生成 证书。
- 客户端用内置的 根证书(CA 公钥) 验证证书签名是否可信、域名是否匹配、是否过期,确认"这个公钥确实属于该网站"。
- 根证书预装在操作系统/浏览器中,是信任链的起点。
简化握手流程:Client Hello(带随机数、支持的算法)→ Server Hello + 证书(含公钥)→ 客户端验证证书、生成 pre-master 用公钥加密发回 → 双方用三个随机数算出对称会话密钥 → 之后对称加密通信。
21. Controller 退出后是否取消网络请求
结论:视数据用途而定,不能一刀切。
- 应取消:请求的数据只服务于当前已退出的 Controller(如详情页加载内容)。不取消会:浪费流量/电量、回调时 Controller 已释放可能触发逻辑错误、占用连接资源。很多人因偷懒忘了取消。
- 不应取消:请求会产生 副作用或全局价值 的,例如下单、上报、写入缓存、数据对全局有用——即使页面退出也应让它完成。
工程实践:把请求生命周期绑定到 Controller(如随 dealloc cancel、或用 RxSwift disposeBag/Combine cancellable);对幂等且有缓存价值的请求允许其完成并缓存结果。
八、数据结构
22. HashMap 的实现
结构:HashMap 内部是一个数组(桶 bucket),每个桶里存 双向链表或红黑树。
- 存值:用 hash 函数算出索引 → 定位桶 → 加到该桶的链表上。若链表过长或元素过多,会 扩容,或把链表 转为红黑树(Java 8 阈值为 8)以把查找从 O(n) 降到 O(log n)。
- 取值:hash 算索引 → 找到桶 → 用 key 逐个比较找到对应 value。
- 复杂度:理想下增删查均摊 O(1)(因元素过多会扩容、控制负载因子)。
对比二分查找:二分查找要求元素可随机访问,必须存在连续内存(数组),查找 O(log n) 很快,但插入/删除为保持有序需移动元素,成本高。HashMap 用空间换时间、不要求有序,更适合频繁增删的键值场景。
以上题目均源自 忆江南的博客(CSDN) 的文章主题,答案在原文基础上结合 objc4 源码、Apple WWDC(《Optimizing App Startup Time》)等资料整理校对,便于面试复习。