ios带底层深度

8 阅读24分钟

题目 1:消息发送机制

标准答案

OC 中方法调用的本质是 objc_msgSend(receiver, selector, ...),整个过程分三大阶段:

阶段一:消息发送

  1. 检查 receiver 是否为 nil,是则直接返回 0/nil

  2. 通过 receiver 的 isa 指针找到其 Class(arm64 下 isa 是 Non-pointer isa,需要 & ISA_MASK 取出类指针)

  3. 在 Class 的 cache_t 方法缓存中查找(汇编实现,hash 算法 = selector & mask,开放寻址线性探测)

  4. 缓存未命中,在 Class 的 method_list 中查找(已排序用二分查找,未排序用线性查找)

  5. 当前类未找到,沿 superclass 链逐级向上查找(每级先查缓存再查方法列表)

  6. 找到后将 SEL → IMP 缓存到当前类的 cache_t 中,然后调用 IMP

  7. 直到 NSObject 都未找到 → 进入阶段二

阶段二:动态方法解析

  1. 调用 +resolveInstanceMethod:(实例方法)或 +resolveClassMethod:(类方法)

  2. 开发者可以在此用 class_addMethod 动态添加方法

  3. 添加后重走阶段一,且通过 triedResolver 标志保证只解析一次

  4. 若仍未找到 → 进入阶段三

阶段三:消息转发

  1. 快速转发-forwardingTargetForSelector: 返回另一个对象接管消息

  2. 完整转发-methodSignatureForSelector: 返回方法签名 → -forwardInvocation: 处理 NSInvocation

  3. 都未处理 → doesNotRecognizeSelector: → crash

追问 1:objc_msgSend 为什么用汇编实现?

  1. 极致性能:消息发送是 OC 最频繁的操作(每秒调用百万次),汇编能精确控制每一条指令,C 语言做不到

  2. 参数透明转发:objc_msgSend 需要将任意数量、任意类型的参数原封不动地传递给最终的 IMP。C 函数无法实现"不知道有多少参数但全部转发"的功能,而汇编可以直接保持寄存器状态不变然后 jmp

  3. 尾调用优化:汇编中找到 IMP 后用 br x17(间接跳转)而非 bl(函数调用),不会创建新的栈帧,直接复用 objc_msgSend 的栈帧

  4. 避免不必要的寄存器保存/恢复:C 编译器会插入 prologue/epilogue(保存/恢复 callee-saved registers),汇编可以跳过

追问 2:cache_t 的哈希冲突如何解决?

使用开放寻址法中的线性探测。初始 index = selector & mask,如果该位置的 bucket.sel 不匹配,则 index = (index - 1) & mask向前探测,而非向后),直到找到匹配的 sel 或遇到空 slot(sel == 0)。

**为什么向前探测(index - 1)? ** 在 arm64 下,bucket_t 的内存布局是 {IMP, SEL},向前遍历可以利用 ldp 指令同时加载两个连续 64 位值(一条指令读取 IMP + SEL),减少内存访问次数。

追问 3:扩容时为什么清空缓存而不是 rehash?

  1. 方法调用具有时间局部性:最近调用的方法更可能再次被调用,旧缓存中的冷方法价值不大

  2. rehash 开销大:需要遍历旧 buckets、重新计算 hash、插入新 buckets,而扩容后新缓存很快就会被重新填充

  3. 简化实现:清空操作是 O(1)(memset 或 free + malloc),rehash 是 O(n)


题目 2:weak 的实现原理

标准答案

****赋值过程(** __**weak id weakObj = obj** ): **

  1. 编译器将赋值转换为 objc_storeWeak(&weakObj, obj) 调用

  2. 根据 obj 的地址哈希,找到对应的 SideTable(全局有 64 个 SideTable,StripedMap 通过 (addr >> 4) ^ (addr >> 9)) % 64 取模定位)

  3. 在 SideTable 的 weak_table_t(弱引用表)中以 obj 地址为 key 查找 weak_entry_t

  4. 如果不存在 → 创建新的 weak_entry_t,插入 weak_table

  5. &weakObj(weak 指针变量本身的地址)添加到 weak_entry_t 的引用数组中

  6. 同时设置 obj 的 isa.weakly_referenced = 1

**对象释放时自动置 nil: **

  1. dealloc_objc_rootDeallocrootDeallocobject_disposeobjc_destructInstance

  2. clearDeallocatingclearDeallocating_slow

  3. 检查 isa.weakly_referenced,如果为 1:

  4. weak_table 中找到该对象的 weak_entry_t

  5. 遍历引用数组,将每个 weak 指针(*referrer)置为 nil

  6. weak_table 中移除该 weak_entry_t

  7. 如果 isa.has_sidetable_rc,同时清除 SideTable 中的引用计数

追问 1:SideTable 为什么有 64 个?

**减少锁竞争。 ** 每个 SideTable 有独立的 spinlock_t 锁。如果全局只有一个 SideTable,所有对象的 retain/release/weak 操作都要争抢同一把锁,多线程下性能极差。64 个 SideTable 将竞争分散到不同的锁上。为什么是 64?太少竞争仍然激烈,太多浪费内存(每个 SideTable 包含一把锁 + 两个哈希表),64 是经验最优值。

追问 2:weak_entry_t 为什么有 inline 优化?

weak_entry_t 内部使用 union:当弱引用指针 ≤ 4 个时用内联数组inline_referrers[4]),超过 4 个时切换为动态数组referrers 指针 + hash 结构)。

原因:大多数对象的 weak 引用者不超过 4 个(通常就 1-2 个 delegate/weak self)。内联数组避免了额外的 malloc 开销和指针间接寻址,提升缓存命中率。


题目 3:Block 的底层实现

标准答案

Block 本质是一个 OC 对象(结构体),包含 isaFuncPtr(函数指针)、FlagsDescriptor(大小 + copy/dispose 函数)以及捕获的变量。

**三种类型: **

| 类型 | 位置 | 条件 |

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

| __NSGlobalBlock__ | 数据段(.data) | 不捕获 auto 变量 |

| __NSStackBlock__ | 栈 | 捕获了 auto 变量(MRC 或 ARC 下未被强引用) |

| __NSMallocBlock__ | 堆 | Stack Block 调用了 copy(ARC 下赋值给 __strong 变量自动 copy) |****

**变量捕获规则: **

  • 局部 auto 变量 → 值捕获(结构体内保存副本)

  • 局部 static 变量 → 地址捕获(结构体内保存指针)

  • 全局变量 → 不捕获(直接访问)

  • self值捕获(捕获的是 self 这个指针的值)

****** __**block** 修饰符: ** 将变量包装成 ** __**Block_byref_xxx** 结构体,结构体中有 ** __**forwarding** 指针。Block 捕获的是该结构体的指针。

追问 1:__forwarding 指针的两种状态?****

  • Block 在栈上时:栈上 byref.__forwarding 指向栈上的自身

  • Block copy 到堆上后__block 变量也被 copy 到堆上,此时:

  - 栈上 byref.__forwarding指向堆上的副本

  - 堆上 byref.__forwarding → 指向自身

  • 意义:无论通过栈上还是堆上的 byref 访问变量,byref->__forwarding->value 总是访问到堆上的同一份数据,保证了数据一致性

追问 2:Block copy 时 capture 的对象怎么处理?

Block 从栈 copy 到堆时,Descriptor 中的 copy 函数被调用,内部对每个捕获的变量调用 _Block_object_assign

  • 对象类型BLOCK_FIELD_IS_OBJECT):根据所有权修饰符执行 retain 或 weak 注册

  • Block 类型BLOCK_FIELD_IS_BLOCK):对嵌套 Block 执行 _Block_copy

  • ****** __block 变量**** **BLOCK_FIELD_IS_BYREF** ):将 ** __**Block_byref** 结构体 copy 到堆上

Block 释放时对称地调用 dispose 函数 → _Block_object_dispose → release/weak 取消注册。


题目 4:RunLoop 的原理

标准答案

RunLoop 本质是一个 do-while 循环,保持线程存活。核心流程(简化):


1. 通知 Observer: kCFRunLoopEntry(进入)

2. 通知 Observer: kCFRunLoopBeforeTimers(即将处理 Timer)

3. 通知 Observer: kCFRunLoopBeforeSources(即将处理 Source0)

4. 处理 Source0(非端口事件,如触摸事件)

5. 如果有 Source1 就绪 → 跳到步骤 9

6. 通知 Observer: kCFRunLoopBeforeWaiting(即将休眠)

7. 调用 mach_msg 休眠,等待唤醒

8. 通知 Observer: kCFRunLoopAfterWaiting(已唤醒)

9. 处理事件(Timer / Source1 / GCD 主队列 block)

10. 判断是否退出,否则回到步骤 2

11. 通知 Observer: kCFRunLoopExit(退出)

**Mode 机制: ** 每次 RunLoop 只能在一个 Mode 下运行。Source/Timer/Observer 注册到特定 Mode。NSRunLoopCommonModes 不是真正的 Mode,而是将 item 同时注册到所有标记为 Common 的 Mode 中(默认包含 Default + UITracking)。

追问 1:mach_msg 是什么?休眠为什么不消耗 CPU?

mach_msg 是 Mach 内核的消息收发系统调用。RunLoop 调用 mach_msg(msg, MACH_RCV_MSG, ...) 进入内核态,线程被内核挂起(从可运行队列移除),CPU 调度器不再分配时间片给这个线程,所以不消耗 CPU。当有消息到达端口(Source1 / Timer / dispatch_main_queue 等发送 mach_msg),内核将线程重新加入可运行队列,线程恢复执行。

这与 while(1){} 的空循环完全不同——空循环是用户态的忙等,线程仍在运行消耗 CPU。

追问 2:autorelease 对象什么时候释放?

主线程 RunLoop 注册了两个 Observer:

  • kCFRunLoopEntry(优先级最高):调用 _objc_autoreleasePoolPush() 创建新池

  • kCFRunLoopBeforeWaiting + kCFRunLoopExit(优先级最低):先 _objc_autoreleasePoolPop() 释放旧池中的对象,再 _objc_autoreleasePoolPush() 创建新池

所以 autorelease 对象在当前 RunLoop 迭代即将休眠时被释放。一般理解为"当前事件处理完毕后"释放。手动 @autoreleasepool {} 则在出花括号时立即释放。


题目 5:Category 的底层原理

标准答案

Category 编译后生成 category_t 结构体,包含实例方法列表、类方法列表、协议列表、属性列表。

**运行时加载过程: ** _objc_initmap_images_read_images → 遍历 __objc_catlist 段中的所有 category → attachCategories → 将 category 的方法列表插入到类的 class_rw_ext_t.methods 数组的最前面

** "覆盖"原理: ** 方法没有被真正覆盖,宿主类的方法仍然存在。只是 lookUpImpOrForward 遍历方法列表数组时从前往后找,先找到 category 的方法就返回了。多个 category 有同名方法时,后编译的排在前面(取决于 Build Phases → Compile Sources 的顺序)。

**不能添加成员变量的原因: ** 类的 instanceSize 存储在编译期生成的 class_ro_t 中(只读),运行时添加成员变量会改变实例大小,导致已存在的实例内存布局错误和子类偏移量失效。

追问 1:class_rw_ext_t 是什么?

iOS 14 的优化。原来每个类都要创建完整的 class_rw_t(包含可动态修改的方法/属性/协议数组),但实测约 90% 的类运行时不会被修改。优化后 class_rw_tro_or_rw_ext 默认直接指向 class_ro_t(只读,不需要额外分配),只有当需要动态修改时(如加载 category、class_addMethod)才创建 class_rw_ext_t。全系统节省约 14MB 内存。


题目 6:KVO 的底层原理

标准答案

当对象被 addObserver:forKeyPath: 时:

  1. Runtime 动态创建子类 NSKVONotifying_ClassName(继承自原类)

  2. 将对象的 isa 指向这个动态子类(isa swizzling)

  3. 动态子类重写被观察属性的 setter,内部实现:

   - [self willChangeValueForKey:@"xxx"](记录旧值)

   - [super setXxx:xxx](调用原类的 setter)

   - [self didChangeValueForKey:@"xxx"](计算新值,遍历所有 observer 回调 observeValueForKeyPath:

  1. 动态子类还重写了 -class(返回原类伪装)、-dealloc(恢复 isa)、-_isKVOA(返回 YES)

追问 1:为什么用 isa swizzling 而不是 Method Swizzling?

**Method Swizzling 影响所有实例。 ** method_exchangeImplementations 修改的是类的方法列表中的 IMP,该类的所有实例调用该方法时都会走新 IMP。但 KVO 的需求是只对被观察的特定实例生效,不应影响同类的其他实例。

**isa swizzling 只影响单个实例。 ** 只修改了特定对象的 isa 指针,指向动态子类。其他同类实例的 isa 仍指向原类,不受任何影响。

追问 2:动态子类重写了哪些方法?

| 方法 | 作用 |

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

| -setXxx:(被观察属性的 setter) | 在赋值前后插入 will/didChangeValueForKey: |

| -class | 返回原类([obj class] 看不出是 KVO 子类) |

| -dealloc | 在释放时将 isa 恢复为原类 |

| -_isKVOA | 标识这是一个 KVO 动态子类,返回 YES |

注意:setter 的 IMP 根据属性类型不同有不同实现:_NSSetIntValueAndNotify_NSSetObjectValueAndNotify_NSSetBoolValueAndNotify_NSSetFloatValueAndNotify 等。


题目 7:AutoreleasePool 的底层

标准答案

@autoreleasepool {} 编译后变为 objc_autoreleasePoolPush() + objc_autoreleasePoolPop(token)

底层由 AutoreleasePoolPage 双向链表实现。每个 page 大小 4096 字节(虚拟内存页大小),页头约 56 字节用于存储 magicnextthreadparentchild 等元数据,剩余空间约 4040 字节存放 autorelease 对象的指针(约 505 个)。

push 时在当前 page 的 next 位置插入一个 POOL_BOUNDARY(nil),next++,返回 POOL_BOUNDARY 的地址作为 token。

autorelease 时在 next 位置存入对象指针,next++。page 满了就创建新 page(child)。

pop(token) 时从 next-1 开始从后往前逐个取出对象指针调用 objc_release,直到遇到 token(POOL_BOUNDARY)为止。

追问 1:page 大小是多少?

4096 字节 = PAGE_MIN_SIZE = 一个虚拟内存页。这不是巧合——与虚拟内存页对齐,可以最大化内存使用效率,减少内存碎片。

追问 2:怎么找到 POOL_BOUNDARY?

push 返回的 token 就是 POOL_BOUNDARY 在 page 中的地址。pop 时传入 token,通过地址计算得知它在哪个 page(pageForPointer(token) = 将地址向下对齐到 4096 边界),然后从当前 page 的 next-1 开始释放直到 token 位置。

追问 3:子页的释放策略?

pop 完成后,如果当前 page 有 child page:保留一个空的 child page(作为缓存,避免下次 push 时又要 malloc),但释放 child 的 child 及其后续所有 page。即最多保留一个空的预备 page。


题目 8:GCD 死锁

标准答案

经典死锁场景:主线程中 dispatch_sync(dispatch_get_main_queue(), block)

**原因分析: **

  • dispatch_sync 将 block 提交到主队列,并阻塞当前线程(主线程)等待 block 完成

  • 主队列是串行队列,block 排在队列中等待执行

  • 但 block 的执行需要当前正在主线程运行的代码(包含 sync 调用)先完成

  • sync 等 block 完成 → block 等 sync 所在代码完成 → 互相等待 → 死锁

**本质规则: ** 在串行队列正在执行的任务中,对同一个串行队列 dispatch_sync 就会死锁。不仅限于主队列。

追问 1:dispatch_sync 底层如何检测死锁?

GCD 内部通过比较当前线程的 dispatch_queue 标识与目标队列是否相同来检测直接死锁。如果检测到当前线程正在执行目标串行队列的任务,又对它 sync → 直接 DISPATCH_CLIENT_CRASH(0, "dispatch_sync called on queue already owned by current thread")

但 GCD 无法检测间接死锁(如队列 A sync 到队列 B,队列 B 又 sync 到队列 A),这种情况会静默死锁(线程卡住),不会 crash。

追问 2:GCD 线程池大小是多少?

GCD 使用全局线程池,线程数量由 libdispatch 内核动态管理。上限通常是 64 个工作线程DISPATCH_WORKQ_MAX_PTHREAD_COUNT),但实际创建数量取决于系统负载和 CPU 核心数。大量 dispatch_async 到并发队列不会无限创建线程——超过上限的任务会排队等待。但仍可能出现线程爆炸(thread explosion):如果 64 个线程都阻塞在 sync/lock/IO 上,GCD 可能被迫创建更多线程来执行其他 non-blocked 任务。


题目 9:启动优化

标准答案

冷启动分 pre-main(dyld 加载)和 post-main(业务初始化)两个阶段。

**pre-main 优化: **

  1. 减少动态库数量(合并或转为静态库,Apple 建议自定义 dylib ≤ 6 个)

  2. 减少 OC 类数量(合并小类、删除无用类,每个类都要在 _objc_init 中注册)

  3. 减少 +load 方法(移到 +initialize 懒加载)

  4. 二进制重排:通过 Clang 插桩获取启动函数调用顺序,生成 order file,让链接器将启动函数排列在连续内存页中,减少 Page Fault

**post-main 优化: **

  1. didFinishLaunching 中区分必要/非必要初始化,非必要延迟执行

  2. 首页数据预加载 + 骨架屏

  3. 非核心 SDK 延迟初始化或移到子线程

追问 1:Page Fault 一次多少毫秒?

一次 Page Fault 约 0.3~1ms(包含:磁盘 IO 读取文件页 → iOS 代码签名验证 → 页表更新 → TLB 刷新)。启动时如果有 300 次 Page Fault → 额外耗时 90300ms。二进制重排后可减少到 30-50 次 → 节省 50200ms。

追问 2:Clang 插桩的原理?

编译时加 -fsanitize-coverage=func,trace-pc-guard,编译器在每个函数入口插入 __sanitizer_cov_trace_pc_guard(&guard) 调用。运行时在该回调中通过 __builtin_return_address(0) 获取调用者的 PC 地址,记录到线程安全的队列中。启动完成后将所有 PC 通过 dladdr 转换为符号名,去重后输出 order file。

追问 3:dyld3 闭包是什么?

dyld3(iOS 13+)将首次启动时的解析结果(所有 dylib 的依赖关系、符号绑定地址、初始化器调用顺序等)缓存到磁盘的 Launch Closure 文件中。后续启动直接读取闭包,跳过大部分解析工作。系统 App 的闭包在系统更新时预生成,三方 App 在首次启动或更新后首次启动时生成。


题目 10:卡顿优化

标准答案

卡顿 = 主线程无法在 **16.67ms(60fps)/ 8.33ms(120fps ProMotion) ** 内完成一帧的处理。

**CPU 端优化: **

  • 预计算布局、缓存 Cell 高度

  • 子线程预解码图片

  • 减少复杂的 Auto Layout(用 Frame Layout 替代)

  • 文本排版预计算(boundingRectWithSize: 在子线程执行)

  • 对象创建/销毁移到子线程

**GPU 端优化: **

  • 避免离屏渲染(圆角用 UIBezierPath 预绘制、阴影设 shadowPath、避免 mask)

  • 减少图层混合(设 opaque = YES、避免大量半透明重叠)

  • 图片尺寸匹配 UIImageView 大小(避免 GPU 缩放)

  • 减少视图层级

追问 1:如何在子线程获取主线程调用栈?

  1. 保存主线程的 thread_t(mach thread port)

  2. 在子线程中调用 thread_suspend(mainThread) 暂停主线程

  3. 调用 thread_get_state(mainThread, ARM_THREAD_STATE64, ...) 获取主线程的寄存器状态(包括 PC、FP、LR)

  4. 从 FP(帧指针寄存器 x29)开始沿栈帧链回溯:每个栈帧的 FP+8 处是返回地址(LR),*FP 是上一帧的 FP

  5. dladdr() 将每个返回地址转换为函数名

  6. 调用 thread_resume(mainThread) 恢复主线程

追问 2:为什么离屏渲染慢?

正常渲染 GPU 按"画家算法"从后往前逐层绘制到帧缓冲区(On-Screen Buffer),一次遍历完成。离屏渲染需要:

  1. 创建额外的离屏缓冲区(Offscreen Buffer)

  2. 上下文切换:GPU 从 On-Screen Buffer 切换到 Offscreen Buffer 渲染,完成后再切回(上下文切换 = 保存/恢复渲染状态、帧缓冲区绑定等,非常昂贵)

  3. 等待所有子层渲染完成才能应用 mask/clip/group opacity 等操作

追问 3:VSync 机制是什么?

VSync(垂直同步信号)由显示器硬件以固定频率发出(60Hz = 每 16.67ms 一次)。iOS 使用双缓冲机制:GPU 渲染到后缓冲区(Back Buffer),VSync 到来时交换前后缓冲区(Buffer Swap)。如果 VSync 到来时渲染未完成,该帧无法交换 → 掉帧。CADisplayLink 就是基于 VSync 信号的回调。


题目 11:循环引用

标准答案

**常见场景与解决: **

| 场景 | 原因 | 解决方案 |

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

| delegate | delegate 属性 strong 引用 | 用 weak 修饰 delegate |

| Block 捕获 self | Block strong capture self | __weak typeof(self) weakSelf |

| NSTimer | target 被 Timer 强引用 | NSProxy / block API / 适时 invalidate |

| WKWebView messageHandler | addScriptMessageHandler 强引用 | WeakScriptMessageDelegate 中间对象 |

| 通知中心 block 观察者 | block 捕获 self | __weak |

| CADisplayLink | 同 NSTimer | 同 NSTimer |

追问 1:MLeaksFinder 原理?

Hook UINavigationControllerpopViewControllerAnimated: 等方法。VC 被 pop 后,调用 [vc willDealloc] → 内部创建 __weak 引用 + dispatch_after(2秒) 检查 weak 引用是否还有值。如果 2 秒后 weak 引用非 nil → 对象未释放 → 可能有循环引用 → 弹窗警告。同时递归检查 VC 的所有子视图和子控制器。可选配合 FBRetainCycleDetector 用 DFS 遍历对象引用图找到环。

追问 2:NSProxy vs NSObject 的消息转发差异?

**NSObject: ** 收到未知消息时先走完整的方法查找流程(缓存 → 方法列表 → 父类链 → 动态解析),全部失败后才进入消息转发。如果 NSObject 本身有同名方法(如 classrespondsToSelector:),就不会走转发。

**NSProxy: ** 作为抽象代理类,几乎没有自己的方法实现。收到消息后直接走 methodSignatureForSelector: + forwardInvocation: 消息转发,不经过方法查找。所以 NSProxy 能完美代理所有消息(包括 classisKindOfClass: 等),而 NSObject 子类做代理会"截获"这些方法导致转发失败。这就是为什么 NSTimer 防循环引用推荐用 NSProxy 而非 NSObject。


题目 12:线程安全与锁

标准答案

iOS 中 10 种线程同步方案(性能从高到低):

| 锁 | 类型 | 性能 | 特点 |

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

| os_unfair_lock | 互斥锁 | ★★★★★ | iOS 10+,替代 OSSpinLock |

| dispatch_semaphore | 信号量 | ★★★★☆ | 灵活,可控制并发数 |

| pthread_mutex | 互斥锁 | ★★★★ | POSIX 标准,支持递归/条件 |

| NSLock | 互斥锁 | ★★★☆ | OC 封装的 pthread_mutex |

| NSCondition | 条件锁 | ★★★ | 配合条件变量使用 |

| pthread_rwlock | 读写锁 | ★★★ | 多读单写 |

| NSRecursiveLock | 递归锁 | ★★☆ | 同线程可重入 |

| NSConditionLock | 条件锁 | ★★ | 带条件值的锁 |

| @synchronized | 递归锁 | ★ | 最方便但最慢 |

| OSSpinLock | 自旋锁 | ★★★★★ | 已废弃,有优先级反转问题 |

追问 1:OSSpinLock 优先级反转的完整过程?

假设三个线程:A(高优先级)、B(中)、C(低)。

  1. C 获取 OSSpinLock,进入临界区

  2. A 就绪,抢占 C 的 CPU 时间(高优先级优先调度)

  3. A 尝试获取锁 → 自旋等待(while 循环忙等)

  4. A 持续占用 CPU 自旋,因为 A 优先级最高,调度器不会给 C 分配时间

  5. C 无法执行 → 无法释放锁 → A 永远拿不到锁

  6. B 就绪后也抢占 C → C 更无法运行

  7. 实际效果:高优先级线程 A 被低优先级线程 C 阻塞 → 优先级反转

追问 2:os_unfair_lock 如何解决?

  1. 不自旋:等待线程调用 __ulock_wait 系统调用进入内核态休眠,不消耗 CPU

  2. **Priority Inheritance(优先级继承) **:内核知道锁的持有者是谁(__ulock_wait 传入 owner thread),如果高优先级线程在等待低优先级线程的锁 → 内核临时将低优先级线程的优先级提升到等待线程的优先级 → 低优先级线程获得 CPU 时间 → 释放锁 → 恢复原始优先级

  3. Unfair 策略:释放锁后不保证 FIFO 唤醒,刚释放锁的线程可能立刻再获取 → 减少上下文切换,提升吞吐量


题目 13:事件传递与响应链

标准答案

**事件传递(Hit-Testing)——从上到下: **

UIApplicationUIWindowhitTest:withEvent: 递归:

  1. 检查 userInteractionEnabled && !hidden && alpha > 0.01

  2. pointInside:withEvent: 判断触摸点是否在范围内

  3. 从后往前遍历子视图,对每个子视图递归调用 hitTest:

  4. 子视图返回非 nil → 返回该子视图(最深层的合适视图)

  5. 没有子视图命中 → 返回自身

**事件响应(Responder Chain)——从下到上: **

hitViewsuperView → ... → ViewControllerUIWindowUIApplicationAppDelegate

某个 Responder 处理了事件(实现 touchesBegan: 等)则停止传递,都没处理则丢弃。

追问 1:hitTest 为什么从后往前遍历子视图?

subviews 数组中,后添加的子视图在数组末尾,视觉上显示在最上层。从后往前遍历 = 先检查最上层的视图,符合用户期望——触摸时先响应最上面的视图。如果从前往后遍历,底层视图可能会"抢走"本该由上层视图接收的事件。

追问 2:手势识别器和 touch 事件的竞争?

UIWindow 将事件同时传递给 hitView 和相关的 UIGestureRecognizer。

  1. hitView 开始收到 touchesBegan: 等事件

  2. 手势识别器同时在分析手势

  3. 如果手势识别成功.recognized / .began):

   - 调用手势的 target-action

   - 调用 hitView 的 touchesCancelled:cancelsTouchesInView 默认 YES)

   - hitView 不再收到后续 touch 事件

  1. 如果手势识别失败.failed):

   - hitView 正常接收所有 touch 事件

delaysTouchesBegan = YES 时,手势识别器在识别期间会延迟 hitView 的 touchesBegan:,识别失败后才补发。


题目 14:HTTPS 原理

标准答案

HTTPS = HTTP + TLS。核心是 TLS 握手建立安全通道:

  1. Client Hello:客户端发送 TLS 版本、支持的加密套件列表、随机数 A

  2. Server Hello:服务端选定加密套件,发送随机数 B + 服务器证书(含公钥)

  3. 证书验证:客户端验证证书链(服务器证书 → 中间 CA → 根 CA),检查域名、有效期、吊销状态

  4. 密钥交换:客户端生成随机数 C(Pre-Master Secret),用服务器公钥加密发送

  5. 生成会话密钥:双方用 A + B + C 通过 PRF 函数生成相同的对称加密密钥

  6. 加密通信:后续所有数据使用对称加密(如 AES-256-GCM)

**为什么用混合加密? ** 非对称加密(RSA/ECDHE)安全但慢(比对称慢 100-1000 倍),只用于安全传输密钥。对称加密(AES)快,用于实际数据传输。

追问 1:TLS 1.2 vs 1.3 的区别?

| 特性 | TLS 1.2 | TLS 1.3 |

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

| 握手次数 | 2-RTT | 1-RTT(甚至 0-RTT 恢复) |

| 密钥交换 | RSA / DHE / ECDHE | 仅 ECDHE / X25519(移除 RSA 密钥交换) |

| 加密套件 | 大量可选(含不安全的) | 精简到 5 个(都是 AEAD) |

| 前向保密 | 可选 | 强制(移除了非 PFS 的算法) |

| 0-RTT | 不支持 | 支持(Session Resumption) |

**前向保密(PFS) **:即使服务器的长期私钥泄漏,过去的会话也无法被解密(因为每次会话的 ECDHE 密钥是临时的)。

追问 2:证书固定(SSL Pinning)的两种方式?

  1. **证书固定(Certificate Pinning) **:App 内置服务器证书的完整副本,TLS 握手时比较服务器返回的证书是否与内置证书完全一致。最严格,但证书更新时需要发版。

  2. **公钥固定(Public Key Pinning) **:App 内置服务器证书的公钥(或公钥的 hash),只比较公钥是否一致。证书可以更新(只要公钥不变),更灵活。

AFNetworking 对应:AFSSLPinningModeCertificate(方式1)和 AFSSLPinningModePublicKey(方式2)。


题目 15:Swift 内存管理

标准答案

Swift 同样使用 ARC,但有一些区别:

  • strong(默认):强引用,引用计数 +1

  • weak:弱引用,可选类型,对象释放后自动变 nil

  • unowned:无主引用,非可选类型,对象释放后访问会 crash

闭包循环引用:使用捕获列表 [weak self][unowned self]

Swift 引用计数存储:对象头部有 16 字节(8 字节 metadata pointer + 8 字节 refCounts),refCounts 中用位域存储 Strong RC 和 Unowned RC。

追问 1:weak 和 unowned 的底层区别?

**weak 的底层: **

  1. 当对象第一次被 weak 引用时,Runtime 创建 HeapObjectSideTableEntry

  2. 对象的 refCounts 字段从 inline 模式(直接存储 RC)切换为 side table 模式(存储指向 Side Table Entry 的指针)

  3. weak 引用实际指向 Side Table Entry,而非对象本身

  4. 对象 deinit 后,Side Table Entry 仍然存在(因为 weak RC > 0),但标记对象已 deinit

  5. weak 引用访问时检查标记 → 返回 nil

  6. 最后一个 weak 引用消失 → Side Table Entry 释放 → 对象内存释放

**unowned 的底层: **

  1. 不需要 Side Table(如果没有 weak 引用)

  2. 直接操作 inline 的 Unowned RC 字段(原子操作)

  3. 对象 deinit 后:Strong RC = 0,但 Unowned RC > 0 → 对象内存不释放(保持分配状态)

  4. 访问 unowned 引用时检查 Strong RC → 为 0 → fatalError("Attempted to read an unowned reference but object was already deallocated")

  5. 最后一个 unowned 引用消失 → Unowned RC = 0 → 对象内存真正释放

**性能差异: ** weak 需要创建 Side Table + 维护额外的 weak RC + 每次访问检查并拆包 Optional → 比 unowned 慢 2-3 倍。

追问 2:Side Table Entry 的作用?

HeapObjectSideTableEntry 是 Swift Runtime 为有 weak 引用的对象创建的辅助数据结构,包含:

  • 指向原对象的指针

  • Strong Reference Count

  • Unowned Reference Count  

  • Weak Reference Count

  • 对象状态标志(是否已 deinit)

关键设计:weak 引用指向 Side Table Entry 而非对象本身。这样对象 deinit 后释放主存储(如果 Unowned RC 也为 0),但 Side Table Entry 仍存活,weak 引用可以安全地检查状态并返回 nil,而不是访问已释放的内存。只有当所有 weak 引用都消失后(Weak RC = 0),Side Table Entry 才被释放。


本文档深入到源码级和汇编级,覆盖 iOS 面试中 99% 以上的深度知识点。每道题的答案按照「先回答核心原理 → 再深入底层细节 → 最后回答追问」的结构,适合面试时由浅入深地展开。理解"为什么这样设计"比记住"怎么实现"更重要。