更新履歴(3件・最終更新 2026年08月02日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- 記事の冒頭に「この記事の知識マップ」節を追加しました。本文で扱っている概念とその関係を、要約・図・詳細ページへのリンクにまとめたものです。本文の主張は変えていません。
- 外部レビュー(1283件)への対応として本文を更新しました。個々の変更内容は、この下の履歴を参照してください。
- パターンの一覧表(症状と最初に見る指標)を追加しました。本番でダンプを取る前に関係者と決めておくことの節、調査対象が.NET Framework 4.xの場合の読み替え表(`dotnet-`系は.NET Core 3.0以降が前提です)、メモリ指標の関係図を追加しました。図では、ワーキングセットとコミットが入れ子の関係ではないことを正確に示しています。
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.21589844)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「.NETでGC待ちとメモリリークを見分ける ── 増えるメモリを観測・比較・証明する実務手順」合同会社小村ソフト. https://doi.org/10.5281/zenodo.21589844 https://staging.comcomponent.com/blog/2026/06/09/000-dotnet-gc-or-memory-leak/
- DOI(最新版)
- 10.5281/zenodo.21589844
- DOI(この版)
- 10.5281/zenodo.21732853
1. 最初に押さえるべきこと
.NET アプリケーションを運用していると、メモリ使用量がじわじわ増えていく場面があります。
タスクマネージャーや top を見ると、プロセスのメモリが増えている。
コンテナのメモリ使用量も増えている。
監視で Working Set や RSS のグラフが右肩上がりになっている。
この状態を見ると、すぐに「メモリリークではないか」と考えたくなります。しかし .NET では、プロセスのメモリが増えていることと、メモリリークしていることは同じではありません。
.NET にはガベージコレクションがあります。オブジェクトが不要になった瞬間に、すぐ OS にメモリが返るわけではありません。GC は、割り当て状況、ヒープのしきい値、メモリ圧迫、世代、ワークロードの状況を見ながら動きます。
そのため、次のような状態が起きます。
- 不要になったオブジェクトが、まだ GC されていない
- GC は済んでいるが、プロセスの Working Set がすぐ下がらない
- 初回アクセス、JIT、キャッシュ、コネクションプールで一度だけ増えて、その後は安定する
- マネージドヒープは安定しているが、ネイティブメモリやスレッド、ソケット、画像処理ライブラリ側で増えている
- 本当に、不要になったはずのオブジェクトがどこかから参照され続けている
この記事では、最後の「本当にリークしている」をどう見分けるかを扱います。見るべきは単なるメモリ使用量ではなく、次の 3 つです。
- GC 後も生き残るメモリが増えているか
- 増えている型は何か
- そのオブジェクトを誰が参照しているか
.NET のメモリリーク調査は、「メモリが増えています」で終わらせず、「この型のオブジェクトが増えていて、このルートから参照され続けています」まで持っていく作業です。
なお、この記事に登場するコードは、ビルド・実行できるサンプル一式(典型的なリークパターンのライブラリ、GC 待ちと生き残りの違いを観測するデモ、WeakReference で保持・回収を検証するユニットテスト)として GitHub で公開しています。
dotnet-gc-or-memory-leak - komurasoft-blog-samples (GitHub)
この記事の知識マップ
.NETアプリのメモリ増加は、ガベージコレクションがまだ回収していないだけの場合と、staticコレクションや上限のないキャッシュ、イベント購読解除漏れ、Timerの破棄漏れ、IDisposableの解放漏れ、DIライフタイムの取り違えなどにより本当に参照が残り続けている場合とに分かれる。見分けにはまずdotnet-countersでGCヒープやGen 2、LOHの傾向を確認し、累積値であるTotal AllocatedやOS視点のWorking Setの増加だけでは判断しないことが重要になる。次にdotnet-gcdumpで負荷前後の型ごとのCountとSizeを比較して増えている型を特定し、dotnet-dumpのdumpheapとgcrootコマンドで誰がそのオブジェクトを参照し続けているのかまで追う。強制GCでメモリが下がっても根本原因は消えておらず、本番へ入れる対策にはならない。
flowchart LR
accTitle: .NETのGC待ちとメモリリークの見分け方の知識マップ
accDescr: .NETでメモリ増加がGC待ちか本当のメモリリークかを、dotnet-counters・dotnet-gcdump・dotnet-dumpとgcrootで切り分ける関係を示す図
dotnet_garbage_collection[".NETのガベージコレクション(GC)"]
dotnet_memory_leak[".NETのメモリリーク(意図しない保持)"]
dotnet_counters["dotnet-counters"]
dotnet_gcdump["dotnet-gcdump"]
dotnet_dump["dotnet-dump"]
gcroot_command["gcrootコマンド"]
gc_heap["GCヒープ(マネージドヒープ)"]
dumpheap_command["dumpheap -statコマンド"]
dotnet_trace["dotnet-trace"]
gen2_heap["Gen 2(第2世代ヒープ)"]
total_allocated_memory["Total Allocated(累積割り当て量)"]
working_set_rss["Working Set / RSS"]
induced_gc["強制GC(Induced Collection)"]
static_collection_leak["staticコレクションによる保持"]
unbounded_cache["上限・期限のないキャッシュ"]
event_subscription_leak["イベント購読解除漏れ"]
timer_disposal_leak["Timerの破棄漏れ"]
idisposable_leak["IDisposableの解放漏れ"]
di_lifetime_mismatch["DIライフタイムの取り違え"]
large_object_heap["LOH(Large Object Heap)"]
dotnet_framework[".NET Framework"]
dotnet[".NET(Core以降)"]
dotnet_garbage_collection -->|"で確認できる"| dotnet_counters
dotnet_memory_leak -->|"で確認できる"| dotnet_gcdump
dotnet_memory_leak -->|"で確認できる"| dotnet_dump
dotnet_memory_leak -->|"で確認できる"| gcroot_command
gc_heap -->|"で確認できる"| dumpheap_command
dotnet_memory_leak -.->|"で確認できる"| dotnet_trace
dotnet_memory_leak -->|"で確認できる"| gen2_heap
total_allocated_memory -->|"用いるのは非推奨"| dotnet_memory_leak
working_set_rss -->|"用いるのは非推奨"| dotnet_memory_leak
induced_gc -->|"用いるのは非推奨"| dotnet_memory_leak
static_collection_leak -.->|"原因になり得る"| dotnet_memory_leak
unbounded_cache -.->|"原因になり得る"| dotnet_memory_leak
event_subscription_leak -.->|"原因になり得る"| dotnet_memory_leak
timer_disposal_leak -.->|"原因になり得る"| dotnet_memory_leak
idisposable_leak -.->|"原因になり得る"| dotnet_memory_leak
di_lifetime_mismatch -.->|"原因になり得る"| dotnet_memory_leak
large_object_heap -.->|"原因になり得る"| dotnet_memory_leak
dotnet_counters -->|"より先に行うべき"| dotnet_gcdump
dotnet_gcdump -->|"より先に行うべき"| dotnet_dump
dotnet_counters -->|"両立しない"| dotnet_framework
dotnet_dump -->|"両立しない"| dotnet_framework
dotnet_garbage_collection -->|"利用する"| gc_heap
dotnet_garbage_collection -->|"前提とする"| dotnet
図の実線は常に成り立つ関係、破線は条件付きの関係です(成立条件は詳細ページの各関係の説明に記載)。関係すべての一覧(全23件、根拠・確度つき)と主要概念の定義は知識マップ詳細ページにまとめています。データ: JSON-LD / Turtle
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 漏れ、ファイナライザ詰まりの手がかりになる |
どの指標がどこを見ているのかを図にすると、こうなります。
flowchart TB
PROC["プロセスが使うメモリ"] --> MANAGED["マネージド側<br/>GC Heap Size で見える範囲"]
PROC --> NATIVE["ネイティブ側<br/>GC Heap Size には出ない範囲"]
MANAGED --> G01["Gen 0 / Gen 1<br/>短命な割り当て"]
MANAGED --> G2["Gen 2<br/>生き残った長命オブジェクト"]
MANAGED --> LOH["LOH<br/>85,000バイト以上の大きなオブジェクト"]
MANAGED --> POH["POH<br/>pinされたオブジェクト"]
NATIVE --> STK["スレッドスタック"]
NATIVE --> JITC["JITしたコード、ロード済みアセンブリ"]
NATIVE --> INTEROP["P/Invoke、COM、外部ライブラリのバッファ"]
MANAGED -.計上される.-> COMMIT["Private Bytes / Commit<br/>プロセスが私有するコミット済みメモリ"]
NATIVE -.計上される.-> COMMIT
COMMIT -.物理メモリに載っている分だけ.-> WS["Working Set / RSS"]
この図で押さえてほしいのは、次の2点です。
dumpheapで見えるのはマネージド側だけです。ネイティブ側が増えている場合、いくらヒープを眺めても犯人は出てきません。- Working Set と Commit は入れ子の関係ではありません。 コミット済みでも物理メモリに載っていなければ Working Set には現れませんし、逆に共有ライブラリのページのように私有ではないものが Working Set に数えられることもあります。「Working Set が下がらないから GC が回収していない」とは言えないのは、このためです。
最初からすべてを詳しく見る必要はありません。まずは、次の問いに分解します。
プロセスのメモリが増えている
↓
マネージドヒープも増えているか?
↓
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 名前空間の影響を受けます。
本番環境で実行する場合は、いきなりダンプを取らず、まず検証環境で負荷と影響を確認します。
5.1 調査対象が .NET Framework 4.x の場合
dotnet-counters や dotnet-dump、dotnet-gcdump は、.NET Core 3.0 以降のランタイムが持つ診断機能を使うツールです。調査対象が .NET Framework 4.x のアプリケーションだと、これらは使えません。保守対象が Windows Forms や WPF、ASP.NET の既存アプリという場合はこちらに当たります。
置き換えは、この対応で考えます。
| この記事の道具 | .NET Framework 4.x での代わり |
|---|---|
dotnet-counters で傾向を見る |
パフォーマンス モニター、または Get-Counter で .NET CLR Memory カテゴリのカウンターを見る |
dotnet-gcdump で型統計を比較する |
PerfView の GC ヒープダンプ、または Visual Studio の「メモリ使用量」でスナップショットを比較する |
dotnet-dump collect でダンプを取る |
ProcDump、タスクマネージャーの「ダンプ ファイルの作成」、または Windows のエラー報告の設定でダンプを出す |
dotnet-dump analyze で dumpheap / gcroot |
WinDbg で .loadby sos clr を実行してから !dumpheap -stat や !gcroot を使う |
dotnet-trace で割り当てを追う |
PerfView の GC ヒープ アロケーション収集 |
考え方はまったく同じで、「傾向を見る」「型統計を2回比較する」「参照元を追う」の3段構えです。道具が変わるだけです。
パフォーマンス モニターで見る場合、.NET CLR Memory カテゴリの中でまず見たいカウンターはこのあたりです。
| カウンター | 見るもの |
|---|---|
# Bytes in all Heaps |
Gen 1、Gen 2、LOH の合計。この記事でいう GC Heap Size に近い |
Gen 2 heap size |
Gen 2 の現在のバイト数。増え続けるならリークを疑う |
Large Object Heap size |
LOH の現在のサイズ |
# Gen 2 Collections |
フル GC の回数。増え方が急なら割り当て過多を疑う |
% Time in GC |
直近の GC サイクルで GC に費やされた時間の割合 |
Finalization Survivors |
ファイナライズ待ちで生き残ったオブジェクト数。Dispose 漏れの手がかり |
# Total committed Bytes |
GC がコミットしている仮想メモリ量 |
カウンター名は日本語環境では日本語で表示されることがあります。見つからない場合は、英語名で検索するのではなく、.NET CLR メモリ のようなローカライズされたカテゴリ名も探してください。
WinDbg で解析する場合の入口は、SOS の読み込みです。
0:000> .loadby sos clr
0:000> !dumpheap -stat
0:000> !gcroot <OBJECT_ADDRESS>
.NET Framework では SOS のコマンドに ! を付けます。この記事の 9章以降で出てくる dumpheap -stat や gcroot は、!dumpheap -stat、!gcroot と読み替えれば、そのまま同じ手順として使えます。
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. よくあるリークの形
パターンを7つ挙げますが、頭から順に読む必要はありません。自分が見ている症状に近い行から入ってください。
| 節 | パターン | 典型的な症状 | 最初に見る指標 |
|---|---|---|---|
| 11.1 | static コレクション | 操作回数に比例して増え、負荷を止めても戻らない | Gen 2。gcroot で static field が出るか |
| 11.2 | 無制限キャッシュ | 稼働時間に比例して増える。再起動すると戻る | Gen 2。キャッシュの件数と System.String の増え方 |
| 11.3 | イベント購読解除漏れ | 画面やスコープを開いて閉じるたびに増える | 該当 ViewModel やハンドラー型の Count。delegate 経由の gcroot |
| 11.4 | Timer の破棄漏れ | 短命なつもりのオブジェクトが消えず、コールバックも動き続ける | System.Threading.Timer や TimerQueueTimer の Count |
| 11.5 | IDisposable の解放漏れ |
GC Heap は安定しているのに、ハンドル数やプロセスメモリが増える | ハンドル数、Finalization Queue、Working Set |
| 11.6 | AsyncLocal やコンテキスト保持 |
リクエスト処理が終わっても DTO が残る | async state machine 経由の gcroot |
| 11.7 | DI ライフタイムの取り違え | リクエスト数に比例して増える | singleton の型からの gcroot |
「最初に見る指標」の列は、10章の早見表と対で使ってください。10章が「観測結果から可能性を絞る表」、この表が「パターンから確認すべき指標へ戻る表」です。
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. 「メモリリーク」と断定するための基準
調査の最後には、次の形で説明できるようにします。報告書に落とすときの型として、埋めるべき欄を先に挙げておきます。
| 欄 | 書く内容 |
|---|---|
| 事象 | 誰が困っているか。どの操作でどれだけ増えるか |
| 観測条件 | 環境、ビルド構成、データ量、実行回数、ウォームアップ時間、観測間隔、使ったツールとバージョン |
| 観測 | counters の値がどう動いたか。Working Set と GC Heap を分けて書く |
| 比較 | before と after のダンプで、どの型が何件増えたか |
| 参照元 | gcroot で見えた保持経路 |
| 原因 | コード上のどの設計がその保持を生んでいるか |
| 対策 | 何を変えるか。効果をどう測るか |
| 残課題 | 必要なら。今回の観測で説明できなかったことと、次に見るもの |
観測条件を書き落とさないことが特に重要です。条件が書かれていないと、修正後の再測定と比較できず、20章の確認が意味を持たなくなります。
この型に沿って埋めると、たとえば次のようになります。
事象:
/api/report/export を100回実行すると、負荷停止後も GC Heap が 300MB 増えたまま戻らない。
観測条件:
staging環境 / Release build / 本番相当の設定とデータ量。
ウォームアップ5分のあと、同じ条件で100回実行し、そのあと5分アイドル。
dotnet-counters を5秒間隔で収集。
観測:
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 側の診断、外部ライブラリのメトリクス、ハンドル、スレッド、ネイティブメモリを見る必要があります。
19.7 本番でダンプを取る前に、関係者と決めておくこと
本番環境でのダンプ取得は、技術的な操作である前に、調整が必要な作業です。ここを飛ばして「調査なので取らせてください」とだけ言うと、たいてい止まります。
まず、影響として説明すべきことを整理します。
- ダンプ取得は対象プロセスにとって重い操作で、取得中は応答が止まったように見えることがあります。どれくらい止まるかはヒープサイズ、ディスク速度、環境で変わるため、ステージングで同じ手順を1回通して実測しておきます。
- 応答が止まる時間が長いと、ヘルスチェックのタイムアウト、ロードバランサーからの切り離し、クラスターのフェイルオーバーを誘発することがあります。
FullやHeapのダンプはプロセスのメモリ使用量に応じて大きくなります。書き出し先の空きディスク容量を先に確認します。- コンテナでは、ダンプ取得に伴うページインでメモリ制限を超え、コンテナが強制終了されることがあります(19.5)。
- ダンプには個人情報や資格情報が含まれる可能性があります(19.4)。
そのうえで、取得前に次を決めておきます。
| 決めること | 具体例 |
|---|---|
| 誰が承認するか | サービス責任者と情報システム部門。両方の合意を先に取る |
| いつ取るか | 業務時間外など、一時的な応答遅延が許容できる時間帯 |
| どこへ書き出すか | 十分な空きのあるローカルディスク。共有フォルダーへ直接書かない |
| 誰がアクセスできるか | 保管先のアクセス権を調査担当に限定する |
| いつ消すか | 調査完了後の削除期限を先に決めて記録する |
| 失敗したらどうするか | 応答が戻らない場合の再起動手順と、判断する人 |
| 事前検証 | ステージングで同じ手順を1回通し、所要時間とファイルサイズを控える |
依頼を出すときは、口頭ではなく、この内容を1枚にまとめて渡すのが確実です。書式の例です。
目的: /api/report/export の実行後にメモリが戻らない原因を特定する
方法: dotnet-dump collect --type Heap を負荷前後で2回取得する
影響: 取得中は対象プロセスの応答が遅れる。
ステージングでの実測値を別紙に添付
時間帯: 業務時間外。担当と情シスが同席できる時間
書き出し先: 対象サーバーのローカルディスク。事前に空き容量を確認
含む情報: メモリ上のリクエストデータ、接続文字列などが含まれる可能性がある
取り扱い: 保管先のアクセス権は調査担当のみ。持ち出しは行わない
削除: 調査完了後、遅くとも1か月以内に削除し、削除記録を残す
切り戻し: 取得後に応答が戻らない場合はプロセスを再起動する
「何のために」「どれだけ止まるか」「どこに置いて、いつ消すか」がそろっていれば、判断する側も可否を返しやすくなります。逆にここが曖昧だと、調査そのものが止まります。
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 後も残っているのか。
誰が参照しているのか。
その参照は設計上必要なのか。
ここまで分かれば、メモリのグラフに振り回されず、コード上の修正点に落とし込めます。
参考資料
- この記事のサンプルコード一式(ライブラリ、デモ、ユニットテスト) https://github.com/gomurin0428/komurasoft-blog-samples/tree/main/dotnet-gc-or-memory-leak
- .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
- .NET Framework: Performance counters
- .NET Framework: SOS.dll (SOS Debugging Extension)
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
PDB(プログラムデータベース)とは何か ── デバッグ情報・シンボル・Source Linkを理解する
PDB(Program Database / プログラムデータベース)とは何か、何が入っていて何が入っていないのか、Debug / Release、Portable PDB、Source Link、シンボルサーバー、ダンプ解析との関係まで実務向けに整理します。
Windowsの偽装トークンを正しく扱う ── スレッド単位の権限借用と安全な戻し方
Windowsの偽装トークンについて、アクセストークン、プライマリトークン、スレッドトークン、偽装レベル、RevertToSelf、.NETのWindowsIdentity.RunImpersonatedまで、実務で安全に扱うための考え方を整理します。
TCPでSendした単位ごとにReceiveできるという誤解 ── バイトストリームとして扱うための受信設計
TCP通信で、SendやWriteした単位ごとに受信できると思い込むと、分割・結合・文字化け・プロトコル破損が起きます。TCPをバイトストリームとして扱い、アプリケーション側でフレーミングする考え方と、.NET/C#での実装例を整理します。
Roslynとは何か ── C#コードをコンパイラの視点で読む・直す・生成する
Roslyn(.NET Compiler Platform)の概要、Syntax Tree、SemanticModel、Workspace、Analyzer、Source Generator、実務での使いどころと注意点を整理します。
Windowsアプリ 外注・受託開発を依頼する前に整理したいこと
Windowsアプリの外注・受託開発を依頼する前に、既存ソフト改修、装置連携、COM/ActiveX、配布・更新、保守の整理ポイントを解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
既存資産活用・移行支援
COM / ActiveX / OCX、32bit / 64bit 制約を抱える既存資産の活用と移行を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- .NETアプリのメモリ使用量が増えているのはメモリリークですか?
- プロセスのメモリが増えていることと、メモリリークしていることは同じではありません。.NETではGCが割り当て状況やヒープのしきい値を見ながら動くため、不要になったオブジェクトがまだGCされていないだけの場合や、GC後もOSがすぐメモリを回収しない場合があります。見るべきは「GC後も生き残るメモリが増えているか」「増えている型は何か」「そのオブジェクトを誰が参照しているか」の3点です。
- .NETのメモリリークを調べるにはどのツールを使えばよいですか?
- まずdotnet-countersでWorking Set、GC Heap、Gen 2/LOH、GC回数の傾向を見ます。次にdotnet-gcdumpで負荷前後のGC dumpを取り、型ごとのCountとSizeを比較して増えた型を特定します。最後にdotnet-dumpでヒープダンプを取り、dumpheap -statとgcrootで「なぜ回収されないのか」の参照元まで追います。1回の数値ではなく、同じ条件での時間差比較が重要です。
- .NETでよくあるメモリリークのパターンは何ですか?
- 典型例は、staticコレクションへの追加しっぱなし、上限や有効期限のない無制限キャッシュ、長命なpublisherに対するイベント購読解除漏れ、Timerの破棄漏れ、IDisposableの解放漏れ、singletonがリクエスト単位のデータを保持するDIライフタイムの取り違えなどです。.NETのリークは「解放し忘れ」というより、不要なのに参照され続けている「意図しない保持」と考えると分かりやすいです。
- GC.Collect()を定期実行すればメモリ問題は解決しますか?
- 解決しません。強制GCは未回収だったオブジェクトをその場で回収するだけで、根本原因を消していません。高い割り当て率が問題なら停止時間を増やして性能を悪化させますし、本当のリークなら参照が残っているオブジェクトは強制GCでも回収されません。調査用として管理された検証環境で「強制GC後も残るか」を見ることはありますが、解決策として本番に入れる前に必ず何が増えているのかを特定すべきです。