MSDN マガジン > Home > 発行物 > 2008 > June >  CLR 徹底解剖 : 大きなオブジェクト ヒープの秘密
CLR 徹底解剖
大きなオブジェクト ヒープの秘密
Maoni Stephens

CLR のガベージ コレクタ (GC) は、オブジェクトを小さなオブジェクトと大きなオブジェクトに分けて扱います。オブジェクトが大きい場合、オブジェクトに関連付けられた属性のいくつかは、オブジェクトが小さい場合に比べて、より重要な意味を持つようになります。たとえば、最適化 (メモリをヒープ上の他の場所にコピーする処理) のコストが高くなります。今月のコラムでは、大きなオブジェクト ヒープを詳細に見ていきます。大きなオブジェクトの定義、その収集方法、大きなオブジェクトがパフォーマンスに与える影響などについて説明します。

大きなオブジェクト ヒープと GC
Microsoft® .NET Framework 1.1 および 2.0 では、オブジェクトのサイズが 85,000 バイト以上の場合に、大きなオブジェクトと見なしています。この数値は、パフォーマンス チューニングの結果として決定されたものです。オブジェクトの割り当てが要求されたときに、オブジェクトのサイズがこのしきい値条件を満たしている場合は、大きなオブジェクト ヒープ上に割り当てられます。これは厳密には何を意味するのでしょうか。それを理解するために、.NET ガベージ コレクタに関する基本的な事項をいくつか説明します。
多くの方は既にご存知と思われますが、.NET ガベージ コレクタは、世代に基づくコレクタです。オブジェクトは、世代 0、世代 1、世代 2 の 3 つの世代に分かれています。この区分は、適切にチューニングされたアプリケーションにおいて、ほとんどのオブジェクトが世代 0 で終了することが期待できるよう設けられています。たとえば、サーバー アプリケーションでは、各要求に関連付けられた割り当ては、その要求の完了後に終了する必要があります。処理中の割り当て要求は、世代 1 に進み、その世代で終了します。本質的に、世代 1 は、短期間存在するオブジェクトの領域と長期間存在するオブジェクトの領域との間でバッファとして機能します。
世代の観点で見ると、大きなオブジェクトは世代 2 に属します。これは、大きなオブジェクトが世代 2 の収集時にだけ収集されるためです。ある世代の収集時には、それより存在期間の短い世代もすべて収集されます。したがって、たとえば、世代 1 のガベージ コレクションが行われるときには、世代 1 と世代 0 の両方が収集されます。また、世代 2 のガベージ コレクションが行われるときには、ヒープ全体が収集されます。この理由により、世代 2 のガベージ コレクションは、フル ガベージ コレクションとも呼ばれます。このコラムでは、フル ガベージ コレクションの代わりに世代 2 ガベージ コレクションという用語を使用しますが、これらは同じ意味です。
世代は、ガベージ コレクタ ヒープの論理的なビューです。物理的には、オブジェクトはマネージ ヒープ セグメント上に存在します。マネージ ヒープ セグメントは、ガベージ コレクタがマネージ コードに代わって OS から (VirtualAlloc の呼び出しによって) 予約するメモリのチャンクです。CLR が読み込まれると、2 つの初期ヒープ セグメントが割り当てられます。1 つは小さなオブジェクト用、もう 1 つは大きなオブジェクト用で、それぞれ小さなオブジェクト ヒープ (SOH)、大きなオブジェクト ヒープ (LOH) と呼ばれます。
割り当て要求は、これらのマネージ ヒープ セグメントのいずれかにマネージ オブジェクトを配置することで満足されます。オブジェクトが 85,000 バイト未満の場合は SOH セグメントに配置され、それ以上の場合は LOH セグメントに配置されます。セグメントに割り当てるオブジェクトが増加していくと、セグメントは (小さいチャンクへと) コミットされます。
SOH の場合、ガベージ コレクションで終了しなかったオブジェクトは次の世代に昇格し、たとえば、世代 0 で残ったオブジェクトは世代 1 のオブジェクトと見なされます。ただし、最も古い世代で終了しなかったオブジェクトは、引き続き最も古い世代と見なされます。つまり、世代 2 で終了しなかったオブジェクトは世代 2 のオブジェクトのままであり、LOH で終了しなかったオブジェクトは LOH オブジェクト (世代 2 で収集) のままです。ユーザー コードでは、世代 0 (小さなオブジェクト) または LOH (大きなオブジェクト) のオブジェクトだけを割り当てることができます。ガベージ コレクタだけが、世代 1 (世代 0 で残ったオブジェクトを昇格) または世代 2 (世代 1 および世代 2 で残ったオブジェクトを昇格) のオブジェクトを "割り当てる" ことができます。
ガベージ コレクションがトリガされると、ガベージ コレクタは、現在存在しているオブジェクトをひととおりトレースし、それらを最適化します。ただし、LOH の場合は最適化のコストが高いため、CLR チームでは、それらを一掃し、終了したオブジェクトの空きリストを作成することに決めました。これらのオブジェクトは、新しい大きなオブジェクトの割り当て要求を満たすために、後で再利用できます。隣接する複数の終了したオブジェクトは、1 つの空きオブジェクトに結合されます。
ここで 1 つ重要な注意点として、LOH は現在は最適化されませんが、将来は最適化されるようになる可能性があります。したがって、大きなオブジェクトを割り当てたときに、そのオブジェクトが移動されないようにするには、オブジェクトを固定する必要があります。
ここに示した図は、説明のために用意したものです。ヒープで何が起こっているかを単純に示すことが目的なので、ここではごくわずかなオブジェクトしか使用していません。実際には、他にもずっと多くのオブジェクトが存在しています。
図 1 に示したシナリオでは、世代 0 の GC で Obj1 と Obj3 が終了した後に世代 1 を形成し、世代 1 の GC で Obj2 と Obj5 が終了した後に世代 2 を形成しています。
図 1 SOH の割り当てとガベージ コレクション (クリックすると拡大画像が表示されます)
図 2 では、Obj1 と Obj2 を終了させた世代 2 のガベージ コレクションの後、Obj1 と Obj2 が占有していたメモリから 1 つの空き領域を形成し、Obj4 に対する割り当て要求を満足させるためにその空き領域を使用しています。最後のオブジェクト Obj3 の後、セグメントの末尾までの領域は、以降の割り当て要求を満足させるために引き続き使用できます。
図 2 LOH の割り当てとガベージ コレクション (クリックすると拡大画像が表示されます)
大きなオブジェクトの割り当て要求を満足させるだけの十分な空き領域がない場合は、まず追加のセグメントを OS から取得することを試みます。それが失敗した場合は、多少の領域を解放できることを期待して、世代 2 のガベージ コレクションをトリガします。
世代 2 のガベージ コレクション中に、有効なオブジェクトが存在していないセグメントを (VirtualFree を呼び出すことで) 解放して OS に返す処理を行っています。最後の有効なオブジェクトからセグメントの末尾までのメモリは、デコミットされます。また、空き領域は引き続きコミットされていますが、リセットされます。つまり、OS はその領域のデータをディスクに書き戻す必要がありません。図 3 では、セグメントを解放して OS に返し (セグメント 2)、残りのセグメント上で、より多くの領域をデコミットしています。新しい大きなオブジェクトの割り当て要求を満足させるために、セグメント末尾のデコミットされた領域を使用する必要がある場合は、メモリを再度コミットします。
図 3 ガベージ コレクション中に LOH で解放される終了セグメント (クリックすると拡大画像が表示されます)
コミット/デコミットの詳細については、VirtualAlloc に関する MSDN® ドキュメント (go.microsoft.com/fwlink/?LinkId=116041) を参照してください。

大きなオブジェクトが収集されるタイミング
大きなオブジェクトがいつ収集されるかを知るために、まず、一般的にガベージ コレクションが行われるタイミングについて説明します。ガベージ コレクションは、次のいずれかの条件が満たされたときに行われます。
割り当てが世代 0 または大きなオブジェクトのしきい値を超えた場合 ほとんどの GC は、マネージ ヒープ上の割り当てによって発生します (これが最も一般的なケースです)。
System.GC.Collect が呼び出された場合 世代 2 に対して GC.Collect が呼び出されると (GC.Collect に引数を渡さないか、または GC.MaxGeneration を引数として渡した場合)、LOH はマネージ ヒープの残りの部分と共に、直ちに収集されます。
システムのメモリが不足している場合 これは、OS からメモリ使用率が高いという通知を受け取ったときに起こります。世代 2 の GC を行うことが有効であると判断した場合は、それをトリガします。
しきい値は、各世代のプロパティの 1 つです。オブジェクトを世代に割り当てると、その世代のメモリ量が増加し、世代のしきい値に近づきます。そして、ある世代のしきい値を超えたときには、その世代に対してガベージ コレクションがトリガされます。したがって、小さなオブジェクトまたは大きなオブジェクトを割り当てると、世代 0 および LOH のそれぞれのしきい値が消費されます。また、ガベージ コレクタがオブジェクトを世代 1 および世代 2 に割り当てると、世代 1 のしきい値が消費されます。これらのしきい値は、プログラムの実行時に動的に調整できます。

LOH のパフォーマンスへの影響
次に、割り当てのコストを見てみましょう。CLR では、すべての新しいオブジェクトに対して解放時にメモリがクリアされることが保証されています。これは、大きなオブジェクトの割り当てコストが完全に、メモリのクリアによって占められることを意味します (ガベージ コレクションをトリガしない限り)。1 バイトをクリアするのに 2 サイクルかかるとすると、大きなオブジェクトの中で最小のものであっても、クリアするのに 170,000 サイクルかかることになります。ユーザーが割り当てる大きなオブジェクトのサイズは、多少大きすぎる場合も珍しくありません。2 GHz のマシンで 16 MB のオブジェクトをクリアする場合、約 16 ミリ秒かかります。これはかなり大きなコストです。
ここで、コレクションのコストについて見ていきます。前に述べたとおり、LOH と世代 2 は一緒に収集されます。そのどちらかのしきい値を超えたときに、世代 2 のコレクションがトリガされます。LOH のしきい値によって世代 2 がトリガされた場合、世代 2 自体は、ガベージ コレクション後に非常に小さくなるとは限りません。ここで、世代 2 にあまり多くのデータがない場合には、問題はありません。しかし、世代 2 のサイズが大きい場合は、世代 2 のガベージ コレクションが多くの回数トリガされると、パフォーマンス上の問題が生じる可能性があります。多くの大きなオブジェクトがごく一時的に割り当てられ、さらに、大きな SOH がある場合には、ガベージ コレクションの実行に過度な時間を費やす可能性があります。非常に大きなオブジェクトの割り当てと解放を引き続き繰り返すと、割り当てコストが相当に増加することは言うまでもありません。
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 番目よりも大きな比重を占めます。したがって、大きなオブジェクトのプールを割り当てて、一時的なオブジェクトを割り当てる代わりにそれを再利用できれば最善です。そのようなバッファ プールの良い例を Yun Jin 氏がブログで紹介しています (go.microsoft.com/fwlink/?LinkId=115870)。もちろん、バッファのサイズをより大きくすることをお考えでしょう。

LOH のパフォーマンス データの収集
LOH に関連したパフォーマンス データを収集するにはいくつかの方法があります。ここでは、それらを説明する前に、なぜそれが必要かについてお話しましょう。
特定の領域に対するパフォーマンス データの収集を開始する前の状況としては、その領域に着目する理由が既にわかっているか、または既知の他の領域を調べ尽くしても、観測されているパフォーマンス上の問題を説明できる原因が見つからなかったことが考えられます。
詳細については、著者の書いたブログ記事をお読みになることをお勧めします (go.microsoft.com/fwlink/?LinkId=116467 を参照)。ここでは、メモリと CPU の基礎について述べています。さらに、2006 年 11 月号に掲載されたメモリ問題の調査に関する CLR 徹底解剖の記事では、マネージ ヒープに関連する可能性のあるマネージ プロセス内のパフォーマンス問題の診断手順を説明しています (msdn2.microsoft.com/magazine/cc163528 を参照)。
.NET CLR のメモリ パフォーマンス カウンタは、通常、パフォーマンスの問題について調べる最初の手順として有効です。LOH に関連するカウンタは、世代 2 のコレクション回数と、大きなオブジェクト ヒープのサイズです。世代 2 のコレクション回数は、プロセスが開始されてから世代 2 のガベージ コレクションが何回実行されたかを示します。このカウンタは、世代 2 のガベージ コレクション (フル ガベージ コレクションとも呼ばれます) の終了時にインクリメントされます。カウンタには、最後に測定された値が表示されます。
大きなオブジェクト ヒープのサイズは、空き領域を含めた大きなオブジェクト ヒープの現在のサイズ (バイト数) です。このカウンタは、各割り当て時にではなく、ガベージ コレクションの終了時に更新されます。
パフォーマンス カウンタを見る一般的な方法は、パフォーマンス モニタ (PerfMon.exe) で表示することです。図 4 に示すように、対象のプロセスに対して表示するカウンタを追加するには、[カウンタの追加] を使用します。
図 4 パフォーマンス モニタにカウンタを追加する (クリックすると拡大画像が表示されます)
パフォーマンス カウンタのデータは、PerfMon のログ ファイルに保存できます。また、パフォーマンス カウンタに対してプログラムでクエリを実行することもできます。多くのユーザーは、日常的なテスト プロセスの一部として、そのような方法でカウンタのデータを収集しています。カウンタに通常範囲外の値を見つけたときは、他の手段を使用して、調査に役立つ詳細な情報を取得できます。

デバッガを使用する
始める前に、ここで説明するデバッグ コマンドは Windows® デバッガに適用されることに注意してください。実際にどのようなオブジェクトが LOH に存在するかを確認する必要がある場合は、CLR で提供される SoS デバッガ拡張機能を使用できます。これについては、前にも述べた 2006 年 11 月号の記事で説明されています。LOH の分析出力の例を図 5 に示します。
図 5 で太字になっている部分は、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 バイトが空き領域となっています。
0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
generation 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 の合計サイズが 85,000 バイトより少なくなる場合もあります。それはなぜでしょうか。その理由は、ランタイム自体が実際に LOH を使用して、大きなオブジェクトよりも小さいいくつかのオブジェクトを割り当てるためです。
LOH は最適化されないため、LOH が断片化の元なのではないかと疑う人もいます。実際のところ、まず断片化とは何を意味するのかを明確にする必要があります。マネージ オブジェクト間の空き領域の量 (SoS で !dumpheap -type Free を実行したときの表示内容) からわかるように、マネージ ヒープには断片化が生じています。また、仮想メモリ (VM) アドレス領域にも断片化が生じています。これは、MEM_FREE として示されるメモリであり、windbg で各種のデバッガ コマンドを使用して確認できます (go.microsoft.com/fwlink/?LinkId=116470 を参照)。図 6 に、仮想メモリ領域の断片化 (太字の部分) を示します。
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)
前述したように、マネージ ヒープの断片化は割り込み要求に対して発生します。より一般的に仮想メモリの断片化が見られるケースは、一時的な大きなオブジェクトが、OS から新しいマネージ ヒープ セグメントを取得して空のセグメントを OS に解放するために、頻繁にガベージ コレクションを必要とするような場合です。
LOH が VM の断片化を生じさせているかどうかを確認するには、VirtualAlloc と VirtualFree にブレークポイントを設定して、その呼び出し元を確認します。たとえば、OS から 8 MB を超える VM チャンクを割り当てようとしているオブジェクトを調べる場合、次のようにブレークポイントを設定できます。
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 というスタートアップ フラグを指定します (go.microsoft.com/fwlink/?LinkId=116471 を参照)。これを指定すると、空のセグメントを解放して OS に返す代わりに、これらのセグメント上のメモリを単にデコミットして、スタンバイ リストに含めます。スタンバイ リスト上のセグメントは、後で新しいセグメント要求を満たすために使用されます。したがって、次に新しいセグメントが必要になったときには、スタンバイ リスト上に十分に大きなセグメントがあれば、それを使用します。
これは、非常に大きなセグメントに対しては行われないことに注意してください。この機能は、既に取得済みのセグメントを保持する必要のあるアプリケーションに対しても有用です。たとえば、メモリ不足エラーを防ぐために VM 領域の断片化をできるだけ回避する必要があるサーバー アプリケーションなどです。通常、そのようなアプリケーションはマシン上で占有率の高いアプリケーションであるため、この機能を実行できます。この機能を使用する際には、アプリケーションを慎重にテストして、メモリ使用量が十分に安定しているのを確認することを強くお勧めします。
大きなオブジェクトは高コストです。CLR では、すべての新しく割り当てられるオブジェクトに対してメモリのクリアを保証するため、新しく割り当てる大きなオブジェクトのメモリをクリアする必要があり、割り当てコストは高くなります。LOH はヒープの残り部分と共に収集されるため、アプリケーションのパフォーマンスにどのような影響があるかを注意深く分析してください。マネージ ヒープおよび VM 領域の断片化を避けるために、大きなオブジェクトはできる限り再利用することをお勧めします。
最後に、現在の時点で、LOH はコレクション時に最適化されませんが、これは実装の詳細事項であり、そのことに依存した処理は避けてください。したがって、何かが GC によって移動されないよう保証するには、常にそれを固定する必要があります。ここで学んだ LOH の新しい知識を活用して、ヒープを適切に制御してください。

ご意見やご質問は clrinout@microsoft.com まで英語でお送りください。

Maoni Stephens は、マイクロソフトの CLR チームでガベージ コレクタに取り組んでいる上級開発者です。CLR チームに参加する前は、マイクロソフトのオペレーティング システム グループに数年間在籍していました。

Page view tracker