メモリ管理とガベージコレクション (GC)
.NETのメモリ管理
.NETは「マネージド(Managed)」な環境であり、メモリの割り当てと解放はランタイム(CLR: Common Language Runtime)によって自動的に管理されます。しかし、効率的なアプリケーションを作成するためには、その仕組みを理解しておく必要があります。
スタック (Stack) と ヒープ (Heap)
メモリ領域は大きく分けて「スタック」と「ヒープ」の2つがあります。
| 領域 | 特徴 | 格納されるもの | 速度 |
|---|---|---|---|
| スタック | LIFO (Last In First Out) 構造。メソッド呼び出しごとに確保され、終了時に自動解放される。 | 値型 (int, bool, structなど)、参照型の参照(ポインタ) | 非常に高速 |
| ヒープ | 任意の順序で確保・解放される大きなメモリ領域。GCによって管理される。 | 参照型 (class, string, arrayなど) の実体 | スタックより遅い |
値型 (Value Type) と 参照型 (Reference Type)
-
値型 (
struct,enum, 数値型):- データそのものを保持します。
- 変数への代入は「値のコピー」になります。
- 通常はスタックに配置されます(クラスのフィールドとして使われる場合はヒープ)。
-
参照型 (
class,interface,delegate,string):- データの実体はヒープにあり、変数はその「場所(アドレス)」を保持します。
- 変数への代入は「参照のコピー」になります。
ガベージコレクション (GC)
ガベージコレクション(GC)は、ヒープ上の不要になった(どこからも参照されなくなった)メモリを自動的に解放する仕組みです。
世代別GC (Generational GC)
.NETのGCはパフォーマンスを最適化するために、オブジェクトを「世代(Generation)」で管理します。
-
Generation 0 (Gen 0):
- 最も若いオブジェクト。
- 短命なオブジェクト(一時変数など)がここに割り当てられます。
- GCは頻繁に発生しますが、非常に高速です。
-
Generation 1 (Gen 1):
- Gen 0のGCを生き残ったオブジェクトが昇格します。
- Gen 0とGen 2のバッファ的な役割を果たします。
-
Generation 2 (Gen 2):
- Gen 1のGCを生き残った長寿命オブジェクト(静的変数、シングルトンなど)。
- GCの頻度は低いですが、コストが高く(Full GC)、アプリケーションの一時停止(Stop the World)時間が長くなる可能性があります。
アンマネージドリソースと IDisposable
ファイルハンドル、データベース接続、ネットワークソケットなどの「アンマネージドリソース(OSが管理するリソース)」は、GCだけでは適切に解放できません。
これらを明示的に解放するために IDisposable インターフェースと Dispose メソッドが使用されます。
using ステートメント
IDisposable を実装したオブジェクトは、using ステートメントを使用することで、スコープを抜けた際に自動的に Dispose() が呼ばれることが保証されます。
// 古い書き方
using (var stream = new FileStream("file.txt", FileMode.Open))
{
// 処理
} // ここで stream.Dispose() が呼ばれる
// C# 8.0 以降の推奨(using 宣言)
using var stream = new FileStream("file.txt", FileMode.Open);
// 処理
// メソッド終了時に stream.Dispose() が呼ばれる
ベストプラクティス
1. 不要なメモリ割り当てを避ける
ループ内でのオブジェクト生成や文字列結合は、GCの負荷を高める主な原因です。
- 文字列結合:
+演算子の代わりにStringBuilderを使用する。 - コレクションの初期容量:
List<T>やDictionary<T, U>を作成する際、要素数が予測できる場合はコンストラクタで容量(Capacity)を指定する。
// Bad: 内部配列の再確保が何度も発生する
var list = new List<int>();
for (int i = 0; i < 10000; i++) list.Add(i);
// Good: 最初に確保されるため再確保が発生しない
var list = new List<int>(10000);
for (int i = 0; i < 10000; i++) list.Add(i);
2. 大きなオブジェクト (LOH) の扱い
85KB以上のオブジェクトは LOH (Large Object Heap) という特別な領域に置かれ、Gen 2扱いとなります。
LOHのGCはコストが高く、メモリの断片化(フラグメンテーション)も起こりやすいため、頻繁な生成・破棄は避けるべきです。
可能な限り ArrayPool<T> などを使用してバッファを再利用します。
3. ファイナライザ (デストラクタ) を避ける
ファイナライザ(~ClassName())を持つオブジェクトは、GCによって即座に回収されず、ファイナライズキューに入れられ、別スレッドで実行された後に回収されます(2回のGCが必要)。
アンマネージドリソースを直接ラップするクラス以外では、ファイナライザを実装しないでください。
SafeHandle を使用すれば、多くの場合ファイナライザは不要です。
4. Span<T> / Memory<T> の活用 (高度)
配列の一部を切り出す(スライスする)際、Substring や LINQ の Skip/Take を使うと新しい配列が生成されます。
Span<T> や Memory<T> を使用すると、メモリのコピーを行わずに配列の一部を参照できるため、アロケーションをゼロにできます。
string text = "Hello, World!";
ReadOnlySpan<char> span = text.AsSpan();
ReadOnlySpan<char> hello = span.Slice(0, 5); // 新しい文字列は生成されない
Disposeパターンの実装
自分でアンマネージドリソースを扱うクラスを作成する場合は、Disposeパターンを実装する必要があります。
public class ResourceHolder : IDisposable
{
private bool _disposed = false;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this); // ファイナライザの実行を抑制
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// マネージドリソースの解放 (IDisposableなメンバなど)
}
// アンマネージドリソースの解放
_disposed = true;
}
// ファイナライザ (C++のデストラクタ構文)
~ResourceHolder()
{
Dispose(false);
}
}