C#のメモリ管理を完全理解:GC・スタック/ヒープ・メモリリーク対策まで初心者向けに解説

はじめに

C#は、メモリ管理を自動で行ってくれる言語です。CやC++のように、開発者が毎回メモリを確保して解放する必要は基本的にありません。

そのため、C#は初心者にとって扱いやすい言語です。しかし、「自動で管理されるからメモリを気にしなくてよい」というわけではありません。

C#でも、メモリ使用量が増え続けたり、不要なオブジェクトが残り続けたり、ファイルやDB接続などのリソースを解放し忘れたりすることがあります。いわゆるメモリリークのような問題は、C#でも十分に起こります。

この記事では、C#のメモリ管理について、GC、スタック、ヒープ、メモリリーク、Dispose、using、メモリ使用量を減らす実践テクニックまで、初心者にもわかりやすく解説します。

1. C#のメモリ管理とは?初心者がまず押さえる全体像

C#のメモリ管理を理解するうえで、まず押さえておきたいのは「C#では多くのメモリ管理を.NETランタイムが担当している」という点です。

C#で作成したプログラムは、通常.NET上で動作します。.NETにはCLRという実行環境があり、その中にGC、つまりガベージコレクションの仕組みがあります。

GCは、プログラムが使わなくなったオブジェクトを検出し、メモリから回収する仕組みです。これにより、開発者が明示的にメモリを解放しなくても、多くの場合は安全にメモリが管理されます。

1-1. C#ではメモリ管理を誰が行うのか

C#では、主に.NETランタイムとGCがメモリ管理を行います。

たとえば、次のようにオブジェクトを作成したとします。

C#
var user = new User();

このとき、new User()によってオブジェクトがメモリ上に作られます。C#では、このオブジェクトを使い終わったあとに、C++のようにdeleteを書く必要はありません。

C#
// C#では基本的に不要
// delete user;

オブジェクトがどこからも参照されなくなると、GCが不要と判断し、適切なタイミングで回収します。

ただし、GCが管理するのは主にマネージメモリです。ファイルハンドル、DB接続、ネットワークソケット、画像リソースなど、OSや外部リソースに関わるものは、開発者がDisposeusingを使って明示的に解放すべき場合があります。

1-2. C#のメモリ管理が重要な理由

C#では自動メモリ管理があるとはいえ、メモリについて理解していないと、次のような問題が起こります。

アプリケーションを長時間動かしているとメモリ使用量が増え続ける、画面を閉じたはずなのにオブジェクトが残り続ける、大量データ処理で急に動作が重くなる、GCが頻繁に動いてパフォーマンスが低下する、ファイルやDB接続が解放されずエラーになる、といった問題です。

特にWebアプリ、デスクトップアプリ、ゲーム、バッチ処理、画像処理、大量データ処理では、C#のメモリ管理を理解しているかどうかが安定性に大きく影響します。

1-3. C/C++との違い:手動解放と自動管理

CやC++では、メモリの確保と解放を開発者が明示的に行う場面が多くあります。

C++では、たとえば次のようにnewで確保したメモリをdeleteで解放します。

C++
User* user = new User();
delete user;

このdeleteを忘れるとメモリリークになります。逆に、すでに解放したメモリを再度使うと不正アクセスにつながります。

一方、C#では次のように書くだけです。

C#
var user = new User();

このオブジェクトが不要になったあと、メモリの回収はGCが行います。

ただし、C#でも「参照が残っているオブジェクト」はGCに回収されません。つまり、不要になったオブジェクトでも、どこかから参照され続けていればメモリ上に残り続けます。

これがC#でメモリリークが起こる大きな原因です。

1-4. この記事で理解できること

この記事では、C#のメモリ管理について次の内容を順番に解説します。

C#のスタックとヒープの違い、値型と参照型のメモリ配置、GCの基本、世代別GC、LOH、メモリが解放される流れ、メモリリークの原因、IDisposableusingの使い方、メモリ使用量を減らす実践テクニック、メモリリークの調査方法、よくある誤解、実用的なサンプルコードです。

C#のメモリを理解すると、単に「動くコード」を書くだけでなく、「安定して長時間動くコード」を書けるようになります。

2. C#のメモリ領域:スタックとヒープの違い

C#のメモリを理解するうえで、最初に知っておきたいのがスタックとヒープです。

ざっくり言うと、スタックはメソッド呼び出しやローカル変数の管理に使われる領域で、ヒープはnewで作成されたオブジェクトなどが置かれる領域です。

ただし、「値型は必ずスタック」「参照型は必ずヒープ」と単純に覚えるのは正確ではありません。値型であっても、クラスのフィールドや配列の要素として使われる場合はヒープ上のオブジェクト内部に配置されます。

2-1. スタックとは

スタックは、メソッド呼び出しごとに使われるメモリ領域です。

メソッドが呼ばれると、そのメソッド用の作業領域がスタックに積まれます。メソッド内のローカル変数や引数などは、この領域で管理されることがあります。

メソッドが終了すると、そのメソッド用のスタック領域はまとめて破棄されます。

C#
void Sample()
{
int x = 10;
int y = 20;
int sum = x + y;
}

この例では、xysumのようなローカル変数は、実行時にスタック上で扱われる可能性があります。

スタックの特徴は、確保と解放が非常に高速なことです。一方で、サイズには限りがあり、大きすぎるデータを大量に置く用途には向いていません。

2-2. ヒープとは

ヒープは、オブジェクトを動的に確保するためのメモリ領域です。

C#でnewを使って作成したクラスのインスタンスや配列は、通常ヒープ上に作成されます。

C#
var user = new User();
var numbers = new int[1000];

この場合、Userオブジェクトやint配列の本体はヒープ上に作られます。

ヒープ上のオブジェクトは、メソッドが終了してもすぐに消えるとは限りません。どこかから参照されている限り、GCはそのオブジェクトを必要なものと判断します。

2-3. 値型と参照型のメモリ配置

C#には、大きく分けて値型と参照型があります。

値型の代表例は、intdoubleboolcharDateTimestructenumなどです。

参照型の代表例は、classstring、配列、objectdelegateなどです。

値型は値そのものを保持します。

C#
int a = 10;
int b = a;
b = 20;

Console.WriteLine(a); // 10
Console.WriteLine(b); // 20

b = aとすると、値がコピーされます。そのため、bを変更してもaには影響しません。

一方、参照型はオブジェクトそのものではなく、オブジェクトへの参照を保持します。

C#
var user1 = new User { Name = "Alice" };
var user2 = user1;

user2.Name = "Bob";

Console.WriteLine(user1.Name); // Bob
Console.WriteLine(user2.Name); // Bob

user2 = user1とすると、オブジェクト本体がコピーされるのではなく、同じオブジェクトへの参照がコピーされます。そのため、user2から変更するとuser1にも反映されます。

2-4. ローカル変数・オブジェクト・配列はどこに置かれるのか

C#では、変数とオブジェクト本体を分けて考えると理解しやすくなります。

C#
void Sample()
{
int number = 10;
var user = new User();
int[] scores = new int[3];
}

numberは値型のローカル変数です。実行時にはスタック上で扱われる可能性があります。

userはローカル変数ですが、中身はUserオブジェクトへの参照です。参照そのものはスタック上で扱われる可能性があり、new User()で作られたオブジェクト本体はヒープ上にあります。

scoresもローカル変数ですが、中身は配列への参照です。配列本体はヒープ上に作成されます。配列がint[]のような値型配列であっても、配列本体はオブジェクトなのでヒープに置かれます。

2-5. スタックとヒープの違いをコード例で理解する

次のコードを見てください。

C#
class User
{
public string Name { get; set; } = "";
}

void Sample()
{
int age = 30;
User user = new User();
user.Name = "Alice";
}

このコードでは、ageは値型のローカル変数です。userは参照型のローカル変数です。

イメージとしては、次のようになります。

スタック:
age = 30
user = ヒープ上のUserオブジェクトへの参照

ヒープ:
Userオブジェクト
Name = "Alice"

Sampleメソッドが終了すると、スタック上のageuserは不要になります。

ただし、ヒープ上のUserオブジェクトがすぐに消えるとは限りません。どこからも参照されていなければ、次回以降のGCで回収対象になります。

3. C#のGCとは?ガベージコレクションの基本

GCとは、Garbage Collectionの略です。日本語ではガベージコレクションと呼ばれます。

GCの役割は、不要になったオブジェクトを自動的に見つけて、メモリを回収することです。

C#では、開発者が明示的にメモリを解放しなくても、多くのオブジェクトはGCによって自動的に回収されます。

3-1. GCが不要なオブジェクトを回収する仕組み

GCは、簡単に言うと「まだ使えるオブジェクト」と「もう使えないオブジェクト」を判断します。

GCはまず、GCルートと呼ばれる場所から参照をたどります。GCルートには、実行中のメソッドのローカル変数、staticフィールド、CPUレジスタ、GCハンドルなどが含まれます。

GCルートから参照をたどって到達できるオブジェクトは、まだ使われる可能性があるため回収されません。

一方、どこからも到達できないオブジェクトは、不要なオブジェクトと判断され、回収対象になります。

3-2. 到達可能なオブジェクトと不要なオブジェクト

次のコードを見てください。

C#
var user = new User();
user = null;

最初の行で、ヒープ上にUserオブジェクトが作られ、user変数がそれを参照します。

次の行でuser = null;とすると、user変数はUserオブジェクトを参照しなくなります。

この時点で、他にそのオブジェクトを参照しているものがなければ、そのUserオブジェクトはGCから見て到達不能になります。つまり、回収対象になります。

ただし、回収対象になったからといって、すぐにメモリから消えるわけではありません。実際に回収されるのは、GCが実行されたタイミングです。

3-3. 世代別GC:第0世代・第1世代・第2世代

.NETのGCは、世代別GCという仕組みを使っています。

オブジェクトは大きく第0世代、第1世代、第2世代に分けて管理されます。

第0世代は、作成されたばかりの新しいオブジェクトが置かれる領域です。多くのオブジェクトは短命なので、第0世代のGCは比較的頻繁に実行されます。

第1世代は、第0世代のGCを生き残ったオブジェクトが移動する中間的な領域です。

第2世代は、長く生き残っているオブジェクトが置かれる領域です。第2世代のGCは、第0世代よりも負荷が高く、頻繁に発生するとアプリケーションのパフォーマンスに影響します。

世代別GCの考え方は、「多くのオブジェクトはすぐ不要になる」という経験則に基づいています。短命なオブジェクトを効率よく回収し、長寿命のオブジェクトは何度も検査しすぎないようにすることで、GC全体の効率を高めています。

3-4. LOH(Large Object Heap)とは

LOHはLarge Object Heapの略で、大きなオブジェクトを格納するためのヒープ領域です。

.NETでは、一定サイズ以上の大きなオブジェクト、代表的には大きな配列などがLOHに配置されます。

C#
byte[] buffer = new byte[100_000];

このような大きな配列は、LOHに配置される可能性があります。

LOHに置かれるオブジェクトはサイズが大きいため、頻繁に作成と破棄を繰り返すとメモリの断片化やGC負荷の原因になります。

画像処理、大きなファイルの読み込み、大量データのバッファ処理などでは、LOHを意識することが重要です。

3-5. GCが実行されるタイミング

GCが実行されるタイミングは、開発者が完全に制御するものではありません。

主に、ヒープの使用量が増えたとき、メモリ確保が必要になったとき、OSからのメモリ圧力が高まったとき、ランタイムが必要と判断したときなどに実行されます。

また、GC.Collect()を呼び出すことで明示的にGCを要求することもできます。

C#
GC.Collect();

しかし、通常のアプリケーションでGC.Collect()を安易に呼ぶべきではありません。GCのタイミングは.NETランタイムが最適化しているため、手動で呼ぶとかえってパフォーマンスが悪化することがあります。

4. C#でメモリが解放される流れ

C#でメモリが解放される流れを理解するには、「変数が使われなくなること」と「メモリが実際に回収されること」を分けて考える必要があります。

変数のスコープを抜けたり、参照がなくなったりすると、オブジェクトはGCの回収対象になります。

しかし、回収対象になった瞬間にメモリが解放されるわけではありません。実際の解放は、GCが実行されたタイミングで行われます。

4-1. 変数のスコープとメモリ解放の関係

変数にはスコープがあります。スコープとは、その変数を参照できる範囲のことです。

C#
void Sample()
{
var user = new User();
user.Name = "Alice";
}

この例では、user変数はSampleメソッドの中だけで使えます。Sampleメソッドが終了すると、user変数はスコープ外になります。

この時点で、Userオブジェクトを参照するものが他になければ、そのオブジェクトはGCの回収対象になります。

ただし、次のように外部に参照を渡している場合は注意が必要です。

C#
static List<User> users = new();

void Sample()
{
var user = new User();
users.Add(user);
}

この場合、Sampleメソッドが終了しても、staticなusersリストがUserオブジェクトを参照し続けます。そのため、GCはそのオブジェクトを回収できません。

4-2. nullを代入するとメモリはすぐ解放されるのか

nullを代入すると、その変数はオブジェクトを参照しなくなります。

C#
var data = new byte[10_000_000];
data = null;

この場合、他に参照がなければ、byte配列はGCの回収対象になります。

しかし、nullを代入した瞬間にメモリが即座に解放されるわけではありません。実際にメモリが回収されるのはGCが動いたタイミングです。

また、nullを代入しても、別の変数やコレクションが同じオブジェクトを参照していれば回収されません。

C#
var data = new byte[10_000_000];
var list = new List<byte[]> { data };

data = null;

// listが参照を保持しているため、配列は回収されない

このように、C#のメモリ管理では「参照が残っているかどうか」が重要です。

4-3. GC.Collectを安易に使うべきではない理由

GC.Collect()を呼ぶと、GCの実行を明示的に要求できます。

C#
GC.Collect();

一見便利に見えますが、通常は安易に使うべきではありません。

理由は、GCが実行されるとアプリケーションの処理が一時的に止まることがあるからです。特に第2世代GCやLOHを含むGCは負荷が高く、頻繁に実行するとパフォーマンスが低下します。

.NETのGCは、メモリ使用量やオブジェクトの生存状況を見ながら効率的に動作するよう設計されています。開発者が手動でGCを呼ぶよりも、ランタイムに任せたほうがよいケースがほとんどです。

GC.Collect()を検討するのは、大量の一時オブジェクトを使う処理が明確に終わった後など、かなり限定的な場面です。それでも、実際に効果があるかどうかは計測して判断すべきです。

4-4. メモリ解放を確認する簡単なコード例

次のコードでは、大きな配列を作成したあと、参照を外してGCを実行しています。

C#
using System;

class Program
{
static void Main()
{
Console.WriteLine($"Before: {GC.GetTotalMemory(false):N0} bytes");

byte[] data = new byte[50_000_000];

Console.WriteLine($"Allocated: {GC.GetTotalMemory(false):N0} bytes");

data = null;

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Console.WriteLine($"After GC: {GC.GetTotalMemory(true):N0} bytes");
}
}

このコードでは、data = null;によって配列への参照を外しています。その後、GC.Collect()でGCを実行し、GC.GetTotalMemory()でメモリ使用量の目安を確認しています。

ただし、この結果は実行環境や.NETのバージョン、最適化状況によって変わります。実務では、このような簡易確認だけでなく、Visual Studio診断ツールやメモリプロファイラを使って確認するのが一般的です。

5. C#で起こりやすいメモリリークの原因

C#にはGCがありますが、メモリリークが起こらないわけではありません。

C#におけるメモリリークの多くは、「不要になったオブジェクトへの参照が残り続ける」ことで発生します。

GCは、参照されているオブジェクトを勝手に削除することはできません。そのオブジェクトが本当に不要かどうかを、GCは意味的には判断できないからです。

5-1. C#でもメモリリークは起こるのか

C#でもメモリリークは起こります。

C++のようにdelete忘れで直接メモリリークするケースとは異なりますが、参照が残っている限りGCはオブジェクトを回収できません。

たとえば、使い終わった画面、古いキャッシュ、イベント購読済みのオブジェクト、staticコレクション内のデータなどが参照され続けると、メモリ使用量が増え続ける原因になります。

つまり、C#のメモリリークは「解放し忘れ」というより「参照を手放し忘れ」と考えると理解しやすいです。

5-2. イベント購読の解除忘れ

C#で非常によくあるメモリリークの原因が、イベント購読の解除忘れです。

C#
publisher.SomeEvent += subscriber.HandleEvent;

イベントを購読すると、発行元であるpublisherが購読者であるsubscriberへの参照を保持します。

そのため、subscriberが不要になっても、publisherが生きている限りsubscriberはGCに回収されません。

C#
publisher.SomeEvent -= subscriber.HandleEvent;

不要になったタイミングでイベント購読を解除することが重要です。

特に、長寿命のオブジェクトのイベントを短寿命のオブジェクトが購読する場合は注意が必要です。たとえば、アプリケーション全体で生き続けるサービスのイベントを、画面やViewModelが購読する場合などです。

5-3. static変数によるオブジェクト保持

static変数はアプリケーションの実行中、長く生き続けることが多いです。

そのため、staticなフィールドやコレクションにオブジェクトを追加したままにすると、GCに回収されません。

C#
static List<User> Users = new();

void AddUser(User user)
{
Users.Add(user);
}

このコードでは、Usersリストから削除しない限り、追加されたUserオブジェクトは参照され続けます。

キャッシュ用途でstaticコレクションを使う場合は、上限数、期限、削除タイミングを設計する必要があります。

5-4. コレクションに不要な参照を残すケース

List<T>Dictionary<TKey, TValue>Queue<T>Stack<T>などのコレクションに不要なオブジェクトを入れたままにすると、そのオブジェクトは回収されません。

C#
var cache = new Dictionary<string, byte[]>();

cache["image1"] = new byte[10_000_000];
cache["image2"] = new byte[10_000_000];

このように大きなデータをDictionaryに保存し続けると、メモリ使用量が増えます。

不要になったら削除します。

C#
cache.Remove("image1");

すべて不要になった場合はClear()を使います。

C#
cache.Clear();

ただし、Clear()してもコレクション自体の内部容量がすぐに小さくなるとは限りません。大量データを扱う場合は、必要に応じてコレクション自体を作り直すことも検討します。

5-5. IDisposableのDispose忘れ

IDisposableを実装しているオブジェクトは、使い終わったらDispose()を呼ぶべきです。

代表例は、FileStreamStreamReaderSqlConnectionHttpResponseMessage、画像関連オブジェクトなどです。

C#
var stream = new FileStream("sample.txt", FileMode.Open);
// 処理
stream.Dispose();

しかし、例外が発生するとDispose()が呼ばれない可能性があります。

そのため、通常はusingを使います。

C#
using var stream = new FileStream("sample.txt", FileMode.Open);
// 処理

usingを使うと、スコープを抜けるときに自動的にDispose()が呼ばれます。

5-6. タイマー・非同期処理・Taskによる参照保持

タイマーや非同期処理も、メモリリークの原因になることがあります。

たとえば、System.Threading.Timerやイベント付きタイマーを使っている場合、タイマーがコールバック対象のオブジェクトを参照し続けることがあります。

C#
_timer = new Timer(_ => DoWork(), null, 0, 1000);

このようなタイマーを停止・破棄しないと、対象オブジェクトが生き続ける可能性があります。

C#
_timer.Dispose();

また、長時間終了しないTaskやキャンセルされない非同期処理も注意が必要です。

C#
private CancellationTokenSource? _cts;

public void Start()
{
_cts = new CancellationTokenSource();
_ = RunAsync(_cts.Token);
}

public void Stop()
{
_cts?.Cancel();
_cts?.Dispose();
}

非同期処理にはCancellationTokenを渡し、不要になったらキャンセルできるように設計しましょう。

5-7. 大きなオブジェクトや画像・ファイル処理での注意点

画像、動画、PDF、大きなCSV、大容量バイナリなどを扱う場合は、メモリ使用量が急増しやすくなります。

たとえば、巨大なファイルを一度にすべて読み込むと、大きな配列が作成されます。

C#
byte[] data = File.ReadAllBytes("large-file.dat");

ファイルサイズが大きい場合は、ストリームで少しずつ処理するほうが安全です。

C#
using var stream = new FileStream("large-file.dat", FileMode.Open, FileAccess.Read);
byte[] buffer = new byte[8192];

int read;
while ((read = stream.Read(buffer, 0, buffer.Length)) > 0)
{
// 読み込んだ分だけ処理
}

大きなオブジェクトを頻繁に作成する処理では、LOHやGC負荷も意識する必要があります。

6. IDisposableとusingでリソースを正しく解放する

C#のメモリ管理では、GCだけでなくIDisposableusingの理解も重要です。

GCはマネージメモリを回収しますが、ファイル、DB接続、ソケット、OSハンドルなどのリソースは、使い終わったタイミングで明示的に解放すべきです。

6-1. マネージリソースとアンマネージリソースの違い

マネージリソースとは、.NETのGCによって管理されるリソースです。通常のC#オブジェクトや配列などが該当します。

アンマネージリソースとは、GCだけでは直接管理できないリソースです。ファイルハンドル、ウィンドウハンドル、DB接続、ネットワークソケット、ネイティブメモリなどが該当します。

C#のオブジェクトがアンマネージリソースを内部的に持っている場合、そのリソースを解放するためにDispose()が必要になります。

6-2. IDisposableとは

IDisposableは、使い終わったリソースを明示的に解放するためのインターフェイスです。

C#
public interface IDisposable
{
void Dispose();
}

IDisposableを実装している型は、使い終わったらDispose()を呼ぶ必要があります。

C#
var reader = new StreamReader("sample.txt");
try
{
string text = reader.ReadToEnd();
}
finally
{
reader.Dispose();
}

ただし、毎回try-finallyを書くのは手間なので、通常はusingを使います。

6-3. Disposeメソッドの役割

Dispose()の役割は、不要になったリソースを明示的に解放することです。

たとえば、FileStream.Dispose()を呼ぶと、内部で保持しているファイルハンドルが解放されます。SqlConnection.Dispose()を呼ぶと、DB接続に関するリソースが解放されます。

重要なのは、Dispose()は「すべてのメモリを即座に解放する命令」ではないということです。

Dispose()は主にリソースの解放を行います。オブジェクト自体のメモリを回収するのはGCです。

つまり、Dispose()とGCは役割が違います。

6-4. using文とusing宣言の使い方

C#では、usingを使うことでDispose()の呼び忘れを防げます。

従来のusing文は次のように書きます。

C#
using (var reader = new StreamReader("sample.txt"))
{
string text = reader.ReadToEnd();
Console.WriteLine(text);
}

usingブロックを抜けると、自動的にreader.Dispose()が呼ばれます。

C# 8.0以降では、using宣言も使えます。

C#
using var reader = new StreamReader("sample.txt");

string text = reader.ReadToEnd();
Console.WriteLine(text);

using宣言では、変数のスコープを抜けるときにDispose()が呼ばれます。コードがすっきりするため、現在はこちらの書き方もよく使われます。

6-5. ファイル・DB接続・Streamでの実践例

ファイルを読み込む例です。

C#
using var reader = new StreamReader("sample.txt");

string text = reader.ReadToEnd();
Console.WriteLine(text);

ファイルに書き込む例です。

C#
using var writer = new StreamWriter("output.txt");

writer.WriteLine("Hello, C# memory management!");

DB接続の例です。

C#
using var connection = new SqlConnection(connectionString);
connection.Open();

using var command = connection.CreateCommand();
command.CommandText = "SELECT COUNT(*) FROM Users";

var count = command.ExecuteScalar();
Console.WriteLine(count);

このように、IDisposableを実装しているオブジェクトは、原則としてusingで囲むと安全です。

7. C#のメモリ使用量を減らす実践テクニック

C#のメモリ使用量を減らすには、単にGCに任せるだけでは不十分です。

不要なオブジェクト生成を減らし、データ構造を適切に選び、リソースを早めに解放し、必要に応じてSpan<T>やオブジェクトプールを活用することが重要です。

7-1. 不要なオブジェクト生成を減らす

C#ではオブジェクト生成が簡単ですが、ループ内で大量に生成するとGC負荷が高くなります。

C#
for (int i = 0; i < 100000; i++)
{
var user = new User();
// 処理
}

必要なオブジェクトだけを作成し、使い回せるものは使い回すことで、メモリ使用量とGC回数を減らせます。

ただし、過度な使い回しはコードを複雑にすることがあります。まずは計測し、本当に問題になっている箇所に対して最適化することが大切です。

7-2. 文字列結合にはStringBuilderを使う

C#のstringは不変です。つまり、一度作成された文字列は変更できません。

そのため、ループ内で+=による文字列結合を繰り返すと、大量の一時文字列が作成されます。

C#
string result = "";

for (int i = 0; i < 10000; i++)
{
result += i.ToString();
}

このような場合は、StringBuilderを使います。

C#
var builder = new StringBuilder();

for (int i = 0; i < 10000; i++)
{
builder.Append(i);
}

string result = builder.ToString();

特に大量の文字列を組み立てる処理では、StringBuilderを使うことでメモリ効率が改善します。

7-3. 配列・List・Dictionaryの容量に注意する

List<T>Dictionary<TKey, TValue>は、要素数が増えると内部配列を拡張します。

拡張時には新しい配列を確保して、既存データをコピーすることがあります。そのため、最初から要素数の目安がわかっている場合は、初期容量を指定すると効率的です。

C#
var list = new List<int>(10000);

Dictionaryでも同様です。

C#
var map = new Dictionary<string, User>(10000);

また、大量の要素を削除しても、内部容量がすぐに縮小されるとは限りません。メモリを解放したい場合は、TrimExcess()や再作成を検討します。

C#
list.Clear();
list.TrimExcess();

ただし、TrimExcess()もコストがあるため、頻繁に呼ぶべきではありません。

7-4. Span<T>・Memory<T>を活用する場面

Span<T>Memory<T>は、配列や文字列の一部をコピーせずに扱うための仕組みです。

たとえば、配列の一部を処理したい場合、従来は新しい配列を作ってコピーすることがありました。

C#
int[] source = { 1, 2, 3, 4, 5 };
int[] part = source.Skip(1).Take(3).ToArray();

この場合、新しい配列が作成されます。

Span<T>を使うと、元の配列の一部を参照できます。

C#
int[] source = { 1, 2, 3, 4, 5 };
Span<int> part = source.AsSpan(1, 3);

foreach (var value in part)
{
Console.WriteLine(value);
}

Span<T>は高性能な処理でよく使われます。特に、文字列解析、バイナリ処理、ファイル処理、ネットワーク処理などで有効です。

ただし、初心者が最初から多用する必要はありません。まずは通常の配列やListでわかりやすく書き、パフォーマンス上の課題が出たときに検討するとよいでしょう。

7-5. オブジェクトプールを使うべきケース

オブジェクトプールとは、オブジェクトを毎回作成・破棄するのではなく、使い終わったオブジェクトを再利用する仕組みです。

大量の一時オブジェクトを頻繁に作る処理では、オブジェクトプールによってGC負荷を減らせる場合があります。

たとえば、大きなバッファを何度も作る代わりに、ArrayPool<T>を使うことがあります。

C#
using System.Buffers;

byte[] buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024);

try
{
// bufferを使った処理
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}

ArrayPool<T>を使うと、配列を借りて、使い終わったら返すことができます。

ただし、返却後の配列を使ってはいけません。また、機密情報を含むデータを扱う場合は、返却前にクリアする必要があります。

C#
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);

オブジェクトプールは便利ですが、管理が複雑になるため、必要な場面で慎重に使うべきです。

7-6. LINQの使いすぎによるメモリ消費に注意する

LINQはC#らしい読みやすいコードを書ける便利な機能です。

C#
var result = users
.Where(u => u.IsActive)
.Select(u => u.Name)
.ToList();

しかし、大量データに対してLINQを多用すると、一時オブジェクトや列挙子、リストが作成され、メモリ使用量が増えることがあります。

特に、ToList()ToArray()を何度も呼ぶコードには注意が必要です。

C#
var activeUsers = users.Where(u => u.IsActive).ToList();
var names = activeUsers.Select(u => u.Name).ToList();

必要がなければ、遅延評価のまま処理する、または1回のループで処理することも検討しましょう。

C#
foreach (var user in users)
{
if (!user.IsActive)
{
continue;
}

Console.WriteLine(user.Name);
}

LINQは悪いものではありません。可読性とパフォーマンスのバランスを見ながら使うことが大切です。

8. C#のメモリリークを調査・検出する方法

C#のメモリリークを調査するには、感覚ではなくツールを使って確認することが重要です。

メモリ使用量が増えているように見えても、それが本当にリークなのか、GC前で一時的に増えているだけなのか、キャッシュとして意図的に保持しているのかを見極める必要があります。

8-1. Visual Studio診断ツールでメモリ使用量を確認する

Visual Studioには、メモリ使用量を確認できる診断ツールがあります。

デバッグ実行中に診断ツールを開くと、プロセスメモリやヒープの状態を確認できます。

特に便利なのがメモリスナップショットです。ある時点のメモリ状態を保存し、別の時点と比較することで、どの型のオブジェクトが増えているかを確認できます。

たとえば、画面を開く前、画面を開いた後、画面を閉じた後でスナップショットを取得します。画面を閉じた後もViewModelや画面オブジェクトが残っている場合、イベント購読やstatic参照などが原因かもしれません。

8-2. メモリスナップショットの見方

メモリスナップショットを見るときは、単純なメモリ使用量だけでなく、オブジェクト数と参照元を見ることが重要です。

特に確認すべきポイントは、特定の型のインスタンス数が増え続けていないか、不要になったはずの画面やViewModelが残っていないか、大きな配列や文字列が大量に残っていないか、staticフィールドやイベントから参照されていないか、コレクション内に不要なデータが残っていないかです。

メモリリーク調査では、「なぜこのオブジェクトがまだ参照されているのか」を追うことが重要です。

8-3. dotMemoryやPerfViewなどの分析ツール

より詳細に調査したい場合は、専用のメモリプロファイラを使います。

代表的なツールには、JetBrains dotMemory、PerfView、Visual Studio Profilerなどがあります。

dotMemoryはGUIで見やすく、オブジェクトの参照関係やメモリ使用量を調べやすいツールです。

PerfViewは.NETのパフォーマンス分析に強力なツールで、GCの発生状況やヒープの状態を詳しく調査できます。

本格的なメモリリークやGC負荷の調査では、これらのツールを使うことで原因を特定しやすくなります。

8-4. GCログ・パフォーマンスカウンターの確認

サーバーアプリケーションや長時間動作するアプリケーションでは、GCの発生頻度やメモリ使用量を継続的に監視することが重要です。

確認すべき情報には、GCの回数、第0世代・第1世代・第2世代GCの発生頻度、ヒープサイズ、LOHの使用量、プロセスのメモリ使用量、CPU使用率などがあります。

第2世代GCが頻繁に発生している場合、長寿命オブジェクトが多すぎる可能性があります。

LOHの使用量が大きく変動している場合、大きな配列や画像データの扱いに問題があるかもしれません。

8-5. メモリリーク調査で見るべきポイント

メモリリークを調査するときは、次のような流れで確認すると効率的です。

まず、メモリ使用量が本当に増え続けているかを確認します。一時的に増えているだけなら、リークではない可能性もあります。

次に、GCを実行した後もメモリが下がらないかを確認します。GC後も不要なオブジェクトが残っている場合、どこかから参照されている可能性があります。

その後、メモリスナップショットを比較し、増え続けている型を特定します。

最後に、そのオブジェクトの参照元をたどり、イベント、static変数、コレクション、タイマー、非同期処理などが原因になっていないかを確認します。

9. C#のメモリ管理でよくある誤解

C#のメモリ管理には、初心者が誤解しやすいポイントがいくつかあります。

誤解したまま開発すると、メモリリークやパフォーマンス低下の原因になります。

9-1. GCがあるからメモリリークは起きない?

これは誤解です。

GCは、到達不能になったオブジェクトを回収します。しかし、不要なオブジェクトでも参照が残っていれば、GCは回収できません。

たとえば、staticコレクションに追加されたオブジェクト、イベント購読により参照されているオブジェクト、タイマーから参照されているオブジェクトなどは、不要になっても残り続けることがあります。

C#でもメモリリークは起こります。

9-2. nullを入れれば必ずメモリは解放される?

これも誤解です。

nullを代入すると、その変数からの参照は外れます。しかし、他の場所から同じオブジェクトが参照されていれば、GCは回収できません。

また、参照が完全になくなったとしても、メモリが実際に解放されるのはGCが実行されたタイミングです。

nullは「その変数から参照しないようにする」だけであり、「即時にメモリを解放する命令」ではありません。

9-3. Disposeすればすべてのメモリが解放される?

Dispose()は、主にファイルハンドルやDB接続などのリソースを解放するためのものです。

Dispose()を呼んでも、そのオブジェクト自体のマネージメモリが即座にGCで回収されるわけではありません。

オブジェクトのメモリは、参照がなくなり、GCが実行されたときに回収されます。

つまり、Dispose()とGCは役割が違います。Dispose()はリソース解放、GCはメモリ回収を担当します。

9-4. GC.Collectを呼べば高速化できる?

多くの場合、これは誤解です。

GC.Collect()を呼ぶとGCを実行できますが、GCにはコストがあります。処理が一時停止したり、CPUを消費したりするため、安易に呼ぶと逆に遅くなることがあります。

通常は、GCのタイミングを.NETランタイムに任せるべきです。

本当にGC.Collect()が必要かどうかは、必ず計測して判断しましょう。

9-5. 値型は必ずスタックに置かれる?

これもよくある誤解です。

値型はスタックに置かれることが多い、という説明は初心者向けにはわかりやすいですが、厳密には正しくありません。

値型がローカル変数として使われる場合、スタック上で扱われることがあります。

しかし、値型がクラスのフィールドである場合、その値はヒープ上のオブジェクト内部に含まれます。

C#
class User
{
public int Age;
}

この場合、Ageは値型ですが、Userオブジェクトがヒープ上にあるため、Ageもそのオブジェクト内部に存在します。

また、値型がボックス化されると、ヒープ上にオブジェクトとして配置されます。

C#
int x = 10;
object obj = x; // ボックス化

そのため、「値型は必ずスタック」と覚えるのではなく、「値型は値そのものを表す。配置場所は使われ方によって変わる」と理解するのが正確です。

10. C#のメモリ管理を学ぶためのサンプルコード

ここでは、C#のメモリ管理を理解するためのサンプルコードを紹介します。

スタックとヒープ、GCの動作、メモリリークの例、修正例、usingによる安全なリソース解放を順番に見ていきましょう。

10-1. スタックとヒープを理解するサンプル

C#
using System;

class User
{
public string Name { get; set; } = "";
}

class Program
{
static void Main()
{
int age = 30;
User user = new User();

user.Name = "Alice";

Console.WriteLine(age);
Console.WriteLine(user.Name);
}
}

このコードでは、ageは値型のローカル変数です。

userは参照型のローカル変数で、new User()によって作成されたオブジェクト本体はヒープ上に配置されます。

user変数には、ヒープ上のUserオブジェクトへの参照が入っています。

10-2. GCの動作を確認するサンプル

C#
using System;

class Program
{
static void Main()
{
Console.WriteLine("Start");
Console.WriteLine(GC.GetTotalMemory(false));

CreateLargeObject();

Console.WriteLine("After allocation");
Console.WriteLine(GC.GetTotalMemory(false));

GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();

Console.WriteLine("After GC");
Console.WriteLine(GC.GetTotalMemory(true));
}

static void CreateLargeObject()
{
byte[] data = new byte[50_000_000];
Console.WriteLine(data.Length);
}
}

CreateLargeObject()メソッド内で大きな配列を作成しています。

メソッドが終了すると、data変数はスコープ外になります。他に参照がなければ、配列はGCの回収対象になります。

その後、GCを実行すると、メモリ使用量が下がる可能性があります。

ただし、実際の数値は実行環境によって異なります。

10-3. メモリリークが起きるコード例

イベント購読の解除忘れによるメモリリークの例です。

C#
using System;

class Publisher
{
public event EventHandler? SomethingHappened;

public void Raise()
{
SomethingHappened?.Invoke(this, EventArgs.Empty);
}
}

class Subscriber
{
public Subscriber(Publisher publisher)
{
publisher.SomethingHappened += OnSomethingHappened;
}

private void OnSomethingHappened(object? sender, EventArgs e)
{
Console.WriteLine("Event received");
}
}

このコードでは、SubscriberPublisherのイベントを購読しています。

しかし、購読解除をしていません。

Publisherが長く生き続ける場合、PublisherSubscriberへの参照を保持し続けます。そのため、Subscriberが不要になってもGCに回収されない可能性があります。

10-4. メモリリークを修正したコード例

イベント購読を解除できるように修正します。

C#
using System;

class Publisher
{
public event EventHandler? SomethingHappened;

public void Raise()
{
SomethingHappened?.Invoke(this, EventArgs.Empty);
}
}

class Subscriber : IDisposable
{
private readonly Publisher _publisher;

public Subscriber(Publisher publisher)
{
_publisher = publisher;
_publisher.SomethingHappened += OnSomethingHappened;
}

private void OnSomethingHappened(object? sender, EventArgs e)
{
Console.WriteLine("Event received");
}

public void Dispose()
{
_publisher.SomethingHappened -= OnSomethingHappened;
}
}

使う側では、usingや明示的なDispose()で購読を解除します。

C#
var publisher = new Publisher();

using (var subscriber = new Subscriber(publisher))
{
publisher.Raise();
}

usingブロックを抜けるとDispose()が呼ばれ、イベント購読が解除されます。

10-5. usingでリソースを安全に解放するコード例

ファイルを読み込む例です。

C#
using System;
using System.IO;

class Program
{
static void Main()
{
using var reader = new StreamReader("sample.txt");

string text = reader.ReadToEnd();

Console.WriteLine(text);
}
}

StreamReaderIDisposableを実装しています。

using varを使うことで、スコープを抜けるときに自動的にDispose()が呼ばれます。

例外が発生した場合でも、適切にリソースが解放されるため、ファイルやStreamを扱うときはusingを使うのが基本です。

11. C#のメモリ管理に関するFAQ

最後に、C#のメモリ管理でよくある質問に答えます。

11-1. C#でメモリ解放は必要ですか?

通常のマネージオブジェクトについては、開発者が明示的にメモリを解放する必要はありません。

不要になったオブジェクトは、参照がなくなればGCの回収対象になります。

ただし、IDisposableを実装しているオブジェクトは、使い終わったらDispose()を呼ぶ必要があります。ファイル、DB接続、Stream、ソケットなどを扱う場合は、usingを使って確実に解放しましょう。

11-2. C#のメモリリークはどうやって見つけますか?

Visual Studio診断ツールやdotMemory、PerfViewなどのメモリプロファイラを使って調査します。

基本的には、メモリスナップショットを複数取得し、どの型のオブジェクトが増え続けているかを確認します。

その後、残っているオブジェクトの参照元をたどり、static変数、イベント、コレクション、タイマー、非同期処理などが原因になっていないか確認します。

11-3. GCはいつ実行されますか?

GCは、.NETランタイムが必要と判断したタイミングで実行されます。

主なきっかけは、ヒープ使用量の増加、新しいメモリ確保の必要性、OSからのメモリ圧力などです。

開発者がGC.Collect()を呼んで明示的に実行することもできますが、通常はランタイムに任せるべきです。

11-4. DisposeとGCの違いは何ですか?

Dispose()は、ファイルハンドル、DB接続、ソケットなどのリソースを明示的に解放するための仕組みです。

GCは、不要になったマネージオブジェクトのメモリを回収する仕組みです。

つまり、Dispose()はリソース解放、GCはメモリ回収を担当します。

Dispose()を呼んだからといって、オブジェクト自体のメモリが即座に回収されるわけではありません。

11-5. スタックとヒープの違いを一言で説明すると?

スタックはメソッド呼び出しやローカル変数のための高速な作業領域で、ヒープはnewで作成されたオブジェクトなどを保存するための領域です。

ただし、値型が必ずスタックに置かれるわけではありません。値型でも、クラスのフィールドや配列の要素として使われる場合はヒープ上のオブジェクト内部に配置されます。

11-6. メモリ使用量が増え続ける場合は何を確認すべきですか?

まず、本当にメモリリークなのかを確認します。GC前に一時的に増えているだけの場合もあります。

次に、GC後もメモリが下がらないかを確認します。

そのうえで、メモリスナップショットを取得し、増え続けているオブジェクトの型を特定します。

よく確認すべきポイントは、イベント購読の解除忘れ、static変数やstaticコレクション、不要なデータを保持したListやDictionary、Dispose忘れ、停止していないタイマー、キャンセルされていない非同期処理、大きな配列や画像データの保持です。

まとめ

C#はGCによってメモリ管理を自動化しているため、CやC++のように手動でメモリを解放する場面は多くありません。

しかし、C#のメモリ管理を完全にGC任せにしてよいわけではありません。

スタックとヒープの違い、値型と参照型の動作、GCの仕組み、世代別GC、LOH、IDisposableusing、メモリリークの原因を理解しておくことで、より安定したC#アプリケーションを作れるようになります。

特に重要なのは、不要になったオブジェクトへの参照を残さないことです。

イベント購読の解除忘れ、static変数、コレクション、タイマー、非同期処理、Dispose忘れは、C#でメモリリークを引き起こしやすい代表的な原因です。

また、メモリ問題を解決するときは、勘に頼らず、Visual Studio診断ツールやメモリプロファイラを使って計測することが大切です。

C#のメモリ管理を正しく理解すれば、GCを味方につけながら、メモリ効率がよく、長時間安定して動作するアプリケーションを開発できます。