導入
私はSasaki。Windowsクライアントの設計レビューと改修を長くやっている、少し口うるさい先輩だ。
ある日の午後、後輩の Go(22歳)が、タスクマネージャーのスクリーンショットを片手にやって来た。
「Sasakiさん、レポート出力のサービス、メモリリークしてます。
監視グラフも右肩上がりで、再起動するまで下がらないんです。
とりあえず、1時間ごとに GC.Collect() を呼ぶ処理を入れていいですか」
グラフは、たしかにきれいな右肩上がりだった。
ただ、タスクマネージャーのグラフというやつは、右肩上がりになるだけで人を有罪にする。
本当に有罪かどうかは、別の証拠で決めるものだ。
「待て。その GC.Collect()、何を回収する想定で入れるんだ」
「え……増えてるメモリを、です」
「増えてるのが 何 か、まだ誰も知らないだろ」
「メモリが増えている」と「リークしている」は別物
.NET では、プロセスのメモリが増えていることと、メモリリークしていることは同じではない。
GC があるので、不要になったオブジェクトが即座に回収されるわけでもないし、回収されたメモリが即座に OS に返るわけでもない。だから、こういう状態が普通に起きる。
| 観測 | リークとは限らない理由 |
|---|---|
| Working Set / RSS が増えている | OS 視点のメモリで、マネージドヒープの生存オブジェクト量とは一致しない |
| Total Allocated が増えている | 起動後の累積割り当て量なので、アプリが動けば基本的に増える |
| 起動直後に増える | JIT、型ロード、初期キャッシュ、接続プールでよく起きる |
| メモリが下がらない | GC が回収しても、プロセスが OS にすぐメモリを返すとは限らない |
そして、.NET の「メモリリーク」は C/C++ の「解放し忘れ」とは形が違う。
もう業務上は不要なのに、static フィールド、キャッシュ、イベント、Timer などから参照され続けているため、GC から見るとまだ使用中に見える状態。
GC は賢いが、業務上不要かどうかまでは分からない。
参照されているなら、生きていると判断する。だから .NET のリークは「漏れ」というより 「意図しない保持」 だ。
「じゃあ何を見れば、リークだって言えるんですか」
いい質問だ。見るのはメモリの総量ではなく、この 3 つになる。
- GC 後も生き残るメモリが増えているか
- 増えている型は何か
- そのオブジェクトを誰が参照しているか
そこで読んでもらった記事
私は Go に、次の記事を送った。
.NETでGC待ちとメモリリークを見分ける ── 増えるメモリを観測・比較・証明する実務手順
https://comcomponent.com/blog/2026/06/09/000-dotnet-gc-or-memory-leak/
「GC.Collect() を書く前にこれを読め。
グラフを見て騒ぐ段階から、この型がこのルートから参照され続けています と言える段階まで、手順がそのまま書いてある」
翌朝、Go が言った。
「……メモリリークって、解放し忘れ じゃなくて 参照され続けてる なんですね。
だったら GC.Collect() を何回呼んでも、参照が残ってるオブジェクトは回収されない。あれ、入れても意味なかったんだ」
そう。そこに気づけば、あとは手を動かすだけだ。
まず dotnet-counters で「傾向」を見る
いきなりダンプを取らない。最初に見るのは傾向だ。
dotnet tool install --global dotnet-counters
dotnet-counters ps # 対象プロセスのPIDを探す
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
ここで Working Set、GC Heap Size、Gen 2 / LOH、Total Allocated、GC 回数を眺める。
グラフの形によって、容疑者がまるで変わる。
| 観測 | 可能性 | 次の一手 |
|---|---|---|
| Total Allocated だけ増える | 通常の割り当て、または割り当て過多 | リーク修正ではなく割り当て削減を検討 |
| Working Set は増えるが GC Heap は安定 | ネイティブメモリ、スレッド、ハンドル、外部ライブラリ |
dumpheap を見ても主犯は出ない。OS 側を見る |
| GC Heap が負荷中だけ増え、停止後に戻る | GC 待ち、一時割り当て | リークではない。性能改善の話になることはある |
| GC 後の Gen 2 / LOH の底が上がり続ける | 長命オブジェクトの保持 | ここで初めてダンプに進む |
Go のサービスは 4 番目だった。同じ操作を繰り返すたびに、Gen 2 の底が上がる。
負荷を止めても戻らない。次の負荷でさらに底が上がる。
ここまで来て、ようやく「リーク候補」と呼べる。
ダンプを2回取って「増えた型」を見る
メモリリークは「増え続ける傾向」なので、1 回のスナップショットでは判断できない。
before / after の 2 回(できれば負荷停止後にもう 1 回)取って比較する。
軽く見るなら dotnet-gcdump。
dotnet-gcdump collect --process-id <PID> --output before.gcdump
# 負荷をかける
dotnet-gcdump collect --process-id <PID> --output after.gcdump
dotnet-gcdump report after.gcdump > after-heap.txt
深く見るなら dotnet-dump。
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
dotnet-dump analyze after.dmp
> dumpheap -stat
ここで Go が早速やらかしかけた。
「System.String が 1 億 8 千万バイト!これが犯人ですね!」
違う。System.String や System.Byte[] は、どんなアプリでも上位に出る。
見るべきは 「大きい型」ではなく「増えた型」 だ。before と after で Count と Size を比べて、操作回数に比例して増えている型を探す。
Go のケースでは、MyApp.Models.ReportResult が 1 万 2 千件増えていた。
gcroot で「なぜ回収されないか」を見る
増えた型が分かっても、まだ半分だ。
dumpheap -stat は「何が多いか」を教えてくれるが、修正につながるのは「なぜ残っているか」のほうで、それを教えてくれるのが gcroot だ。
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
Go の画面には、こう出た。
MyApp.Services.ReportCache._items
-> Dictionary<string, ReportResult>
-> ReportResult
犯人のコードは、こうだった。
public sealed class ReportCache
{
private readonly Dictionary<string, ReportResult> _cache = new();
public ReportResult GetOrCreate(string userId, DateTime date)
{
var key = $"{userId}:{date:O}";
if (_cache.TryGetValue(key, out var report))
{
return report;
}
report = BuildReport(userId, date);
_cache[key] = report;
return report;
}
}
ReportCache は singleton。キーは「ユーザーID + 日時」。
上限なし、期限なし、削除なし。
「キャッシュのつもりだったんですけど……キーに時刻が入ってるから、ヒットせずに増える一方ですねこれ」
そういうことだ。キャッシュは意図的にメモリを使うものだからリークではない。
ただし、上限も期限もないキャッシュは、実質的なメモリリーク になる。キーにリクエスト ID、現在時刻、GUID あたりが混ざっていたら、ほぼ確定だ。
ちなみに GC.Collect() を入れていたらどうなったか
仮にあの場で定期 GC.Collect() を入れていたら、こうなっていた。
| 強制 GC 後の挙動 | 意味 |
|---|---|
| 大きく下がり、ベースラインが安定 | GC 待ちが主因だった(=そもそも放置でよかった) |
| 少し下がるが、繰り返すたびに底が上がる | 一部が生き残っている。リークは消えていない |
| ほとんど下がらない | 参照され続けている。今回のケースはこれ |
つまり、GC 待ちなら不要な停止時間を増やすだけ。本物のリークなら効かない。
どちらに転んでも、根本原因はそのまま残る。「下がったから解決」は、調査の終わりではなく観測の 1 つにすぎない。
Goがどう直して、どう証明したか
-
DictionaryをMemoryCacheに置き換え、サイズ上限と有効期限を設定した - キーから時刻を外し、キャッシュとして意味のある粒度に直した
- キャッシュ件数とヒット率をメトリクスに出した
- 修正前と同じ条件(同じ操作を 100 回、同じウォームアップ、同じ観測間隔)で再測定した
結果はこうだ。
修正前: 100回実行後、Gen 2 が +300MB。負荷停止後も戻らない
修正後: 100回実行後、+20MB 以内で安定。ReportResult は停止後にベースラインへ戻る
報告も「メモリが増えなくなりました」ではなく、こうなった。
「ReportResult が ReportCache._items から参照され続けていたのが原因で、上限と期限を入れた結果、同一条件で Gen 2 の増加が +300MB から +20MB 以内になりました」
事象、観測、増えた型、参照元、原因、対策がつながっている。
ここまで言えれば、グラフに振り回される側から、グラフを説明する側に回れる。
以後、メモリの相談で最初に聞く4問
それ以来、うちでは「メモリが増えてます」という相談に対して、まずこの 4 つを聞くようになった。
| 問い | これが分かると |
|---|---|
| 何が増えているのか | Working Set か、GC Heap か、Gen 2 / LOH か |
| どの GC 後も残っているのか | GC 待ちとリーク候補を分けられる |
| 誰が参照しているのか | static、キャッシュ、イベント、Timer、DI ライフタイムまで絞れる |
| その参照は設計上必要なのか | 「リーク」か「設計どおりの保持」かが決まる |
4 つ目まで答えられて、初めて「メモリリーク」と断定していい。
まとめ
- プロセスのメモリが増えていることと、リークしていることは別物
- .NET のリークは「解放し忘れ」ではなく「意図しない保持」
- 順番は counters で傾向 → ダンプ 2 回で増えた型 →
gcrootで参照元 - 見るべきは「大きい型」ではなく「増えた型」
-
GC.Collect()の定期実行は、GC 待ちにも本物のリークにも解決策にならない - 修正の証明は、同一条件での before / after 比較で行う
メモリのグラフは、右肩上がりというだけで人を疑わせる。
だが判決を出すのは、グラフではなく gcroot だ。
イベント購読解除漏れ・Timer・AsyncLocal・DI ライフタイムなどリークの典型パターン集、LOH の読み方、dotnet-trace の使いどころ、本番・コンテナ環境でダンプを取るときの注意点まで含めた詳細版はこちら。
https://comcomponent.com/blog/2026/06/09/000-dotnet-gc-or-memory-leak/
(了)