Skip to main content

メモリ管理とガベージコレクション (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)」で管理します。

  1. Generation 0 (Gen 0):

    • 最も若いオブジェクト。
    • 短命なオブジェクト(一時変数など)がここに割り当てられます。
    • GCは頻繁に発生しますが、非常に高速です。
  2. Generation 1 (Gen 1):

    • Gen 0のGCを生き残ったオブジェクトが昇格します。
    • Gen 0とGen 2のバッファ的な役割を果たします。
  3. 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);
}
}