一連の C# dis から飛び火?したのか、Huomoto san のポストに、さまざまな「GC だとメモリリークしない」意見が飛び交っていますが、C# でもメモリリークしますよ、ってのを証左しておきます。
参照カウンタで制御しているから、GC(ガベージコレクション)が起こってメモリーリークはない、と思い込んでいると、実は GC のあるプログラム言語(Java も同じ)で「メモリーリーク関係の対策を見落とす」ことになるので危険です。普段使っているときには、あまり気にしないのですが、リアルタイム制御とか増減のある大量データを扱うときに、気にしておいてください。なんか、イイ感じにコードを高速化したと思ったら、その先でメモリーリークを起こすことになります。
当然、リークが発生してしまうこと自体が「設計ミス」というのは同意はしますが、リークが発生する可能性を否定しまうと、設計考慮の範囲外になってしまって、設計ミスにすら気づかないことになります。後知恵的に設計ミスや考慮不足をなじるよりも、境界値に近いパフォーマンスを得るようなときは転ばぬ先の杖、石橋を叩いて渡る気分でいきましょう。もちろん、普通の Web アプリを作る場合にはあまり必要ないのですが(実際には、Web サーバーのときは必要なんだけど、それは別の機会に)
リフレクションでオブジェクト生成時
検証コードを一から作るのは面倒なので、ベースは Copilot に作って貰います。Copilot の出した生コードは、そのままでは駄目なので、多少手を加えていますが、一応合格点は上げられると思います(考えているかも、という意味で)
プロンプトは、こんな感じ。手元の適当な生成AIチャットでも可能でしょう。
C# でメモリーリークをする、サンプルコードを作って。リフレクションを使って create object を大量にすると参照されるポインタがなくても、手動の GC を動かさないとメモリが解放されない状態になる
using System;
using System.Collections.Generic;
using System.Reflection;
namespace MemoryLeakDemo
{
class LeakyClass
{
public byte[] Data = new byte[1024 * 1024]; // 1MB
public LeakyClass()
{
// ランダムにデータを初期化
new Random().NextBytes(Data);
}
}
class Program
{
static void Main(string[] args)
{
List<object> leakyList = new List<object>();
for (int i = 0; i < 5000; i++)
{
#if false
// リフレクションでオブジェクトを生成
Type type = typeof(LeakyClass);
ConstructorInfo ctor = type.GetConstructor(Type.EmptyTypes);
object instance = ctor.Invoke(null);
#else
// 通常の方法でオブジェクトを生成
LeakyClass instance = new LeakyClass();
#endif
// Finalizer を抑制(GC の対象になりにくくなる)
// GC.SuppressFinalize(instance);
// 明示的に参照を保持(GC が回収できない)
leakyList.Add(instance);
Console.WriteLine($"Created instance {i}");
}
Console.WriteLine("大量のオブジェクトを生成しました。GC を強制しない限りメモリは解放されません。");
Console.ReadLine();
// 解放する筈
leakyList.Clear();
Console.WriteLine("leakyList の解放");
Console.ReadLine();
// 手動で GC を動かすことでメモリを解放可能
GC.Collect();
// GC.WaitForPendingFinalizers();
Console.WriteLine("GC を強制実行しました。");
Console.ReadLine();
}
}
}type.GetConstructor のように、DI を使うとメモリーリークが発生しやすいです。GC が追跡しにくくなるんですね。実は、このコードだと new LeakyClass() でもリークするので、検証コードとしてはいまいちなんですが、まあ、リークのサンプルコードということで。
- leakyList.Add(instance) で、参照を保持しているが、
- leakyList.Clear(); でメモリを解放していると思ったが、解放はされない
- 明示的に GC.Collect(); が必要
おそらくスコープの関係だと思うのですが、3 の時点で、GC.Collect() を実行しても解放はされません。

これは、.NET のガベージコレクションの解放が3種類あって、世代ごとに解放されるタイミングが違うからです。大量に取得した new LeakyClass のインスタンスは、leakyList.Clear() した時点で解放されるように見えますが、この瞬間では解放されません。メモリの状態や生存期間(lifetime)によっては、GC.Collect の呼び出し後も残ることが多いです。.NET の GC の場合、GC メモリが自動拡張されるために、物理メモリを圧迫してしまうリスクがあります。この場合は、コンソールで1つしか動かしていないのですが、Web サーバーでの内部処理やアプリケーションサーバーのような扱いをしているときは、このような不意なメモリ取得は他のプロセスに悪影響を与えてしまいます。
解決案としては、LeakyClass 専用のアロケーターを作ります。5000件ぐらいだと IDisposable を実装して内部メモリを外出しにするとか、リークの量を減らす方法も活用できます。ゲームサーバーだと 100万件とかが多いでしょうから、もう少し別な対処をする必要があるでしょう(ゲームの方は知らないので、調べてみてください)
相互参照によるメモリリーク
もうひとつ典型的なリークのパターンを紹介しましょう。リスト構造の中で、各オブジェクトが相互参照や循環参照になっている場合です。これは参照しているのだから、参照カウントで GC の対象にならないので合ってはいるのですが、このオブジェクト群を管理しているリストやルートノードを解放しても、その先のオブジェクトが解放されることはありません。
これは、GC の仕様で、解放の深度はそこまで追わないという設計になっているためです。追えないことはないのでしょうが、GC のためにそこまで実行時に時間を掛けるのは馬鹿馬鹿しいので、実装者のほうでメモリを管理してね、という「意図的なメモリーリーク」です。
なので、この意図的な GC の設計を知らないと、相互参照をさせてリークしてしまうんですね。つまりは「仕様です」というやつです。この GC 設計は .NET 特有なのか、JVM の GC にも使われているか私は知りません。
相互参照をしたオブジェクトのリストを作っておいて、おおもとの参照ポインタを消しても相互参照したオブジェクトが残ってしまう例を示して。
using System;
using System.Collections.Generic;
using static System.Runtime.InteropServices.JavaScript.JSType;
namespace CyclicReferenceLeak
{
class Node
{
public Node Partner;
public byte[] Payload = new byte[1024 * 1024]; // 1MB
public Node()
{
// ランダムにデータを初期化
new Random().NextBytes(Payload);
}
}
class Program
{
static void Main(string[] args)
{
List<Node> rootList = new List<Node>();
for (int i = 0; i < 5000; i++)
{
Node a = new Node();
Node b = new Node();
// 相互参照を構築
a.Partner = b;
b.Partner = a;
// rootList に追加(GC の対象外)
rootList.Add(a);
}
Console.WriteLine("相互参照したオブジェクトを 1000 組作成しました。Enter で rootList をクリアします。");
Console.ReadLine();
// rootList をクリア → おおもとの参照が消える
rootList.Clear();
Console.WriteLine("rootList をクリアしました。GC を強制しない限り、相互参照のためメモリは解放されません。");
Console.ReadLine();
// GC を強制実行
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("GC を強制実行しました。");
Console.ReadLine();
}
}
}これも Copilot に作って貰ったもので(つまりは、Copilot の codex のモデルには GC のメモリリーク関係の情報が入っているということですよ)、Node の a と b が相互に参照しています。保持リストとして rootList.Add を使っているのですが、rootList.Clear() の瞬間にはメモリは解放されません。これも GC の仕様です。そのあとに、GC.Collect() を実行しますが、以下のように、この PC ではメモリは解放されません。
この業務 PC は、先日メモリを豊富にしたばかりなので、10GB 程度ではびくともしないんですよね。もうちょっとチープなメモリ(8GBとか16GBあたり)で試してみると、OS ごとロックされるか、アプリケーションが固まるのが判別できるはずです。Windows 上でやると、Swap ファイルを食い尽くして C ドライブの SSD がいっぱいいっぱいになるので、やめておいたほうが良いです。OS が落ちる現象は、メモリーリークで落ちるのではなく、swap ファイルの食い尽くしで OS が利用するテンポラリファイルが作れなくなったときです。ブルースクリーンになりますね。

正しく検証したい場合は、Hyper-V で 8GB 程度の Windows 11 を作成して、その中で実験するとよいです。メモリを制限させると、swap ファイルを作成する様子がわかります。ただし、上記のようなプロファイルを見るために Visual Studio を入れないとけないのが結構手間ですね。仕事でやると 1,2 日がかりというところでしょうか。
参考書
「Garbage Collection」という洋書があって、これはほぼ唯一の GC の解説書です。他にも 「プログラミング .NET Framework」の本にも GC の章はあるのですが、あまり解説はない(GC.Collect あたりのざっとした解説)ので、まあ、これを買うしかないですよね(と言ってみるテスト)。


ただ、これを買ったのは15年程前なので、いまだといくつか出ていますね。GC の世代とか、マーク&スイープ方式の詳しい解説とか図つきであります。
余談ですが、java の場合は -vm でメモリサイズを制御できるので、.NET 環境のような swap を食い尽くすことがありません。ただし、java の場合は -vm のメモリを喰い付くしてしまってプログラムが不意に止まります。これ、どちらを優先するかにもよるのですが、運用時にアプリが落ちないことを優先=.NETの GC にするか、運用時に OS が落ちないようにするか= Java の GC ということです。今だとメモリが潤沢にあるので、Java の -vm を大き目に取っておくことができますが、サーバー運用のときは -vm を大きく取り過ぎると他のプログラム(他のプログラムも java だったりする)を圧迫してしまうので、.NET 方式が良かった時期もあるのです。これはどちらの方式も取れるといいんですが、今だとできるのかな。10年程前 .NET のほうで調べたときには、-vm 方式が使えないので、先の紹介したような独自アロケート方式で対応することにしたのです。
