.NET のガベージコレクション(GC)は基本的にランタイムが自動で行ってくれるため、アプリケーションコードから直接触る機会は多くありません。しかし、メモリ使用量の監視、ファイナライザを持つオブジェクトの後始末、レイテンシ要求の厳しい区間での GC 抑制など、「自動任せでは届かない場面」 に踏み込みたいときに使うのが System.GC クラスです。
この記事では System.GC が提供する主な API を整理し、それぞれの 役割・使いどころ・落とし穴 をコード例とともに解説します。
System.GC とは
System.GC は System 名前空間に定義された static クラスで、ガベージコレクタへの問い合わせと制御の窓口です。
- インスタンス化はできない(すべて静的メンバー)
- メソッド群は大きく分けて次のカテゴリに分かれる
- GC の起動・待機(
Collect、WaitForPendingFinalizersほか) - GC の挙動制御(
KeepAlive、SuppressFinalize、ReRegisterForFinalize) - メモリ情報の取得(
GetTotalMemory、GetGCMemoryInfo、CollectionCount) - ネイティブメモリ圧の通知(
AddMemoryPressure/RemoveMemoryPressure) - 低レイテンシ領域(
TryStartNoGCRegion/EndNoGCRegion)
- GC の起動・待機(
GC の世代(generation)や仕組みそのものについては別記事に譲り、ここでは API としての System.GC に焦点を当てます。
GC を起動・待機する
GC.Collect() — 明示的に GC を走らせる
GC.Collect() は GC を強制的に起動するメソッドです。引数なし版は全世代を対象に回収し、引数で世代やモードを指定することもできます。
// すべての世代を対象に GC を実行
GC.Collect();
// 第 0 世代のみ
GC.Collect(0);
// 全世代・強制(GCCollectionMode.Forced)・ブロッキング
GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true);
GCCollectionMode には次の値があります。
| 値 | 意味 |
|---|---|
Default |
ランタイムにモードを委ねる(現在は Forced と同等) |
Forced |
即座に GC を実施 |
Optimized |
GC が有益と判断した場合のみ実施 |
Aggressive(.NET 5+) |
より積極的にメモリを解放(コストは高い) |
使うべきでない理由
GC.Collect() は 本番コードでは原則使わない のが鉄則です。
- GC のヒューリスティクスを乱し、結果としてパフォーマンスが悪化することが多い
- 全世代回収は stop-the-world の時間が長くなり、レスポンスが落ちる
- 「とりあえず呼ぶ」では、世代昇格を促進してしまうケースもある
それでも使う場面
限定的ながら、合理的に使える場面はあります。
- ベンチマーク・メモリリークの調査:到達不能オブジェクトを確実に回収してから測定したいとき
- 大量の一時オブジェクトを生成し終えた直後:明確な区切りで、その後の応答性を優先したいとき(例:起動直後の初期化処理の終わりなど)
- テストコード:ファイナライザの動作確認
GC.WaitForPendingFinalizers() — ファイナライザの完了を待つ
GC.Collect() だけではファイナライザは「キューに積まれる」だけで、実行は別スレッドで非同期に行われます。ファイナライザによって解放されるリソースまで含めて確実に後始末したい場合は、WaitForPendingFinalizers() を組み合わせます。
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect(); // ファイナライザ実行で再到達不能になったオブジェクトを回収
この 「Collect → WaitForPendingFinalizers → Collect」 のイディオムは、ファイナライザを持つオブジェクトをテストや計測の前にきれいに片付けたいときの定番です。
GC の挙動を制御する
GC.KeepAlive(object) — 早すぎる回収を防ぐ
JIT 最適化の都合で、ローカル変数が「最後に使われた行」を超えると、メソッドの戻り前であってもオブジェクトが GC の対象になることがあります。これは P/Invoke で アンマネージド側にハンドルを渡している最中 に問題になります。
public void Process()
{
var handle = new MyNativeHandle();
NativeMethod(handle.RawHandle); // ここで handle 自体は「使い終わった」とみなされうる
// … 長い処理 …
// ここで GC が走り、handle がファイナライズされ、ネイティブ側のハンドルが無効化される可能性
GC.KeepAlive(handle); // この行まで handle が「生きている」ことを保証する
}
GC.KeepAlive は 何もしないメソッド ですが、JIT に対して「この時点でこの参照は使われている」と伝える役割を持ちます。SafeHandle を使えば多くの場合不要になりますが、生のハンドルを扱う際の保険として覚えておくべき API です。
GC.SuppressFinalize(object) — ファイナライザの実行を抑制する
Dispose パターンの定番として登場するメソッドです。Dispose() でリソースを解放し終えたあとは、ファイナライザを走らせる必要がないため、GC に「このオブジェクトのファイナライザはスキップしてよい」と伝えます。
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
ファイナライザを持つオブジェクトはファイナライズキューを経由するためコストが高く、世代の昇格も起きやすくなります。Dispose 済みなら抑制することで GC 負荷を減らせます。
GC.ReRegisterForFinalize(object)
逆に、いったん SuppressFinalize したオブジェクトを再度ファイナライズキューに登録するメソッドです。オブジェクトの再利用(プーリング)など、極めて限定的な場面で使います。基本的には使う必要がありません。
メモリ情報を取得する
GC.GetTotalMemory(bool forceFullCollection)
現在マネージドヒープに割り当てられている推定バイト数を返します。forceFullCollection: true を渡すと、GC を実行してからの値を返します。
long before = GC.GetTotalMemory(forceFullCollection: false);
DoWork();
long after = GC.GetTotalMemory(forceFullCollection: true);
Console.WriteLine($"increase: {after - before:N0} bytes");
簡易的なメモリ使用量モニタリング、ベンチマーク、リークの当たりをつけるのに便利です。
GC.CollectionCount(int generation)
指定した世代で発生した GC の回数を返します。ある区間で何回 GC が走ったかを測りたいときに使います。
int g0 = GC.CollectionCount(0);
int g1 = GC.CollectionCount(1);
int g2 = GC.CollectionCount(2);
RunHotPath();
Console.WriteLine($"Gen0: {GC.CollectionCount(0) - g0}, " +
$"Gen1: {GC.CollectionCount(1) - g1}, " +
$"Gen2: {GC.CollectionCount(2) - g2}");
ホットパスでの GC 発生回数を 0 に近づける という最適化指標として実用性が高い API です。
GC.GetGCMemoryInfo()(.NET Core 3.0+)
より詳細なメモリ情報を返す API です。ヒープサイズ、フラグメンテーション、最後の GC が並行(concurrent)だったか、停止時間など、GC.GetTotalMemory よりはるかにリッチな情報が取れます。
GCMemoryInfo info = GC.GetGCMemoryInfo();
Console.WriteLine($"HeapSize: {info.HeapSizeBytes:N0}");
Console.WriteLine($"Fragmented: {info.FragmentedBytes:N0}");
Console.WriteLine($"PauseDuration: {info.PauseDurations[0]}");
監視・診断ツールを自作する際の基礎になります。
GC.MaxGeneration
最大世代番号(通常は 2)を返すプロパティ。GC.Collect(GC.MaxGeneration) のように世代をハードコードしないために使います。
ネイティブメモリ圧を GC に伝える
マネージドオブジェクト自体は小さくても、内部で巨大なアンマネージドメモリを保持している場合(画像バッファ、ネイティブライブラリのコンテキストなど)、GC はその「真のサイズ」を知りません。結果として GC が走るタイミングが遅れ、メモリ使用量が膨れ上がります。
GC.AddMemoryPressure(long bytesAllocated) / GC.RemoveMemoryPressure(long bytesAllocated)
アンマネージド側で確保したバイト数を GC に通知する API です。これにより、GC は「このマネージドオブジェクトを抱え続けると重い」と判断し、回収を早めてくれます。
public sealed class NativeImage : IDisposable
{
private IntPtr _ptr;
private readonly long _size;
public NativeImage(long size)
{
_ptr = NativeAlloc(size);
_size = size;
GC.AddMemoryPressure(_size);
}
public void Dispose()
{
if (_ptr != IntPtr.Zero)
{
NativeFree(_ptr);
_ptr = IntPtr.Zero;
GC.RemoveMemoryPressure(_size);
}
GC.SuppressFinalize(this);
}
}
Add と Remove を必ずペアで 呼ぶこと、サイズは正確に通知することがポイントです。
低レイテンシ領域 — TryStartNoGCRegion
ゲームのフレーム処理、リアルタイム取引、低レイテンシ通信など、「この区間では絶対に GC を走らせたくない」 というケース向けの API です。
if (GC.TryStartNoGCRegion(totalSize: 16 * 1024 * 1024))
{
try
{
// この区間では GC が(基本的に)走らない
DoLatencyCriticalWork();
}
finally
{
GC.EndNoGCRegion();
}
}
- 引数で「区間中に確保できる最大バイト数」を宣言する
- 確保量を超えると GC 抑制は解除される
- 区間が長すぎる、確保サイズが大きすぎると失敗する
無闇に使うと、結局区間終了時に大きな GC が発生して逆効果になりがちです。プロファイラで GC が問題だと裏付けが取れたとき にだけ検討するべき機能です。
まとめ — System.GC の使い分け早見表
| 目的 | 主な API |
|---|---|
| GC を強制的に起動する(基本は使わない) | GC.Collect、GC.WaitForPendingFinalizers |
| 早すぎる回収を防ぐ(P/Invoke 連携) | GC.KeepAlive |
| Dispose 済みオブジェクトのファイナライズを抑制 | GC.SuppressFinalize |
| メモリ使用量・GC 発生数を観測 | GC.GetTotalMemory、GC.CollectionCount、GC.GetGCMemoryInfo |
| アンマネージド確保量を GC に通知 | GC.AddMemoryPressure / GC.RemoveMemoryPressure |
| 低レイテンシ区間で GC を抑制 | GC.TryStartNoGCRegion / GC.EndNoGCRegion |
System.GC は強力ですが、ほとんどの API は「使わないことが正解」 という独特の立ち位置を持つクラスです。日常的に呼ぶべきものは Dispose パターン中の SuppressFinalize くらいで、それ以外は 計測・診断・特殊要件への対処 が用途の中心になります。
「GC を制御する前に、まず GC が問題かを計測する」——System.GC を扱う際の鉄則として押さえておきたい考え方です。