整理自 忆江南的博客(CSDN)

31 阅读20分钟

整理自 忆江南的博客(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 表,避免遍历开销。

三个关键阶段

  1. 初始化__weak id obj = value; 编译器转为 objc_initWeak(&obj, value)
  2. 添加引用objc_initWeakobjc_storeWeak()weak_register_no_lock(),把 weak 指针的地址登记到对象对应的 weak_entry_t
  3. 释放:对象 dealloc 时最终调用 objc_clear_deallocatingweak_clear_no_lock,取出该对象所有 weak 指针地址,逐个置 nil,再把 entryweak_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 的关系

一句话答案@autoreleasepoolAutoreleasePoolPage(双向链表 + 栈)实现;主线程通过在 RunLoop 注册 Observer,在循环开始 push、休眠/结束时 pop,自动管理 autorelease 对象。

实现

  • @autoreleasepool {} 编译为 objc_autoreleasePoolPush()objc_autoreleasePoolPop()
  • 底层是以页(AutoreleasePoolPage,约 4KB)为节点的双向链表,对象 autorelease 即压入当前 page 栈。push 插入哨兵 POOL_BOUNDARYpop 一路向上 release 到上一个哨兵。

与 RunLoop 的关系(主线程)

  • 主线程 RunLoop 注册了两个相关 Observer:
    • 优先级最高的监听 Entry(即将进入 RunLoop),调用 push 创建释放池。
    • 优先级最低的监听 BeforeWaiting(准备休眠)与 Exit,休眠前 pop 旧池并 push 新池,退出时 pop。
  • 因此一次迭代中产生的 autorelease 对象,会在本次循环休眠前被释放。

高频追问

  • 何时手动加 @autoreleasepool? for 循环里创建大量临时对象时,手动包一层及时释放、降低内存峰值。
  • 子线程有释放池吗? 默认没开 RunLoop,但首次 autorelease 时系统自动创建 pool,线程退出时销毁。

二、App 启动与性能优化

5. App 启动流程(main 之前发生了什么)

  1. 解析 Info.plist:加载闪屏、建立沙箱、权限检查。
  2. 加载 Mach-O:内核 fork 进程并映射可执行文件,识别为动态链接,加载 dyld 并交出控制权。
  3. 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)))、全局对象初始化。
  4. main()UIApplicationMaindidFinishLaunching

优化方向:减少/合并动态库;减少 +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++ constructormain()+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 之前,运行时通过 realizeClassmethodizeClassattachLists 把分类方法插入类的方法列表。
  • 方法"覆盖"的本质:分类方法被插到原方法列表 前面objc_msgSend 查找时先命中分类方法,造成"覆盖"假象,原方法仍在。多个分类间的覆盖顺序取决于编译顺序(后编译的在更前)。
  • 不能添加实例变量:类的 instanceSize/ivar 布局在编译期就已确定,运行期 attach 无法改变内存布局;需要"属性"时只能借助 关联对象
  • Category vs Extension(类扩展):Extension 在编译期就是类的一部分,可加 ivar、私有方法;Category 是运行期追加。

11. 消息转发与"多继承"

一句话答案:OC 不支持多继承,但可通过 消息转发 把无法处理的消息转交给其他对象,间接达到"多继承"效果。

消息发送 & 三次补救机会

  1. 消息发送objc_msgSend(receiver, selector, ...)——receiver 是消息接收者,selector 是要调用的方法。沿 isa → superclass 链查 IMP,找到就调用。
  2. 找不到则进入转发,依次给三次机会:
    • 动态方法解析 +resolveInstanceMethod::运行期用 class_addMethod 动态加方法。
    • 快速转发 -forwardingTarget:for::返回一个备援接收者,把消息整体转给它(开销小,常用于"组合实现多继承")。
    • 完整转发 -methodSignatureForSelector: + -forwardInvocation::把消息打包成 NSInvocation,可任意修改 target/selector/参数,灵活但开销大。
  3. 都不处理 → doesNotRecognizeSelector:unrecognized selector crash。

间接实现多继承的手段:消息转发、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:禁止被继承。
  • unavailabledeprecatedoverloadable 等。

执行顺序上 +load 优先于 attribute 指定的 constructor(同一 image 内,类的 +load 在 image 所有 constructor 之前由 dyld 触发)。


四、UI 与事件

13. UI 渲染原理与卡顿成因

渲染流程

  1. VSync 信号 到来后,系统图形服务通过 CADisplayLink 等机制通知 App。
  2. CPU 阶段:主线程计算显示内容——视图创建、布局计算(Layout)、图片解码、文本绘制(Draw)。
  3. GPU 阶段:CPU 把内容提交给 GPU,GPU 做变换、合成、渲染。
  4. GPU 把渲染结果写入 帧缓冲区(frame buffer),等下一个 VSync 到来时显示到屏幕。

卡顿成因(掉帧):由于垂直同步机制,若在一个 VSync 周期内 CPU 或 GPU 没能完成内容提交,这一帧就会被丢弃、等待下一次机会,表现为掉帧/卡顿。

优化方向

  • CPU 侧:减少视图层级与离屏计算;提前在子线程做文本/图片的预排版、预解码;用 CALayer 轻量绘制;缓存 cell 高度;避免主线程阻塞。
  • GPU 侧:减少离屏渲染(圆角 + masksToBounds、阴影、shouldRasterizemask);减少透明视图混合(blending);控制纹理尺寸不超过 GPU 上限;避免大量 layer 同时动画。

14. 事件传递与响应链(hitTest / pointInside)

两个阶段

  1. 寻找第一响应者(hit-test,自上而下)
    • 触摸事件由 UIApplicationUIWindow 开始,递归调用 hitTest:withEvent:
    • hitTest: 内部先用 pointInside:withEvent: 判断触点是否在自身范围内:
      • 不在范围、hiddenalpha < 0.01userInteractionEnabled == NO → 返回 nil,整棵子树被跳过。
      • 在范围内 → 倒序遍历 subviews(后添加的在上层,优先命中),对每个子视图递归 hitTest:;子视图返回非 nil 则用它,否则返回自己。
    • 最终返回的 view 即为 first responder
  2. 事件响应(响应链,自下而上)
    • 若 first responder 不处理(未重写 touchesBegan: 等或调用 super),事件沿响应链向上传递:view → superview → ... → ViewController → window → application → 丢弃。

常见应用

  • 扩大按钮点击区域 / 让超出父视图的子视图可点击:重写 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 完成消息触发/转发。流程:
    1. 调用方把"目标名 + Action 名 + 参数字典"传给 CTMediator。
    2. CTMediator 用 NSClassFromString 拼出 Target_xxx 类、NSSelectorFromString 拼出 Action_xxx: 方法。
    3. 通过 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 的生命周期方法(didFinishLaunchingapplicationDidEnterBackground 等)作为"事件",让各模块声明自己关心哪些事件并自行注册,由一个分发器在对应时机遍历调用。各模块的初始化代码搬回各自模块内部,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》)等资料整理校对,便于面试复习。