Windows システムの大きなオブジェクト ヒープ

.NET のガベージ コレクター (GC) は、オブジェクトを小さなオブジェクトと大きなオブジェクトに分けます。 オブジェクトが大きい場合、その属性のいくつかは、オブジェクトが小さい場合に比べて、より重要な意味を持つようになります。 たとえば、最適化 (つまり、メモリをヒープ上の他の場所にコピーする処理) のコストが高くなる場合があります。 そのため、ガベージ コレクターは、大きなオブジェクトを大きなオブジェクト ヒープ (LOH) に配置します。 この記事では、大きなオブジェクトの定義、大きなオブジェクトの収集方法、大きなオブジェクトがパフォーマンスに与える影響などについて説明します。

重要

この記事では、Windows システムのみで実行されている .NET Framework と .NET Core の大きなオブジェクト ヒープについて説明します。 その他のプラットフォームの .NET 実装で実行されている LOH については取り上げません。

オブジェクトが LOH と見なされる条件

オブジェクトのサイズが 85,000 バイト以上の場合、大きなオブジェクトと見なされます。 この数値は、パフォーマンス チューニングによって決定されたものです。 85,000 バイト以上のオブジェクトの割り当て要求の場合、ランタイムはそれを大きなオブジェクト ヒープに割り当てます。

ガベージ コレクターに関する基本事項をいくつか確認することで、これが何を意味するのかを理解しやすくなります。

ガベージ コレクターは世代別コレクターです。 世代 0、世代 1、世代 2 という 3 つの世代があります。 世代が 3 つあるのは、適切にチューニングされたアプリでは、ほとんどのオブジェクトが世代 0 で終了するためです。 たとえば、サーバー アプリでは、各要求に関連付けられた割り当ては、その要求の完了後に終了する必要があります。 処理中の割り当て要求は、世代 1 に進み、その世代で終了します。 本質的に、世代 1 は、短期間存在するオブジェクトの領域と長期間存在するオブジェクトの領域との間でバッファーとして機能します。

新しく割り当てられたオブジェクトにより、新しいオブジェクトが生成されます。また、新しく割り当てられたオブジェクトは暗黙的にジェネレーション 0 コレクションになります。 ただし、大きなオブジェクトであれば、大きなオブジェクト ヒープ (LOH) に移されます。これは、"ジェネレーション 3" と呼ばれることもあります。 ジェネレーション 3 は、ジェネレーション 2 の一部として論理的に収集される、物理的なジェネレーションです。

大きなオブジェクトは世代 2 に属します。これは、大きなオブジェクトが世代 2 の収集中にのみ収集されるためです。 ある世代の収集時には、それより存在期間の短い世代もすべて収集されます。 たとえば、世代 1 の GC が行われるときには、世代 1 と世代 0 の両方が収集されます。 また、世代 2 の GC が行われるときには、ヒープ全体が収集されます。 そのため、世代 2 の GC はフル GC とも呼ばれます。 この記事では、フル GC ではなく、世代 2 の GC と呼びますが、どちらの用語も使用できます。

世代では、GC ヒープの論理ビューが提供されます。 物理的には、オブジェクトはマネージド ヒープ セグメントに存在します。 マネージド ヒープ セグメント は、GC がマネージド コードに代わって VirtualAlloc 関数を呼び出して OS から予約するメモリのチャンクです。 CLR が読み込まれると、GC は 2 つの初期ヒープ セグメントを割り当てます。その 1 つは小さなオブジェクト用 (小さなオブジェクト ヒープ、つまり SOH) で、もう 1 つは大きなオブジェクト用 (大きなオブジェクト ヒープ) です。

割り当て要求は、これらのマネージド ヒープ セグメントにマネージド オブジェクトを配置すると満たされます。 オブジェクトが 85,000 バイト未満の場合、SOH のセグメントに配置されます。それ以外の場合は、LOH セグメントに配置されます。 セグメントに割り当てるオブジェクトが増加していくと、セグメントは (小さいチャンクへと) コミットされます。 SOH の場合、GC で残ったオブジェクトは次の世代に昇格されます。 世代 0 のコレクションで残ったオブジェクトは、世代 1 のオブジェクトと見なされるようになり、以後同様です。 ただし、最も古い世代で終了しなかったオブジェクトは、引き続き最も古い世代と見なされます。 つまり、世代 2 で残ったオブジェクトは世代 2 のオブジェクトであり、LOH で残ったオブジェクトは LOH オブジェクト (世代 2 で収集) となります。

ユーザー コードは、世代 0 (小さなオブジェクト) または LOH (大きなオブジェクト) にのみ割り当てることができます。 GC だけが、世代 1 (世代 0 で残ったオブジェクトを昇格) と世代 2 (世代 1 で残ったオブジェクトを昇格) のオブジェクトを "割り当てる" ことができます。

ガベージ コレクションがトリガーされると、GC は、ライブ オブジェクトをトレースし、それらを最適化します。 ただし、最適化のコストが高いため、GC は LOH を一掃 し、終了したオブジェクトから空きリストを作成します。これらのオブジェクトは、大きなオブジェクトの割り当て要求を満たすために、後で再利用できます。 隣接する複数の終了したオブジェクトは、1 つの空きオブジェクトに結合されます。

.NET Core と .NET Framework (.NET Framework 4.5.1 以降) には GCSettings.LargeObjectHeapCompactionMode プロパティが含まれています。これを使用して、ユーザーは LOH を次のフル ブロッキング GC 中に最適化するよう指定できます。 将来的には、.NET で LOH を自動的に最適化できるようになります。 つまり、大きなオブジェクトを割り当てたときに、そのオブジェクトが移動されないようにするには、オブジェクトを固定する必要があります。

図 1 に示したシナリオでは、GC は世代 0 の GC で Obj1Obj3 が終了した後に世代 1 を形成し、世代 1 の GC で Obj2Obj5 が終了した後に世代 2 を形成しています。 この図と次の図は単に例を示すためのものであることに注意してください。ヒープで何が起こっているかをわかりやすくするために、図にはごくわずかなオブジェクトしか含まれていません。 実際には、通常、GC にはより多くのオブジェクトが含まれます。

Figure 1: A gen 0 GC and a gen 1 GC
図 1: 世代 0 および世代 1 の GC。

図 2 では、Obj1Obj2 を終了させた世代 2 の GC の後、GC は、Obj1Obj2 が占有していたメモリから隣接する空き領域を形成し、Obj4 に対する割り当て要求を満たすためにその空き領域を使用しています。 最後のオブジェクト Obj3 からセグメントの末尾までの領域は、割り当て要求を満たすために使用することもできます。

Figure 2: After a gen 2 GC
図 2: 世代 2 の GC の後

大きなオブジェクトの割り当て要求に応じるだけの十分な空き領域がない場合、GC はまず、OS からさらにセグメントを取得することを試みます。 それが失敗した場合、多少の領域を解放できることを期待して、世代 2 の GC をトリガーします。

世代 1 または世代 2 の GC 中に、ガベージ コレクターは、ライブ オブジェクトが存在しないセグメントを、VirtualFree 関数を呼び出して解放し、OS に戻します。 最後の有効なオブジェクトからセグメントの末尾までの領域はデコミットされます (ただし、世代 0/世代 1 が有効な短期セグメントの場合を除きます。この場合、アプリケーションですぐに割り当てられるため、ガベージ コレクターは一部をコミットしたままにします)。 また、空き領域はリセットされてもコミットされたままです。つまり、OS ではその領域のデータをディスクに書き戻す必要がありません。

LOH は世代 2 の GC 中にのみ収集されるため、LOH セグメントを解放できるのはその GC 中のみとなります。 図 3 に示したシナリオでは、ガベージ コレクターは 1 つのセグメント (セグメント 2) を解放して OS に戻し、残りのセグメントでより多くの領域をデコミットしています。 大きなオブジェクトの割り当て要求を満たすために、セグメント末尾のデコミットされた領域を使用する必要がある場合は、メモリを再度コミットします (コミット/デコミットの詳細については、VirtualAlloc に関するドキュメントを参照してください)。

Figure 3: LOH after a gen 2 GC
図 3:世代 2 の GC 後の LOH

大きなオブジェクトが収集されるタイミング

一般に、GC は次の 3 つの状態のいずれかで発生します。

  • 割り当てが世代 0 または大きなオブジェクトのしきい値を超えている場合。

    しきい値は世代のプロパティの 1 つです。 世代のしきい値は、ガベージ コレクターがオブジェクトをそれに割り当てるときに設定されます。 しきい値を超えたときに、その世代に対して GC がトリガーされます。 小さな、または大きなオブジェクトを割り当てる場合は、世代 0 と LOH のしきい値をそれぞれ使用します。 ガベージ コレクターがオブジェクトを世代 1 と世代 2 に割り当てると、それらのしきい値が使用されます。 これらのしきい値は、プログラムの実行時に動的に調整されます。

    これは一般的なケースです。マネージド ヒープでの割り当てにより、ほとんどの GC が発生します。

  • GC.Collect メソッドが呼び出されます。

    パラメーターなしの GC.Collect() メソッドが呼び出されたか、別のオーバーロードに引数として GC.MaxGeneration が渡された場合、マネージド ヒープの残りの部分と共に LOH が収集されます。

  • システムのメモリが不足している場合。

    これは、ガベージ コレクターが、OS からメモリ使用率が高いという通知を受け取ったときに起こります。 ガベージ コレクターは、世代 2 の GC を行うことが有効であると判断した場合は、それをトリガーします。

LOH のパフォーマンスへの影響

大きなオブジェクト ヒープへの割り当ては、次のようにパフォーマンスに影響します。

  • 割り当てコスト。

    CLR では、すべての新しいオブジェクトに対して解放時にメモリがクリアされることが保証されています。 これは、大きなオブジェクトの割り当てコストが、メモリのクリアによって占められることを意味します (GC をトリガーしない限り)。 1 バイトをクリアするのに 2 サイクルかかるとすると、大きなオブジェクトの中で最小のものであっても、クリアするのに 170,000 サイクルかかります。 2 GHz のコンピューター上で 16 MB のオブジェクトのメモリをクリアする場合、約 16 ミリ秒かかります。 これはかなり大きなコストです。

  • コレクション コスト。

    LOH と世代 2 は一緒に収集されるため、いずれかのしきい値を超えると、世代 2 のコレクションがトリガーされます。 LOH のしきい値によって世代 2 の収集がトリガーされた場合、世代 2 が GC 後に非常に小さくなるとは限りません。 世代 2 にあまり多くのデータがない場合、影響は最小限になります。 ただし、世代 2 が大きい場合は、世代 2 の多くの GC がトリガーされると、パフォーマンス上の問題が生じることがあります。 多くの大きなオブジェクトが一時的に割り当てられており、大きな SOH がある場合は、GC の実行に過度な時間を費やす可能性があります。 さらに、非常に大きなオブジェクトの割り当てと解放を引き続き繰り返すと、割り当てコストが相当に増加します。

  • 参照型の配列要素。

    LOH 上の非常に大きなオブジェクトは、通常は配列です (非常に大きなインスタンス オブジェクトを持つということは、ごくまれです)。 配列の要素に参照が多く含まれる場合、要素に参照が多く含まれない場合には存在しないコストが発生します。 要素に参照が含まれない場合、ガベージ コレクターで配列を確認する必要はまったくありません。 たとえば、配列を使用してバイナリ ツリーのノードを格納する場合、これを実装する方法の 1 つは、ノードの右および左のノードを実際のノードで参照することです。

    class Node
    {
       Data d;
       Node left;
       Node right;
    };
    
    Node[] binary_tr = new Node [num_nodes];
    

    num_nodes が大きい場合、ガベージ コレクターは、要素ごとに少なくとも 2 回の参照を行う必要があります。 他に、左右のノードのインデックスを格納するという方法もあります。

    class Node
    {
       Data d;
       uint left_index;
       uint right_index;
    } ;
    

    左のノードのデータを left.d として参照する代わりに、それを binary_tr[left_index].d として参照します。 また、ガベージ コレクターでは、左右のノードに対して参照を確認する必要がありません。

3 つの要因のうち、通常は最初の 2 つが 3 番目より重要です。 そのため、一時オブジェクトを割り当てる代わりに再利用する大きなオブジェクトのプールを割り当てることをお勧めします。

LOH のパフォーマンス データの収集

特定の領域のパフォーマンス データを収集するには、次のような状況である必要があります。

  1. この領域に着目する理由がわかっている。
  2. 既知の他の領域を調べつくしたが、確認されたパフォーマンス上の問題を説明できる原因が見つからなかった。

メモリと CPU の基礎の詳細については、ブログの「Understand the problem before you try to find a solution」(解決策を見つける前に問題を理解する) を参照してください。

次のツールを使用して、LOH のパフォーマンスに関するデータを収集することができます。

.NET CLR メモリ パフォーマンス カウンター

.NET CLR メモリ パフォーマンス カウンターは、通常、パフォーマンスの問題について調べる最初の手順として有効です (ただし、ETW イベントを使用することをお勧めします)。 パフォーマンス カウンターを見る一般的な方法は、パフォーマンス モニター (perfmon.exe) で表示することです。 [追加] (Ctrl + A キー) を選び、対象のプロセスに対して表示するカウンターを追加します。 パフォーマンス カウンターのデータは、ログ ファイルに保存できます。

.NET CLR メモリ カテゴリの次の 2 つのカウンターは LOH に関連しています。

  • # Gen 2 Collections

    プロセスが開始されてから世代 2 の GC が発生した回数を示します。 このカウンターは、世代 2 のコレクション (フル ガベージ コレクションとも呼ばれる) の最後にインクリメントされます。 このカウンターは、最後に計測された値を表示します。

  • Large Object Heap size

    LOH の現在のサイズ (空き領域を含む) をバイト単位で表示します。 このカウンターは、割り当てがなされるたびに更新されるのではなく、ガベージ コレクションが 1 回終了するごとに更新されます。

Screenshot that shows adding counters in Performance Monitor.

PerformanceCounter クラスを使って、プログラムでパフォーマンス カウンターのクエリを実行することもできます。 LOH の場合、CategoryName として ".NET CLR メモリ" を、CounterName として "ラージ オブジェクト ヒープ サイズ" を指定します。

PerformanceCounter performanceCounter = new()
{
    CategoryName = ".NET CLR Memory",
    CounterName = "Large Object Heap size",
    InstanceName = "<instance_name>"
};

Console.WriteLine(performanceCounter.NextValue());

日常的なテスト プロセスの一環としてプログラムでカウンターを収集するのが一般的です。 カウンターに通常範囲外の値を見つけたときは、他の手段を使用して、調査に役立つ詳細なデータを取得します。

Note

ETW ではより豊富な情報が提供されるため、パフォーマンス カウンターではなく、ETW イベントを使用することをお勧めます。

ETW イベント

ガベージ コレクターは、ヒープで行われる内容とその理由を理解するのに役立つ、豊富な ETW イベントのセットを提供します。 次のブログ記事には、ETW を使用して GC イベントを収集および理解する方法が示されています。

一時 LOH の割り当てによる過度の世代 2 の GC を特定するには、GC のトリガー理由の列を確認します。 大きな一時オブジェクトのみを割り当てる簡単なテストの場合は、次の PerfView コマンドを使用して、ETW イベントに関する情報を収集できます。

perfview /GCCollectOnly /AcceptEULA /nogui collect

結果は次のようになります。

Screenshot that shows ETW events in PerfView.

ご覧のとおり、すべての GC は世代 2 の GC であり、AllocLarge によってすべてトリガーされます。これは、大きなオブジェクトの割り当てにより、この GC がトリガーされたことを意味します。 LOH Survival Rate % 列に 1% と示されているため、これらの割り当ては一時的なものであることがわかります。

これらの大きなオブジェクトを割り当てたユーザーを示す追加の ETW イベントを収集することができます。 たとえば、以下のコマンド ラインを使用します。

perfview /GCOnly /AcceptEULA /nogui collect

この場合、AllocationTick イベントが収集されます。このイベントは、約 100 K 相当の割り当てごとに発生します。 つまり、イベントは、大きなオブジェクトが割り当てられるたびに発生します。 次に、大きなオブジェクトを割り当てた呼び出し履歴を示す、GC ヒープ割り当てビューのいずれかを確認できます。

Screenshot that shows a garbage collector heap view.

ご覧のとおり、これは、Main メソッドから大きなオブジェクトを割り当てるだけの非常に簡単なテストです。

デバッガー

メモリ ダンプしかない状態で、LOH に実際にどのオブジェクトが存在するかを確認する必要がある場合は、.NET で提供される SoS デバッガー拡張を使用できます。

Note

このセクションに示されているデバッグ コマンドは、Windows デバッガーに適用できます。

LOH の分析からのサンプル出力を次に示します。

0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
sdgeneration 1 starts at 0x013e1b6c
generation 2 starts at 0x013e1000
ephemeral segment allocation context: none
segment   begin allocated     size
0018f2d0 790d5588 790f4b38 0x0001f5b0(128432)
013e0000 013e1000 013e35f8 0x000025f8(9720)
Large object heap starts at 0x023e1000
segment   begin allocated     size
023e0000 023e1000 033db630 0x00ffa630(16754224)
033e0000 033e1000 043cdf98 0x00fecf98(16699288)
043e0000 043e1000 05368b58 0x00f87b58(16284504)
Total Size 0x2f90cc8(49876168)
------------------------------
GC Heap Size 0x2f90cc8(49876168)
0:003> !dumpheap -stat 023e1000 033db630
total 133 objects
Statistics:
MT   Count   TotalSize Class Name
001521d0       66     2081792     Free
7912273c       63     6663696 System.Byte[]
7912254c       4     8008736 System.Object[]
Total 133 objects

LOH のヒープ サイズは (16,754,224 + 16,699,288 + 16,284,504) = 49,738,016 バイトです。 アドレス 023e1000 と 033db630 の間で、8,008,736 バイトが System.Object オブジェクトの配列によって占有され、6,663,696 バイトが System.Byte オブジェクトの配列によって占有され、2,081,792 バイトが空き領域によって占有されています。

デバッガーによって、LOH の合計サイズが 85,000 バイトより少ないことが示される場合があります。 その理由は、ランタイム自体が LOH を使用して、大きなオブジェクトよりも小さいいくつかのオブジェクトを割り当てるためです。

LOH は最適化されないため、LOH が断片化の元であると考えられる場合もあります。 断片化の意味を以下に示します。

  • マネージド ヒープの断片化: マネージド オブジェクト間の空き領域の量で示されます。 SoS では、!dumpheap –type Free コマンドはマネージド オブジェクト間の空き領域の量を示します。

  • 仮想メモリ (VM) アドレス空間の断片化: MEM_FREE としてマークされるメモリです。 windbg でさまざまなデバッガー コマンドを使用して取得することができます。

    次の例は、VM 領域内の断片化を示しています。

    0:000> !address
    00000000 : 00000000 - 00010000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    00010000 : 00010000 - 00002000
    Type     00020000 MEM_PRIVATE
    Protect 00000004 PAGE_READWRITE
    State   00001000 MEM_COMMIT
    Usage   RegionUsageEnvironmentBlock
    00012000 : 00012000 - 0000e000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    … [omitted]
    -------------------- Usage SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Pct(Busy)   Usage
    701000 (   7172) : 00.34%   20.69%   : RegionUsageIsVAD
    7de15000 ( 2062420) : 98.35%   00.00%   : RegionUsageFree
    1452000 (   20808) : 00.99%   60.02%   : RegionUsageImage
    300000 (   3072) : 00.15%   08.86%   : RegionUsageStack
    3000 (     12) : 00.00%   00.03%   : RegionUsageTeb
    381000 (   3588) : 00.17%   10.35%   : RegionUsageHeap
    0 (       0) : 00.00%   00.00%   : RegionUsagePageHeap
    1000 (       4) : 00.00%   00.01%   : RegionUsagePeb
    1000 (       4) : 00.00%   00.01%   : RegionUsageProcessParametrs
    2000 (       8) : 00.00%   00.02%   : RegionUsageEnvironmentBlock
    Tot: 7fff0000 (2097088 KB) Busy: 021db000 (34668 KB)
    
    -------------------- Type SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    7de15000 ( 2062420) : 98.35%   : <free>
    1452000 (   20808) : 00.99%   : MEM_IMAGE
    69f000 (   6780) : 00.32%   : MEM_MAPPED
    6ea000 (   7080) : 00.34%   : MEM_PRIVATE
    
    -------------------- State SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    1a58000 (   26976) : 01.29%   : MEM_COMMIT
    7de15000 ( 2062420) : 98.35%   : MEM_FREE
    783000 (   7692) : 00.37%   : MEM_RESERVE
    
    Largest free region: Base 01432000 - Size 707ee000 (1843128 KB)
    

より一般的に VM の断片化が見られるケースは、大きな一時オブジェクトが、OS から新しいマネージド ヒープ セグメントを頻繁に取得したり空のセグメントを開放して OS に戻したりすためにガベージ コレクターを必要とするような場合です。

LOH が VM の断片化の原因かどうかを確認するには、VirtualAllocVirtualFree にブレークポイントを設定して、その呼び出し元を確認します。 たとえば、OS から 8 MB を超える仮想メモリ チャンクを割り当てようとしているオブジェクトを調べる場合、次のようにブレークポイントを設定できます。

bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"

このコマンドは、VirtualAlloc が 8 MB (0x800000) より大きい割り当てサイズで呼び出された場合にのみ、中断してデバッガーを起動し、呼び出し履歴を表示します。

CLR 2.0 では VM Hoarding という機能が追加されました。これは、セグメント (大きなオブジェクト ヒープと小さなオブジェクト ヒープを含む) が頻繁に取得され、解放されるシナリオで役立つ場合があります。 VM Hoarding を指定するには、ホスティング API で、STARTUP_HOARD_GC_VM というスタートアップ フラグを指定します。 空のセグメントを解放して OS に戻す代わりに、CLR はこれらのセグメント上のメモリをデコミットして、スタンバイ リストに含めます (大きすぎるセグメントの場合、CLR はこれを行わないことに注意してください)。CLR は、新しいセグメント要求を満たすために、後でこれらのセグメントを使用します。 次にアプリで新しいセグメントが必要になったときに、CLR は、このスタンバイ リストに十分に大きなセグメントがあれば、それを使用します。

VM Hoarding は、メモリ不足の例外を回避するために、システム上で実行されている優先度の高いアプリである一部のサーバー アプリなど、既に取得されているセグメントに保持する必要があるアプリケーションでも役立ちます。

この機能を使用する際には、アプリケーションを慎重にテストして、アプリケーションのメモリ使用量が十分に安定しているのを確認することを強くお勧めします。