.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つです。

  1. GC 後も生き残るメモリが増えているか
  2. 増えている型は何か
  3. そのオブジェクトを誰が参照しているか

.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 ヒープを詳しく見て、dumpheapgcroot で参照元まで追う
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

見るのは、型ごとの CountSize です。

たとえば、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.StringSystem.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 -statgcroot
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;
    }
}

この例では、userIddate の組み合わせが増え続ければ、キャッシュも増え続けます。

特に危険なのは、キーに次のような値を含めるケースです。

  • リクエスト 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.TimerPeriodicTimer、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();
}

この例では StreamReaderstream を閉じるので大きな問題にならないことが多いですが、所有権が曖昧なコードでは漏れが起きます。

基本は 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つです。

  1. 大きなオブジェクトを頻繁に作る
  2. 大きなオブジェクトを長く持ち続ける
  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

比較するときは、beforeafter だけでなく、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 が増えていないか
  • TimerCancellationTokenSource が増えていないか

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.StringSystem.Byte[] は多くのアプリで大きく見えます。

大事なのは、時間差で増えたか、そして誰が持っているかです。

19.4 ダンプには機密情報が入る

メモリダンプには、リクエスト、認証情報、接続文字列、個人情報、業務データが含まれる可能性があります。

保存場所、持ち出し、共有、削除ルールを決めます。

19.5 コンテナではダンプ取得がリスクになる

コンテナのメモリ制限が厳しい場合、ダンプ取得による追加メモリやページインで OOM Kill されることがあります。

本番コンテナで取る前に、ステージングで試し、制限値、ディスク容量、権限、PID 名前空間を確認します。

19.6 GC Heap 以外のリークもある

.NET の調査だからといって、すべてが GC ヒープに出るわけではありません。

次のような問題では、GC Heap が安定していてもプロセスメモリが増えることがあります。

  • ネイティブライブラリ
  • P/Invoke
  • COM
  • 画像処理
  • 圧縮ライブラリ
  • 暗号処理
  • DB ドライバ
  • ソケット
  • Marshal.AllocHGlobal
  • NativeMemory.Alloc
  • スレッド過多

この場合は、dotnet-dumpdumpheap だけでは足りません。 OS 側の診断、外部ライブラリのメトリクス、ハンドル、スレッド、ネイティブメモリを見る必要があります。

20. 修正後の確認

リークらしい箇所を修正したら、同じ手順で再測定します。

修正前:
  100回実行後、Gen 2 が +300MB
  ReportResult が +12,000件

修正後:
  100回実行後、Gen 2 が +20MB 以内で安定
  ReportResult は負荷停止後にベースラインへ戻る
  キャッシュ件数は上限 1,000 件で安定

修正確認では、必ず同じ条件で比較します。

  • 同じデータ量
  • 同じ回数
  • 同じ負荷時間
  • 同じウォームアップ
  • 同じ観測間隔
  • 同じツール

メモリ調査は、修正前後の比較が弱いと説得力が出ません。

21. まとめ

.NET でメモリが増えているとき、最初にやるべきことは、リークと決めつけることではありません。

次の順番で切り分けます。

  1. Working Set / RSS だけで判断しない
  2. dotnet-counters で GC Heap、Gen 2、LOH、GC 回数を見る
  3. 負荷中、負荷停止後、時間差で比較する
  4. dotnet-gcdumpdotnet-dump で増えた型を見る
  5. gcroot で参照元を見る
  6. static、キャッシュ、イベント、Timer、DI ライフタイム、非同期コンテキストを確認する
  7. GC Heap が安定しているなら、ネイティブメモリや OS 側の問題も疑う

「GC されていないだけ」と「メモリリークしている」の違いは、最後は参照で決まります。

不要になったオブジェクトが参照されていなければ、GC のタイミングで回収されます。 不要なはずなのに参照され続けていれば、GC は回収できません。

つまり、調査のゴールはこうです。

何が増えているのか。
どの GC 後も残っているのか。
誰が参照しているのか。
その参照は設計上必要なのか。

ここまで分かれば、メモリのグラフに振り回されず、コード上の修正点に落とし込めます。

参考資料

同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。

このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。

この記事は次のサービスページにつながります。近い入口からご覧ください。

著者プロフィール

記事の著者プロフィールページです。

小村 豪

合同会社小村ソフト 代表

Windows ソフト開発、技術相談、不具合調査を中心に、既存資産が残る案件や原因が見えにくい障害調査に強みがあります。

ブログ一覧に戻る