.NET のガベージコレクションの仕組み
この記事について
.NET のガベージコレクションが何をしているのか、基本的な動作の雰囲気を説明する記事です。
正確性よりも分かりやすさと短さを優先しています。より詳しくは末尾の参考文献をご覧ください。
ガベージコレクションとは
ガベージコレクション(GC)は、C# などで使われるメモリ管理の仕組みです。例えば C++ では確保したメモリを手動で解放しますが、C#では GC が不要になったメモリを自動的に解放してくれます。
マーク・アンド・スイープ方式
自動的に消すためにはまず使われなくなったオブジェクトを識別する必要があります。.NET ではマーク・アンド・スイープ方式が使われます。これは使われているオブジェクト全てにマークを付けて、最後までマークが付かなかったオブジェクトを消す(= スイープする)という方法です。
どうやってマークを付けるかというと、ルート(static フィールドやローカル変数など)にあるオブジェクトから全ての参照を再帰的に辿ってマークしていくだけです。どこからも辿れないなら使えないので消しても大丈夫ということです。
世代別ガベージコレクション
マークを付けるために全ての参照を辿ると、オブジェクトの数に比例して処理時間も増えてしまいます。GCが動いている間は基本的にアプリの処理が止まるのでこれは問題になります。処理時間を抑えるために GC を世代別にする工夫があります。
まず C# のオブジェクトが世代0、世代1、世代2に分けて管理されます。
- 生成直後のオブジェクトは世代0になります。
- GC が実行されても消されずに残っている 世代0のオブジェクトは世代1に昇格します。
- 再び GC が実行されてされてもまだ残っている世代1のオブジェクトは世代2に昇格します。世代2が最後の世代でこれ以上昇格することはありません。
次に、この仕組みが処理時間を短くするのにどう役立つのか GC 処理の流れを見ていきます。
- 新しいオブジェクトは世代0になるので、まず世代0に使われるメモリが大きくなってきます。ある程度メモリ使用量が増えると世代0のオブジェクトを消してメモリを解放するための GC が実行されます。一時変数のような使用期間が短いオブジェクトはこれで消され、残っているオブジェクトは世代1に昇格します。また世代0が大きくなるたびに世代0だけで GC が実行されます。世代1に昇格させた分は GC に含まないことで処理時間を抑えられます。
- 世代1も世代0からどんどん昇格してくるうちにサイズが大きくなります。世代1のメモリ使用量が増えた段階で、世代0と一緒に世代1の GC も実行されます。世代0だけよりは対象が多くなり処理時間がかかりますが、実行される頻度は世代0より少なくなります。
- 世代2にも世代1の GC で生き残ったオブジェクトが昇格してきますが、世代0と1よりさらに GC が実行される頻度は少なくなるはずです。世代2の GC は全ての世代が GC されるのでフル GC とも呼ばれます。これは時間がかかる処理なので実行頻度を少なくすることは重要です。
このように長く生き残ったオブジェクトほど後ろの世代に昇格していきます。一般的に、長期間残っているオブジェクトはその後もずっと残っている確率が高いです。世代2のこれからも残っていそうなオブジェクトよりも、作られたばかりで消える確率が高い世代0を高頻度にチェックするのは理に適っています。
GC write barrier
先ほどの世代別 GC の話では少し不十分です。世代0が GC されるときに世代0のオブジェクトの参照をチェックするだけでは、世代2や世代1のオブジェクトから参照されている世代0のオブジェクトが消されてしまいます。
これを解決するために write barrier という仕組みがあります。世代2のオブジェクトに世代0のオブジェクトが代入される際に、その世代2のオブジェクトがあるメモリ領域にフラグが立ちます。フラグが立った領域は世代0の GC でも参照が辿られるようになります。
ガベージコレクションはいつ実行されるか
世代0のメモリ使用量が多くなると世代0の GC が実行されると説明しました。どれくらいのサイズで実行されるかの閾値は世代ごとに決められています。この閾値は実行中に自動的に調整されます。
例えば、GCを実行してもほとんどオブジェクトを消せなかった場合は閾値が上げられるかもしれません。オブジェクトが消せないのに GC を実行しても処理時間がかかるだけで何の意味もないからです。
逆にたくさん消せた場合は、閾値を下げてより頻繁に GC を実行した方がいいかもしれません。
自動実行されるのを待たずに、GC.Collect() で GC を強制的に実行することもできます。ただし使い方には注意です。本来なら世代0や世代1で消えるはずのオブジェクトが、変に GC が実行されたせいで世代2になってなかなか消えなくなるかもしれません。
コンパクション
GC で使われないオブジェクトが消されていくと、消された領域とまだ使われている領域が交互に並んだ状態になっていきます。これをメモリ断片化と言いますが、そのままにしておくと問題になります。極端に断片化が進めばメモリ確保に失敗することもあります。
.NET の GC はこれも自動的に解決してくれます。使われているオブジェクトの位置を動かして隙間が少なくなるようにまとめます。これがコンパクションです。
コンパクションは GC と同じタイミングで実行されます。以下のような流れになります。
- マーキングフェーズです。前述の説明の通り、GC 対象となっている世代のオブジェクトで参照が辿れる物にマークを付けます。
- 計画フェーズです。GC でどれくらい断片化がおこり、コンパクションにどれだけ効果があるかを計算します。あまり効果がなければフェーズ3と4はスキップされます。
- 再配置フェーズです。オブジェクトを再配置する先のアドレスを計算します。そしてそのオブジェクトを参照している変数を全て見つけて、その変数が再配置先を指すように書き換えます。
- コンパクションフェーズです。オブジェクトを先ほど計算した再配置先へコピーします。
- スイープフェーズです。マークが付いていないオブジェクトを消して空き領域を増やします。
次回予告
オブジェクトを解放するために .NET がどのようなことをしているかを説明しました。反対にオブジェクトを生成したときに何が起きているのかも知らないと片手落ちです。次回は生成時の処理についてまとめる予定です。
参考文献
ガベージコレクションについて日本語で説明されています。.NET の GC はこの中では Mark-Compact GC と Generational GC の組み合わせになります。
.NET のガベージコレクションについての公式ドキュメントです。まずはここから。
さらに詳細な説明があります。バージョンが少し古いですが日本語訳もありました。
ガベージコレクションの実装まで解説してくれる YouTube シリーズです。英語ですがスライドがあるので自動字幕と合わせて見れます。