.NETでGC待ちとメモリリークを見分ける ── 増えるメモリを観測・比較・証明する実務手順
· 小村 豪 · .NET, CSharp, GC, MemoryLeak, Diagnostics, dotnet-counters, dotnet-dump, 運用, 既存資産活用
1. 最初に押さえるべきこと
.NET アプリケーションを運用していると、メモリ使用量がじわじわ増えていく場面があります。
タスクマネージャーや top を見ると、プロセスのメモリが増えている。
コンテナのメモリ使用量も増えている。
監視で Working Set や RSS のグラフが右肩上がりになっている。
この状態を見ると、すぐに「メモリリークではないか」と考えたくなります。
しかし、.NET では、プロセスのメモリが増えていることと、メモリリークしていることは同じではありません。
.NET にはガベージコレクションがあります。オブジェクトが不要になった瞬間に、すぐ OS にメモリが返るわけではありません。GC は、割り当て状況、ヒープのしきい値、メモリ圧迫、世代、ワークロードの状況を見ながら動きます。
そのため、次のような状態が起きます。
- 不要になったオブジェクトが、まだ GC されていない
- GC は済んでいるが、プロセスの Working Set がすぐ下がらない
- 初回アクセス、JIT、キャッシュ、コネクションプールで一度だけ増えて、その後は安定する
- マネージドヒープは安定しているが、ネイティブメモリやスレッド、ソケット、画像処理ライブラリ側で増えている
- 本当に、不要になったはずのオブジェクトがどこかから参照され続けている
この記事では、最後の「本当にリークしている」をどう見分けるかを整理します。
結論から言うと、見るべきものは単なるメモリ使用量ではありません。
見るべきなのは、次の3つです。
- GC 後も生き残るメモリが増えているか
- 増えている型は何か
- そのオブジェクトを誰が参照しているか
.NET のメモリリーク調査は、「メモリが増えています」で終わらせず、「この型のオブジェクトが増えていて、このルートから参照され続けています」まで持っていく作業です。
2. まず「メモリリーク」の意味をそろえる
.NET でいうメモリリークは、C や C++ のように「確保したメモリを解放し忘れた」という形だけではありません。
マネージドコードでは、GC がオブジェクトを回収します。GC が回収できるかどうかは、「そのオブジェクトに到達できる参照がまだ残っているか」で決まります。
つまり、.NET の典型的なメモリリークはこうです。
もう業務上は不要なのに、static フィールド、キャッシュ、イベント、Timer、コレクション、DI のライフタイム、非同期コンテキストなどから参照され続けているため、GC から見るとまだ使用中に見える状態。
GC は賢いですが、業務上不要かどうかは分かりません。 参照されているなら、生きていると判断します。
そのため、.NET では「リーク」というより「意図しない保持」と考えると分かりやすくなります。
一方で、次の状態は、すぐにはメモリリークとは言えません。
| 状態 | リークとは限らない理由 |
|---|---|
| Working Set / RSS が増えている | OS がプロセスに割り当てたメモリであり、マネージドヒープの生存オブジェクト量とは一致しない |
| Total Allocated が増えている | 起動後に割り当てた累積量なので、アプリが動けば基本的に増える |
| 瞬間的に GC Heap が増える | 次の GC まで未回収のオブジェクトが残っているだけかもしれない |
| 起動直後に増える | JIT、型ロード、初期キャッシュ、接続プール、テンプレート展開などでよく起きる |
| LOH が大きい | 大きな配列やバッファの再利用、断片化、プール戦略の影響かもしれない |
| メモリが下がらない | GC が回収しても、プロセスが OS にすぐメモリを返すとは限らない |
逆に、次の状態がそろうほど、メモリリークの疑いは強くなります。
| 観測結果 | 意味 |
|---|---|
| 同じ操作を繰り返すたびに、GC 後のヒープが増える | 生き残るオブジェクトが増えている |
| Gen 2 や LOH のサイズが増え続ける | 長生きしているオブジェクト、または大きなオブジェクトが残っている |
| 複数回のダンプで同じ型の Count / Size が増える | 増加している型を特定できる |
gcroot で static、イベント、キャッシュ、長命サービスからの参照が見える |
GC が回収できない理由を説明できる |
| 負荷を止めても、十分な時間または検証用 GC 後に戻らない | 単なる一時的割り当てではない可能性が高い |
3. 「何のメモリ」を見ているのかを分ける
メモリ調査で最初に混乱するのは、いろいろなメモリ指標が混ざることです。
同じ「メモリ」でも、意味が違います。
| 指標 | 見るもの | 読み方 |
|---|---|---|
| Working Set / RSS | 物理メモリ上に載っているプロセスのページ | OS 視点のメモリ。GC ヒープそのものではない |
| Private Bytes / Commit | プロセスが私有しているコミット済みメモリ | ネイティブメモリ、スタック、JIT コード、GC セグメントなども含む |
| GC Heap Size | マネージドヒープ上のオブジェクト量 | .NET の GC 対象メモリを見る入口 |
| Total Allocated | 起動後に割り当てた累積量 | 基本的に増える。リーク判定には単独では使わない |
| Gen 0 / Gen 1 / Gen 2 | 世代別のヒープ | Gen 2 に残るものは長生きしている |
| LOH | 85,000 バイト以上の大きなオブジェクトが入るヒープ | 大きな配列、文字列、バッファで増えやすい |
| POH | pin されたオブジェクト向けのヒープ | ネイティブ連携や固定化の影響を見る手がかりになる |
| Finalization Queue | ファイナライズ待ちのオブジェクト | Dispose 漏れ、ファイナライザ詰まりの手がかりになる |
最初からすべてを詳しく見る必要はありません。
まずは、次の問いに分解します。
プロセスのメモリが増えている
↓
マネージドヒープも増えているか?
↓
GC 後も生き残る量が増えているか?
↓
どの型が増えているか?
↓
誰が参照しているか?
この順番を守ると、「見た目のメモリ増加」と「本当のリーク」を混同しにくくなります。
4. 判断フロー
実務では、次の流れで切り分けると進めやすいです。
1. 再現条件を決める
- どの API、画面、ジョブ、バッチで増えるのか
- 何回実行すると増えるのか
- 負荷を止めたらどうなるのか
2. dotnet-counters で傾向を見る
- Working Set
- GC Heap
- Gen 2 / LOH
- Total Allocated
- GC 回数
3. 時間差で比較する
- 起動直後
- ウォームアップ後
- 負荷中
- 負荷停止後
- 同じ操作を N 回繰り返した後
4. ダンプを2回以上取る
- before
- after
- 可能なら負荷停止後も取る
5. 増えている型を探す
- dumpheap -stat
- gcdump report
- Visual Studio / PerfView
6. 参照元を確認する
- gcroot
- gchandles
- finalizequeue
7. 判定する
- GC待ち
- 正常なキャッシュ増加
- マネージドメモリリーク
- ネイティブメモリの問題
- LOH断片化または一時的な大容量割り当て
大事なのは、1回の数値で判断しないことです。
メモリリークは「増え続ける傾向」です。 そのため、1点の値ではなく、同じ条件で時間差比較します。
5. 使うツール
この記事では、主に次のツールを使います。
| ツール | 使いどころ |
|---|---|
dotnet-counters |
実行中プロセスの GC や Working Set の傾向を見る |
dotnet-gcdump |
生きているマネージドオブジェクトの統計を軽めに取る |
dotnet-dump |
ヒープを詳しく見て、dumpheap や gcroot で参照元まで追う |
| Visual Studio Memory Usage | Windows で GUI 比較したい場合に使う |
| PerfView | Windows で GC / heap / trace を深く見る場合に使う |
dotnet-trace |
割り当てや GC イベントを時系列で追いたい場合に使う |
まずは CLI ツールを入れます。
dotnet tool install --global dotnet-counters
dotnet tool install --global dotnet-dump
dotnet tool install --global dotnet-gcdump
dotnet tool install --global dotnet-trace
すでに入っている場合は更新します。
dotnet tool update --global dotnet-counters
dotnet tool update --global dotnet-dump
dotnet tool update --global dotnet-gcdump
dotnet tool update --global dotnet-trace
調査対象のプロセスを探します。
dotnet-counters ps
以後の例では、対象プロセス ID を <PID> と書きます。
Linux や macOS、コンテナ環境では、診断ツールと対象プロセスが同じユーザーで動いている必要があります。また、環境によっては TMPDIR や診断ポート、コンテナの PID 名前空間の影響を受けます。
本番環境で実行する場合は、いきなりダンプを取らず、まず検証環境で負荷と影響を確認します。
6. まず dotnet-counters で傾向を見る
最初に見るのは、詳細なダンプではなく、傾向です。
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime
出力には .NET のバージョンによって多少の違いがあります。
.NET 9 以降では System.Runtime の Meter 名で、.NET 8 以前では従来の EventCounter 名で表示されることがあります。
見るポイントは次です。
| 見る項目 | 何を見るか |
|---|---|
dotnet.process.memory.working_set |
OS 視点のプロセス常駐メモリ |
dotnet.gc.last_collection.heap.size |
直近 GC 後の世代別ヒープサイズ |
dotnet.gc.last_collection.memory.committed_size |
GC がコミットしているメモリ量 |
dotnet.gc.heap.total_allocated |
起動後の累積割り当て量 |
dotnet.gc.collections |
世代別 GC 回数 |
dotnet.gc.pause.time |
GC 停止時間の累積 |
まずは、対象を絞って監視しても構いません。
dotnet-counters monitor \
--process-id <PID> \
--refresh-interval 3 \
--counters System.Runtime[dotnet.process.memory.working_set,dotnet.gc.last_collection.heap.size,dotnet.gc.last_collection.memory.committed_size,dotnet.gc.heap.total_allocated,dotnet.gc.collections]
あとで見返すなら、CSV に保存します。
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output counters.csv \
--counters System.Runtime
この時点で見たいのは、次の違いです。
6.1 Total Allocated だけが増える
dotnet.gc.heap.total_allocated は累積値です。
アプリケーションがリクエストを処理すれば、オブジェクトを割り当てます。 割り当てたオブジェクトがすぐ不要になり、GC で回収されても、累積割り当て量は増えます。
そのため、Total Allocated が増えているだけではメモリリークとは言えません。
見るべきなのは、割り当てたあとに残っているかです。
Total Allocated: 増える
GC Heap Size: ある程度上下しながら安定する
Gen 2 / LOH: 増え続けない
この場合は、リークというより、割り当て量が多いアプリケーションです。
対策は、リーク修正ではなく、割り当て削減、バッファ再利用、LINQ の多用見直し、文字列生成の削減、シリアライズ処理の見直しなどになります。
6.2 Working Set は増えるが GC Heap は安定する
Working Set や RSS が増えているのに、GC Heap が安定していることがあります。
この場合、マネージドオブジェクトのリークとは限りません。
考えられるものは次です。
- JIT されたコード
- ロードされたアセンブリ
- スレッドスタック
- ネイティブライブラリのメモリ
Marshal.AllocHGlobalなどのアンマネージドメモリ- 画像、圧縮、暗号、DB ドライバなどのネイティブ側バッファ
- ソケット、ファイルハンドル、SSL、HTTP/2、gRPC などの内部バッファ
- OS がプロセスからすぐ物理ページを回収していないだけ
この状態で dumpheap をいくら見ても、主犯が見つからないことがあります。
判断の目安はこうです。
Working Set / RSS: 増える
GC Heap Size: 安定
Gen 2 / LOH: 安定
この場合は、.NET の managed heap leak ではなく、ネイティブメモリ、ハンドル、スレッド数、ソケット、外部ライブラリを疑います。
dotnet-counters だけで終わらず、OS のツール、コンテナメトリクス、ハンドル数、スレッド数、ネイティブヒープ、外部ライブラリのメトリクスも見ます。
6.3 GC Heap が増えるが、負荷停止後に戻る
負荷中に GC Heap が増えるのは自然です。
リクエストが多い。 一時オブジェクトが多い。 大きな JSON を扱う。 一時的にリストや配列を作る。
このような場合、次の GC までヒープは増えます。
負荷を止めると、GC が走り、ヒープが戻ることがあります。
負荷中: GC Heap が増える
負荷停止後: GC Heap が下がる、または一定値に戻る
繰り返し後: ベースラインが増え続けない
この場合は、「GC されていないだけ」または「一時的な割り当てが多い」と判断できます。
ただし、負荷中の一時割り当てが多すぎると、GC 回数や停止時間が増えて性能問題になります。 リークではなくても、性能改善の対象にはなります。
6.4 GC 後の Gen 2 / LOH が増え続ける
注意したいのはこのパターンです。
同じ操作を繰り返す
↓
Gen 2 が増える
↓
LOH が増える
↓
負荷を止めても戻らない
↓
次の測定でもさらに増える
Gen 2 は長生きしているオブジェクトが入る世代です。 LOH は大きな配列や文字列などが入りやすいヒープです。
ここが増え続ける場合は、リーク、無制限キャッシュ、巨大バッファ保持、イベント購読解除漏れ、static コレクション、長命サービスによる保持を疑います。
この段階で、次に進みます。
7. 「GC待ち」かどうかを確認する考え方
「まだ GC されていないだけか」を見るには、十分な GC の機会があった後の状態を見ます。
ただし、本番コードに安易に GC.Collect() を入れてはいけません。
GC.Collect() は GC を強制します。特に全世代のブロッキング GC はアプリケーションの停止時間を作ります。通常の運用では、GC に任せるのが基本です。
それでも調査では、管理された検証環境で「強制 GC 後も残るか」を見ることがあります。
検証用のコンソールアプリや再現環境なら、次のようなコードでフル GC 後の状態を確認できます。
static void ForceFullGcForDiagnosticsOnly()
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
}
ポイントは、これを解決策として使わないことです。
これは、あくまで調査用です。
確認したいのは次です。
操作前
↓
操作を N 回繰り返す
↓
負荷を止める
↓
十分待つ、または検証環境でフル GC を誘発する
↓
GC 後のヒープが操作前に近い値へ戻るか
戻るなら、GC 待ちまたは一時割り当ての可能性が高いです。
戻らず、同じ操作を繰り返すたびにベースラインが上がるなら、何かが生き残っています。 その「何か」をダンプで探します。
8. dotnet-gcdump で軽く比較する
最初の比較には dotnet-gcdump が便利です。
dotnet-gcdump は、実行中の .NET プロセスから GC dump を取得し、ヒープ上の型ごとの統計を見るのに使えます。
dotnet-gcdump collect --process-id <PID> --output before.gcdump
負荷をかけた後、もう一度取ります。
dotnet-gcdump collect --process-id <PID> --output after.gcdump
CLI で簡単なレポートを見ることもできます。
dotnet-gcdump report before.gcdump > before-heap.txt
dotnet-gcdump report after.gcdump > after-heap.txt
見るのは、型ごとの Count と Size です。
たとえば、after で次のような型が大きく増えていれば、調査対象になります。
Size (Bytes) Count Type
============ ===== ====
180,000,000 2,000,000 System.String
120,000,000 1,000,000 MyApp.Models.Customer
90,000,000 25,000 System.Byte[]
大事なのは、「大きい型」ではなく「増えた型」です。
System.String や System.Byte[] は多くのアプリで上位に出ます。
上位にあるだけでは犯人とは限りません。
比較の観点はこうです。
| before → after | 読み方 |
|---|---|
| Count がほぼ同じ | その型は主犯ではない可能性が高い |
| Count も Size も増える | 候補になる |
MyApp.* の型が増える |
業務ロジック上の保持を疑いやすい |
System.Byte[] が増える |
バッファ、シリアライズ、画像、圧縮、HTTP、DB などを疑う |
System.String が増える |
キャッシュ、ログ、JSON、辞書キー、重複文字列を疑う |
Task, Timer, CancellationTokenSource が増える |
非同期処理、Timer、キャンセル解除漏れを疑う |
dotnet-gcdump は比較の入口として使いやすい一方、取得時に Gen 2 GC を誘発します。ヒープが大きい環境やレイテンシに厳しい環境では、停止時間や追加メモリ消費に注意します。
Windows なら、.gcdump を Visual Studio や PerfView で開いて比較できます。
非 Windows 環境では、CLI の report で型統計を見て、参照元の深掘りは dotnet-dump に進むのが実務的です。
9. dotnet-dump でヒープと参照元を見る
「増えている型」が見えてきたら、次は「なぜ回収されないのか」を見ます。
そのためには、dotnet-dump でダンプを取り、SOS コマンドで解析します。
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-1.dmp
時間を置いて、もう一度取ります。
dotnet-dump collect \
--process-id <PID> \
--type Heap \
--output myapp-2.dmp
ダンプ取得は重い操作です。 特に Full / Heap ダンプはサイズが大きく、プロセスやコンテナに負荷をかけます。本番で取る場合は、時間帯、ディスク容量、コンテナのメモリ制限、個人情報や機密情報の混入に注意します。
解析します。
dotnet-dump analyze myapp-2.dmp
まずはヒープ全体の統計を見ます。
> dumpheap -stat
出力は、型ごとの件数とサイズです。
MT Count TotalSize Class Name
00007f... 120000 3840000 MyApp.Models.Order
00007f... 250000 8000000 System.String
00007f... 10000 40000000 System.Byte[]
特定の型を絞ります。
> dumpheap -stat -type MyApp.Models.Order
または、特定の MethodTable で絞ります。
> dumpheap -mt <MT>
インスタンスのアドレスが分かったら、参照元を調べます。
> gcroot <OBJECT_ADDRESS>
ここが一番重要です。
gcroot で、なぜそのオブジェクトが生きているのかを確認します。
たとえば、次のような参照経路が見えたとします。
static MyApp.CustomerCache._items
-> System.Collections.Concurrent.ConcurrentDictionary<string, Customer>
-> MyApp.Models.Customer
-> System.String
この場合、GC が回収しない理由は明確です。
Customer は static なキャッシュから参照されています。
GC から見ると、まだ使用中です。
ここで初めて、次の判断ができます。
- そのキャッシュは本当に必要か
- 上限があるか
- 有効期限があるか
- キーが増え続ける設計になっていないか
- テナント、ユーザー、日付、リクエスト ID などをキーにして無限に増えていないか
メモリリーク調査で大事なのは、dumpheap -stat で終わらないことです。
dumpheap -stat は「何が多いか」を教えてくれます。
gcroot は「なぜ残っているか」を教えてくれます。
修正につながるのは後者です。
10. 見分け方の早見表
実務でよくあるパターンを整理します。
| 観測 | 可能性 | 次に見るもの |
|---|---|---|
| Total Allocated だけ増える | 通常の割り当て、または割り当て過多 | Allocation Rate、GC 回数、CPU、dotnet-trace |
| Working Set は増えるが GC Heap は安定 | ネイティブメモリ、JIT、スタック、OS 側の保持 | スレッド数、ハンドル数、ネイティブツール、外部ライブラリ |
| GC Heap が負荷中だけ増え、停止後に戻る | GC 待ち、一時割り当て | 負荷停止後の Gen 2 / LOH、GC 回数 |
| GC 後の Gen 2 が増え続ける | 長命オブジェクトの保持 | dumpheap -stat、gcroot |
| LOH が増え続ける | 大きな配列、バッファ、断片化、巨大文字列 | System.Byte[]、System.Char[]、LOH、Free 領域 |
System.String が大きい |
文字列キャッシュ、JSON、ログ、辞書キー | 文字列を保持している自前型を探す |
System.Byte[] が大きい |
バッファ、シリアライズ、画像、圧縮、通信 | 所有している型、ArrayPool 返却漏れ、ネイティブ連携 |
Task が増える |
完了しない非同期処理、待機キュー | async の待ち合わせ、キャンセル、チャネル、キュー |
Timer が増える |
Timer 破棄漏れ | Dispose、登録解除、長命サービス |
CancellationTokenSource が増える |
CTS 破棄漏れ、リンクトークン過多 | Dispose、リンク解除、タイムアウト生成箇所 |
EventHandler や delegate が残る |
イベント購読解除漏れ | publisher / subscriber の寿命差 |
| Finalization Queue が増える | Dispose 漏れ、ファイナライザ詰まり |
finalizequeue、ファイナライザスレッド |
| Pinned handle が多い | pin されたバッファ、ネイティブ連携 | gchandles、POH、固定化箇所 |
11. よくあるリークの形
11.1 static コレクション
もっとも分かりやすい形です。
public static class CustomerStore
{
private static readonly List<Customer> Customers = new();
public static void Add(Customer customer)
{
Customers.Add(customer);
}
}
このコードでは、Customers に追加した Customer は、プロセスが生きている限り残り続けます。
一時的な保存のつもりでも、static から参照されている限り GC は回収しません。
修正の方向は、用途によって変わります。
- 上限を設ける
- 有効期限を設ける
MemoryCacheなどのキャッシュ機構を使う- 明示的に削除する
- static をやめ、適切なライフタイムのサービスに移す
- 永続化が目的なら DB や外部ストレージに移す
重要なのは、「static が悪い」ではありません。
static に置いたものは長命になる、という性質を理解して使うことです。
11.2 無制限キャッシュ
キャッシュは意図的にメモリを使います。
そのため、キャッシュによる増加は、設計どおりならリークではありません。
しかし、上限や期限がないキャッシュは、実質的なメモリリークになります。
public sealed class ReportCache
{
private readonly Dictionary<string, Report> _cache = new();
public Report 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;
}
}
この例では、userId と date の組み合わせが増え続ければ、キャッシュも増え続けます。
特に危険なのは、キーに次のような値を含めるケースです。
- リクエスト ID
- 現在時刻
- GUID
- セッション ID
- ユーザー入力を正規化せず使った文字列
- SQL や検索条件をそのまま文字列化したもの
キャッシュは、次の条件を決めておきます。
| 条件 | 例 |
|---|---|
| 最大件数 | 10,000件まで |
| 最大サイズ | 256MBまで |
| 有効期限 | 最後のアクセスから30分 |
| 絶対期限 | 作成から6時間 |
| 破棄条件 | テナント削除、ユーザー削除、設定変更 |
| 監視項目 | 件数、推定サイズ、ヒット率、退避回数 |
「キャッシュだから増えてよい」ではなく、「どこまで増えてよいか」を決める必要があります。
11.3 イベント購読解除漏れ
イベントは、寿命の長い publisher が寿命の短い subscriber を参照し続けるとリークになります。
public sealed class OrderViewModel
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
OrderService が singleton で、OrderViewModel が画面ごとに作られる場合、OrderService のイベントが OrderViewModel を参照し続けます。
画面を閉じても、購読解除しなければ ViewModel は残ります。
修正例です。
public sealed class OrderViewModel : IDisposable
{
private readonly OrderService _service;
public OrderViewModel(OrderService service)
{
_service = service;
_service.OrderChanged += OnOrderChanged;
}
public void Dispose()
{
_service.OrderChanged -= OnOrderChanged;
}
private void OnOrderChanged(object? sender, OrderChangedEventArgs e)
{
// update view model
}
}
gcroot では、delegate や event handler 経由の参照として見えることがあります。
このパターンは、WPF、WinForms、長命サービス、メッセージブローカー、イベントアグリゲータでよく出ます。
11.4 Timer の破棄漏れ
System.Threading.Timer や PeriodicTimer、Reactive Extensions の subscription なども、破棄しないと残ります。
public sealed class PollingWorker
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
private void Poll()
{
// polling
}
}
この PollingWorker が一時的なオブジェクトのつもりなら、Timer を破棄する設計が必要です。
public sealed class PollingWorker : IDisposable
{
private readonly Timer _timer;
public PollingWorker()
{
_timer = new Timer(_ => Poll(), null, TimeSpan.Zero, TimeSpan.FromSeconds(10));
}
public void Dispose()
{
_timer.Dispose();
}
private void Poll()
{
// polling
}
}
Timer はコールバックの delegate を持ち、そこから対象オブジェクトへ参照がつながることがあります。
11.5 IDisposable の解放漏れ
IDisposable の解放漏れは、必ずしもマネージドヒープのリークとして見えるとは限りません。
ファイル、ソケット、DB 接続、ネイティブハンドル、バッファなどのリソース問題として出ることがあります。
public async Task<string> ReadAsync(string path)
{
var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
この例では StreamReader が stream を閉じるので大きな問題にならないことが多いですが、所有権が曖昧なコードでは漏れが起きます。
基本は using / await using で所有権を明確にします。
public async Task<string> ReadAsync(string path)
{
await using var stream = File.OpenRead(path);
using var reader = new StreamReader(stream);
return await reader.ReadToEndAsync();
}
Dispose 漏れは、次のような症状として出ます。
- ハンドル数が増える
- ソケットが増える
- ファイルが閉じられない
- ネイティブメモリが増える
- Finalization Queue が増える
- GC Heap は安定しているのにプロセスメモリが増える
この場合は、dumpheap だけでは足りません。
OS のハンドルやソケット、外部ライブラリの状態も見ます。
11.6 AsyncLocal やコンテキスト保持
AsyncLocal<T> は便利ですが、入れるものが大きいと長く残ることがあります。
ログの相関 ID のような小さな値なら問題になりにくいです。 しかし、ユーザー情報、リクエストボディ、大きな DTO、DB コンテキストのようなものを入れると、意図しない保持につながります。
public static class RequestContext
{
public static readonly AsyncLocal<RequestInfo?> Current = new();
}
AsyncLocal は非同期フローに乗るため、単純な static フィールドより見つけにくいことがあります。
入れるものは小さく、明確にし、不要になったら null に戻す設計を検討します。
11.7 DI ライフタイムの取り違え
ASP.NET Core などの DI では、singleton、scoped、transient の寿命が違います。
長命の singleton が、リクエストごとのデータを保持すると、リクエストが終わってもオブジェクトが残ることがあります。
public sealed class AuditBuffer
{
private readonly List<RequestAudit> _items = new();
public void Add(RequestAudit item)
{
_items.Add(item);
}
}
これが singleton なら、_items はアプリの寿命と同じです。
設計としてバッファするなら、上限、送信、削除、バックプレッシャーが必要です。 単に「あとで見るかもしれない」程度なら、ログや外部ストレージに出すべきです。
12. LOH は特に誤解しやすい
LOH は Large Object Heap の略です。 .NET では、大きなオブジェクトは通常の小さなオブジェクトとは別のヒープに置かれます。 代表例は大きな配列です。
var buffer = new byte[1024 * 1024 * 10]; // 10MB
LOH でよくある問題は、次の3つです。
- 大きなオブジェクトを頻繁に作る
- 大きなオブジェクトを長く持ち続ける
- 大きなオブジェクトの作成と破棄で断片化する
LOH が増えているからといって、すぐリークとは限りません。
大きなバッファを再利用する設計なら、一定サイズまで増えたあと安定することがあります。 また、GC が回収しても、Working Set がすぐ下がるとは限りません。
ただし、次の状態は疑うべきです。
System.Byte[]が操作ごとに増えるSystem.Char[]や巨大Stringが増える- 画像、PDF、Excel、ZIP、暗号、圧縮処理の後に戻らない
ArrayPool<T>.Rentした配列を返していない- 大きなレスポンスを丸ごとメモリに載せている
MemoryStream.ToArray()を多用している
ArrayPool<T> を使う場合は、必ず返します。
var pool = ArrayPool<byte>.Shared;
var buffer = pool.Rent(1024 * 1024);
try
{
// use buffer
}
finally
{
pool.Return(buffer);
}
ただし、プールに返したからといって、プロセスのメモリがすぐ下がるとは限りません。 プールは再利用のためにメモリを保持することがあります。
ここでも、見るべきなのは「増え続けるか」「上限があるか」「再利用されているか」です。
13. gcroot の読み方
gcroot は、あるオブジェクトがどこから参照されているかを表示します。
典型的なルートには次があります。
| ルート | 意味 |
|---|---|
| static field | 型の static フィールドから参照されている |
| local variable / stack | 実行中スレッドのスタックから参照されている |
| GC handle | GCHandle、pin、delegate、interop などから参照されている |
| finalization queue | ファイナライズ待ちで保持されている |
| thread / async state machine | 実行中または待機中の非同期処理が保持している |
調査でよく見るべきポイントは、寿命の差です。
長命オブジェクト
-> 短命のはずのオブジェクト
この形が出たら、リーク候補です。
たとえば、次は怪しいです。
SingletonService
-> List<RequestContext>
-> RequestContext
-> LargeDto
SingletonService はアプリ全体で生きます。
その中にリクエスト単位の RequestContext が溜まっているなら、設計を見直す必要があります。
一方で、次のようなルートは、タイミングによっては正常です。
Thread stack
-> Controller action local variable
-> RequestDto
リクエスト処理中なら、ローカル変数が残っていて当然です。
そのため、ダンプはタイミングが重要です。
負荷中だけでなく、負荷停止後、キューが空になった後、一定時間アイドルにした後のダンプも取ると判断しやすくなります。
14. 「強制GCで下がったから解決」ではない
調査中に GC.Collect() を呼んだらメモリが下がった。
このとき、「では定期的に GC.Collect() すればよい」と考えるのは危険です。
強制 GC は、根本原因を消していません。 単に未回収だったオブジェクトをその場で回収しただけです。
もし高い割り当て率が問題なら、強制 GC は停止時間を増やして性能を悪化させます。 もし本当のリークなら、参照が残っているオブジェクトは強制 GC でも回収されません。
調査で見るべきなのは、次の違いです。
| 強制 GC 後 | 判断 |
|---|---|
| 大きく下がり、その後ベースラインが安定 | GC 待ち、または一時割り当てが主因 |
| 少し下がるが、繰り返すたびに底が上がる | 一部が生き残っている。リーク候補 |
| ほとんど下がらない | 参照され続けている、または GC ヒープ以外が主因 |
| GC Heap は下がるが Working Set は下がらない | OS / GC セグメント / ネイティブ側の保持の可能性 |
本番で GC.Collect() を定期実行する前に、必ず「何が増えているのか」を特定します。
15. 実務で使う調査手順
ここからは、実際に調査するときの手順としてまとめます。
15.1 再現シナリオを固定する
まず、調査条件を固定します。
対象: /api/report/export
操作: 同じ条件で100回実行
測定間隔: 5秒
観測時間: ウォームアップ5分 + 負荷10分 + アイドル5分
環境: staging / Release build / Production相当設定
メモリ調査では、毎回違う操作をしながら見ると判断できません。
「何をしたら増えたのか」を固定します。
15.2 ベースラインを取る
起動直後ではなく、ウォームアップ後をベースラインにします。
理由は、起動直後には次のような一度きりの増加があるからです。
- JIT
- DI コンテナ構築
- 設定読み込み
- 初回 DB 接続
- 初回 TLS / HTTP 接続
- JSON シリアライザのメタデータ生成
- Razor / テンプレートの初期化
- ロガーやメトリクスの初期化
次のような順番にします。
1. アプリ起動
2. ヘルスチェックや代表APIを数回叩く
3. 1〜5分ほど待つ
4. ベースラインとして counters と dump を取る
15.3 負荷中に counters を取る
dotnet-counters collect \
--process-id <PID> \
--refresh-interval 5 \
--format csv \
--output report-export-counters.csv \
--counters System.Runtime
並行して、再現操作を行います。
見たいのは、グラフの形です。
正常に近い形:
負荷中に増える
GCで上下する
負荷停止後に戻る
ベースラインが増え続けない
怪しい形:
操作回数に比例して増える
Gen 2 / LOH の底が上がる
負荷停止後も戻らない
次の負荷でさらに底が上がる
15.4 ダンプを2回取る
負荷前後で取ります。
dotnet-dump collect --process-id <PID> --type Heap --output before.dmp
# 負荷をかける
dotnet-dump collect --process-id <PID> --type Heap --output after.dmp
余裕があれば、負荷停止後にも取ります。
# 負荷停止後、キューが空になり、一定時間待ってから
dotnet-dump collect --process-id <PID> --type Heap --output idle-after.dmp
比較するときは、before と after だけでなく、idle-after が重要です。
負荷中に増えていても、アイドル後に戻るならリークではない可能性があります。
15.5 増えた型を見る
dotnet-dump analyze after.dmp
> dumpheap -stat
before 側も同じように見ます。
手作業でも構いませんが、まずは上位の型を比較します。
見る観点は次です。
- 自社名前空間の型が増えているか
System.Stringの裏に自社型がいないかSystem.Byte[]を誰が持っているかList<T>やDictionary<TKey,TValue>が増えていないかTaskや async state machine が増えていないかTimerやCancellationTokenSourceが増えていないか
15.6 参照元を見る
候補のオブジェクトアドレスを拾い、gcroot します。
> dumpheap -type MyApp.Models.ReportResult
> gcroot <OBJECT_ADDRESS>
gcroot の結果から、保持している親を探します。
MyApp.Services.ReportCache
-> Dictionary<string, ReportResult>
-> ReportResult
ここまで来ると、コードレビューの対象が見えてきます。
ReportCacheは singleton か- 上限はあるか
- 削除されるか
- キーは増え続けるか
- ReportResult は大きすぎないか
- キャッシュではなく DB やファイルに逃がすべきか
16. dotnet-trace を使う場面
dotnet-dump は、ある瞬間のスナップショットです。
「結果として何が残っているか」を見るのに向いています。
一方で、「いつ、どこで大量に割り当てているか」を見たい場合は dotnet-trace を使います。
たとえば、GC 関連のイベントを含めてトレースします。
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:01:00 \
--clrevents gc+gchandle \
--clreventlevel informational \
--output gc-trace.nettrace
割り当てサンプリングまで見たい場合は、イベント量が増えるため、検証環境で短時間から始めます。
dotnet-trace collect \
--process-id <PID> \
--duration 00:00:00:30 \
--clrevents gc+gcsampledobjectallocationhigh \
--clreventlevel informational \
--output allocation-trace.nettrace
トレースは、ダンプとは別の角度で役立ちます。
| 見たいこと | 向いている道具 |
|---|---|
| 何が残っているか | dump / gcdump |
| 誰が参照しているか | dump + gcroot |
| いつ大量に割り当てたか | trace |
| GC がいつ起きたか | counters / trace |
| 停止時間が問題か | counters / trace |
リーク調査では、まず dump で「残っているもの」を見て、必要なら trace で「作っている場所」を見ると効率的です。
17. コード上で確認用メトリクスを出す
本格的な診断は外部ツールで行うべきですが、アプリ側に簡単な診断ログを入れておくと役立ちます。
たとえば、管理者向けエンドポイントや定期ログで、GC 情報を出す方法です。
public static class GcDiagnostics
{
public static object Snapshot()
{
var info = GC.GetGCMemoryInfo();
return new
{
TotalMemory = GC.GetTotalMemory(forceFullCollection: false),
HeapSizeBytes = info.HeapSizeBytes,
FragmentedBytes = info.FragmentedBytes,
MemoryLoadBytes = info.MemoryLoadBytes,
HighMemoryLoadThresholdBytes = info.HighMemoryLoadThresholdBytes,
Gen0Collections = GC.CollectionCount(0),
Gen1Collections = GC.CollectionCount(1),
Gen2Collections = GC.CollectionCount(2)
};
}
}
この情報だけでリーク判定はできません。
ただし、障害時に次の判断がしやすくなります。
- Gen 2 が急増しているか
- HeapSize が増えているか
- FragmentedBytes が増えているか
- TotalMemory とプロセスメモリの差が大きいか
- デプロイ後から傾向が変わったか
アプリログに入れる場合は、出しすぎに注意します。 高頻度に重い診断を行うと、それ自体が負荷になります。
18. 「メモリリーク」と断定するための基準
調査の最後には、次の形で説明できるようにします。
事象:
/api/report/export を100回実行すると、負荷停止後も GC Heap が 300MB 増えたまま戻らない。
観測:
dotnet-counters で Gen 2 の heap size が操作回数に比例して増えた。
Working Set だけでなく GC Heap も増えていた。
比較:
before.dmp と after.dmp を比較したところ、MyApp.Models.ReportResult が 12,000 件増えていた。
参照元:
gcroot で MyApp.Services.ReportCache._items から参照されていた。
原因:
ReportCache が singleton で、ユーザーID + 現在時刻をキーにしており、削除・期限・上限がなかった。
対策:
MemoryCache に置き換え、サイズ上限と有効期限を設定した。
キャッシュ件数をメトリクス化した。
ここまで説明できれば、単なる「メモリが増えています」ではありません。
再現条件、観測値、増えた型、参照元、原因、対策がつながっています。
19. 調査時の注意点
19.1 Release ビルドで見る
Debug ビルドは、最適化やローカル変数の寿命、デバッグ情報の影響で、実運用と見え方が変わることがあります。
本番相当の調査では、Release ビルド、運用に近い設定、近いデータ量で確認します。
19.2 起動直後だけで判断しない
起動直後は、いろいろな初期化でメモリが増えます。
ウォームアップ後のベースラインを取り、そこから増えるかを見ます。
19.3 1回のダンプで犯人扱いしない
ヒープ上位にある型が、必ずしも犯人ではありません。
System.String や System.Byte[] は多くのアプリで大きく見えます。
大事なのは、時間差で増えたか、そして誰が持っているかです。
19.4 ダンプには機密情報が入る
メモリダンプには、リクエスト、認証情報、接続文字列、個人情報、業務データが含まれる可能性があります。
保存場所、持ち出し、共有、削除ルールを決めます。
19.5 コンテナではダンプ取得がリスクになる
コンテナのメモリ制限が厳しい場合、ダンプ取得による追加メモリやページインで OOM Kill されることがあります。
本番コンテナで取る前に、ステージングで試し、制限値、ディスク容量、権限、PID 名前空間を確認します。
19.6 GC Heap 以外のリークもある
.NET の調査だからといって、すべてが GC ヒープに出るわけではありません。
次のような問題では、GC Heap が安定していてもプロセスメモリが増えることがあります。
- ネイティブライブラリ
- P/Invoke
- COM
- 画像処理
- 圧縮ライブラリ
- 暗号処理
- DB ドライバ
- ソケット
Marshal.AllocHGlobalNativeMemory.Alloc- スレッド過多
この場合は、dotnet-dump の dumpheap だけでは足りません。
OS 側の診断、外部ライブラリのメトリクス、ハンドル、スレッド、ネイティブメモリを見る必要があります。
20. 修正後の確認
リークらしい箇所を修正したら、同じ手順で再測定します。
修正前:
100回実行後、Gen 2 が +300MB
ReportResult が +12,000件
修正後:
100回実行後、Gen 2 が +20MB 以内で安定
ReportResult は負荷停止後にベースラインへ戻る
キャッシュ件数は上限 1,000 件で安定
修正確認では、必ず同じ条件で比較します。
- 同じデータ量
- 同じ回数
- 同じ負荷時間
- 同じウォームアップ
- 同じ観測間隔
- 同じツール
メモリ調査は、修正前後の比較が弱いと説得力が出ません。
21. まとめ
.NET でメモリが増えているとき、最初にやるべきことは、リークと決めつけることではありません。
次の順番で切り分けます。
- Working Set / RSS だけで判断しない
dotnet-countersで GC Heap、Gen 2、LOH、GC 回数を見る- 負荷中、負荷停止後、時間差で比較する
dotnet-gcdumpやdotnet-dumpで増えた型を見るgcrootで参照元を見る- static、キャッシュ、イベント、Timer、DI ライフタイム、非同期コンテキストを確認する
- GC Heap が安定しているなら、ネイティブメモリや OS 側の問題も疑う
「GC されていないだけ」と「メモリリークしている」の違いは、最後は参照で決まります。
不要になったオブジェクトが参照されていなければ、GC のタイミングで回収されます。 不要なはずなのに参照され続けていれば、GC は回収できません。
つまり、調査のゴールはこうです。
何が増えているのか。
どの GC 後も残っているのか。
誰が参照しているのか。
その参照は設計上必要なのか。
ここまで分かれば、メモリのグラフに振り回されず、コード上の修正点に落とし込めます。
参考資料
- .NET: Fundamentals of garbage collection
- .NET: Debug a memory leak
- .NET CLI: dotnet-counters diagnostic tool
- .NET CLI: dotnet-dump diagnostic tool
- .NET CLI: dotnet-gcdump diagnostic tool
- .NET CLI: dotnet-trace diagnostic tool
- .NET: Induced collections
- .NET: Large object heap
- .NET: Workstation and server garbage collection
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Windowsの偽装トークンを正しく扱う ── スレッド単位の権限借用と安全な戻し方
Windowsの偽装トークンについて、アクセストークン、プライマリトークン、スレッドトークン、偽装レベル、RevertToSelf、.NETのWindowsIdentity.RunImpersonatedまで、実務で安全に扱うための考え方を整理します。
TCPでSendした単位ごとにReceiveできるという誤解 ── バイトストリームとして扱うための受信設計
TCP通信で、SendやWriteした単位ごとに受信できると思い込むと、分割・結合・文字化け・プロトコル破損が起きます。TCPをバイトストリームとして扱い、アプリケーション側でフレーミングする考え方と、.NET/C#での実装例を整理します。
C#(CSharp)でPowerShellを実行して、オブジェクトとして受け取る方法
C#からPowerShellを起動し、文字列ではなくPSObjectとして結果を受け取る方法を、PowerShell SDK、AddCommand、AddParameter、BaseObject、Properties、エラー処理まで実務目線で整理します。
PesterによるPowerShellのテスト整備 ── 運用スクリプトを壊しにくくする実務の型
PowerShellスクリプトをPester v5でテストし、日付処理、ファイル操作、削除処理、モック、CI実行までを安全に整備する実務手順を整理します。
Windowsはなぜ今の形になったのか:開発者から見た歴代Windowsの進化
Windows 95からWindows 11までの変化を、見た目の年表ではなく、互換性、安定性、権限管理、ドライバ、Win32、.NET、セキュリティなどWindowsアプリ開発者の視点で整理します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
既存資産活用・移行支援
COM / ActiveX / OCX、32bit / 64bit 制約を抱える既存資産の活用と移行を支援します。
著者プロフィール
記事の著者プロフィールページです。
小村 豪
合同会社小村ソフト 代表
Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。
公開リンク