次の方法で共有


ガベージ コレクターの基本とパフォーマンスのヒント

 

リコ マリアーニ
Microsoft Corporation

2003 年 4 月

概要: .NET ガベージ コレクターは、メモリを適切に使用し、長期的な断片化の問題を発生させず、高速割り当てサービスを提供します。 この記事では、ガベージ コレクターのしくみについて説明し、ガベージ コレクション環境で発生する可能性のあるパフォーマンスの問題について説明します。 (10ページ印刷)

適用対象:
   Microsoft® .NET Framework

内容

はじめに
簡略化されたモデル
ガベージの収集
パフォーマンス
終了
まとめ

はじめに

ガベージ コレクターを適切に使用する方法と、ガベージ コレクション環境で実行するときに発生する可能性があるパフォーマンスの問題を理解するには、ガベージ コレクターのしくみと、それらの内部動作が実行中のプログラムに与える影響の基本を理解することが重要です。

この記事は 2 つの部分に分かれています。まず、簡略化されたモデルを使用して共通言語ランタイム (CLR) のガベージ コレクターの性質について一般的に説明し、その構造のパフォーマンスへの影響について説明します。

簡略化されたモデル

説明のために、マネージド ヒープの次の簡略化されたモデルを検討してください。 これは実際に実装される ものではありません

図 1. マネージド ヒープの簡略化されたモデル

この簡略化されたモデルの規則は次のとおりです。

  • ガベージ コレクション可能なすべてのオブジェクトは、連続する 1 つのアドレス空間から割り当てられます。
  • ヒープは 、ヒープのごく一部だけを調べることでほとんどのガベージを排除できるように、 世代 に分割されます (後で詳しく説明します)。
  • 世代内のオブジェクトはすべてほぼ同じ年齢です。
  • 番号の大きい世代は、古いオブジェクトを含むヒープの領域を示します。これらのオブジェクトは、安定している可能性がはるかに高くなります。
  • 最も古いオブジェクトは最も低いアドレスに、新しいオブジェクトは増加するアドレスで作成されます。 (上記の図 1 では、アドレスが増加しています)。
  • 新しいオブジェクトの割り当てポインターは、メモリの使用済み (割り当て済み) 領域と未使用 (空き領域) 領域の境界をマークします。
  • ヒープは、デッド オブジェクトを削除し、ライブ オブジェクトをヒープのアドレスの低い端にスライドさせることで、定期的に圧縮されます。 これにより、新しいオブジェクトが作成されるダイアグラムの下部にある未使用領域が拡張されます。
  • メモリ内のオブジェクトの順序は、適切な局所性のために、作成された順序のままです。
  • ヒープ内のオブジェクト間にギャップはありません。
  • 一部の空き領域のみが コミットされます。 必要に応じて、 予約済み アドレス範囲のオペレーティング システムから** より多くのメモリが取得されます。

ガベージの収集

理解する最も簡単な種類のコレクションは、完全に圧縮されたガベージ コレクションであるため、まずそのことを説明します。

完全なコレクション

完全なコレクションでは、プログラムの実行を停止し、GC ヒープ内のすべての ルート を見つける必要があります。 これらのルートはさまざまな形式で提供されますが、特にヒープを指すスタック変数とグローバル変数です。 ルートから始めて、すべてのオブジェクトにアクセスし、移動するたびにオブジェクトをマークするすべての訪問オブジェクトに含まれるすべてのオブジェクト ポインターに従います。 このようにして、コレクターは 到達可能 なすべてのオブジェクトまたは ライブ オブジェクトを検出します。 もう 1 つのオブジェクト ( 到達できない オブジェクト) は、現在 非難されています

図 2. GC ヒープへのルート

到達不能なオブジェクトが特定されたら、後で使用するためにその領域を再利用する必要があります。この時点でコレクターの目的は、 ライブ オブジェクトをスライドアップし、無駄な領域を排除することです。 実行を停止すると、コレクターはそれらのオブジェクトをすべて移動し、すべてのポインターが新しい場所に適切にリンクされるようにすべてのポインターを修正しても安全です。 残っているオブジェクトは、次の世代の番号に昇格され (つまり、世代の境界が更新されます)、実行を再開できます。

部分コレクション

残念ながら、完全なガベージ コレクションはコストがかかりすぎて毎回実行できないため、コレクションに世代を含める方法について説明するのが適切です。

まず、非常に幸運な架空のケースを考えてみましょう。 最近の完全なコレクションがあり、ヒープがうまく圧縮されたとします。 プログラムの実行が再開され、一部の割り当てが行われます。 実際には、多数の割り当てが行われ、十分な割り当ての後、メモリ管理システムは収集する時間を決定します。

ここで私たちは幸運を得る場所です。 前回のコレクション以降に実行されていたすべての時点で、古いオブジェクトに対してまったく書き込まれず、新しく割り当てられたのは ジェネレーション 0 (gen0) だけであり、オブジェクトが書き込まれたとします。 これが起こると、ガベージ コレクション プロセスを大幅に簡素化できるため、大きな状況になります。

通常の完全収集の代わりに、すべての古いオブジェクト (gen1、 gen2) がまだ存在しているか、少なくともそれらのオブジェクトを見る価値がない十分な生きていると想定できます。 さらに、それらのいずれも書かれていたので(私たちがどれほど幸運であるかを思い出してください)、古いオブジェクトから新しいオブジェクトへのポインタはありません。 だから私たちができることは、いつもと同じようにすべての根を見て、古いオブジェクトを指している根があれば、それらの根を無視するだけです。 他のルート (gen0 を指すルート) については、すべてのポインターに従って通常どおり進めます。 古いオブジェクトに戻る内部ポインターが見つかるたびに、無視されます。

そのプロセスが完了すると、古い世代のオブジェクトを訪問することなく、Gen0 のすべてのライブ オブジェクトを訪問することになります。 その後、gen0 オブジェクトは通常どおり非難され、そのメモリ領域だけをスライドアップし、古いオブジェクトは妨げずに残します。

今、これは私たちにとって本当に素晴らしい状況です。デッドスペースのほとんどは、チャーンが多い若いオブジェクトにある可能性が高いことがわかります。 多くのクラスは、戻り値、一時文字列、列挙子や whatnot などの他のユーティリティ クラスの並べ替えのために一時オブジェクトを作成します。 Gen0 だけを見ると、ごく少数のオブジェクトだけを見ることで、デッドスペースの大部分を簡単に取り戻す方法が得られます。

残念ながら、少なくとも一部の古いオブジェクトは新しいオブジェクトを指すために変更される必要があるため、この方法を使用するのに十分な幸運はありません。 その場合は、無視するだけでは十分ではありません。

世代を書き込みバリアと連携させる

上記のアルゴリズムを実際に機能させるには、どの古いオブジェクトが変更されたかを知る必要があります。 ダーティ オブジェクトの場所を記憶するために、カード テーブルと呼ばれるデータ構造を使用し、このデータ構造を維持するために、マネージド コード コンパイラによっていわゆる書き込みバリアが生成されます。これら 2 つの概念は、世代ベースのガベージ コレクションの成功の中心です。

カード テーブルはさまざまな方法で実装できますが、これを考える最も簡単な方法はビットの配列です。 カード テーブル内の各ビットは、ヒープ上のメモリの範囲を表します。たとえば、128 バイトとします。 プログラムがオブジェクトを何らかのアドレスに書き込むたびに、書き込みバリア コードは、書き込まれた 128 バイトのチャンクを計算し、対応するビットを カード テーブルに設定する必要があります。

このメカニズムを使用して、コレクション アルゴリズムを再検討できるようになりました。 gen0 ガベージ コレクションを実行している場合は、前述のようにアルゴリズムを使用して古い世代へのポインターを無視できますが、完了したら、カード テーブルで変更済みとしてマークされたチャンク上にあるすべてのオブジェクト内のすべてのオブジェクト ポインターも見つける必要があります。 私たちはそれらを根のように扱う必要があります。 これらのポインターも考慮すると、gen0 オブジェクトだけを正しく収集します。

この方法は、カードテーブルが常に満杯になった場合にはまったく役に立たなくなりますが、実際には古い世代からのポインターの中で実際に変更されるポインターは比較的少ないため、このアプローチでは大幅な節約が行われます。

パフォーマンス

作業方法の基本的なモデルが用意できたので、問題が発生して遅くなる可能性のあるものを考えてみましょう。 これは、コレクターから最高のパフォーマンスを得るために避けるべきことの種類を私たちに良いアイデアを与えます。

割り当てが多すぎます

これは本当に間違って行くことができる最も基本的なものです。 ガベージ コレクターを使用して新しいメモリを割り当てるのは非常に高速です。 上の図 2 でわかるように、通常は、割り当てポインターを移動して"割り当て済み" 側の新しいオブジェクトの領域を作成する必要があります。それ以上の速度は得られません。 ただし、遅かれ早かれガベージ コレクションが発生し、すべての処理が等しい場合は、それより早く発生する方が適しています。 そのため、新しいオブジェクトを作成するときは、1 つだけの作成が高速であっても、それが本当に必要であり、適切であることを確認する必要があります。

これは明らかなアドバイスのように聞こえるかもしれませんが、実際には、記述する小さなコード行が多くの割り当てをトリガーする可能性があることを非常に簡単に忘れてしまいます。 たとえば、何らかの比較関数を記述していて、オブジェクトにキーワード フィールドがあり、指定された順序でキーワードで比較で大文字と小文字を区別しないようにするとします。 ここでは、最初のキーワード (keyword)が非常に短い可能性があるため、キーワード文字列全体を比較することはできません。 String.Split を使用してキーワード (keyword)文字列を分割し、大文字と小文字を区別しない通常の比較を使用して各部分を比較したくなるでしょう。 正しく聞こえますか?

まあ、それはそのようにやっていることが判明したので、それほど良い考えではありません。 String.Split は文字列の配列を作成します。これは、キーワード文字列に最初に含まれるすべてのキーワード (keyword)に対して 1 つの新しい文字列オブジェクトと、配列の 1 つ以上のオブジェクトを意味します。 Yikes! これをある種のコンテキストで行っている場合、それは 多くの 比較であり、2 行の比較関数によって非常に多くの一時オブジェクトが作成されるようになりました。 突然、ガベージコレクターはあなたの代わりに非常に懸命に働くつもりです、そして最も賢いコレクションスキームでさえ、クリーンするゴミがたくさんあります。 割り当てをまったく必要としない比較関数を記述することをお勧めします。

Too-Large割り当て

malloc()などの従来のアロケーターを使用する場合、プログラマは割り当てのコストが比較的高いことがわかっているため、malloc() の呼び出しをできるだけ少なくするコードを記述することがよくあります。 これは、チャンクで割り当てる方法に変換されます。多くの場合、必要なオブジェクトを投機的に割り当てることで、総割り当てを減らすことができます。 事前に割り当てられたオブジェクトは、何らかの種類のプールから手動で管理され、効果的に一種の高速カスタム アロケーターを作成します。

マネージド の世界では、いくつかの理由から、このプラクティスの説得力ははるかに低くなります。

まず、割り当てを行うコストは非常に低く、従来のアロケーターのように無料のブロックを検索する必要はありません。発生する必要があるのは、移動する必要がある空き領域と割り当てられた領域の境界です。 割り当てのコストが低いということは、プールする最も説得力のある理由が存在しないことを意味します。

次に、事前割り当てを選択した場合は、当然ながら、即時のニーズに必要なよりも多くの割り当てを行うことになります。これにより、不要であった可能性のある追加のガベージ コレクションが強制される可能性があります。

最後に、ガベージ コレクターは、手動でリサイクルしているオブジェクトの領域を再利用できません。グローバルな観点からは、現在使用されていないオブジェクトを含むすべてのオブジェクトがまだ 存在するためです。 大量のメモリが無駄にされ、すぐに使用できるが、使用中のオブジェクトは手元に残らない場合があります。

これは、事前割り当ては常に悪い考えであるとは言えません。 たとえば、特定のオブジェクトを強制的に一緒に割り当てることを望むかもしれませんが、アンマネージド コードの場合よりも一般的な戦略として説得力が低い可能性があります。

ポインターが多すぎます

ポインターの大きなメッシュであるデータ構造を作成すると、2 つの問題が発生します。 まず、オブジェクトの書き込みが多数あり (下の図 3 を参照)、次に、そのデータ構造を収集するときに、ガベージ コレクターにすべてのポインターを従わせ、必要に応じてすべてを変更します。 データ構造が有効期間が長く、あまり変更されない場合、コレクターは、完全なコレクションが (gen2 レベルで) 発生したときに、これらすべてのポインターにアクセスするだけで済みます。 しかし、トランザクションの処理の一環として、一時的にこのような構造を作成すると、はるかに頻繁にコストが支払われます。

図 3: ポインターが重いデータ構造

ポインターの負荷が高いデータ構造には、ガベージ コレクション時間とは関係なく、他の問題が発生する可能性があります。 ここでも、前に説明したように、オブジェクトが作成されると、割り当ての順序で連続して割り当てられます。 これは、たとえばファイルから情報を復元することによって、大規模で複雑なデータ構造を作成する場合に便利です。 異なるデータ型がある場合でも、すべてのオブジェクトはメモリ内で閉じられます。これにより、プロセッサはそれらのオブジェクトにすばやくアクセスできます。 ただし、時間が経過し、データ構造が変更されると、新しいオブジェクトを古いオブジェクトにアタッチする必要がある可能性があります。 これらの新しいオブジェクトは後で作成されるため、メモリ内の元のオブジェクトの近くにはありません。 ガベージ コレクターがメモリを最適化しても、オブジェクトはメモリ内でシャッフルされず、単に "スライド" して無駄な領域を削除するだけです。 結果として得られる障害は、時間の経過と同時に非常に悪くなる可能性があるため、データ構造全体の新しいコピーを作成し、すべてうまく詰め込み、古い無秩序な障害をコレクターから当然のように非難させる可能性があります。

ルートが多すぎます

ガベージ コレクターは、コレクション時にルートに特別な処理を施す必要があります。常に列挙し、順番に正式に考慮する必要があります。 gen0 コレクションは、考慮すべき根の洪水を与えない範囲でのみ高速にすることができます。 ローカル変数の間に多くのオブジェクト ポインターを持つ深く再帰的な関数を作成すると、結果は実際には非常にコストがかかる可能性があります。 このコストは、これらのすべての根を考慮する必要があるだけでなく、それらの根があまり長く生き続けている可能性がある gen0 オブジェクトの数が非常に多い場合にも発生します (以下で説明します)。

オブジェクトの書き込みが多すぎます

以前の説明を再度参照して、マネージド プログラムがオブジェクト ポインターを変更するたびに、書き込みバリア コードもトリガーされることを覚えておいてください。 これは、次の 2 つの理由で不適切な場合があります。

まず、書き込みバリアのコストは、最初に行おうとしていたコストに相当する可能性があります。 たとえば、ある種の列挙子クラスで単純な操作を行っている場合は、すべてのステップでメイン コレクションから列挙子にキー ポインターの一部を移動する必要がある場合があります。 これは、書き込みバリアのためにこれらのポインターをコピーするコストを実質的に 2 倍にし、列挙子のループごとに 1 回以上行う必要があるため、実際には避けたいことがあります。

2 つ目は、実際に古いオブジェクトに書き込んでいる場合、書き込みバリアのトリガーが 2 倍悪い点です。 古いオブジェクトを変更すると、次のガベージ コレクションが発生したときに、実質的にチェックに追加のルートが作成されます (前述)。 古いオブジェクトを十分に変更した場合、最年少の世代のみを収集することに関連する通常の速度の向上を効果的に否定できます。

これらの 2 つの理由は、もちろん、どのような種類のプログラムでも書き込みを行わない通常の理由によって補完されます。 すべてのものが等しいので、プロセッサのキャッシュをより経済的に使用できるように、メモリの少ない部分 (実際には読み取りまたは書き込み) に触れる方が良いです。

ほぼ長い寿命のオブジェクトが多すぎます

最後に、ジェネレーションガベージコレクターの最大の落とし穴は、多くのオブジェクトの作成です。これは、正確に一時的なものではなく、正確に長命でもありません。 これらのオブジェクトは、gen0 コレクション (最も安い) によってクリーンアップされないため、多くの問題を引き起こす可能性があります。これは、まだ必要になるためです。また、まだ使用されているため、Gen1 コレクションを存続させることさえありますが、その後すぐに死んでしまう可能性があります。

問題は、オブジェクトが gen2 レベルに到着すると、完全なコレクションだけがそれを取り除き、ガベージ コレクターが合理的に可能な限りそれらを遅延させるのに十分なコストがかかります。 したがって、多くの"ほぼ長命"のオブジェクトを持つ結果は、あなたの世代2 は、潜在的に驚くべき速度で成長する傾向があるということです。それはあなたが望むほど速くクリーンアップされないかもしれません、そしてそれがクリーンアップされるとき、それは確かにあなたが望んだよりもはるかにコストがかかります。

このようなオブジェクトを回避するために、最適な防御ラインは次のようになります。

  1. 使用している一時領域の量に注意して、できるだけ少ないオブジェクトを割り当てます。
  2. 有効期間の長いオブジェクト サイズを最小限に抑えます。
  3. スタック上のオブジェクト ポインターはできるだけ少なくします (これらはルートです)。

これらの操作を行うと、Gen0 コレクションの効果が高くなり、Gen1 は非常に速く成長しません。 その結果、gen1 コレクションの実行頻度が低くなり、Gen1 コレクションを実行することが慎重になると、中程度の有効期間オブジェクトは既に停止し、その時点で安価に復旧できます。

物事が順調に進んでいる場合は、安定した状態の操作中に、gen2 のサイズはまったく増加しません!

終了

簡単な割り当てモデルでいくつかのトピックを取り上げたので、もう 1 つの重要な現象について説明できるように、少し複雑にしたいと思います。これはファイナライザーとファイナライズのコストです。 簡単に言うと、ファイナライザーは任意のクラスに存在できます。これは、ガベージ コレクターが、そのオブジェクトのメモリを回収する前に、それ以外の場合はデッド オブジェクトを呼び出すことを約束するオプションのメンバーです。 C# では、~Class 構文を使用してファイナライザーを指定します。

最終処理がコレクションに与える影響

ガベージ コレクターが最初に、それ以外の場合は死んでいるオブジェクトを検出したが、まだ終了する必要がある場合は、その時点でそのオブジェクトの領域を再利用しようとする試みを破棄する必要があります。 オブジェクトは、ファイナライズが必要なオブジェクトのリストに追加されます。さらに、コレクターは、ファイナライズが完了するまでオブジェクト内のすべてのポインターが有効なままであることを確認する必要があります。 これは基本的に、ファイナライズを必要とするすべてのオブジェクトがコレクターの観点から見た一時的なルート オブジェクトのようなものであると言うのと同じです。

コレクションが完了すると、適切な名前の ファイナライズ スレッドによって、ファイナライズ が必要なオブジェクトの一覧が表示され、ファイナライザーが呼び出されます。 これが行われると、オブジェクトは再び死んで、自然に通常の方法で収集されます。

最終処理とパフォーマンス

このファイナライズの基本的な理解により、いくつかの非常に重要なことを既に推測できます。

最初に、ファイナライズが必要なオブジェクトは、それ以外のオブジェクトよりも長く存続します。 実際、彼らはずっと長 生きることができます。 たとえば、gen2 にあるオブジェクトを最終処理する必要があるとします。 ファイナライズはスケジュールされますが、オブジェクトは Gen 2 のままであるため 次の Gen2 コレクションが発生するまで再収集されません。 それは確かに非常に長い時間である可能性があり、実際には、物事がうまくいっている場合、gen2コレクションはコストがかかるため、非常にまれに発生させたいからです。 ファイナライズが必要な古いオブジェクトは、何百もの Gen0 コレクションがない場合、その領域が再利用される前に数十を待つ必要がある場合があります。

2 つ目は、ファイナライズが必要なオブジェクトが、付随的損害を引き起こします。 内部オブジェクト ポインターは有効なままである必要があるため、ファイナライズを直接必要とするオブジェクトはメモリ内に残るだけでなく、オブジェクトが直接間接的に参照するすべてのものもメモリ内に残ります。 オブジェクトの巨大なツリーが、ファイナライズを必要とする単一のオブジェクトによって固定されている場合、ツリー全体が長く残る可能性があります。 したがって、ファイナライザーを慎重に使用し、可能な限り少ない内部オブジェクト ポインターを持つオブジェクトに配置することが重要です。 先ほど示したツリーの例では、ファイナライズが必要なリソースを別のオブジェクトに移動し、そのオブジェクトへの参照をツリーのルートに保持することで、問題を簡単に回避できます。 そのささやかな変更では、1つのオブジェクト(うまくいけば素敵な小さなオブジェクト)だけが残り、ファイナライズコストは最小限に抑えられます。

最後に、ファイナライザー スレッドのファイナライズが必要なオブジェクトの作成作業です。 ファイナライズ プロセスが複雑なプロセスの場合、1 つだけのファイナライザー スレッドは、これらの手順の実行に多くの時間を費やします。これにより、作業のバックログが発生する可能性があるため、ファイナライズを待機するオブジェクトが増える可能性があります。 したがって、ファイナライザーが可能な限り少ない作業を行うことは非常に重要です。 また、ファイナライズ中にすべてのオブジェクト ポインターは有効なままですが、これらのポインターが既にファイナライズ済みのオブジェクトにつながる可能性があるため、役に立たない可能性があることに注意してください。 一般に、ポインターが有効であっても、ファイナライズ コードでオブジェクト ポインターをフォローすることは避けるのが最も安全です。 安全で短いファイナライズ コード パスが最適です。

IDisposable と Dispose

多くの場合、 IDisposable インターフェイスを実装することで、そのコストを回避するために常に最終処理する必要があるオブジェクトの場合があります。 このインターフェイスは、有効期間がプログラマによく知られているリソースを再利用するための代替方法を提供します。これは実際にはかなり発生します。 もちろん、オブジェクトが単にメモリのみを使用し、ファイナライズや破棄をまったく必要としない場合は、まだ良いです。ただし、ファイナライズが必要で、オブジェクトの明示的な管理が簡単で実用的な場合が多い場合は、 IDisposable インターフェイスを実装することは、ファイナライズ コストを回避または少なくとも削減するための優れた方法です。

C# のパーランスでは、このパターンは非常に便利なパターンです。

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

Dispose を手動で呼び出すと、コレクターがオブジェクトを維持し、ファイナライザーを呼び出す必要がなくなります。

まとめ

.NET ガベージ コレクターは、メモリを適切に使用し、長期的な断片化の問題を発生させず、高速割り当てサービスを提供しますが、最適なパフォーマンスよりもはるかに少ない処理を行うことができます。

アロケーターを最大限に活用するには、次のようなプラクティスを検討する必要があります。

  • 特定のデータ構造で同時に使用するすべてのメモリ (または可能な限り) を割り当てます。
  • 複雑さの少ないペナルティで回避できる一時的な割り当てを削除します。
  • オブジェクト ポインターが書き込まれる回数 (特に古いオブジェクトに対する書き込み) を最小限に抑えます。
  • データ構造内のポインターの密度を減らします。
  • ファイナライザーを限定的に使用し、可能な限り "リーフ" オブジェクトでのみ使用します。 これを支援するために必要に応じてオブジェクトを中断します。

Allocation Profiler などのツールを使用して主要なデータ構造を確認し、メモリ使用量プロファイルを実行する定期的な方法は、メモリ使用量を効果的に維持し、ガベージ コレクターが最適に動作するようにするための長い道のりになります。