「Android/CS基礎 - メモリ管理」シリーズのPart3です。
先に、Part2を読むことを推奨します。
また、本記事は私がAndroidのメモリ管理やGCについて学んだ内容をまとめたものです。誤りや改善点がありましたら、コメントでご指摘いただけると嬉しいです🙇♂️
この記事で理解できること
- ARTとは
- 最新のARTにおけるGCアルゴリズムについて
- メモリリークパターンとその対策
ART (Android Runtime) とは
概要
ART (Android Runtime) は、Android 5.0以降からAndroid端末に搭載されているランタイム環境です。
Android 5.0以前はDalvik VMという環境が使われていましたが、パフォーマンス向上のためARTに置き換えられました。
Dalvik VMとARTの比較
| 項目 | Dalvik VM | ART |
|---|---|---|
| コンパイル方式 | JIT(実行時) | AOT+JIT(事前+実行時) |
| コンパイルタイミング | アプリ起動時・実行中 | インストール時 + アイドル時 |
| 起動速度 | 遅い | 速い |
| ストレージ使用量 | 少ない(DEXのみ) | 多い(DEX + コンパイル済みコード) |
| バッテリー消費 | 大きい | 小さい |
| GCアルゴリズム | Mark & Sweep中心 | Concurrent Copying GC |
| GC一時停止時間 | 長い(数十〜数百ms) | 短い(数ms以下) |
| メモリ圧縮 | なし | あり(断片化解消) |
| 世代別GC | 限定的 | 完全実装 |
| 導入時期 | Android 1.0〜4.4 | Android 5.0以降 |
Dalvik VMは現在ではほとんど使われていないため、詳細は省略します。
ARTの目的
ARTの目的は、Androidアプリのパフォーマンスと効率性を向上させることです。
具体的には以下の観点から改善を図っています:
パフォーマンスの向上
- アプリの起動速度を高速化
- 実行速度の向上
- UI操作のレスポンス改善(GC停止時間の短縮)
バッテリー効率の改善
- 実行時コンパイルの削減 → CPU使用率低下
- 効率的なメモリ管理 → GC頻度の削減
メモリ管理の最適化
- 高度なGCアルゴリズムの導入(後述)
- メモリ断片化の解消
- メモリ使用効率の向上
ARTの役割
ARTはAndroidにおいて以下の役割を担っています。
アプリケーションの実行基盤
- DEXバイトコードのコンパイル(AOT/JIT)
- Java/Kotlinコードの実行
- メモリ管理(GC、ヒープ管理)
HAL層を経由したハードウェア操作
これはAOSP (Android Open Source Project) と呼ばれるAndroid OSのアーキテクチャ図です。
水色のレイヤーがARTで、その上下にSystem Services層とHAL層が存在します。
HAL (Hardware Abstraction Layer) とは、ハードウェア操作を抽象化したレイヤーです。
ARTはJNI (Java Native Interface) を通じてネイティブコードを実行し、HAL層経由でハードウェアにアクセスします。
例:アプリでQRコードを読み込む場合
[アプリコード (Kotlin/Java)]
↓ ARTで実行
[JNI経由でネイティブコード呼び出し]
↓ HAL層経由
[カメラハードウェア制御]
System Services層との連携
System Servicesは、アプリの起動管理、通知、位置情報など、システム全体を管理する特権プロセス群です。
重要なポイント:
- System ServicesはARTの上で動作するJavaプロセス
- System ServicesもARTに依存して実行される
ARTはSystem Servicesを含む、すべてのJava/Kotlinコードの実行基盤となっています。
最新のARTにおけるGCアルゴリズムについて
現代のARTでは「Concurrent Copying GC」というアルゴリズムが採用されています。
従来のGCアルゴリズムの課題
前回、GCアルゴリズムの基本は「Mark & Sweep方式」ということを学びました。
しかし、これには様々な欠点がありました。
欠点1. STW時間が長い
アルゴリズム実行中、アプリはSTW(Stop The World)という状態に変化します。
これはその名の通り、アプリ自体が停止するのです。(某アニメに登場するザ・ワールドを使うスタンドみたいなやつ)
一見、「まさかメインスレッドで処理してるのか?」と引っかかりますが実際は違います。
停止しないと、GC実行中にコードが実行されてさらに参照を変えられるとGC実行時点でのオブジェクトグラフと実体の依存関係の整合性が取れなくなるのです。

こうなると悲惨です。
不要なのにマークされるのでメモリリークは発生するし、GC側はまだオブジェクトが存在するものとして扱ってるので何かしらの拍子にアクセスしたら実はnullでNPEクラッシュ😛などというオチがあり得ます。
ですから、STW状態に遷移する必要があるのです。
ただし、STW時間が長いと当然画面は止まるのでUXは最悪です。
なので、話の焦点は「どうやればSTW時間を短くできるか」に移動するというわけです。
欠点2.メモリの断片化
Mark & Sweepアルゴリズムによりオブジェクトが解放されたとしましょう。
しかし、そうなると問題が出てきます。
それは利用可能メモリの断片化(フラグメンテーション)が発生することです。
このように中途半端にメモリが解放されるので、実際は使えるのにサイズが合わず利用されないメモリ領域などが発生してしまうことも問題でした。
現代のGCアルゴリズムについて
これらの欠点を補うために考案されたアルゴリズムが「Concurrent Copying GC」アルゴリズムです。
これらの全体のアルゴリズムは4つのフェーズに分解され、下記のフローに沿って進行します。

Phase1. 初期マーク
初期マークではそれぞれのGC Rootから直接参照できるノードのみをマークします。
このマークされた地点たちはPhase2にて走査処理の起点とされます。

Phase2. 並行マーク

並行マークでは、GCスレッドを使ってPhase1でマークしたノードを起点に順番に走査が走ります。
しかし、これはアプリスレッド(メインスレッド)ではないため、UXは低下しないのです。
Write Barrierも非常に重要な役割です。
マーク処理の最中に参照が変わったらオブジェクトグラフと実体が乖離して大問題となります。
これを防ぐのがWrite Barrierです。
Write Barrierは参照が書き換えられたノードに対し印を付与します。
Phase4で当該ノードは整合を保つため再走査します。
Phase3. 並行コピー

並行コピーでは、Phase2までの段階で到達可能ノードのみを新たなスペースへコピーします。
こちらもGCスレッドで行うため、UXへ影響を及ぼしません。
本作業を行う目的は空きメモリの断片化によるメモリ不足(フラグメンテーション)を防ぐためです。
簡潔に言えば、空きスペースと使用スペースをしっかり区切ってヒープ領域を最大限活用しよう、ということですね。
では、新しいスペースへコピーしたあと、外部が古いスペースへアクセスしてデータを取ろうとした場合どうなるかというと、転送ポインタという情報を辿ってしっかり新しいスペースまで導いてくれます。
転送ポインタは、データのお引越し先を記してくれたものです。
この役割をRead Barrierと言います。
Phase4. 最終処理

最後ですね。
ここでは、Write / Read Barrierの精算を行います。具体的には、
Write Barrier:Phase2で印をつけたノードを新しいスペースへコピー
Read Barrier:Phase3で新しいスペースに移行したにも関わらず、まだ古いスペースのポインタを参照している箇所があればそれらを新しいスペースへのポインタに書き換え。そして古いスペースは解放する。
Read Barrierの精算の際、必ずSTWが発生します。
なぜならアプリが動いていると運が悪い場合に古いスペースのポインタを参照しないようにと書き換える前に参照処理が走ってしまう可能性が考えられるからです。そうなると、解放されたポインタ(つまりアクセスしてはいけない領域)にアクセスしてしまいアプリクラッシュの発生元になります。
ここまでの流れを通して1回分のGCが完了するというわけですね。
メモリリークパターン
メモリリークパターンを2つ紹介します。
本当はもっとあるのですが紹介しきれないので各自で調べてみてください。
Singletonでアクティビティ保持
// ❌ NGパターン
object Singleton {
var activity: Activity? = null
}
class MyActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Singleton.activity = this // リーク!
}
}
// ✅ OKパターン
object Singleton {
var activityRef: WeakReference<Activity>? = null
}
解説:
Part2で説明したようにstatic領域はGC Rootになります。
そうすると、activityデータがnullにならない限り、staticのGC Rootからアクセスできてしまうからです。
つまり、到達可能フラグがONになるので回収されずリークします。
そこで、弱参照を用いることで参照元であるActivity自体が消えたら自動で回収されるようにすればリークを防げます。
Companion object でContext保持
// ❌ NGパターン
class MyUtil {
companion object {
private var context: Context? = null
fun init(context: Context) {
this.context = context // ActivityのContextを渡すとリーク
}
}
}
class MyActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
MyUtil.init(this) // リーク!
}
}
// ✅ OKパターン
class MyUtil {
companion object {
private var appContext: Context? = null
fun init(context: Context) {
this.appContext = context.applicationContext // ApplicationContextを使う
}
}
}
解説:
companion object = staticなオブジェクトです。
そこに対し、Activityが生存している間しか使えないActivityContextを引き渡せば、先ほどと同じ原理でリークします。
そのため、ApplicationContextを渡すようにしましょう。
こちらは、アプリが生存している間はずっと使えるためリークに繋がりません。
まとめ
いかがでしたでしょうか。
ここまで、かなり長かったですね。しかしここを押さえるとメモリリークが発生した場合の対処方法が、より理論立てて検討できるようになったのではないでしょうか?
Java / Kotlinはランタイムがメモリ管理を隠蔽してくれる素晴らしい言語ですが、その内部を知ってるかどうかでメモリリークに対する意識一つとっても大きく変わるはずです。
現に、これを読む前と後ではみなさんのメモリ管理に対する意識も変化しているのではないでしょうか。
近年、さまざまなフレームワークやライブラリが登場しています。
一方で、プロセスやスレッド、GCなどの基礎(Fundamental)は不変です。基礎を抑えることで今後のITの変化にも着いていきやすくなると思います!!

