WWDC Quick Look 💓 By SwiftGGTeam
Analyze heap memory

Analyze heap memory

观看原视频

Highlight

堆内存通常是应用内存占用的大头,Apple 提供了从 Xcode Memory Report 到 Instruments Allocations 的完整工具链来诊断瞬态增长、持续增长和内存泄漏三类问题。


核心内容

堆内存(heap memory)是应用运行时动态分配的内存区域,通过 malloccalloc、Swift/ObjC 的对象实例化来使用。堆内存通常是应用内存占用的大头,因为 dirty 页面会被计入应用的内存限制(04:22)。这场 Session 系统讲解了五类堆内存问题:瞬态增长(内存尖刺)、持续增长(只增不减)、内存泄漏(对象无法释放)、运行时性能问题(频繁分配/释放导致卡顿),以及如何用 Apple 的工具链逐一排查。

工具链覆盖四个层面:Xcode Memory Report(看整体趋势)、Instruments Allocations(看分配历史和调用栈)、Memory Graph Debugger(看对象引用关系)、命令行工具(heapmalloc_historyvmmapLeaks)(04:10)。

Session 用 Destination Video 示例项目做演示。第一个问题是反复打开图片选择器导致内存尖刺到接近 1GB,最终 OOM 崩溃(05:46)。通过 Allocations Instrument 追踪发现,每次打开都创建了大量未释放的 autorelease pool 内容页,原因是循环中调用 Objective-C 框架 API 时产生的 autoreleased 对象都累积到同一个 pool 中(10:54)。解决方案是用嵌套的 autoreleasepool 包裹循环体,确保每次迭代结束时立即释放(12:10)。

第二个问题是内存持续增长。修复瞬态增长后,发现内存呈阶梯状上升——每次打开图片选择器都会增长且不回落(13:09)。用 Mark Generation 功能分离出各时段的持续分配,发现大量 Data 存储分配来自 ThumbnailLoader(15:23)。通过 Memory Graph Debugger 追踪引用链,发现这些对象被全局缓存 globalImageCache 持有,而缓存键用的是当前时间而非文件创建时间——导致每次调用都生成新的缓存条目(18:58)。

第三个问题是内存泄漏。Memory Graph Debugger 发现 ThumbnailRenderer、ThumbnailLoader 和一个闭包上下文形成了引用循环(21:43)。闭包默认强捕获了 renderer,导致三者互相强引用无法释放。解法是用 [weak renderer] 捕获列表(23:40)。


详细内容

堆内存基础

堆内存是应用虚拟内存的一部分,用于存放动态分配和长期存活的对象。每块堆分配来自 16KB 的内存页,页面有三种状态(02:05):

  • Clean:未写入或只读映射,可随时丢弃
  • Dirty:已写入,计入内存占用,压力下会被压缩或换出
  • Swapped:已压缩或写入磁盘

只有 Dirty 和 Swapped 计入应用的 memory footprint。堆内存通常占大头(02:51)。

MallocStackLogging 是重要的调试功能,记录每个分配的调用栈和时间戳(03:47),可在 Xcode Scheme 的 Diagnostics 标签页勾选启用。

瞬态增长:Autorelease Pool

瞬态增长的特征是内存呈尖刺状——快速上升然后回落。这会触发内存压力,导致系统压缩/换出内存甚至终止应用(07:56)。

在 Allocations Instrument 中,可以通过两种方式定位(08:10):

  1. Created & Still Living:选中从波谷到波峰的时间段,查看哪些分配在增长后仍存活
  2. Created & Destroyed:选中较大时间段,查看哪些分配在该时段内被创建并销毁

修复瞬态增长的关键代码(12:16):

func loadThumbnails(with renderer: ThumbnailRenderer) {
    for photoURL in urls {
        autoreleasepool {
            // 循环体内部创建的对象会在每次迭代结束时释放
            renderer.faultThumbnail(from: photoURL)
        }
    }
}

关键点:

  • autoreleasepool 创建一个局部作用域,其中产生的 autoreleased 对象会在作用域结束时被释放
  • 原代码中循环产生的 autoreleased 对象都累积到线程的顶层 pool 中,直到循环结束才释放
  • 嵌套 pool 确保每次迭代后立即释放,避免内存尖刺

持续增长:缓存键错误

持续增长的特征是内存呈阶梯状上升——每次操作后增长但不回落。用 Mark Generation 功能可以分离不同时间段产生的持续分配(14:08)。

修复缓存键错误的关键代码(19:28):

func faultThumbnail(from photoURL: URL) {
  // 错误:使用当前时间作为缓存键
  // let timestamp = UInt64(Date.now.timeIntervalSince1970)

  // 正确:使用文件创建时间作为缓存键
  let timestamp = cacheKeyTimestamp(for: photoURL)
  let cacheKey = CacheKey(url: photoURL, timestamp: timestamp)

  let thumbnail = cacheProvider.thumbnail(for: cacheKey) {
    return makeThumbnail(from: photoURL)
  }
  images.append(thumbnail.image)
}

关键点:

  • 原代码使用 Date.now 生成缓存键,导致每次调用都生成不同的键
  • 同一个文件每次访问都会创建新的 PhotoThumbnail 对象并加入全局缓存
  • 修复后使用文件的创建时间戳,确保相同文件使用同一缓存键

内存泄漏:引用循环

内存泄漏是可达但永远不会再使用的对象。闭包默认强捕获引用,容易形成引用循环(22:10)。

修复引用循环的关键代码(23:40):

// 错误:闭包强捕获 renderer
loader.completionHandler = {
  self.thumbnails = renderer.images // 隐式强捕获
}

// 正确:使用 weak 捕获
loader.completionHandler = { [weak renderer] in
  guard let renderer else { return }
  self.thumbnails = renderer.images
}

关键点:

  • 闭包默认强捕获所有引用的变量
  • [weak renderer] 创建弱引用,避免形成引用循环
  • guard let renderer 确保在闭包执行时对象仍然存在

Memory Graph Debugger 会用黄色三角形标记泄漏的对象(20:03),可以按类型筛选定位泄漏源。

引用类型:weak vs unowned

weakunowned 都可以避免强引用循环,但行为不同(26:51):

  • weak:总是可选类型,对象释放后自动变为 nil,需要额外分配 weak reference storage
  • unowned:直接持有对象地址,不占用额外内存,但访问已释放对象会崩溃

性能差异示例(30:11):

// weak 需要为每个对象分配额外的 weak reference storage
weak var holder: Swallow?

// unowned 直接持有地址,无额外开销
unowned let holder: Swallow

关键点:

  • weak 引用需要分配额外的存储空间来跟踪弱引用
  • unowned 引用无额外开销,但必须保证被引用对象生命周期更长
  • 使用 weak 时需检查 swift_weakLoadStrong() 等 runtime 函数的调用频率

引用循环的隐式陷阱

将方法赋值给闭包属性时会隐式捕获 self29:07):

class ByteProducer {
  let data: Data
  private var generator: ((Data) -> UInt8)? = nil

  init(data: Data) {
    self.data = data
    // 错误:defaultAction 隐式使用 self,形成引用循环
    generator = defaultAction
  }

  func defaultAction(_ data: Data) -> UInt8 {
    // ...
  }
}

// 正确:显式闭包 + weak 捕获
generator = { [weak self] data in
  return self?.defaultAction(data)
}

// 或者使用 unowned(当 generator 生命周期不超出 ByteProducer 时)
generator = { [unowned self] data in
  return self.defaultAction(data)
}

关键点:

  • 方法作为闭包使用时会隐式捕获 self
  • 需要用显式闭包和捕获列表来控制引用类型
  • unowned 适用于闭包生命周期明确不超出被捕获对象的情况

工具使用建议

  • MallocStackLogging:开发时启用,记录分配调用栈(03:47
  • Memory Graph Debugger:暂停时捕获内存快照,查看引用关系(04:32
  • Instruments Allocations:分析内存趋势和分配历史(05:15
  • 命令行工具heapmalloc_historyvmmapLeaks 用于离线分析(04:50

核心启发

1. 在循环中调用框架 API 时使用嵌套 autoreleasepool

当循环中调用会产生 autoreleased 对象的 API(尤其是 Objective-C 框架)时,用嵌套的 autoreleasepool 包裹循环体。这能确保每次迭代结束时立即释放临时对象,避免内存尖刺。

为什么值得做: 原始实现会导致所有 autoreleased 对象累积到线程的顶层 pool 中,直到 pool 被 drain 才释放——在循环中这可能需要很长时间。

怎么开始: 在循环中添加 autoreleasepool { ... },用 Xcode Memory Report 或 Instruments Allocations 验证内存尖刺是否消失。

2. 为缓存数据使用稳定的键

缓存时必须确保键能唯一标识数据,且同一数据的多次访问能命中缓存。使用文件 URL 作为键时,应结合文件的修改时间或创建时间,而非当前时间。

为什么值得做: 错误的缓存键会导致缓存永远 miss,每次访问都创建新对象并加入缓存,造成内存持续增长。

怎么开始: 审查所有缓存代码,确认缓存键的稳定性。对于文件资源,使用文件属性(如 creationDatecontentModificationDate)而非运行时时间。

3. 闭包捕获引用时默认使用 weak,仅在保证生命周期时用 unowned

闭包默认强捕获引用,容易形成引用循环。使用 [weak self] 打破循环是安全做法。当闭包生命周期明确不超出被捕获对象时(如内部闭包、同步回调),可以用 [unowned self] 节省 weak reference storage 的开销。

为什么值得做: weak 引用需要为每个对象分配额外的存储空间,在大量对象时开销显著。unowned 无额外开销但访问已释放对象会崩溃。

怎么开始: 在所有闭包中使用捕获列表。默认使用 weak,分析后确认生命周期关系时再考虑 unowned

4. 用 Mark Generation 追踪持续增长的来源

当内存呈阶梯状增长时,用 Instruments Allocations 的 Mark Generation 功能在不同时间点标记,可以分离出各时段产生的持续分配。

为什么值得做: 持续增长可能来自多个来源,Mark Generation 能将增长按时间分段,帮助定位具体哪段代码导致了增长。

怎么开始: 在 Instruments 中重现增长模式,在增长前后点击 Mark Generation 按钮,然后按增长大小排序查看各代际的分配类型。

5. 验证修复后检查连锁泄漏

修复一个内存泄漏后,可能发现其他泄漏也消失了——因为被泄漏对象引用的其他对象也会随之泄漏(24:08)。

为什么值得做: 泄漏对象可能持有其他对象的强引用,修复根因泄漏后,被持有的对象也能正常释放。

怎么开始: 修复泄漏后重新运行 Memory Graph Debugger,确认泄漏数量减少或消失,并检查是否有新的根因泄漏。


关联 Session

评论

GitHub Issues · utterances