Swiftで発生するメモリリークの原因と対策!ARCの仕組みを理解して循環参照を確実に防ぐデバッグチュートリアル

SwiftのARCと循環参照をデバッグしてメモリリークを防ぐ解説イメージ プログラミング言語

Swiftのメモリ管理は、いったん仕組みを理解してしまえば難解ではありません。
ですが、実務では「画面を閉じても解放されない」「インスタンスが残り続けて動作が重くなる」といった形で、メモリリークが静かに潜みます。
特にARCは自動で参照を管理してくれる一方で、循環参照が起きると解放のきっかけを失い、問題が表面化しにくいのが厄介です。

本記事では、Swiftでメモリリークが発生する典型的な原因を整理しながら、ARCの基本動作を論理的に分解していきます。
さらに、strong referenceのつながり方、closureでのcapture、delegate設計、Notificationの扱いなど、実装時に見落としやすいポイントを踏まえて、どこを疑い、どう切り分ければよいのかをデバッグ視点で解説します。

メモリリーク対策は、単にweakを付ければ終わりではありません。
参照の向き、ライフサイクル、所有関係を正しく設計し、XcodeのInstrumentsやdeinitログを使って検証するところまで含めて初めて実用的です。
この記事を読むことで、ARCに任せきりにせず、循環参照を確実に防ぐための考え方と確認手順を、再現性のある形で身につけられるようにします。

Swiftのメモリリークが起きる仕組みを理解する

Swiftのメモリ管理の基本構造を示す解説イメージ

Swiftでは、メモリ管理の多くをARCが自動で担います。
これは便利ですが、万能ではありません。
オブジェクトが不要になったかどうかを最終的に判断するのは、参照カウントが0になるかどうかです。
つまり、どこかで参照が残り続けていれば、ARCは安全のために解放しません。
メモリリークの本質は、解放されるべきインスタンスが「まだ必要だ」と誤認され続ける状態にあります。

ARCが参照カウントをどう管理しているのか

ARCは、オブジェクトへの参照が増えるたびにカウントを1つ増やし、参照が消えるたびに1つ減らします。
実装上は単純に見えますが、重要なのは「参照が存在する場所」が複数ありうることです。
変数への代入、配列や辞書への格納、クロージャによるキャプチャなど、参照を増やす経路は実務で非常に多いです。

たとえば、あるViewControllerが別のオブジェクトを保持し、そのオブジェクトも逆向きにViewControllerを保持しているとします。
このとき、どちらか一方が解放されても、もう一方の参照が残っていれば参照カウントは0になりません。
ARCは参照関係のグラフを「理解」しているわけではなく、単純に数を見ているだけです。
だからこそ、設計段階で所有関係を整理する必要があります。

strong参照が残ると解放されない理由

Swiftの参照は、特に指定しなければstrong参照です。
strong参照は「このインスタンスを所有している」という意味を持ち、所有者が存在する限りオブジェクトは生き続けます。
したがって、必要以上にstrong参照を持つと、意図せず寿命が延びます。

ここでよくある誤解は、「画面を閉じれば自動的に解放されるはず」というものです。
実際には、画面を閉じても、クロージャの中でselfが強く捕捉されていたり、delegateをstrongで持っていたりすると、ViewControllerは解放されません。
特に、イベント通知や非同期処理では、終了時点で参照を外す処理が抜けるとリークが起きやすいです。

  • strong参照は所有権を表すため、不要になったら明示的に切る設計が必要です
  • 参照が残る限り、ARCは安全のために解放しません
  • 「参照を持っている場所」を洗い出すことが、リーク調査の第一歩です

循環参照の典型パターンをまず押さえる

循環参照は、オブジェクト同士が互いを強く参照し合っている状態です。
これが起こると、どちらか一方を外しても、もう一方が残っているために解放されません。
Swiftで特に頻出するのは、親子関係、delegate、クロージャ、Timer、NotificationCenterです。

典型例としては、ViewControllerが子オブジェクトをstrongで保持し、子オブジェクトも親をstrongで保持するケースがあります。
また、クロージャがselfを直接使うと、そのクロージャを保持しているオブジェクトとの間で循環が成立することがあります。
Timerのように継続実行される仕組みも、停止や破棄のタイミングを誤ると残り続けます。

パターン 起きる原因 対策
親子オブジェクト 双方向strong参照 片方をweakにする
クロージャ selfの強参照キャプチャ capture listで弱参照にする
delegate デリゲートをstrong保持 delegateをweakで宣言する

この段階で重要なのは、個別のテクニックを覚えることではなく、参照が循環していないかを構造として確認することです。
ARCは便利ですが、参照関係の責任までは肩代わりしてくれません。
したがって、インスタンスの生成と解放の流れを、所有権ベースで一つずつ追えるようになることが、Swiftでメモリリークを防ぐ最短経路です。

クロージャのキャプチャで発生するメモリリーク対策

クロージャのキャプチャがメモリリークを起こす場面の図

Swiftでメモリリークを調べる際、まず疑うべきなのがクロージャのキャプチャです。
クロージャは周囲の変数を必要に応じて取り込みますが、その際にselfを強く保持すると、呼び出し元のオブジェクトとクロージャの間で参照の輪が生まれることがあります。
特に、非同期処理やイベントハンドラのように、クロージャの寿命がオブジェクト本体より長くなりやすい場面では注意が必要です。
見た目は何の問題もないコードでも、実際には解放されずに残り続けることがあります。

selfを強参照する書き方を見直す

もっとも典型的なのは、クロージャ内でselfをそのまま使っているケースです。
例えば、viewModel.load { self.updateUI() } のような書き方では、viewModelがクロージャを保持し、そのクロージャがselfを保持する構造になりえます。
この関係が成立すると、selfが解放される条件が失われます。
実務では、処理の流れが短くても、内部で非同期キューや遅延実行が入っていると寿命が伸びるため、想定以上にリークしやすいです。

対策としては、クロージャのキャプチャを明示的に見直すことが重要です。
selfを直接使う前に、本当に強参照が必要かを検討します。
必要でないなら、キャプチャリストを使って参照の向きを変えます。
さらに、クロージャの中で必要な値だけをローカル変数に退避させる方法も有効です。
オブジェクト全体を保持するより、必要な情報だけを渡したほうが、所有関係はずっと単純になります。

  • クロージャ内でselfを無条件に使わない
  • オブジェクト全体ではなく、必要な値だけを取り出して扱う
  • 非同期処理では、完了時にクロージャが解放されるかを確認する

weakとunownedの使い分けを整理する

循環参照を避けるための代表的な手段がweakunownedです。
どちらも強参照を避けられますが、意味はかなり異なります。
weakは参照先が解放されると自動的にnilになります。
したがって、参照先の生存期間が不確実な場合はweakを選ぶのが基本です。
一方でunownedは、参照先が常に生きていることを前提にしており、解放後にアクセスするとクラッシュします。

この違いは、単なる文法の選択ではなく、ライフサイクル設計そのものです。
たとえば、delegateのように「参照される側が先に消える可能性がある」関係ではweakが安全です。
逆に、親子のように親が子より必ず長生きする保証があるなら、unownedが選択肢になることもあります。
ただし、その保証が設計上100%でないなら、unownedは避けたほうがよいです。

特性 weak unowned strong
解放後の挙動 nilになる アクセスでクラッシュする 解放を妨げる
使いどころ 生存が不確実な参照 生存が保証された参照 所有が必要な参照

結局のところ、weakunownedの使い分けは、どちらが「便利か」ではなく、どちらがその関係に対して正しい所有モデルかで決めるべきです。
クロージャの中でselfを扱う場合も同様で、参照が必要か、解放されても問題ないか、途中で消える可能性があるかを順に確認すれば、設計の精度は大きく上がります。
メモリリーク対策は、細かな修正の積み重ねというより、参照の意味を正しく定義する作業だと捉えると整理しやすいです。

delegateとNotificationCenterで起きやすいリーク

delegateと通知購読に潜むメモリリークの注意点を示す図

Swiftの実装で見落としやすいのが、delegateとNotificationCenterに起因するメモリリークです。
どちらも「通知する側」と「受け取る側」の関係を簡潔に表現できるため便利ですが、その反面、参照の向きや解除処理を誤ると、オブジェクトがいつまでも解放されません。
特に画面遷移が多いアプリや、非同期イベントが頻繁に飛ぶ構成では、原因が一見わかりにくいまま残り続けます。
重要なのは、仕組みを知るだけでなく、誰が誰を保持しているのかを明確に設計することです。

delegateはweakを前提に設計する

delegateは、一般に「機能を委譲する相手」を指しますが、実装上はただの参照です。
ここでdelegateをstrongで保持すると、呼び出し元とdelegate先が互いを強く抱え込み、循環参照が成立することがあります。
したがって、delegateは原則としてweakで宣言するのが基本です。
これは単なる慣習ではなく、ライフサイクルの非対称性を前提にした設計です。

たとえば、ViewControllerが子オブジェクトを生成し、その子オブジェクトがdelegateとして親ViewControllerを参照する場合、delegate側がstrongだと親が解放されません。
画面を閉じてもdeinitが呼ばれない、という典型的な症状が出ます。
逆にweakであれば、親が先に解放されたときに参照先は自動的にnilになります。
これにより、安全に関係を切ることができます。

  • delegateは「所有」ではなく「通知先」と考える
  • 参照先の生存期間が保証できないならweakを使う
  • delegateの値がnilになっても処理が継続できる設計にしておく

NotificationCenterの解除漏れを防ぐ

NotificationCenterは、オブジェクト間の疎結合を実現する強力な仕組みです。
ただし、通知の登録と解除を適切に管理しないと、観測対象が不要になっても通知購読が残り続けることがあります。
特に、addObserverで登録したままremoveObserverしない、あるいはブロックベースのAPIで返されたトークンを保持したまま破棄しない、といったミスは頻出です。

ここでの本質は、NotificationCenterそのものが悪いのではなく、登録した責任をどこで回収するかが曖昧になることです。
画面の表示中だけ通知を受けたいなら、表示開始時に登録し、終了時に解除する流れを徹底します。
ブロックベースの通知では、トークンをプロパティとして保持し、deinitやライフサイクルの終端で確実に破棄します。
さらに、通知を受ける側が自分自身を強く保持する構造になっていないかも確認が必要です。

項目 登録時の注意 解除時の注意 典型的な失敗
delegate weak前提で保持する 明示的な解除は不要な場合が多い strong保持で循環参照
NotificationCenter トークンや登録先を管理する ライフサイクル終端で解除する 解除漏れでオブジェクトが残る

実務では、通知の解除漏れはコードレビューだけでは見逃しやすいです。
そのため、deinitログを入れて解放確認を行い、画面遷移や再生成を繰り返したときにオブジェクトが増え続けないかを検証します。
delegateは設計で防ぎ、NotificationCenterは運用で漏らさない。
こう分けて考えると、原因の切り分けが格段にしやすくなります。

Timerや閉包ベースAPIの落とし穴

TimerやAPIコールバックで発生するリークの注意図

Timerや閉包ベースAPIは、Swiftで非同期処理を扱ううえで非常に便利です。
しかし、便利であるほど参照関係が見えにくくなり、メモリリークの温床になりやすいです。
特に、一定間隔で繰り返し実行される処理や、完了まで長く生きるコールバックは、selfを意図せず保持し続けることがあります。
コード上はシンプルに見えても、実際にはTimerやAPI側がクロージャを保持し、クロージャがViewControllerやViewModelを保持する構造が成立していることがあります。
この関係を見落とすと、画面を閉じてもオブジェクトが残り、deinitが呼ばれません。

繰り返し実行される処理がselfを保持し続ける問題

Timerの厄介な点は、単発ではなく継続的に動くことです。
scheduledTimerのようなAPIでクロージャを渡した場合、そのクロージャの中でselfを直接参照していると、Timerが生きている間はselfも保持されやすくなります。
繰り返し実行される処理は、終了条件が明示されない限り、メモリ上の寿命も伸び続けます。
つまり、「画面が閉じられたから終わるはず」という期待だけでは不十分です。

この問題は、UI更新のタイマー、定期的なポーリング、リトライ処理などで頻繁に発生します。
処理自体は短くても、呼び出し元のオブジェクトが解放される前提を壊してしまえば、リークとして観測されます。
したがって、Timerを使うときは「誰がTimerを所有し、いつ止めるのか」を先に決めるべきです。
所有者が曖昧だと、Timerだけが独立して残り、内部のクロージャ経由でオブジェクトを閉じ込めてしまいます。

  • selfをクロージャ内で無条件に参照しない
  • Timerの停止条件を設計段階で決める
  • 画面遷移や終了イベントで確実にinvalidateする

終了タイミングを意識したキャンセル設計

閉包ベースAPIでも同じ考え方が必要です。
ネットワークリクエスト、データ取得、アニメーション、非同期イベント購読などは、完了までの間にクロージャが保持されます。
このとき重要なのは、処理が終わったら自然に解放されるのか、それとも明示的にキャンセルしないと残るのかを区別することです。
前者と後者を混同すると、実装は動いていても、裏側でオブジェクトが残り続けます。

たとえば、リクエスト開始時にクロージャを登録し、画面破棄後も結果が来る可能性があるなら、キャンセル処理を設けるべきです。
キャンセルしない設計は、完了ハンドラがselfを抱えたまま走り続ける原因になります。
逆に、処理が終わることが保証されているなら、トークンやタスクを適切に破棄すればよいです。
つまり、重要なのは「APIの使い方」ではなく「終了責任の所在」です。

処理種別 参照が残りやすい箇所 必要な対策 監視ポイント
Timer 繰り返しクロージャ invalidate deinit呼び出し
ネットワーク 完了ハンドラ cancel 画面破棄時の停止
イベント購読 登録トークン 解除 登録数の増加

実装の観点では、weak selfを入れるだけでは十分でないことがあります。
selfを弱くしても、Timerやタスク自体が残っていれば、無駄な処理は続きます。
したがって、キャンセルはメモリ管理の一部として扱うべきです。
終了タイミングを明確に定義し、不要になった瞬間に処理を止める。
この設計ができて初めて、閉包ベースAPIを安全に運用できるようになります。

実際にメモリリークをデバッグする手順

Xcodeでメモリリークを調査するデバッグ画面のイメージ

Swiftのメモリリークは、原因を理屈で理解していても、実際のコード上ではなかなか見つけにくいです。
したがって、推測ではなく観測に基づいて確認する手順が重要になります。
メモリリークのデバッグでは、まず「本来解放されるはずのインスタンスが、いつまで残っているのか」を明確にし、そのうえで参照関係を追跡していきます。
ここで大切なのは、単に怪しい箇所を直感で疑うのではなく、解放確認と循環参照の特定を段階的に分けることです。
順序立てて調べれば、複雑に見えるリークでも切り分けやすくなります。

deinitログで解放確認を行う

もっとも基本的で、かつ有効なのがdeinitログです。
クラスのdeinitにログ出力を入れておくと、インスタンスが本当に解放されたかを即座に確認できます。
たとえば画面を閉じたときにdeinitが呼ばれないなら、そのオブジェクトはどこかで保持され続けていると判断できます。
これはメモリリーク調査の入口として非常に有効です。

ただし、deinitログだけでは原因までは分かりません。
あくまで「解放されていない事実」を示すだけです。
そこで、画面遷移やタスク終了のたびにログを確認し、どのタイミングで残っているのかを絞り込みます。
複数の関連オブジェクトがある場合は、それぞれにdeinitを仕込むと、どの参照が解放の妨げになっているかを把握しやすくなります。
実務では、まずこのレベルで再現条件を固定することが重要です。

  • 画面を閉じた直後にdeinitが呼ばれるか確認する
  • 関連するクラスすべてに一時的なログを入れる
  • 再現性のある操作手順を固定して検証する

Instrumentsで循環参照を特定する

deinitで解放されないことが分かったら、次にInstrumentsで循環参照を追います。
特にLeaksやAllocationsを使うと、オブジェクトの増減や解放状況を可視化できます。
ここで重要なのは、「何が増えているか」だけでなく、「どの参照が残っているか」を確認することです。
単にメモリ使用量が増えているだけでは、リークか一時的なキャッシュかを区別しづらいからです。

Instrumentsでは、特定の画面を開閉したり、特定の処理を繰り返したりして、オブジェクト数が戻るかを見ます。
戻らない場合、そのオブジェクトを保持している経路を辿ります。
クロージャ、delegate、Timer、NotificationCenterなど、これまで疑ってきた要素が実際に参照を持っていないかを確認するわけです。
ここでの作業は、犯人探しではなく、参照グラフの検証です。

確認方法 目的 向いている場面
deinitログ 解放有無の確認 まず最初の切り分け
Allocations オブジェクト増加の観測 操作前後の比較
Leaks 失われた参照の検出 明確なリークの検出

Instrumentsで結果を見たら、必ず実装へ戻って修正し、同じ操作を再度行って検証します。
デバッグは一度で終わらせるものではなく、仮説と確認を繰り返す作業です。
deinitで事実を押さえ、Instrumentsで参照の流れを可視化する。
この二段構えができると、Swiftのメモリリークはかなり体系的に扱えるようになります。

メモリリークを防ぐ設計の原則

Swiftのメモリリーク対策を整理したまとめイメージ

Swiftでメモリリークを根本から防ぐには、個別のバグ修正だけでは不十分です。
重要なのは、コードを書く段階で「どのオブジェクトが誰を所有し、どのタイミングでその関係が終わるのか」を明確にしておくことです。
ARCは強力ですが、参照関係の設計まで自動化してくれるわけではありません。
したがって、リーク対策は後追いのデバッグ作業ではなく、設計原則として組み込むべきです。
言い換えると、参照を増やすより、参照を減らす設計を優先することが、もっとも再現性の高い防御策になります。

まず意識したいのは、所有権の方向です。
オブジェクト同士が相互に強く保持し合うと、解放の起点が失われます。
そのため、親子関係、delegate、クロージャ、通知購読といった接点では、最初から「どちらが所有者で、どちらが観測者か」を区別しておく必要があります。
特にSwiftでは、強参照がデフォルトであるため、何も考えずにコードを書くと所有関係が濃くなりすぎます。
設計とは、必要最小限の保持に抑えることでもあります。

次に、ライフサイクルを短く保つことが重要です。
長寿命のオブジェクトは、それだけ多くの参照を抱えやすく、結果としてリークの起点になりやすいです。
たとえば、ViewControllerが多くの責務を持ちすぎると、タイマー、クロージャ、通知、ネットワーク処理を一箇所で抱え込みます。
その構造自体が複雑さを生み、解放タイミングの把握を難しくします。
責務が分離されていれば、不要になった部分だけを早く切り離せます。

  • 所有者と利用者を分ける
  • 長寿命のオブジェクトに責務を集中させない
  • 参照を持つ理由をコメントではなく構造で示す

さらに、クロージャ設計も原則化しておくべきです。
クロージャは便利ですが、周囲の状態を暗黙に捕捉するため、意図せぬstrong参照を生みやすいです。
そこで、クロージャの中では必要最小限の値だけを扱い、selfを必要とする場面でも、その参照が本当に生存保証されているのかを確認します。
weak selfは単なるおまじないではなく、所有関係を明示するための宣言です。
特に非同期処理では、クロージャの寿命がオブジェクト本体を超えることを前提に設計しなければなりません。

また、解除処理を「後でまとめてやるもの」と考えないことも大切です。
Timerのinvalidate、NotificationCenterの解除、タスクのキャンセルなどは、オブジェクトの終了条件とセットで設計する必要があります。
終了条件が曖昧だと、コード上は動いていても参照が残るからです。
ここで有効なのは、生成と破棄を対にして扱う習慣です。
生成した場所で、どこで終わるかまで決めておくと、設計の抜け漏れが減ります。

設計原則 目的 実装時の判断基準
所有権を明確にする 循環参照を防ぐ 誰が誰を持つか説明できるか
ライフサイクルを分離する 不要な保持を減らす 解放タイミングを局所化できるか
解除を対にする 残存参照を防ぐ 登録と解除が1対1か
暗黙のキャプチャを避ける 予期せぬ保持を減らす selfを明示的に扱えているか

最後に、デバッグしやすい設計にしておくことも原則の一部です。
deinitで解放確認ができるようにし、オブジェクト単位で責務を分けておけば、問題が起きたときの切り分けが速くなります。
メモリリークは「発生してから探す」より、「発生しにくい構造にする」ほうが圧倒的に効率的です。
Swiftで安定した実装を目指すなら、ARCを信用するだけでなく、参照関係そのものを設計対象として扱う姿勢が欠かせません。

コメント

タイトルとURLをコピーしました