C#のメモリ管理を基礎から解説|GC・Dispose・メモリリーク対策までわかる実践ガイド
はじめに
C#は、C/C++のように開発者が明示的にメモリを確保・解放する言語とは異なり、ガベージコレクション(GC)によって不要になったメモリを自動的に回収します。そのため、C#は比較的安全にメモリを扱える言語だといえます。
しかし、「GCがあるからメモリ管理を意識しなくてよい」というわけではありません。実際の開発では、Disposeの呼び忘れ、イベント購読の解除漏れ、static変数による参照保持、不要なオブジェクト生成などが原因で、メモリ使用量の増加やメモリリークが発生することがあります。
特に、Webアプリケーション、Windowsアプリケーション、ゲーム、バッチ処理、常駐サービスのように長時間動作するC#アプリケーションでは、メモリ管理の理解が品質やパフォーマンスに直結します。
この記事では、C#のメモリ管理について、GCの基本、スタックとヒープ、DisposeとIDisposable、メモリリークの原因、実践的な対策、調査方法までを基礎から解説します。
1. C#のメモリ管理とは?まず押さえるべき基本
C#のメモリ管理とは、アプリケーションが使用するメモリをどのように確保し、どのように不要になったメモリを解放するかを扱う仕組みです。
C#では、通常のオブジェクトは.NETランタイムによって管理されます。開発者がnewでオブジェクトを作成すると、必要なメモリが確保されます。そして、そのオブジェクトがどこからも参照されなくなると、GCによって将来的に回収されます。
ただし、C#のメモリ管理には大きく分けて次の2つの観点があります。
1つ目は、GCが管理するマネージドメモリです。これは、C#の通常のオブジェクト、配列、文字列、クラスインスタンスなどが使用するメモリです。
2つ目は、ファイルハンドル、データベース接続、ネットワークソケット、OSリソースなどのアンマネージドリソースです。これらはGCが直接管理するものではないため、Disposeなどを使って明示的に解放する必要があります。
C#のメモリ管理を正しく理解するには、「GCが自動で回収するもの」と「開発者が解放を意識すべきもの」を分けて考えることが重要です。
1-1. C#ではメモリ解放をGCが自動で行う
C#では、newで作成したオブジェクトを明示的にfreeやdeleteで解放する必要はありません。不要になったオブジェクトは、GCが自動的に検出してメモリを回収します。
例えば、次のようなコードを考えます。
C#void Sample()
{
var user = new User();
user.Name = "Taro";
}
このuser変数はメソッド内のローカル変数です。Sampleメソッドの実行が終わると、通常はuserへの参照は失われます。その後、GCが適切なタイミングでUserオブジェクトを不要なものとして回収します。
ただし、メソッド終了と同時にメモリが即座に解放されるわけではありません。GCは必要に応じて実行されるため、不要になったオブジェクトがしばらくメモリ上に残ることもあります。
C#のメモリ管理では、「参照されなくなったオブジェクトはGCの回収対象になるが、回収タイミングはランタイムに任される」と理解しておくことが大切です。
1-2. マネージドメモリとアンマネージドリソースの違い
C#で扱うメモリやリソースは、マネージドメモリとアンマネージドリソースに分けて考える必要があります。
マネージドメモリとは、.NETランタイムが管理するメモリです。クラスのインスタンス、配列、文字列、コレクションなどは基本的にマネージドメモリ上に確保されます。これらはGCの対象になります。
一方、アンマネージドリソースとは、.NETのGCが直接管理しない外部リソースです。代表的なものには、次のようなものがあります。
C#// 例:アンマネージドリソースを内部で扱う代表的な型
FileStream
SqlConnection
Socket
Bitmap
StreamReader
StreamWriter
これらの型は、内部でファイル、ネットワーク、データベース接続、画像ハンドルなどを扱うことがあります。こうしたリソースはGCだけに任せるのではなく、Disposeを呼び出して速やかに解放する必要があります。
つまり、C#では通常のオブジェクトのメモリはGCに任せ、外部リソースを持つオブジェクトはDisposeで明示的に解放するのが基本です。
1-3. C/C++の手動メモリ管理との違い
C/C++では、開発者がメモリの確保と解放を明示的に行う場面が多くあります。例えばCではmallocでメモリを確保し、不要になったらfreeで解放します。C++ではnewとdeleteを使うことがあります。
一方、C#では通常、次のようなコードを書いても、明示的な解放処理は不要です。
C#var person = new Person();
このオブジェクトはGCの管理対象になるため、不要になれば自動的に回収されます。
ただし、C#でも完全にメモリ管理から解放されるわけではありません。例えば、不要になったオブジェクトをstaticフィールドやイベントハンドラが参照し続けていると、GCはそのオブジェクトを「まだ使われている」と判断します。その結果、メモリが回収されず、メモリリークの原因になります。
C#はC/C++よりもメモリ管理の負担が小さい一方で、「参照を残し続けない設計」や「リソースを確実にDisposeする習慣」は必要です。
1-4. メモリ管理を理解すべき理由
C#のメモリ管理を理解すべき理由は、アプリケーションの安定性とパフォーマンスに大きく関わるからです。
メモリ管理を理解していないと、次のような問題が起きやすくなります。
C#// 例:使い終わった接続を解放していない
var connection = new SqlConnection(connectionString);
connection.Open();
このようなコードでDisposeを呼ばないまま処理を終えると、接続リソースが長く保持され、接続数の枯渇やパフォーマンス低下につながる可能性があります。
また、不要なオブジェクトをコレクションに追加し続けると、GCが回収できず、メモリ使用量が増え続けます。長時間動作するアプリでは、最終的にOutOfMemoryExceptionが発生することもあります。
C#のメモリ管理を理解することで、次のようなメリットがあります。
メモリリークを防ぎやすくなる
GCによるパフォーマンス低下を抑えやすくなる
Disposeが必要な場面を判断できる長時間動作するアプリを安定させやすくなる
メモリ使用量の調査や改善がしやすくなる
C#ではGCが多くの処理を自動化してくれますが、開発者がメモリの流れを理解しておくことで、より安全で効率的なコードを書けるようになります。
2. C#のメモリ領域の仕組み
C#のメモリ管理を理解するうえで、スタック領域とヒープ領域の違いは非常に重要です。
大まかにいうと、スタックはメソッド呼び出しやローカル変数の管理に使われる領域で、ヒープはnewで作成されたオブジェクトなどが配置される領域です。
C#では値型と参照型によってメモリ上の扱いが異なります。また、サイズの大きいオブジェクトは通常のヒープとは異なるLOHという領域に配置されることがあります。
2-1. スタック領域とヒープ領域の違い
スタック領域は、メソッド呼び出しごとに使われるメモリ領域です。ローカル変数、引数、戻り先情報などが管理されます。スタックは後入れ先出しの構造で、メソッドが終了すると、そのメソッドで使われていたスタック上のデータはまとめて破棄されます。
一方、ヒープ領域は、newで作成されたオブジェクトが配置される領域です。ヒープ上のオブジェクトは、メソッドが終了しても参照が残っていれば生き続けます。不要になったタイミングでGCの回収対象になります。
例えば、次のコードを見てみます。
C#void Sample()
{
int count = 10;
var user = new User();
}
countは値型のローカル変数なので、基本的にはスタック上に値が置かれます。一方、new User()で作成されたUserオブジェクトはヒープ上に配置され、ローカル変数userにはその参照が入ります。
ただし、実際の配置はJITコンパイラの最適化や実行環境によって変わる場合があります。基本的な理解としては、「短命なローカル情報はスタック、オブジェクト本体はヒープ」と考えるとよいでしょう。
2-2. 値型と参照型のメモリ配置
C#の型は大きく値型と参照型に分けられます。
値型には、int、double、bool、DateTime、structなどがあります。値型は変数そのものが値を保持します。
C#int a = 10;
int b = a;
b = 20;
Console.WriteLine(a); // 10
Console.WriteLine(b); // 20
この例では、b = aによって値がコピーされます。そのため、bを変更してもaには影響しません。
一方、参照型には、class、string、配列、List<T>などがあります。参照型の変数は、オブジェクトそのものではなく、ヒープ上のオブジェクトへの参照を保持します。
C#var user1 = new User { Name = "Taro" };
var user2 = user1;
user2.Name = "Jiro";
Console.WriteLine(user1.Name); // Jiro
Console.WriteLine(user2.Name); // Jiro
この例では、user1とuser2は同じオブジェクトを参照しています。そのため、user2.Nameを変更すると、user1.Nameにも同じ変更が見えます。
メモリ管理の観点では、参照型の扱いに特に注意が必要です。どこかに参照が残っている限り、そのオブジェクトはGCの回収対象になりません。
2-3. オブジェクトの生成から解放までの流れ
C#でオブジェクトが生成されてから解放されるまでの流れは、一般的に次のようになります。
まず、newによってヒープ上にオブジェクトが作成されます。
C#var item = new Item();
次に、変数やフィールド、コレクションなどがそのオブジェクトへの参照を保持します。
C#var list = new List<Item>();
list.Add(item);
この時点では、item変数とlistの両方がオブジェクトに関係しています。仮にitem変数がスコープ外になっても、listが参照を保持していれば、オブジェクトは回収されません。
C#item = null;
// listが参照しているため、Itemオブジェクトはまだ回収対象にならない
その後、listからも削除され、他に参照がなくなると、オブジェクトはGCの回収対象になります。
C#list.Clear();
このように、C#のメモリ管理では「オブジェクトが不要かどうか」は、開発者の意図ではなく、到達可能な参照があるかどうかで判断されます。
2-4. 大きなオブジェクトを扱うLOHとは
C#では、大きなオブジェクトは通常のヒープとは別に、Large Object Heap、略してLOHに配置されます。一般的には、大きな配列や大きな文字列などが対象になります。
例えば、次のような大きな配列はLOHに配置される可能性があります。
C#byte[] buffer = new byte[100_000];
LOHに配置されるオブジェクトはサイズが大きいため、頻繁に作成と破棄を繰り返すと、メモリ使用量やGCの負荷に影響することがあります。
特に、画像処理、大容量ファイル処理、ネットワークバッファ、大量データ処理などでは、大きな配列を何度も生成するコードに注意が必要です。
改善策としては、次のような方法があります。
C#// ArrayPoolを使って大きな配列を再利用する例
var pool = System.Buffers.ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(100_000);
try
{
// bufferを使った処理
}
finally
{
pool.Return(buffer);
}
大きなオブジェクトを扱う場合は、「必要なときに作って捨てる」だけでなく、「再利用できないか」「サイズを抑えられないか」「処理単位を分割できないか」を検討することが重要です。
3. ガベージコレクション(GC)の仕組み
C#のメモリ管理を理解するうえで中心となるのが、ガベージコレクション、つまりGCです。
GCは、不要になったオブジェクトを自動的に検出してメモリを回収する仕組みです。C#では、通常のマネージドオブジェクトについては、開発者が明示的に解放する必要はありません。
ただし、GCの仕組みを理解していないと、メモリリークやパフォーマンス低下の原因を見誤ることがあります。
3-1. GCとは何か
GCとは、プログラムが使用しなくなったメモリを自動的に回収する仕組みです。
C#では、オブジェクトをnewで作成するとヒープに配置されます。GCは、実行中のアプリケーションから到達できるオブジェクトを調べ、到達できないオブジェクトを不要なものとして扱います。
例えば、次のようなコードでは、メソッド終了後にdataへの参照がなくなるため、作成されたオブジェクトはGCの回収対象になります。
C#void CreateData()
{
var data = new byte[1024];
}
GCは、単にメモリを解放するだけでなく、ヒープ上のオブジェクトを整理してメモリを効率的に使えるようにすることもあります。
ただし、GCが実行されるとアプリケーションの処理に影響が出る場合があります。そのため、不要なオブジェクト生成を減らし、GCの負荷を抑えることはパフォーマンス改善につながります。
3-2. 世代別GC:第0世代・第1世代・第2世代
.NETのGCは、世代別GCという仕組みを採用しています。これは、「多くのオブジェクトは短命である」という考えに基づいています。
GCの世代は主に次の3つです。
第0世代は、作成されたばかりの短命なオブジェクトが入る領域です。多くの一時オブジェクトは第0世代で回収されます。
第1世代は、第0世代のGCを生き残ったオブジェクトが移動する中間的な世代です。
第2世代は、長く生き残っているオブジェクトが属する世代です。アプリケーション全体で長期間保持されるオブジェクトなどが含まれます。
例えば、短時間だけ使う一時的な文字列や小さなオブジェクトは第0世代で回収されることが多いです。一方、アプリケーション全体で使う設定情報やキャッシュなどは第2世代まで昇格する可能性があります。
世代別GCでは、第0世代のGCは比較的頻繁に実行されますが、第2世代のGCはコストが高くなりやすいため、頻繁に発生するとパフォーマンスに影響します。
3-3. GCが不要なオブジェクトを判断する仕組み
GCは、オブジェクトが不要かどうかを「参照されているかどうか」で判断します。
より正確には、GCルートと呼ばれる起点から到達可能なオブジェクトは生存しているとみなされます。GCルートには、実行中のメソッドのローカル変数、静的フィールド、CPUレジスタ、ファイナライザキューなどがあります。
次のコードを見てみます。
C#static List<User> Users = new List<User>();
void AddUser()
{
var user = new User();
Users.Add(user);
}
AddUserメソッドが終了すると、ローカル変数userは使われなくなります。しかし、staticフィールドのUsersがそのオブジェクトを保持しているため、GCから見るとそのUserオブジェクトはまだ到達可能です。
つまり、開発者が「もう使っていない」と思っていても、どこかに参照が残っていればGCは回収しません。
C#のメモリリークの多くは、この「不要なのに参照が残っている」状態によって発生します。
3-4. GCが実行されるタイミング
GCは、アプリケーションの状況に応じて.NETランタイムが自動的に実行します。主なきっかけとしては、ヒープの空き容量が少なくなったとき、一定量のメモリ割り当てが行われたとき、システムのメモリ状況に応じて必要と判断されたときなどがあります。
開発者は、通常、GCの実行タイミングを細かく制御する必要はありません。むしろ、GCの実行を前提にして、不要な参照を残さない設計や、不要なオブジェクト生成を減らす設計を行うことが重要です。
また、GCが実行されたからといって、必ずすべての不要オブジェクトが即座に回収されるとは限りません。ファイナライザを持つオブジェクトは回収までに追加のステップが必要になることがあります。
GCのタイミングはランタイムに任せるのが基本です。手動でGCを呼び出すよりも、オブジェクトの寿命を適切に管理することを優先しましょう。
3-5. GC.Collectを安易に使うべきではない理由
C#では、GC.Collect()を使うことでGCの実行を明示的に要求できます。
C#GC.Collect();
しかし、通常のアプリケーションコードでGC.Collect()を安易に呼び出すべきではありません。
理由は、GCの実行にはコストがかかるからです。GCが実行されると、オブジェクトの参照関係を調べたり、メモリを整理したりする必要があります。その結果、アプリケーションの応答性やスループットに悪影響が出る可能性があります。
また、GC.Collect()を呼んだからといって、期待どおりにメモリ使用量が下がるとは限りません。まだ参照されているオブジェクトは回収されませんし、OSにメモリがすぐ返却されるとも限りません。
例外的に、大量の一時オブジェクトを使う処理が終わった直後など、明確な理由がある場合に限って検討されることはあります。しかし、基本方針としてはGCに任せるべきです。
GC.Collect()を使いたくなった場合は、まず「不要な参照が残っていないか」「オブジェクトを作りすぎていないか」「キャッシュが肥大化していないか」を確認しましょう。
4. DisposeとIDisposableの正しい使い方
C#のメモリ管理でGCと並んで重要なのが、DisposeとIDisposableです。
GCはマネージドメモリを自動で回収しますが、ファイル、DB接続、ネットワークソケットなどの外部リソースは、使い終わったら速やかに解放する必要があります。そのために使うのがDisposeです。
4-1. Disposeが必要になるケース
Disposeが必要になるのは、主にアンマネージドリソースや外部リソースを扱うオブジェクトを使う場合です。
代表的な例は次のとおりです。
C#FileStream
StreamReader
StreamWriter
SqlConnection
SqlCommand
HttpResponseMessage
Bitmap
Timer
例えば、ファイルを開いたままDisposeしないと、ファイルハンドルが解放されず、他の処理でファイルを開けなくなることがあります。
C#var stream = new FileStream("sample.txt", FileMode.Open);
// 処理
stream.Dispose();
ただし、実際には手動でDisposeを呼ぶよりも、using文やusing宣言を使う方が安全です。例外が発生した場合でも確実に解放されるからです。
4-2. using文で確実にリソースを解放する
using文を使うと、スコープを抜けるときに自動的にDisposeが呼ばれます。
C#using (var reader = new StreamReader("sample.txt"))
{
string text = reader.ReadToEnd();
Console.WriteLine(text);
}
このコードでは、usingブロックを抜けたタイミングでreader.Dispose()が呼ばれます。ブロック内で例外が発生した場合でも、Disposeは実行されます。
using文は、内部的にはtry-finallyに近い形でリソース解放を保証します。そのため、IDisposableを実装しているオブジェクトを使う場合は、基本的にusingを使うと考えてよいでしょう。
4-3. using宣言と従来のusing文の違い
C#には、従来のusing文に加えて、using宣言という書き方があります。
従来のusing文は、ブロックを明示します。
C#using (var stream = new FileStream("sample.txt", FileMode.Open))
{
// streamを使う処理
}
一方、using宣言では、変数が宣言されたスコープを抜けるときにDisposeが呼ばれます。
C#using var stream = new FileStream("sample.txt", FileMode.Open);
// streamを使う処理
この場合、現在のスコープを抜けるタイミングでstream.Dispose()が呼ばれます。
using宣言はコードをすっきり書ける一方で、解放タイミングがスコープ全体の終わりになる点に注意が必要です。できるだけ早くリソースを解放したい場合は、従来のusing文でスコープを狭くする方が適しています。
4-4. try-finallyでDisposeを保証する方法
usingを使わずにDisposeを保証したい場合は、try-finallyを使います。
C#FileStream? stream = null;
try
{
stream = new FileStream("sample.txt", FileMode.Open);
// streamを使う処理
}
finally
{
stream?.Dispose();
}
finallyブロックは、例外が発生しても実行されます。そのため、Disposeを確実に呼び出せます。
ただし、通常はusing文の方が簡潔で安全です。try-finallyは、複雑な制御が必要な場合や、Dispose以外の後処理もまとめて行いたい場合に使うとよいでしょう。
4-5. FinalizerとDisposeパターンの関係
C#にはファイナライザという仕組みがあります。ファイナライザは、オブジェクトがGCによって回収される前に実行される可能性がある後処理です。
C#class ResourceHolder
{
~ResourceHolder()
{
// ファイナライザ
}
}
ただし、ファイナライザは実行タイミングを制御できません。いつ実行されるかはGCに依存します。また、ファイナライザを持つオブジェクトは回収に追加コストがかかるため、むやみに使うべきではありません。
アンマネージドリソースを直接扱うクラスでは、IDisposableとファイナライザを組み合わせたDisposeパターンを使うことがあります。
C#class NativeResourceHolder : IDisposable
{
private bool _disposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing)
{
// マネージドリソースの解放
}
// アンマネージドリソースの解放
_disposed = true;
}
~NativeResourceHolder()
{
Dispose(false);
}
}
通常のアプリケーション開発では、自分でファイナライザを書く機会は多くありません。多くの場合は、既存のIDisposableな型をusingで正しく使うことが重要です。
4-6. IAsyncDisposableとawait usingの使い方
非同期でリソース解放が必要な場合には、IAsyncDisposableとawait usingを使います。
例えば、非同期でフラッシュやクローズが必要なストリーム、非同期処理を伴う接続、非同期APIを持つリソースなどでは、DisposeAsyncが使われることがあります。
C#await using var resource = new AsyncResource();
await resource.ExecuteAsync();
await usingを使うと、スコープを抜けるときにDisposeAsyncが非同期で呼び出されます。
自作クラスでIAsyncDisposableを実装する例は次のとおりです。
C#class AsyncResource : IAsyncDisposable
{
public async ValueTask DisposeAsync()
{
await Task.Delay(100);
// 非同期の解放処理
}
public Task ExecuteAsync()
{
return Task.CompletedTask;
}
}
同期的な解放で十分な場合はIDisposable、非同期処理を伴う解放が必要な場合はIAsyncDisposableを使い分けます。
5. C#でメモリリークが起きる主な原因
C#にはGCがありますが、メモリリークは発生します。
C#におけるメモリリークの多くは、「不要になったオブジェクトへの参照が残り続けること」で起こります。GCは参照されているオブジェクトを回収できないため、開発者の意図としては不要でも、参照が残っていればメモリに残り続けます。
5-1. イベント購読の解除漏れ
C#でよくあるメモリリークの原因が、イベント購読の解除漏れです。
C#publisher.SomeEvent += subscriber.HandleEvent;
この場合、publisherはsubscriberへの参照を保持します。もしpublisherが長寿命で、subscriberが短寿命の場合、イベント購読を解除しないとsubscriberがGCされません。
C#publisher.SomeEvent -= subscriber.HandleEvent;
特に、画面、ViewModel、コンポーネント、サービスなどがイベントを購読する場合は注意が必要です。画面を閉じてもイベント購読が残っていると、画面オブジェクトがメモリ上に残り続けることがあります。
5-2. static変数やシングルトンによる参照保持
static変数やシングルトンはアプリケーション全体で長く生きるため、そこにオブジェクトを保持するとGCされにくくなります。
C#static List<User> Users = new List<User>();
このリストにユーザー情報を追加し続け、削除しない場合、メモリ使用量は増え続けます。
C#Users.Add(new User());
staticは便利ですが、参照の寿命が長くなるため、不要なオブジェクトを保持し続けない設計が必要です。キャッシュやグローバルな管理クラスでは特に注意しましょう。
5-3. コレクションに不要なオブジェクトを残し続ける
List<T>、Dictionary<TKey, TValue>、Queue<T>などのコレクションに不要なオブジェクトを残し続けることも、メモリリークの原因になります。
C#private readonly List<RequestLog> _logs = new();
public void AddLog(RequestLog log)
{
_logs.Add(log);
}
このコードでは、ログが追加され続ける一方で削除されません。長時間動作するアプリでは、ログが増え続けてメモリを圧迫します。
改善するには、件数の上限を設けたり、古いデータを削除したり、永続化してメモリから外したりする必要があります。
C#public void AddLog(RequestLog log)
{
_logs.Add(log);
if (_logs.Count > 1000)
{
_logs.RemoveAt(0);
}
}
コレクションは参照を保持するため、「入れたものをいつ出すか」を設計しておくことが重要です。
5-4. タイマー・スレッド・Taskによる参照残り
Timer、スレッド、長時間実行されるTaskも、メモリリークの原因になることがあります。
C#_timer = new Timer(_ => DoWork(), null, 0, 1000);
タイマーのコールバックがインスタンスメソッドやラムダ式を参照している場合、対象オブジェクトがタイマー経由で参照され続けることがあります。
不要になったタイマーは必ず停止・破棄しましょう。
C#_timer?.Dispose();
また、キャンセルされないTaskや無限ループするバックグラウンド処理も注意が必要です。CancellationTokenを使って、明示的に終了できる設計にすることが重要です。
C#async Task RunAsync(CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
await Task.Delay(1000, cancellationToken);
}
}
5-5. アンマネージドリソースの解放漏れ
アンマネージドリソースを扱うオブジェクトをDisposeしないと、リソースが解放されず問題が発生します。
C#var stream = new FileStream("sample.txt", FileMode.Open);
// Disposeされていない
ファイル、DB接続、ソケット、画像リソースなどは、使い終わったら速やかに解放する必要があります。
C#using var stream = new FileStream("sample.txt", FileMode.Open);
GCが最終的にオブジェクトを回収する可能性はありますが、外部リソースの解放タイミングをGC任せにするのは危険です。IDisposableを実装している型は、原則としてusingを使いましょう。
5-6. キャッシュ設計のミス
キャッシュはパフォーマンス改善に有効ですが、設計を誤るとメモリリークの原因になります。
例えば、次のように無制限にキャッシュへ追加すると、メモリ使用量が増え続けます。
C#private readonly Dictionary<string, object> _cache = new();
public object GetOrAdd(string key)
{
if (!_cache.TryGetValue(key, out var value))
{
value = CreateValue(key);
_cache[key] = value;
}
return value;
}
キャッシュには、サイズ上限、有効期限、削除ポリシーを設ける必要があります。
C#// 考え方の例
// ・最大件数を超えたら古いものを削除する
// ・一定時間使われなかったものを削除する
// ・MemoryCacheなどの仕組みを利用する
キャッシュは「速くするための仕組み」ですが、「メモリを使い続ける仕組み」でもあります。設計段階でメモリ上限を意識しましょう。
6. メモリリークを防ぐ実践的な対策
C#でメモリリークを防ぐには、GCの仕組みを理解したうえで、不要な参照を残さない設計をすることが重要です。
また、IDisposableなオブジェクトを確実に解放し、長寿命オブジェクトが短寿命オブジェクトを保持し続けないように注意する必要があります。
6-1. 不要な参照を残さない設計にする
メモリリークを防ぐ基本は、不要な参照を残さないことです。
例えば、処理が終わったオブジェクトをコレクションに残し続けると、GCは回収できません。
C#_items.Remove(item);
不要になったデータは、コレクションから削除する、イベント購読を解除する、キャッシュから除外するなど、参照が切れるようにします。
ただし、ローカル変数に対して何でもnullを代入すればよいわけではありません。通常、ローカル変数はスコープを抜ければ参照されなくなります。重要なのは、長寿命なフィールド、static、コレクション、イベントなどに不要な参照を残さないことです。
6-2. イベントは必ず解除する
イベント購読は、購読側の寿命が発行側より短い場合に特に注意が必要です。
C#public void Initialize()
{
_service.Changed += OnChanged;
}
public void Dispose()
{
_service.Changed -= OnChanged;
}
画面やコンポーネントが破棄されるタイミングで、イベント購読を解除するようにします。
特に、WPF、WinForms、Unity、ASP.NETの長寿命サービスなどでは、イベントの参照関係が原因でオブジェクトが残り続けることがあります。
イベントを購読したら、解除する場所もセットで設計しましょう。
6-3. IDisposableを実装・利用する
外部リソースやイベント購読など、明示的な後処理が必要なクラスではIDisposableを実装します。
C#class MyComponent : IDisposable
{
private readonly Service _service;
public MyComponent(Service service)
{
_service = service;
_service.Changed += OnChanged;
}
private void OnChanged(object? sender, EventArgs e)
{
// イベント処理
}
public void Dispose()
{
_service.Changed -= OnChanged;
}
}
このように、Dispose内でイベント解除やリソース解放を行います。
利用側では、usingや明示的なDispose呼び出しで解放します。
C#using var component = new MyComponent(service);
IDisposableは、アンマネージドリソースだけでなく、「使い終わったら解除すべき参照」を扱う場合にも有効です。
6-4. 長寿命オブジェクトに短寿命オブジェクトを持たせすぎない
メモリリークを防ぐうえで重要なのが、オブジェクトの寿命の設計です。
長寿命オブジェクトが短寿命オブジェクトを参照すると、短寿命のはずのオブジェクトが長く生き残ってしまいます。
C#class ApplicationService
{
private readonly List<PageViewModel> _pages = new();
public void Register(PageViewModel page)
{
_pages.Add(page);
}
}
この例では、ApplicationServiceが長寿命である場合、PageViewModelがリストに残り続けるとGCされません。
改善するには、不要になったタイミングで削除する、そもそも長寿命オブジェクトに短寿命オブジェクトを持たせない、弱参照を検討するなどの方法があります。
寿命の短いオブジェクトを、アプリケーション全体で生きるサービスやstaticに登録する場合は、必ず解除方法を用意しましょう。
6-5. キャッシュにはサイズ上限や有効期限を設定する
キャッシュを使う場合は、必ずサイズ上限や有効期限を設定しましょう。
悪い例は、無制限にキャッシュへ追加し続ける実装です。
C#_cache[key] = value;
アクセスされるキーが増え続ける場合、キャッシュも増え続けます。
改善策としては、次のようなルールを設けます。
C#// 例:キャッシュ設計で考えるべき項目
// ・最大件数
// ・最大メモリサイズ
// ・有効期限
// ・最終アクセス時刻
// ・優先度
// ・明示的な削除タイミング
.NETではMemoryCacheなどのキャッシュ機構を利用することもできます。自作キャッシュを作る場合でも、無制限に保持しないことが重要です。
6-6. WeakReferenceを使うべきケース
WeakReferenceは、弱参照を扱うための仕組みです。通常の参照とは異なり、弱参照だけが残っているオブジェクトはGCの回収対象になります。
C#var user = new User();
var weakRef = new WeakReference<User>(user);
user = null;
if (weakRef.TryGetTarget(out var target))
{
Console.WriteLine(target.Name);
}
WeakReferenceは、キャッシュやイベント購読の一部で使える場合があります。ただし、使えば必ずよいというものではありません。
弱参照は、参照先がいつGCされるかわからないため、通常のロジックで多用するとコードが複雑になります。基本は明示的に参照を削除する設計にし、それでもライフサイクル管理が難しい特殊なケースで検討しましょう。
7. メモリ使用量を抑えるC#コーディングのコツ
C#のメモリ管理では、メモリリークを防ぐだけでなく、不要なメモリ使用量を抑えることも重要です。
特に、大量データ処理、高頻度に呼ばれる処理、ゲーム、リアルタイム処理、Web APIなどでは、不要なオブジェクト生成を減らすことでGC負荷を下げられます。
7-1. 不要なオブジェクト生成を減らす
オブジェクト生成自体は悪いことではありませんが、短時間に大量のオブジェクトを生成するとGCの負荷が増えます。
例えば、ループ内で毎回不要なオブジェクトを作るコードには注意が必要です。
C#for (int i = 0; i < 10000; i++)
{
var temp = new SomeObject();
temp.Execute();
}
必要であれば再利用できる設計にします。
C#var temp = new SomeObject();
for (int i = 0; i < 10000; i++)
{
temp.Execute();
}
ただし、無理に再利用すると状態管理が複雑になり、バグの原因になることもあります。パフォーマンス上問題がない場合は、読みやすさを優先して構いません。
重要なのは、頻繁に実行される処理や大量データを扱う処理で、不要な割り当てが発生していないか確認することです。
7-2. string結合はStringBuilderを使い分ける
C#のstringは不変です。そのため、文字列を結合すると新しい文字列が作られます。
C#string result = "";
for (int i = 0; i < 1000; i++)
{
result += i.ToString();
}
このようなループ内の文字列結合では、多くの一時文字列が生成される可能性があります。
この場合はStringBuilderを使うと効率的です。
C#var builder = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
builder.Append(i);
}
string result = builder.ToString();
ただし、少数の文字列結合であれば、通常の+や文字列補間で問題ありません。
C#var message = $"Name: {name}, Age: {age}";
StringBuilderは、大量の文字列を繰り返し結合する場合に使うのが基本です。
7-3. LINQの使いすぎによる一時オブジェクトに注意する
LINQは読みやすいコードを書ける便利な機能ですが、使い方によっては一時オブジェクトや列挙処理が増えることがあります。
C#var result = items
.Where(x => x.IsActive)
.Select(x => x.Name)
.ToList();
このようなコードは多くの場合問題ありません。しかし、高頻度に呼ばれる処理や大量データ処理では、LINQによる割り当てや遅延評価の影響を確認した方がよい場合があります。
必要に応じて、通常のfor文に変更することで割り当てを減らせることがあります。
C#var result = new List<string>();
foreach (var item in items)
{
if (item.IsActive)
{
result.Add(item.Name);
}
}
LINQは可読性に優れています。すべて避ける必要はありません。パフォーマンスが重要な箇所だけ、プロファイリングしたうえで最適化しましょう。
7-4. 配列・List・Dictionaryの容量を適切に扱う
List<T>やDictionary<TKey, TValue>は、要素が増えると内部容量を拡張します。拡張時には新しい配列の確保やコピーが発生することがあります。
追加件数の目安がわかっている場合は、初期容量を指定すると効率的です。
C#var list = new List<int>(1000);
for (int i = 0; i < 1000; i++)
{
list.Add(i);
}
Dictionaryでも同様です。
C#var map = new Dictionary<string, User>(1000);
また、大量に要素を削除した後でも、内部容量がすぐに縮小されるとは限りません。必要に応じてTrimExcessを検討できます。
C#list.TrimExcess();
ただし、頻繁にTrimExcessを呼ぶと逆にコストがかかることがあります。大量削除後に長期間そのコレクションを保持する場合など、効果が見込める場面で使いましょう。
7-5. 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> span = source.AsSpan(1, 3);
foreach (var value in span)
{
Console.WriteLine(value);
}
Span<T>はスタック上で扱われる制約があるため、フィールドに保持できないなどの特徴があります。一方、Memory<T>は非同期処理やフィールド保持が必要な場面でも使いやすい型です。
バッファ処理、文字列解析、ファイル処理、ネットワーク処理などで、コピーを減らしたい場合に活用できます。
7-6. object poolingが有効なケース
object poolingは、オブジェクトを毎回作成・破棄するのではなく、再利用する仕組みです。
例えば、大きなバッファを頻繁に作成する場合は、ArrayPool<T>を使うことでメモリ割り当てを抑えられます。
C#var pool = System.Buffers.ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(4096);
try
{
// bufferを使う処理
}
finally
{
pool.Return(buffer);
}
object poolingが有効なのは、次のようなケースです。
生成コストが高いオブジェクトを頻繁に使う
大きな配列やバッファを繰り返し使う
高頻度処理でGC負荷を減らしたい
ゲームやリアルタイム処理で一時停止を抑えたい
ただし、プールは状態の初期化漏れや返却漏れに注意が必要です。単純なオブジェクトにまで過剰に導入すると、コードが複雑になります。
まずは通常の実装で問題がないか確認し、プロファイリングで必要性が見えた場合に導入するとよいでしょう。
8. C#のメモリ使用状況を調査する方法
C#のメモリ管理では、感覚だけで判断するのではなく、ツールを使って実際のメモリ使用状況を確認することが重要です。
メモリリークが疑われる場合は、「どの型のオブジェクトが増えているのか」「どこから参照されているのか」「GC後も残っているのか」を調査します。
8-1. Visual Studio Diagnostic Toolsで確認する
Visual Studioには、メモリ使用量を確認できるDiagnostic Toolsがあります。デバッグ実行中にメモリ使用量を確認し、スナップショットを取得できます。
基本的な流れは次のとおりです。
C#// 1. デバッグ実行する
// 2. Diagnostic Toolsを開く
// 3. メモリ使用量を確認する
// 4. スナップショットを取得する
// 5. 操作後に再度スナップショットを取得する
// 6. 差分を比較する
例えば、画面を開いて閉じたあとに、本来消えるはずのViewModelが残っていないか確認できます。
Visual Studioだけでも、基本的なメモリリーク調査は可能です。まずは身近なツールとして活用しましょう。
8-2. dotMemoryやPerfViewを使った分析
より詳しい分析には、JetBrains dotMemoryやPerfViewなどのツールが使われます。
dotMemoryはGUIでメモリスナップショットを比較しやすく、どのオブジェクトが残っているか、どこから参照されているかを調べやすいツールです。
PerfViewは.NETのパフォーマンス調査に使われる強力なツールで、GCやメモリ割り当ての詳細な分析に向いています。ただし、機能が多いため、慣れるまでは少し難しく感じるかもしれません。
調査では、次の観点が重要です。
C#// ・増え続けている型は何か
// ・GC後も残っているか
// ・どのオブジェクトが参照を保持しているか
// ・想定より多く生成されている型はないか
// ・大きな配列や文字列が残っていないか
メモリリークの原因はコードを眺めるだけでは見つけにくいことがあります。ツールで事実を確認することが近道です。
8-3. メモリスナップショットでリーク箇所を探す
メモリリーク調査では、スナップショットの比較が有効です。
例えば、画面を開いて閉じる操作でリークが疑われる場合、次のように調査します。
C#// 1. アプリ起動直後にスナップショットAを取得
// 2. 対象画面を開く
// 3. 対象画面を閉じる
// 4. GCを実行してからスナップショットBを取得
// 5. AとBを比較する
本来破棄されるべきオブジェクトがスナップショットBに残っていれば、どこかから参照されています。
調査では、残っているオブジェクトの参照元を確認します。イベント、static、コレクション、タイマー、Taskなどが原因としてよく見つかります。
スナップショット比較では、単にメモリ量だけでなく、オブジェクト数と参照パスを見ることが重要です。
8-4. GCログやパフォーマンスカウンターを見る
GCの発生頻度やメモリ使用量の傾向を調べるには、GCログやパフォーマンスカウンター、ランタイムメトリクスを見る方法があります。
確認したい代表的な項目は次のとおりです。
C#// ・GCの発生回数
// ・第0世代、第1世代、第2世代GCの頻度
// ・ヒープサイズ
// ・LOHの使用状況
// ・割り当て速度
// ・メモリ使用量の増加傾向
第2世代GCが頻繁に発生している場合、長寿命オブジェクトが多い、キャッシュが肥大化している、大きなオブジェクトが多いなどの可能性があります。
Webアプリケーションやサービスでは、アプリ全体のメモリ使用量だけでなく、リクエストごとの割り当て量やピーク時の挙動も見ると効果的です。
8-5. 本番環境で調査するときの注意点
本番環境でメモリ調査を行う場合は、アプリケーションへの影響に注意が必要です。
メモリダンプの取得や詳細なトレースは、CPUやディスク、メモリに負荷をかけることがあります。また、ダンプには個人情報や機密情報が含まれる可能性があります。
本番環境で調査する際は、次の点を意識しましょう。
C#// ・低負荷の時間帯に実施する
// ・必要最小限のデータを取得する
// ・個人情報や機密情報の扱いに注意する
// ・検証環境で手順を確認してから実施する
// ・取得したダンプやログの保管場所を管理する
可能であれば、まず検証環境で再現させて調査します。本番でしか発生しない場合は、監視メトリクス、ログ、軽量なトレースから段階的に調査すると安全です。
9. 実践例で学ぶC#のメモリ管理
ここでは、C#のメモリ管理でよくある問題をコード付きで見ていきます。
メモリリークは、コード単体では問題が見えにくいことがあります。重要なのは、「どのオブジェクトが、どこから参照され続けているか」を意識することです。
9-1. Dispose漏れを修正するサンプル
まず、Dispose漏れの例です。
C#public string ReadFile(string path)
{
var reader = new StreamReader(path);
return reader.ReadToEnd();
}
このコードでは、StreamReaderがDisposeされていません。ファイルハンドルがすぐに解放されない可能性があります。
改善例は次のとおりです。
C#public string ReadFile(string path)
{
using var reader = new StreamReader(path);
return reader.ReadToEnd();
}
または、従来のusing文を使います。
C#public string ReadFile(string path)
{
using (var reader = new StreamReader(path))
{
return reader.ReadToEnd();
}
}
IDisposableを実装している型は、使い終わったタイミングで確実にDisposeしましょう。
9-2. イベント解除漏れによるメモリリークの例
イベント解除漏れの例を見てみます。
C#class Publisher
{
public event EventHandler? Updated;
public void Raise()
{
Updated?.Invoke(this, EventArgs.Empty);
}
}
class Subscriber
{
private readonly Publisher _publisher;
public Subscriber(Publisher publisher)
{
_publisher = publisher;
_publisher.Updated += OnUpdated;
}
private void OnUpdated(object? sender, EventArgs e)
{
Console.WriteLine("Updated");
}
}
このコードでは、SubscriberがPublisherのイベントを購読しています。もしPublisherが長寿命で、Subscriberを破棄したい場合でも、イベント購読が残っている限りSubscriberは参照され続けます。
改善するには、IDisposableを実装してイベントを解除します。
C#class Subscriber : IDisposable
{
private readonly Publisher _publisher;
public Subscriber(Publisher publisher)
{
_publisher = publisher;
_publisher.Updated += OnUpdated;
}
private void OnUpdated(object? sender, EventArgs e)
{
Console.WriteLine("Updated");
}
public void Dispose()
{
_publisher.Updated -= OnUpdated;
}
}
利用側では次のようにします。
C#using var subscriber = new Subscriber(publisher);
イベントを購読するクラスでは、解除タイミングを必ず設計しましょう。
9-3. static参照によるリークの例
staticフィールドにオブジェクトを保持し続ける例です。
C#class UserStore
{
public static List<User> Users { get; } = new();
public static void Add(User user)
{
Users.Add(user);
}
}
このコードでは、追加したUserがUsersに残り続けます。不要になったユーザーも削除されなければGCされません。
改善するには、削除処理を用意します。
C#class UserStore
{
public static List<User> Users { get; } = new();
public static void Add(User user)
{
Users.Add(user);
}
public static void Remove(User user)
{
Users.Remove(user);
}
public static void Clear()
{
Users.Clear();
}
}
ただし、根本的にはstaticで保持する必要があるかを見直すことも重要です。DIコンテナでスコープを管理したり、必要な期間だけ保持する設計にしたりすることで、メモリリークを防ぎやすくなります。
9-4. Listに溜まり続けるオブジェクトを改善する例
ログをメモリ上に溜め続ける例です。
C#class LogBuffer
{
private readonly List<string> _logs = new();
public void Add(string log)
{
_logs.Add(log);
}
}
この実装では、ログが増え続けます。長時間動作するアプリではメモリ使用量が上がり続ける可能性があります。
件数上限を設けた改善例です。
C#class LogBuffer
{
private readonly Queue<string> _logs = new();
private readonly int _maxCount;
public LogBuffer(int maxCount)
{
_maxCount = maxCount;
}
public void Add(string log)
{
_logs.Enqueue(log);
while (_logs.Count > _maxCount)
{
_logs.Dequeue();
}
}
}
このように、保持する件数を制限することでメモリ使用量を一定範囲に抑えられます。
大量のログが必要な場合は、メモリではなくファイル、データベース、ログ基盤などに出力する設計を検討しましょう。
9-5. メモリリーク調査の流れをコード付きで解説
メモリリーク調査では、まず再現手順を作ります。
例えば、次のような画面クラスがあるとします。
C#class PageViewModel
{
private readonly GlobalService _service;
public PageViewModel(GlobalService service)
{
_service = service;
_service.Changed += OnChanged;
}
private void OnChanged(object? sender, EventArgs e)
{
}
}
このPageViewModelは、GlobalServiceのイベントを購読しています。しかし、解除処理がありません。
画面を開いて閉じる操作を繰り返すと、閉じたはずのPageViewModelが残り続ける可能性があります。
調査の流れは次のとおりです。
C#// 1. アプリを起動する
// 2. メモリスナップショットを取得する
// 3. 画面を開いて閉じる
// 4. GC後に再度スナップショットを取得する
// 5. PageViewModelが残っていないか確認する
// 6. 参照元を確認する
原因がイベントであれば、次のように修正します。
C#class PageViewModel : IDisposable
{
private readonly GlobalService _service;
public PageViewModel(GlobalService service)
{
_service = service;
_service.Changed += OnChanged;
}
private void OnChanged(object? sender, EventArgs e)
{
}
public void Dispose()
{
_service.Changed -= OnChanged;
}
}
そして、画面を閉じるタイミングでDisposeを呼びます。
C#viewModel.Dispose();
メモリリーク調査では、「残っているオブジェクト」だけでなく、「なぜ残っているのか」を参照元から確認することが重要です。
10. C#のメモリ管理でよくある誤解
C#のメモリ管理には、よくある誤解があります。誤解したまま対策すると、効果がないどころか、逆にパフォーマンスを悪化させることもあります。
ここでは、特に多い誤解を整理します。
10-1. GCがあるからメモリリークは起きない?
これは誤解です。
GCは、参照されなくなったマネージドオブジェクトを回収します。しかし、不要なオブジェクトへの参照が残っている場合、GCはそのオブジェクトを回収できません。
例えば、次のようにstaticリストに追加し続けると、GCがあってもメモリは解放されません。
C#static List<object> Items = new();
Items.Add(new object());
C#のメモリリークは、主に「不要なのに参照が残っている」ことで発生します。GCがあるからこそ、参照の管理が重要になります。
10-2. Disposeすれば必ずメモリがすぐ解放される?
これも誤解です。
Disposeは主にリソースを解放するためのメソッドです。Disposeを呼んだからといって、マネージドオブジェクトのメモリが即座にGCで回収されるわけではありません。
C#stream.Dispose();
このコードでファイルハンドルなどのリソースは解放されますが、streamオブジェクト自体のメモリ回収タイミングはGCに任されます。
Disposeは「メモリをすぐ消す命令」ではなく、「使い終わったリソースを解放するための約束」と考えるとよいでしょう。
10-3. nullを代入すれば常にメモリ節約になる?
nullを代入すれば常にメモリ節約になるわけではありません。
例えば、メソッド内のローカル変数は、スコープを抜ければ通常は参照されなくなります。
C#void Sample()
{
var data = new byte[1024];
data = null;
}
このような短いメソッドでは、明示的なnull代入に大きな意味はないことが多いです。
一方、長寿命オブジェクトのフィールドやstatic変数が大きなオブジェクトを参照している場合は、nullを代入して参照を切ることが有効な場合があります。
C#_largeBuffer = null;
重要なのは、どの参照がオブジェクトの寿命を延ばしているかを理解することです。
10-4. GC.Collectを呼べば性能が良くなる?
GC.Collect()を呼べば性能が良くなるとは限りません。むしろ、安易に呼ぶと性能が悪化することがあります。
GCはランタイムが適切なタイミングで実行するように設計されています。手動でGCを強制すると、本来必要ないタイミングで重い処理が走る可能性があります。
C#GC.Collect();
このコードを頻繁に呼ぶと、アプリケーションの応答性が悪くなることがあります。
メモリ使用量が気になる場合は、GC.Collect()を呼ぶ前に、不要な参照、過剰なオブジェクト生成、キャッシュ肥大化、Dispose漏れを確認しましょう。
10-5. メモリリークとメモリ使用量増加の違い
メモリリークとメモリ使用量の増加は、似ていますが同じではありません。
メモリリークは、本来不要になったオブジェクトが参照され続け、回収されない状態です。
一方、メモリ使用量の増加は、アプリケーションが必要なデータを保持しているために起こる場合もあります。例えば、大量データを読み込む処理やキャッシュのウォームアップでは、メモリ使用量が増えることがあります。
重要なのは、GC後も不要なオブジェクトが残り続けるかどうかです。
C#// メモリリークの疑い
// ・同じ操作を繰り返すたびにメモリが増え続ける
// ・GC後も対象オブジェクトが残る
// ・参照元を見るとstaticやイベントが保持している
単にメモリ使用量が高いだけでなく、増加傾向や残存オブジェクトを確認することが大切です。
11. C#メモリ管理のベストプラクティス
C#のメモリ管理では、GCに任せる部分と、開発者が責任を持つ部分を分けて考えることが重要です。
基本的には、マネージドメモリの解放はGCに任せます。一方で、IDisposableなリソース、イベント購読、長寿命参照、キャッシュなどは開発者が適切に管理する必要があります。
11-1. 基本はGCに任せる
C#では、通常のマネージドオブジェクトの解放はGCに任せます。
開発者が手動でGCを呼んだり、すべての変数にnullを代入したりする必要はありません。むしろ、過剰な手動管理はコードを複雑にし、パフォーマンスを悪化させることがあります。
基本方針は次のとおりです。
C#// ・通常のオブジェクトはGCに任せる
// ・不要な参照を長く残さない
// ・IDisposableなリソースはDisposeする
// ・GC.Collectは原則使わない
GCの仕組みに逆らうのではなく、GCが回収しやすいように参照の寿命を整理することが大切です。
11-2. IDisposableなオブジェクトは確実にDisposeする
IDisposableを実装しているオブジェクトは、使い終わったら確実にDisposeします。
C#using var connection = new SqlConnection(connectionString);
connection.Open();
特に、ファイル、DB接続、ネットワーク、画像、タイマーなどを扱う型では重要です。
usingやawait usingを使うことで、例外が発生しても安全に解放できます。
C#await using var resource = new AsyncResource();
await resource.ExecuteAsync();
Dispose漏れは、メモリリークだけでなく、ファイルロック、接続枯渇、ハンドル枯渇などの原因にもなります。
11-3. 参照の寿命を短く保つ
メモリ管理では、参照の寿命を短く保つことが重要です。
長く生きるオブジェクトが多くの参照を保持すると、GCが回収できるオブジェクトが減ります。
C#// 注意が必要な参照
// ・staticフィールド
// ・シングルトン
// ・長寿命サービス
// ・グローバルキャッシュ
// ・イベント発行元
短寿命のオブジェクトを長寿命オブジェクトに登録する場合は、解除方法を必ず用意しましょう。
設計段階で、「このオブジェクトは誰が持つのか」「いつ不要になるのか」「不要になったとき参照は切れるのか」を考えることが大切です。
11-4. 長時間動作するアプリでは定期的にメモリを監視する
短時間で終了するコンソールアプリでは問題にならないメモリ使用量でも、長時間動作するアプリでは大きな問題になることがあります。
Webアプリケーション、Windowsサービス、常駐アプリ、ゲームサーバー、バッチ基盤などでは、メモリ使用量を継続的に監視しましょう。
確認すべき指標には次のようなものがあります。
C#// ・プロセスのメモリ使用量
// ・GCヒープサイズ
// ・第2世代GCの回数
// ・LOHの増加
// ・リクエストごとの割り当て量
// ・OutOfMemoryExceptionの有無
メモリ使用量が少しずつ増え続ける場合は、リークの可能性があります。早い段階で傾向を見つけることで、大きな障害を防げます。
11-5. 設計段階でメモリリークしにくい構造にする
メモリリーク対策は、問題が起きてから調査するよりも、設計段階で防ぐ方が効果的です。
例えば、次のような設計を意識します。
C#// ・所有者を明確にする
// ・Disposeの責任範囲を決める
// ・イベント購読と解除をセットにする
// ・キャッシュに上限を設ける
// ・staticに安易にデータを置かない
// ・長寿命サービスが短寿命オブジェクトを保持しない
また、DIコンテナを使う場合は、Singleton、Scoped、Transientのライフタイムを正しく設計することも重要です。
メモリ管理はコードの書き方だけでなく、アプリケーション全体の設計に関わります。
12. C#のメモリ管理に関するFAQ
ここでは、C#のメモリ管理についてよくある質問に回答します。
12-1. C#でメモリ解放は手動でできますか?
通常のマネージドオブジェクトについて、C/C++のように明示的にメモリを解放することはできません。C#では、不要になったオブジェクトはGCが自動的に回収します。
ただし、IDisposableを実装しているオブジェクトについては、Disposeを呼ぶことで外部リソースを明示的に解放できます。
C#using var stream = new FileStream("sample.txt", FileMode.Open);
また、GC.Collect()でGCの実行を要求することはできますが、通常は使うべきではありません。基本はGCに任せ、不要な参照を残さない設計にしましょう。
12-2. DisposeとGCの違いは何ですか?
Disposeは、ファイル、DB接続、ソケットなどのリソースを明示的に解放するための仕組みです。
GCは、参照されなくなったマネージドオブジェクトのメモリを自動的に回収する仕組みです。
違いを簡単にまとめると、次のようになります。
C#// Dispose:使い終わったリソースを明示的に解放する
// GC:不要になったマネージドメモリを自動的に回収する
Disposeを呼んでも、オブジェクト自体のメモリが即座に解放されるわけではありません。メモリ回収はGCの役割です。
12-3. メモリリークはどうやって見つけますか?
メモリリークを見つけるには、メモリスナップショットを比較する方法が有効です。
基本的な流れは次のとおりです。
C#// 1. 操作前のスナップショットを取得する
// 2. リークが疑われる操作を行う
// 3. 操作後にGCを実行してスナップショットを取得する
// 4. 増えている型を確認する
// 5. 参照元をたどる
Visual Studio Diagnostic Tools、dotMemory、PerfViewなどのツールを使うと調査しやすくなります。
特に、イベント、static、コレクション、タイマー、Task、キャッシュが参照元になっていないか確認しましょう。
12-4. usingを書かないと必ずメモリリークしますか?
usingを書かないと必ずメモリリークするわけではありません。
ただし、IDisposableを実装しているオブジェクトを使う場合、usingを書かないとリソース解放が遅れたり、漏れたりする可能性があります。
C#using var reader = new StreamReader("sample.txt");
IDisposableでない通常のオブジェクトにusingは不要です。GCが自動的に回収します。
判断基準は、「その型がIDisposableを実装しているか」「外部リソースを扱っているか」です。IDisposableな型は、基本的にusingで扱うと安全です。
12-5. Unity開発でもC#のメモリ管理は重要ですか?
Unity開発でもC#のメモリ管理は非常に重要です。
Unityでは、フレームごとに大量の一時オブジェクトを生成するとGCが発生し、カクつきの原因になることがあります。また、イベント解除漏れ、コルーチン、オブジェクトプール、テクスチャやアセットの管理なども重要です。
例えば、Update内で不要な文字列生成やLINQを多用すると、毎フレームGC負荷が増えることがあります。
C#void Update()
{
// 毎フレーム不要な割り当てが発生しないか注意する
}
Unityでは、通常のC#のメモリ管理に加えて、UnityEngine.Object、アセット、シーン遷移、オブジェクトプールなど、Unity特有のライフサイクルも理解する必要があります。
まとめ
C#のメモリ管理は、GCによる自動メモリ回収を中心に成り立っています。通常のマネージドオブジェクトは、不要になればGCの回収対象になるため、C/C++のように手動でメモリを解放する必要はありません。
しかし、C#でもメモリ管理を意識しなくてよいわけではありません。IDisposableなオブジェクトをDisposeしない、イベント購読を解除しない、staticやシングルトンが不要な参照を保持する、コレクションやキャッシュにデータを溜め続けるといった原因で、メモリリークやメモリ使用量の増加が発生します。
C#のメモリ管理で大切なのは、次の考え方です。
C#// ・通常のマネージドメモリはGCに任せる
// ・IDisposableなリソースは確実にDisposeする
// ・不要な参照を残さない
// ・イベント購読は解除する
// ・staticやキャッシュの保持期間に注意する
// ・メモリ使用量はツールで確認する
GCは強力な仕組みですが、参照が残っているオブジェクトは回収できません。つまり、C#のメモリ管理では「いつメモリを解放するか」よりも、「不要なオブジェクトへの参照をいつ切るか」が重要です。
C#で安定したアプリケーションを作るためには、GC、Dispose、参照の寿命、メモリリークの原因を理解し、設計と実装の両面でメモリを意識することが欠かせません。

