メモリリーク (memory leak) — アプリケーションが不要になったオブジェクトが占有するメモリを解放しない状況です。モバイル開発ではこれが特に重要です:限られたヒープとスワップの不在が OutOfMemoryError やアプリケーションクラッシュを引き起こします。Purdue University (2022) によると、Google Play 上の Android アプリの 35% が少なくとも1つのメモリリークを含んでいます。典型的なシナリオ、診断ツール、修正方法を見ていきましょう。
重要なポイント
メモリリーク — プログラムがオブジェクトを必要としなくなった後も、割り当てられたメモリがシステムに返却されない状況です。ガベージコレクタは、GC Root からのアクティブな参照チェーンがそれを指しているため、そのようなオブジェクトを生存していると見なします。
Java/Kotlin では、ガベージコレクタは自動的に動作しますが、技術的な参照が存在する場合、オブジェクトが論理的に不要であることを判断できません。開発者は不要な接続を明示的に切断する必要があります。Swift/Objective-C では、ARC が自動的に参照をカウントしますが、 retain cycle がカウンタのゼロ到達を妨げます。
リークの主な危険性は 累積効果 です。各リークは少量のメモリを消費しますが、画面遷移(画面回転、Activity の開閉)を繰り返すと、ヒープ制限に達するまでリークが蓄積されます。
リーク — オブジェクトはコードからアクセス不可能ですが、GC によって削除されていません。ブロート — オブジェクトは論理的に必要ですが、過剰な量で保存されています。ブロートの例:30 MB のワーキングセットに対して 100 MB の画像キャッシュ。どちらの問題も OOM につながりますが、原因と治療方法は異なります。
ART (Android Runtime) は、コンカレントコンパクションを伴う世代別ガベージコレクションを使用します。メモリは Young 世代、Old 世代、Large オブジェクトに分割されます。複数の GC サイクルを生き延びたオブジェクトは Old 世代に移動され、そこで収集の頻度が低くなります — これにより通常のサイクルが高速化されます。
GC は、ヒープ が一定の使用率しきい値(通常 75-85%)に達したときに開始されます。GC 中、アプリケーションのすべてのスレッドが停止されます(STW — Stop The World)。生存オブジェクトが多いほど、ポーズは長くなります。リークは生存オブジェクトの数を増やし、GC ポーズを延長します。
コレクタは、GC Roots からグラフをたどって生存オブジェクトを判断します:静的フィールド、アクティブスレッドのスタック変数、JNI 参照。これらのルートから参照を介して到達可能なオブジェクトはすべて生存と見なされます — 開発者がもう必要ないと知っていてもです。
// 例:GC Root としての静的コレクション — 永続的なリーク
object GlobalHolder {
val listeners = mutableListOf<WeakReference<Any>>()
}
class LeakingFragment : Fragment() {
override fun onCreate(savedInstanceState: Bundle ?= null) {
super.onCreate(savedInstanceState)
GlobalHolder.listeners.add(WeakReference(this))
// WeakReference は GC を妨げない — 正しい動作
}
}
WeakReference が問題を解決します:GC は生存オブジェクトを判断する際に弱参照を無視します。オブジェクトに弱参照のみが残っている場合、次の GC サイクルで収集されます。
Activity Context — Android で最も広範なリークシナリオです。シングルトン、静的フィールド、または長期生存サービスが Activity Context への参照を保持している場合、すべての View を含む Activity 全体が GC によって収集できません。解決策:長期生存オブジェクトには Application Context を使用します。
Handler と送信されたメッセージ — Handler.postDelayed(runnable, delay) は Main Looper キューにメッセージを配置します。遅延が切れる前に Activity が破棄された場合、メッセージはまだキューにあり、Runnable → 匿名クラス → 外部クラス (Activity) を介して参照を保持します。
class SafeActivity : AppCompatActivity() {
private val mainHandler = Handler(Looper.getMainLooper())
private val callback = Runnable { /* update UI */ }
override fun onResume() {
super.onResume()
mainHandler.postDelayed(callback, 5000)
}
override fun onPause() {
mainHandler.removeCallbacks(callback) // 必須:キューをクリア
super.onPause()
}
}
内部クラス — 非静的内部クラスは、外部クラスインスタンスへの暗黙的な参照を持ちます。外部クラスが Activity で、内部クラスが外部に渡される場合(例えば RecyclerView.Adapter に)、Activity は収集できません。
Android Studio Memory Profiler — リアルタイムヒープ監視のための組み込みツール。使用メモリのグラフ、割り当て数、タイプ別のオブジェクトを表示します。ヒープダンプを記録し、MAT で分析するために HPROF 形式でエクスポートできます。
Eclipse MAT (Memory Analyzer Tool) — デスクトップヒープダンプ分析ツール。自動的に Leak Suspects レポートを構築し、最大の retained size を持つオブジェクトを強調表示し、疑わしい各オブジェクトの推定 GC root chain を提案します。
Xcode Memory Graph Debugger — iOS 用。アプリケーションを一時停止し、オブジェクトグラフを可視化します。Retain cycle は赤で強調表示され、任意のオブジェクトをクリックして retain count と参照を確認できます。
| ツール | 機能 | 複雑さ |
|---|---|---|
| Memory Profiler | リアルタイムグラフ、ヒープダンプ、Object Allocation Tracking | 低 |
| Eclipse MAT | Dominator tree、Leak Suspects、OQL クエリ | 中 |
| LeakCanary | 自動検出、通知内のリークトレース | 最小 |
| Xcode Memory Graph | retain cycle の視覚的グラフ、生存オブジェクトリスト | 低 |
Uber Engineering Blog によると、CI/CD パイプラインへの自動メモリプロファイリング(LeakCanary + ヒープダンプ分析)の統合により、本番環境のメモリ関連インシデントが 3 か月以内に 60% 削減されます。
Context の置き換え — オブジェクトが Activity より長生きする場合は、applicationContext を使用します。すべての長期生存オブジェクト(シングルトン、リポジトリ、データベースヘルパー)は、Activity Context ではなく Application Context を受け取る必要があります。例外:Activity 固有のテーマやリソースへのアクセスが必要な UI コンポーネント。
Lifecycle-aware コンポーネント — LifecycleObserver、DefaultLifecycleObserver、またはリアクティブ拡張機能を使用すると、onDestroy で自動的にサブスクリプションがキャンセルされます。Android Jetpack は lifecycleScope と viewModelScope を提供し、対応するライフサイクルイベントでクリーンアップされます。
静的内部クラス — 内部クラスが外部クラスのフィールドにアクセスする必要がない場合は、static にします。静的内部クラスは外部クラスへの暗黙的な参照を持ちません。アクセスが必要な場合は、明示的な参照に WeakReference を使用します。
class MyActivity : AppCompatActivity() {
// ❌ 非静的内部クラス — MyActivity への暗黙的な参照
inner class BadListener : SomeListener {
override fun onEvent() { /*...*/ }
}
// ✅ 静的内部クラス — 暗黙的な参照なし
class GoodListener(private val activityRef: WeakReference<MyActivity>) : SomeListener {
override fun onEvent() { /*...*/ }
}
}
iOS では、キャプチャリストを使用します:作成者より長生きする可能性のあるクロージャでは [weak self]。デリゲートには弱参照を使用します(weak var delegate)。self の生存期間中のみ呼び出されることが保証されているクロージャでは [unowned self] を使用できますが、注意が必要です — 解放されたオブジェクトにアクセスするとクラッシュします。
よくある質問
Android では、複数の画面遷移(Activity A → B → A → B)を実行し、adb shell dumpsys meminfo package_name を確認します。Total PSS が安定して増加し、元の値に戻らない場合 — リークがあります。iOS でも同様に、Xcode の Debug Memory Graph を使用して視覚的に検査します。
はい、コンポーネントが破棄されるときに CoroutineScope がキャンセルされない場合です。GlobalScope で起動されたコルーチンは、Activity の finish() 後も実行を続けます。解決策:viewModelScope(onCleared でキャンセル)または lifecycleScope(onDestroy でキャンセル)を使用します。カスタムスコープの場合は、LifecycleOwner を介して lifecycle-aware スコープを作成します。
Bitmap はピクセルデータを Java ヒープではなくネイティブヒープに保存します。つまり、Java GC は Bitmap の実際のサイズを認識しません。Bitmap で recycle() を呼び出さないか、参照を null にしない場合、ネイティブメモリは解放されません。縮小コピーをロードするには BitmapFactory を inSampleSize とともに使用し、自動キャッシュ管理には Glide/Coil を使用します。
静的フィールド は GC Root です。クラスがロードされている限り(Android では Process が生きている限り)生存します。静的フィールドが Activity、Bitmap、View、またはその他の重いオブジェクトを参照している場合、そのオブジェクトは GC によって収集されることはありません。静的フィールドは永遠の参照です。解決策:WeakReference のみを保存するか、onDestroy で静的フィールドを null にします。
ARC は、強い参照カウントがゼロになると自動的にオブジェクトを解放します。ARC でのリークの唯一の方法は retain cycle です。子が親より長生きする可能性がある親→子参照(デリゲート、data source)には常に weak を使用します。クロージャでは、キャプチャリスト [weak self] を使用し、クロージャ内で self が nil でないことを確認します。
まとめ
ターンキー方式のモバイルアプリケーションを開発します
IT Sectrは2017年からスタートアップや企業向けにiOS・Androidアプリケーションを開発しています。私たちがご相談に乗り、最適なソリューションをご提案します。