WWDC Quick Look 💓 By SwiftGGTeam
Analyze heap memory

Analyze heap memory

元の動画を見る

ハイライト

ヒープメモリは通常アプリメモリ使用量の大部分を占め、Apple は Xcode Memory Report から Instruments Allocations までの完全なツールチェーンで、一過性スパイク、持続的増加、メモリリークの 3 類の問題を診断できます。


主要内容

ヒープメモリ(heap memory)はアプリ実行時に動的割り当てされるメモリ領域で、malloccalloc、Swift/ObjC オブジェクトインスタンス化などで使用されます。ヒープメモリは通常アプリメモリ使用量の大部分を占め、dirty ページがアプリのメモリ制限にカウントされるためです(04:22)。このセッションでは 5 類のヒープメモリ問題を体系的に解説:一過性増加(メモリスパイク)、持続的増加(増加のみ)、メモリリーク(オブジェクト解放不能)、実行時性能問題(頻繁な割り当て/解放によるカクつき)、および Apple ツールチェーンでの逐一トラブルシューティング。

ツールチェーンは 4 層:Xcode Memory Report(全体トレンド)、Instruments Allocations(割り当て履歴とコールスタック)、Memory Graph Debugger(オブジェクト参照関係)、コマンドラインツール(heapmalloc_historyvmmapLeaks)(04:10)。

セッションは Destination Video サンプルプロジェクトでデモ。第 1 の問題:画像ピッカーを繰り返し開くとメモリが約 1GB までスパイクし、最終的に OOM クラッシュ(05:46)。Allocations Instrument で追跡すると、毎回のオープンで多数の未解放 autorelease pool コンテンツページが作成 — 原因はループ内で Objective-C フレームワーク API を呼び出す際に生成された autoreleased オブジェクトが同一 pool に蓄積(10:54)。解決策はループ本体をネストした autoreleasepool で囲み、各イテレーション終了時に即座に解放(12:10)。

第 2 の問題はメモリ持続的増加。一過性増加修正後、メモリが段階状に上昇 — 画像ピッカーを開くたびに増加して戻らない(13:09)。Mark Generation で各期間の持続割り当てを分離すると、ThumbnailLoader 由来の大量 Data ストレージ割り当てを発見(15:23)。Memory Graph Debugger で参照チェーンを追跡 — これらのオブジェクトはグローバルキャッシュ globalImageCache が保持、キャッシュキーがファイル作成時間ではなく現在時刻を使用 — 毎回の呼び出しで新しいキャッシュエントリ生成(18:58)。

第 3 の問題はメモリリーク。Memory Graph Debugger が ThumbnailRenderer、ThumbnailLoader、クロージャコンテキストの参照サイクルを発見(21:43)。クロージャはデフォルトで renderer を強キャプチャし、三者が相互強参照で解放不能。解決策は [weak renderer] キャプチャリスト(23:40)。


詳細

ヒープメモリ基礎

ヒープメモリはアプリ仮想メモリの一部で、動的割り当てと長期生存オブジェクトを格納。各ヒープ割り当ては 16KB メモリページから、ページには 3 状態(02:05):

  • Clean:未書き込みまたは読み取り専用マップ、いつでも破棄可能
  • Dirty:書き込み済み、メモリ使用量にカウント、圧力下で圧縮またはスワップ
  • Swapped:圧縮済みまたはディスク書き込み

Dirty と Swapped のみがアプリの memory footprint にカウント。ヒープメモリが通常大部分を占めます(02:51)。

MallocStackLogging は重要なデバッグ機能で、各割り当てのコールスタックとタイムスタンプを記録(03:47)、Xcode Scheme の Diagnostics タブで有効化。

一過性増加:Autorelease Pool

一過性増加の特徴はメモリスパイク状 — 急上昇後に下降。メモリプレッシャーをトリガーし、システムがメモリ圧縮/スワップまたはアプリ終了(07:56)。

Allocations Instrument では 2 方式で特定(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 関数の呼び出し頻度を確認

参照サイクルの暗黙的罠

メソッドをクロージャプロパティに代入すると self を暗黙キャプチャ(29: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 をキーにする場合は現在時刻ではなくファイルの変更時刻または作成時刻を組み合わせる。

なぜ価値があるか: 誤ったキャッシュキーは永続的キャッシュミス — 毎回アクセスで新オブジェクト作成してキャッシュに追加、メモリ持続的増加。

始め方: 全キャッシュコードを監査してキー安定性を確認。ファイルリソースには実行時時刻ではなくファイル属性(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. 修正後は連鎖リークを確認

1 つのメモリリーク修正後、他のリークも消失する可能性 — リークオブジェクトが保持していた他オブジェクトもリークしていたため(24:08)。

なぜ価値があるか: リークオブジェクトは他オブジェクトへの強参照を保持、根本原因リーク修正後に保持オブジェクトも正常解放。

始め方: リーク修正後 Memory Graph Debugger を再実行、リーク数減少または消失を確認、新しい根本原因リークがないかチェック。


関連セッション

コメント

GitHub Issues · utterances