モバイルのメモリ管理とARC
アプリが謎のメモリリークで落ちる前に読みたい。iOSのARCが循環参照を作る仕組みとweak/unownedの使い分け、Androidの世代別GCとの思想の違いを原理から整理。
- 1.iOSのARCはコンパイル時に retain/release を自動挿入する静的解析であり、実行時GCではない。強参照の循環(retain cycle)はARCでは検出も回収もできず、weak/unownedで手動的に断ち切る必要がある。
- 2.AndroidはランタイムのトレースGC(世代別GC)で到達可能性を実行時に判定するため循環参照も回収できるが、GC一時停止やメモリプレッシャー通知への対応が課題になる。
- 3.ARCは決定論的な即時解放、GCは非決定論的だが循環に強いという設計思想の違いは、それぞれのプラットフォームのオブジェクトライフサイクルAPI設計にまで影響している。
参照カウントとトレースGCという二つの答え
オブジェクトが「もう使われていない」とどう判定し、いつメモリを返すか。この問いにiOSとAndroidは対照的な答えを出しています。iOSのARC(Automatic Reference Counting)は参照カウント方式、Androidは世代別GC(Garbage Collection)を核とするトレース方式です。どちらも「到達可能性がなくなったら解放する」という目的は同じですが、判定のタイミングと循環参照への耐性がまったく異なります。この違いを原理から理解すると、なぜiOSではweak/unownedの使い分けが設計上の必須事項になり、Androidではリークよりもポーズ時間の方が問題になりやすいのかが見えてきます。
ARCの実体:コンパイラが挿入する静的な参照カウント
ARCは実行時にオブジェクトを走査するガベージコレクタではありません。コンパイラが静的解析でretain/releaseの呼び出しをコード中に自動挿入する、いわば人間が書いていたMRC(Manual Reference Counting)の自動化です。プロパティへの代入やローカル変数のスコープ終了といったコード上の地点ごとに、コンパイラが所有権の増減を機械的に判定してオブジェクトの参照カウントを操作します。
各オブジェクトは自身への強参照(strong reference)の数を保持し、カウントが0になった瞬間に dealloc が呼ばれてメモリが即座に解放されます。これが決定論的なタイミングでの解放という特性を生みます。GCのように「いつか回収される」のではなく、最後の強参照が外れた行で解放が起きると予測できます。
let a = Node() // aの参照カウント = 1
b = a // aの参照カウント = 2
a = nil // aの参照カウント = 1(bがまだ保持)
b = nil // aの参照カウント = 0 → dealloc即時実行
循環参照:ARCが原理的に解決できない問題
参照カウントの弱点は、カウントだけを見ていて全体のグラフ構造を見ていないことです。オブジェクトAがオブジェクトBを強参照し、BもAを強参照している場合、外部からの参照がすべて外れても互いのカウントは1のまま残り続けます。到達可能性で見れば外側から辿れず本来ゴミですが、カウント方式は「誰かが指しているか」しか見ないため、この循環(retain cycle)を検出できずリークします。
典型的な例は、親が子を配列やプロパティで強参照し、子が親へのコールバックやデリゲート参照を強参照で持ち返すケースです。クロージャが self をキャプチャして、そのクロージャ自身をプロパティとして保持するパターンも同じ構造の循環を作ります。
retain cycleはクラッシュや例外を起こさないため発見が遅れがちです。Instrumentsのメモリグラフデバッガでオブジェクト間の強参照グラフを可視化し、期待した瞬間にdeallocが呼ばれているかを確認するのが実務上の主な検出手段になります。
weakとunowned:循環を断ち切る二つの手段
ARCが循環を自動で解決できない以上、所有権グラフを木構造に近づける設計を人間が行う必要があります。そのための修飾子がweakとunownedです。
| 観点 | weak | unowned |
|---|---|---|
| 参照カウントへの影響 | 増やさない | 増やさない |
| 参照先が解放された後 | 自動的にnilになる(Optional型必須) | 解放済み領域を指したまま。アクセスすると未定義動作でクラッシュしうる |
| 実装コスト | 解放を監視する仕組みが必要でわずかにオーバーヘッドあり | 監視なし。オーバーヘッド最小 |
| 使いどころ | 参照先が自分より先に消える可能性がある場合(親子の子から親、delegateなど) | 参照先が自分と同じか自分より長く必ず生存すると設計上保証できる場合 |
weakは、参照先オブジェクトのdealloc時にランタイムがweak参照テーブルを引いて該当のポインタをnilに書き換える仕組み(zeroing weak reference)で安全性を担保します。一方unownedはこの監視コストを払わない代わりに、参照先が先に消えていた場合の安全網がありません。「絶対に自分より長生きする」という設計者の保証だけを頼りにするため、誤ると典型的な「解放済みメモリへのアクセス」によるクラッシュを招きます。
UIKit/AppKitのdelegateプロパティが伝統的にweakなのは、子(View)が親(ViewController)をdelegateとして強参照すると、親→子の強参照と合わさって循環になるためです。子から親への逆方向参照は原則weakにする、という経験則はこの構造から導かれます。
Androidの世代別GC:到達可能性を実行時に判定する
Androidのランタイム(ART)はトレース型のGCを採用しており、ARCとは判定原理が根本的に異なります。参照の数を数えるのではなく、GCルート(スタック上のローカル変数、静的フィールドなど)からポインタを辿って実際に到達できるオブジェクトの集合を実行時に探索し、辿れなかったオブジェクトをまとめて回収します。この方式では循環参照があっても、外側から到達不能であれば正しくゴミと判定され、回収されます。ARCが原理的に不得手とする循環参照の処理を、GCは構造上克服しています。
多くの実装は世代別GC(Generational GC)を採用し、オブジェクトを「生成されたばかりの若い世代」と「生き残った古い世代」に分けて管理します。経験則として大半のオブジェクトは短命であるため、若い世代の領域だけを頻繁かつ高速にスキャンし、そこを生き延びたオブジェクトだけを古い世代に昇格させて走査頻度を下げる。これにより全ヒープを毎回総なめにするより効率的な回収が実現します。
Minor GC(若い世代): 高頻度・短時間、生存者は古い世代へ昇格
Major/Full GC(古い世代含む全体): 低頻度・長時間、断片化対策でコンパクションも
GCの代償:一時停止とメモリプレッシャー通知
トレース型GCは循環に強い一方、走査中はアプリの実行を止める、あるいは制約する必要が生じます。この一時停止(stop-the-world pause、あるいは並行GCでの部分的制約)がフレーム落ちやジャンクの原因になり得るため、モダンなARTは並行・コンパクション型GCでポーズ時間を切り詰める方向に進化してきました。
さらにAndroidはヒープ全体の逼迫を検知する仕組みとして、システムからアプリへメモリプレッシャーを通知するコールバック(onTrimMemoryなど、レベルに応じて段階的にキャッシュ解放を要求する仕組み)を用意しています。アプリはこの通知を受けて、UIがバックグラウンドに回った、あるいはシステム全体のメモリが逼迫しているといった段階ごとに、保持しているキャッシュを自主的に解放することが期待されます。これはARC環境(iOS)にも似た仕組み(メモリ警告通知)がありますが、GC言語ではヒープの逼迫具合をランタイムが継続的に把握しているぶん、通知の粒度がより段階的に設計されています。
二つの思想の違いを俯瞰する
| 観点 | iOS(ARC) | Android(世代別GC) |
|---|---|---|
| 判定方式 | 参照カウント。コンパイル時にretain/release挿入 | 到達可能性のトレース。実行時にGCルートから探索 |
| 解放タイミング | 決定論的。カウント0の瞬間に即時dealloc | 非決定論的。GCサイクルが走るまで遅延しうる |
| 循環参照への耐性 | 弱い。weak/unownedで人間が設計的に回避 | 強い。到達不能なら循環ごと回収可能 |
| 実行への影響 | 解放コストが各所に分散し予測しやすい | GCサイクルでポーズや制約が発生しうる |
| 逼迫時の通知 | メモリ警告通知 | onTrimMemory等の段階的コールバック |
この違いは単なる実装詳細ではなく、両OSのAPI設計思想そのものに表れています。ARCは「所有権グラフを設計者が明示し木構造に保つ」ことを前提に、決定論的な解放という利点と引き換えに循環参照という落とし穴を許容しました。GCは「到達可能性さえ壊さなければ回収は任せてよい」という利便性と引き換えに、ポーズ時間の管理という別の負債を背負っています。どちらが優れているという話ではなく、即時性と予測可能性を取るか、循環への耐性と実装の簡便さを取るかという設計上のトレードオフの表れです。
「ARCは実行時GCではなくコンパイル時に挿入される参照カウント操作である」という点と、「参照カウント方式は原理的に循環参照を検出できない」という点はセットで問われやすい急所です。Android側は「世代別GCが多くのオブジェクトの短命性を前提にしている」ことと、「メモリプレッシャー通知はアプリ側の自主的なキャッシュ解放を促す仕組みである」ことを対比で押さえておくと整理しやすくなります。
まとめ
iOSのARCはコンパイラが挿入する参照カウント操作であり、決定論的な即時解放という強みを持つ一方、強参照の循環をカウントだけでは検出できないという原理的な弱点を抱えています。この弱点をweak(自動でnil化する監視付き参照)とunowned(監視なしで生存保証を設計者が担う参照)で人間側が補うのがiOS開発の要点です。対してAndroidの世代別GCは到達可能性を実行時に走査するため循環参照ごと回収できますが、その代償としてGC一時停止への配慮とメモリプレッシャー通知への追従が必要になります。参照カウントとトレースGCという二つの答えは、決定論的な解放と循環耐性のどちらを優先するかという設計思想の違いを体現しており、それぞれのプラットフォームのメモリ管理APIはこの前提の上に組み立てられています。より低いレイヤーのメモリ管理原理は /os/ の話題とも接続しています。
モバイル開発 Article
モバイルのメモリ管理とARCを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
iOS
比較で見る軸
難易度: advanced / カテゴリ: モバイル開発 / タグ数: 6
導入後に効く点
AndroidはランタイムのトレースGC(世代別GC)で到達可能性を実行時に判定するため循環参照も回収できるが、GC一時停止やメモリプレッシャー通知への対応が課題になる。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- モバイル開発
- タグ数
- 6
判断チェックリスト
- 自社の用途が「iOS / Android」に近いか確認する。
- 強みである「iOSのARCはコンパイル時に retain/release を自動挿入する静的解析であり、実行時GCではない。強参照の循環(retain cycle)はARCでは検出も回収もできず、weak/unownedで手動的に断ち切る必要がある。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。