1. メモリの基本: スタックとヒープ (Stack & Heap)
プログラムが起動すると、メモリにアクセスできるようになる。.NET (CLR — Common Language Runtime) の文脈では、このメモリは自動で管理され、主に二つの領域に分かれる: スタック と ヒープ。データがどこにどう格納されるかを理解することは、効率的で安定したコードを書くために重要だ。
スタック (Stack)
スタックをお皿の山に例えるといい: 新しい皿はいつも上に置き、取り出すのも上から。これは LIFO (Last In, First Out) の原則だ。スタックは非常に高速だが、サイズに制限がある。
スタックに何が保存されるか:
- 値型: 直接宣言された変数の値そのもの。
- オブジェクトへの参照: 参照型の場合、スタックにはヒープ上のオブジェクトへのアドレス(参照)だけが置かれる。
- メソッドのパラメータ: 関数に渡される値。
- ローカル変数: メソッド内で宣言された変数。
- リターンアドレス: メソッド実行後に戻る場所。
スタックのメモリは対応するメソッドが終了するか変数がスコープ外になると自動的に解放され、とても高速だ。
例: スタック上の値型
void MyMethod()
{
int a = 10; // 'a' と値 10 はスタック上
bool flag = true; // 'flag' と値 true はスタック上
char initial = 'Z'; // 'initial' と値 'Z' はスタック上
// ...
} // MyMethodが終了すると 'a', 'flag', 'initial' はスタックから消える。
例: スタック上の参照
class MyObject { }
void AnotherMethod()
{
MyObject objRef; // 'objRef'(参照) はスタック上。オブジェクト自体はまだ作られていない。
// ...
} // AnotherMethodが終了すると 'objRef'(参照)はスタックから消える。
ヒープ (Heap)
ヒープ はずっと大きく柔軟なメモリ領域だ。LIFOのような厳密な順序はなく、データは空いているどこにでも配置される。大きめで長く生きるオブジェクトはヒープに置かれる。
ヒープに何が保存されるか:
- 参照型のオブジェクト: クラス、配列、文字列などのデータは new で作られ、ヒープ上に置かれる。
- 埋め込まれた値型: 値型 (struct) が参照型 (class) のフィールドになっている場合、その値型はオブジェクトの内部(ヒープ)に格納される。
ヒープのメモリ管理は GC (Garbage Collector) によって自動で行われる。
例: クラスオブジェクトはヒープに
class Person { public string Name; public int Age; }
void CreatePerson()
{
Person p = new Person(); // Personオブジェクトはヒープ上。'p'(参照)はスタック上。
p.Name = "Alice"; // 文字列 "Alice"(これもオブジェクト)もヒープ上。
p.Age = 30; // 30 (int, 値型) は Personオブジェクトの中、ヒープ上にある。
// ...
} // CreatePersonが終了すると 'p'(参照)はスタックから消える。
// Personオブジェクトがヒープで到達不能になり、GCの対象になる。
例: ヒープ上の配列
void ProcessArray()
{
int[] numbers = new int[5]; // 5つのintの配列はヒープ上。'numbers'(参照)はスタック上。
numbers[0] = 10; // 要素10は配列の一部としてヒープ上にある。
// ...
} // 配列は参照が残っていないときにGCによって回収される。
2. ガベージコレクション (Garbage Collection)
ヒープのメモリ管理は GC によって自動化されている。.NETの重要な特徴の一つで、手動でメモリを解放する必要(いわゆるメモリリークの典型原因)を減らしてくれる。
目的と動作原理
GC の主目的は、ヒープ上でプログラムからもはや参照されないオブジェクトのメモリを自動的に解放することだ。
- オブジェクトの生成: new によって CLR はヒープに領域を割り当てる。
- 参照の追跡: GC はアクティブな参照を追跡する; オブジェクトはスタック、静的フィールド、または他の到達可能なオブジェクトから参照されていれば「到達可能 (reachable)」とみなされる。
- 「ゴミ」の判定: 参照がない場合 — オブジェクトは「到達不能 (unreachable)」となりゴミと見なされる。
- 回収: 必要に応じて GC は到達可能なものをマーキングし、到達不能なオブジェクトのメモリを解放する。
- コンパクション: 断片化を減らすために、残ったオブジェクトを移動して連続領域を作ることがある。
GCの世代 (Generational GC)
GC は世代を使う。ほとんどのオブジェクトは「若くして死ぬ」からだ:
- 世代0 (Gen 0): 新しく作られたオブジェクト。コレクションは頻繁で高速。
- 世代1 (Gen 1): Gen 0 を生き延びたオブジェクト。コレクションは少し稀で時間がかかる。
- 世代2 (Gen 2): 長生きするオブジェクト。コレクションは最も稀でコストが高い。
いつGCは走るのか?
- 新しい割り当てのための空きメモリが不足したとき。
- アイドル時の定期チェック。
- 明示的な(通常は推奨されない)呼び出し GC.Collect()。
GCの性能
GC の実行は短い停止(ユーザーコードの実行が一時停止する)を引き起こすことがある。ヒープに短命オブジェクトを大量に作るとコレクションが頻繁になり、全体の性能が落ちる。
例: オブジェクトが到達不能になる
class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1MBのデータ
void AllocateAndLose()
{
DataBlock block1 = new DataBlock(); // block1オブジェクトはヒープ上、参照 'block1' はスタック上
// ... 何らかのコード ...
block1 = null; // 今や DataBlock オブジェクトへのアクティブな参照はない。到達不能になった。
// この時点でGCはまだそれを削除していないかもしれないが、回収候補になっている。
// デモのために GC.Collect() を呼んでいる(本番ではやめておこう)
Console.WriteLine("Calling GC.Collect()");
GC.Collect(); // GCは(必ずしもではないが)ここでメモリを回収するかもしれない
}
AllocateAndLose();
// AllocateAndLoseが終わるとスタックから 'block1' が消え、ヒープ上の DataBlock は到達不能となり回収対象になる。
3. アンマネージドリソースの管理
GC は .NET の「マネージド」メモリを扱う。でも アンマネージドリソース は存在しており、これらは GC の管理外で明示的に解放する必要がある:
- ファイルディスクリプタ(開いたファイル)
- ネットワークソケット
- ウィンドウ/グラフィックハンドル(例: GDI+)
- OSから割り当てたメモリ(例えば P/Invoke 経由)
ファイナライザ (Finalizers)
ファイナライザは、オブジェクトが GC によって削除される前にアンマネージドリソースの最終クリーンアップを行う特別なメソッドだ。構文はコンストラクタに似ているがチルダ(~)が付く:
class MyClassWithFinalizer
{
~MyClassWithFinalizer() { /* アンマネージドリソースの解放 */ }
}
GCの呼び出しは予測不可能で遅延があり、ファイナライザは専用のファイナライザスレッドで呼ばれる。デメリットは予測不可能性とオーバーヘッド(ファイナライザを持つオブジェクトは2回のパスが必要になる)だ。だからファイナライザは「保険」と考え、主要な解放メカニズムにするべきではない。
例: 保険としてのファイナライザ
class UnmanagedResourceHolder
{
private bool _resourceReleased = false;
// アンマネージドリソースのシミュレーション
public UnmanagedResourceHolder() => Console.WriteLine("リソースが作成された。");
public void ReleaseResource()
{
if (!_resourceReleased)
{
Console.WriteLine("リソースが明示的なメソッドで解放された。");
_resourceReleased = true;
}
}
// ファイナライザ - GCが最後の手段として呼ぶ
~UnmanagedResourceHolder()
{
Console.WriteLine("ファイナライザが呼ばれた(リソースは明示的に解放されていなかった)。");
ReleaseResource(); // リソースを解放しようと試みる
}
}
インターフェイス IDisposable
IDisposable はリソースを明示的に解放するための推奨メカニズムだ。1つのメソッド void Dispose() を持つ。よくあるパターンは Dispose() を再入可能にして、GC.SuppressFinalize(this) でファイナライザを抑制するものだ。
例: IDisposable の実装
using System.IO;
class MyFileWriter : IDisposable
{
private StreamWriter _writer;
private bool _disposed = false;
public MyFileWriter(string path)
{
_writer = new StreamWriter(path, true);
Console.WriteLine($"ファイル '{path}' を開いた。");
}
public void WriteLog(string message) => _writer.WriteLine(message);
// IDisposable の実装
public void Dispose()
{
Dispose(true); // 実際のDisposeロジックを呼ぶ
GC.SuppressFinalize(this); // GCにファイナライザは不要と伝える
_disposed = true;
}
// 一般的なDisposeパターンのための保護された仮想メソッド
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
if (disposing)
{
// マネージドリソースを解放
_writer?.Dispose();
}
// アンマネージドリソースを解放(あれば)
Console.WriteLine("Disposeでファイルを閉じた。");
}
}
// オプションのファイナライザは「保険」
~MyFileWriter()
{
Console.WriteLine("ファイナライザでファイルを閉じた(Dispose() が呼ばれていなかった)。");
Dispose(false); // 明示的な呼び出しではないことを示してDisposeを呼ぶ
}
}
演算子 using
using 演算子は構文シュガーで、オブジェクトがスコープを抜けるときに例外が発生しても必ず Dispose() を呼ぶことを保証する。IDisposable を実装したオブジェクトを扱う際の最も推奨される方法だ。
例: using による自動クリーンアップ
// 上の MyFileWriter の例を使用
void ProcessFile(string fileName)
{
// MyFileWriter オブジェクトは using ブロック後に自動的に Dispose される
using (var writer = new MyFileWriter(fileName))
{
writer.WriteLog("最初の行。");
writer.WriteLog("二番目の行。");
// ここで例外が起きても Dispose() は呼ばれる
} // ここで自動的に writer.Dispose() が呼ばれる
Console.WriteLine("using ブロックが終了し、ファイルは閉じられた。");
}
ProcessFile("testlog.txt");
// この場合、Dispose() が明示的に呼ばれているので MyFileWriter のファイナライザは実行されない。
これらの概念を理解することは、C#でパフォーマントで信頼性がありスケーラブルなアプリケーションを書くための鍵だよ。
GO TO FULL VERSION