Introduction
このガイドは、Goのガーベジコレクタに関する洞察を提供することによって、上級Goユーザーがアプリケーションコストをよりよく理解するのを助けることを目的としています。また、Goユーザーがこれらの洞察を利用してアプリケーションのリソース利用を改善する方法についてのガイダンスも提供します。ガーベジコレクションに関する知識は前提としていませんが、Goプログラミング言語に対する親しみは前提としています。
Go言語はGo値のストレージを整理する責任を負います。ほとんどの場合、Go開発者はこれらの値がどこに保存されているか、またはその理由を気にする必要はありません。しかし、実際には、これらの値はしばしばコンピュータの物理メモリに保存する必要があり、物理メモリは有限なリソースです。有限であるため、メモリは慎重に管理し、Goプログラムを実行中にメモリが不足しないようにリサイクルする必要があります。Goの実装の仕事は、必要に応じてメモリを割り当て、リサイクルすることです。
自動的にメモリをリサイクルする別の用語はガーベジコレクションです。高レベルでは、ガーベジコレクタ(略してGC)は、アプリケーションに代わってメモリをリサイクルするシステムであり、どの部分のメモリがもはや必要でないかを特定します。Goの標準ツールチェーンは、すべてのアプリケーションに付属するランタイムライブラリを提供し、このランタイムライブラリにはガーベジコレクタが含まれています。
このガイドで説明されているガーベジコレクタの存在は、Go仕様によって保証されているわけではなく、Go値の基盤となるストレージが言語自体によって管理されていることのみが保証されています。この省略は意図的であり、根本的に異なるメモリ管理技術の使用を可能にします。
したがって、このガイドはGoプログラミング言語の特定の実装に関するものであり、他の実装には適用されない可能性があります。具体的には、以下のガイドは標準ツールチェーン(gc
Goコンパイラとツール)に適用されます。gccgoとGollvmは非常に似たGC実装を使用しているため、多くの同じ概念が適用されますが、詳細は異なる場合があります。
さらに、これは生きた文書であり、Goの最新リリースを最もよく反映するように時間とともに変化します。この文書は現在、Go 1.19のガーベジコレクタについて説明しています。
Where Go Values Live
GCに入る前に、まずGCによって管理される必要のないメモリについて話しましょう。
たとえば、ローカル変数に保存されたポインタでないGo値は、Go GCによってまったく管理されない可能性が高く、Goは代わりにそれが作成されたレキシカルスコープに結びついたメモリを割り当てるように手配します。一般的に、これはGCに依存するよりも効率的です。なぜなら、Goコンパイラはそのメモリがいつ解放されるかを事前に決定でき、クリーンアップするためのマシン命令を発行できるからです。通常、この方法でGo値のためにメモリを割り当てることを「スタック割り当て」と呼びます。
この方法でメモリを割り当てることができないGo値は、Goコンパイラがそのライフタイムを決定できないため、ヒープに逃げると言われます。「ヒープ」は、Go値をどこかに配置する必要がある場合のメモリ割り当てのためのキャッチオールと考えることができます。ヒープ上でメモリを割り当てる行為は通常「動的メモリ割り当て」と呼ばれます。なぜなら、コンパイラとランタイムはこのメモリがどのように使用され、いつクリーンアップできるかについて非常に少ない仮定を行うことができるからです。ここでGCが登場します:それは動的メモリ割り当てを特定し、クリーンアップするシステムです。
Go値がヒープに逃げる必要がある理由は多くあります。一つの理由は、そのサイズが動的に決定されることです。たとえば、初期サイズが定数ではなく変数によって決定されるスライスのバックアレイを考えてみてください。ヒープに逃げることはまた推移的である必要があります:Go値への参照がすでに逃げることが決定された別のGo値に書き込まれると、その値も逃げなければなりません。
Go値が逃げるかどうかは、それが使用されるコンテキストとGoコンパイラの逃避分析アルゴリズムの関数です。値がいつ逃げるかを正確に列挙しようとするのは脆弱で困難です:アルゴリズム自体はかなり洗練されており、Goのリリース間で変化します。どの値が逃げ、どの値が逃げないかを特定する方法の詳細については、ヒープ割り当ての排除に関するセクションを参照してください。
Tracing Garbage Collection
ガーベジコレクションは、自動的にメモリをリサイクルする多くの異なる方法を指す場合があります。たとえば、参照カウントです。この文書の文脈では、ガーベジコレクションはトレースガーベジコレクションを指し、ポインタを推移的にたどることによって使用中のいわゆるライブオブジェクトを特定します。
これらの用語をより厳密に定義しましょう。
- オブジェクト—オブジェクトは、1つ以上のGo値を含む動的に割り当てられたメモリの一部です。
ポインタ—オブジェクト内の任意の値を参照するメモリアドレスです。これは自然に
*T
の形式のGo値を含みますが、組み込みのGo値の一部も含まれます。文字列、スライス、チャネル、マップ、インターフェース値はすべて、GCがトレースしなければならないメモリアドレスを含んでいます。オブジェクトと他のオブジェクトへのポインタはオブジェクトグラフを形成します。ライブメモリを特定するために、GCはプログラムのルートからオブジェクトグラフをたどります。ルートは、プログラムによって確実に使用中のオブジェクトを特定するポインタです。ルートの2つの例はローカル変数とグローバル変数です。オブジェクトグラフをたどるプロセスはスキャンと呼ばれます。
この基本的なアルゴリズムはすべてのトレースGCに共通です。トレースGCが異なるのは、ライブメモリが発見された後に何をするかです。GoのGCはマークスイープ技術を使用しており、進捗を追跡するために、GCは出会った値をライブとしてマークします。トレースが完了すると、GCはヒープ内のすべてのメモリをたどり、マークされていないすべてのメモリを割り当て可能にします。このプロセスはスイーピングと呼ばれます。
あなたが知っているかもしれない別の代替技術は、オブジェクトを新しいメモリの部分に移動し、後でアプリケーションのすべてのポインタを更新するために使用される転送ポインタを残すことです。このようにオブジェクトを移動するGCを移動GCと呼びますが、Goは非移動GCです。
The GC cycle
GoのGCはマークスイープGCであるため、広く2つのフェーズで動作します:マークフェーズとスイープフェーズ。この声明は一見自明に思えるかもしれませんが、重要な洞察を含んでいます:すべてのメモリがトレースされるまでメモリを再割り当てすることはできません。なぜなら、まだスキャンされていないポインタがオブジェクトを生かしている可能性があるからです。その結果、スイーピングの行為はマークの行為から完全に分離されなければなりません。さらに、GCはGC関連の作業がないときはまったくアクティブでない場合もあります。GCは、スイーピング、オフ、マークという3つのフェーズを継続的に回転させる、いわゆるGCサイクルを持っています。この文書の目的のために、GCサイクルはスイーピングから始まり、オフになり、次にマークされると考えてください。
次の数セクションでは、GCのコストに対する直感を構築し、ユーザーが自分の利益のためにGCパラメータを調整するのを助けることに焦点を当てます。
Understanding costs
GCは本質的に、さらに複雑なシステムに基づいて構築された複雑なソフトウェアです。GCを理解し、その動作を調整しようとすると、詳細に埋もれてしまうのは簡単です。このセクションは、Go GCのコストを考えるためのフレームワークを提供することを目的としています。
まず、3つの単純な公理に基づいたGCコストのモデルを考えてみましょう。
- 1. GCは2つのリソースのみを含みます:CPU時間と物理メモリ。
- 2. GCのメモリコストは、ライブヒープメモリ、マークフェーズの前に割り当てられた新しいヒープメモリ、および前のコストに比例している場合でも比較的小さいメタデータのためのスペースで構成されます。
注:ライブヒープメモリは、前のGCサイクルによってライブであると判断されたメモリであり、新しいヒープメモリは現在のサイクルで割り当てられたメモリであり、終了時にライブであるかどうかは不明です。 3. GCのCPUコストは、サイクルごとの固定コストと、ライブヒープのサイズに比例してスケールする限界コストとしてモデル化されます。
注:漸近的に言えば、スイーピングはマークおよびスキャンよりも悪化します。なぜなら、ライブでない(すなわち「死んでいる」)メモリを含む全ヒープのサイズに比例した作業を行わなければならないからです。しかし、現在の実装では、スイーピングはマークおよびスキャンよりもはるかに速いため、その関連コストはこの議論では無視できます。このモデルはシンプルですが効果的です:GCの主要なコストを正確に分類します。しかし、このモデルはこれらのコストの大きさや相互作用については何も言及していません。それをモデル化するために、以下の状況を考えてみましょう。これは今後定常状態と呼ばれます。
アプリケーションが新しいメモリを割り当てる速度(バイト/秒)が一定です。
注:この割り当て速度は、この新しいメモリがライブであるかどうかとは完全に別のものであることを理解することが重要です。すべてがライブでない可能性もあれば、すべてがライブである可能性もあり、一部がライブである可能性もあります。(これに加えて、古いヒープメモリも死ぬ可能性があるため、そのメモリがライブであれば、ライブヒープサイズが増加するとは限りません。)
これをより具体的に言うと、各リクエストに対して2 MiBの合計ヒープメモリを割り当てるWebサービスを考えてみましょう。リクエスト中、最大でその2 MiBのうち512 KiBがライブのままで、サービスがリクエストの処理を終えると、すべてのメモリが死にます。さて、単純さのために、各リクエストの処理に約1秒かかると仮定します。次に、1秒あたり100リクエストの安定したストリームが、200 MiB/sの割り当て速度と50 MiBのピークライブヒープをもたらします。アプリケーションのオブジェクトグラフは、毎回ほぼ同じように見えます(オブジェクトは同様のサイズで、ポインタの数はほぼ一定で、グラフの最大深さはほぼ一定です)。
GCの限界コストが一定であると考える別の方法です。注:定常状態は作為的に見えるかもしれませんが、一定の負荷の下でのアプリケーションの動作を代表しています。もちろん、アプリケーションが実行中に負荷が変化することもありますが、通常、アプリケーションの動作は、これらの定常状態がいくつかの一時的な動作の間に連結されているように見えます。*
注:定常状態はライブヒープについて何の仮定もしていません。次のGCサイクルごとに増加する可能性もあれば、減少する可能性も、同じままである可能性もあります。しかし、これらの状況すべてを説明するのは面倒であまり説明的ではないため、ガイドはライブヒープが一定のままである例に焦点を当てます。GOGCセクションでは、非定常なライブヒープシナリオについてもう少し詳しく探ります。*
定常状態では、ライブヒープサイズが一定である限り、GCサイクルは、GCが同じ時間が経過した後に実行される限り、コストモデルで同一に見えます。それは、固定の時間内に、アプリケーションによる固定の割り当て率で、固定の新しいヒープメモリが割り当てられるからです。したがって、ライブヒープサイズが一定であり、その新しいヒープメモリが一定であれば、メモリ使用は常に同じになります。そして、ライブヒープが同じサイズであるため、限界GC CPUコストも同じになり、固定コストは一定の間隔で発生します。
さて、GCが実行されるポイントを後にシフトすると考えてみましょう。そうすると、より多くのメモリが割り当てられますが、各GCサイクルは依然として同じCPUコストを負担します。しかし、他の固定ウィンドウの時間内に完了するGCサイクルが少なくなり、全体のCPUコストが低下します。逆に、GCが早く実行を開始することを決定した場合、割り当てられるメモリは少なくなり、CPUコストはより頻繁に発生します。
この状況は、GCが実行される頻度によって制御される、CPU時間とメモリの間の基本的なトレードオフを表しています。言い換えれば、トレードオフは完全にGC頻度によって定義されます。
もう1つの詳細を定義する必要があります。それは、GCがいつ開始するかを決定することです。これは、特定の定常状態におけるGC頻度を直接設定し、トレードオフを定義します。Goでは、GCがいつ開始するかを決定することが、ユーザーが制御できる主なパラメータです。
GOGC
高レベルでは、GOGCはGC CPUとメモリの間のトレードオフを決定します。
GOGCは、各GCサイクルの後にターゲットヒープサイズを決定することによって機能します。これは次のサイクルの合計ヒープサイズのターゲット値です。GCの目標は、合計ヒープサイズがターゲットヒープサイズを超える前にコレクションサイクルを完了することです。合計ヒープサイズは、前のサイクルの終了時のライブヒープサイズに、前のサイクル以降にアプリケーションによって割り当てられた新しいヒープメモリを加えたものとして定義されます。一方、ターゲットヒープメモリは次のように定義されます:
ターゲットヒープメモリ = ライブヒープ + (ライブヒープ + GCルート) GOGC / 100
たとえば、ライブヒープサイズが8 MiB、goroutineスタックが1 MiB、グローバル変数のポインタが1 MiBのGoプログラムを考えてみましょう。次に、GOGC値が100の場合、次のGCが実行される前に割り当てられる新しいメモリの量は10 MiB、すなわち10 MiBの作業の100%で、合計ヒープフットプリントは18 MiBになります。GOGC値が50の場合、50%、すなわち5 MiBになります。GOGC値が200の場合、200%、すなわち20 MiBになります。
注:GOGCはGo 1.18以降、ルートセットを含むようになりました。それ以前は、ライブヒープのみをカウントしていました。しばしば、goroutineスタックのメモリ量は非常に少なく、ライブヒープサイズがGC作業の他のすべてのソースを支配しますが、数十万のgoroutineを持つプログラムでは、GCが不適切な判断を下していました。
ヒープターゲットはGC頻度を制御します:ターゲットが大きいほど、GCは次のマークフェーズを開始するまで長く待つことができ、その逆もまた然りです。正確な式は推定に役立ちますが、GOGCをその基本的な目的、すなわちGC CPUとメモリのトレードオフのポイントを選択するパラメータとして考えるのが最善です。重要なポイントは、GOGCを2倍にするとヒープメモリのオーバーヘッドが2倍になり、
GC CPUコストが大体半分になるということです。(その理由についての完全な説明は、付録を参照してください。)注:ターゲットヒープサイズはあくまでターゲットであり、GCサイクルがそのターゲットで正確に終了しない理由はいくつかあります。1つには、十分に大きなヒープ割り当てがターゲットを超える可能性があります。しかし、他の理由は、このガイドがこれまで使用してきたGCモデルを超えるGC実装に現れます。詳細については、レイテンシセクションを参照してくださいが、完全な詳細は追加リソースにあります。
GOGCは、
GOGC
環境変数(すべてのGoプログラムが認識する)またはSetGCPercent
APIをruntime/debug
パッケージで設定することによって構成できます。GOGCは、GCを完全にオフにするためにも使用できます(メモリ制限が適用されない場合)。
GOGC=off
を設定するか、SetGCPercent(-1)
を呼び出すことで実現できます。この設定は、GCがトリガーされる前の新しいメモリの量が無制限であるため、概念的にはGOGCを無限大に設定することと同等です。これまでに議論したすべてをよりよく理解するために、以下のインタラクティブなビジュアライゼーションを試してみてください。これは、前述のGCコストモデルに基づいて構築されています。このビジュアライゼーションは、GC作業が完了するのに10秒のCPU時間を要するプログラムの実行を描写しています。最初の1秒で初期化ステップを実行し(ライブヒープを増やす)、その後安定状態に入ります。アプリケーションは合計200 MiBを割り当て、同時に20 MiBがライブです。関連するGC作業がライブヒープからのみ来ると仮定し、(非現実的に)アプリケーションが追加のメモリを使用しないと仮定します。
スライダーを使用してGOGCの値を調整し、アプリケーションが総所要時間とGCオーバーヘッドの観点でどのように反応するかを確認してください。各GCサイクルは、新しいヒープがゼロに落ちるまで終了します。新しいヒープがゼロに落ちるまでにかかる時間は、サイクルNのマークフェーズとサイクルN+1のスイープフェーズの合計時間です。このビジュアライゼーション(およびこのガイド内のすべてのビジュアライゼーション)は、GCが実行されている間アプリケーションが一時停止していると仮定しているため、GC CPUコストは新しいヒープメモリがゼロに落ちるのにかかる時間によって完全に表されます。これは視覚化を簡単にするためだけのものであり、同じ直感が依然として適用されます。X軸は常にプログラムのCPU時間の全体の持続時間を表示するようにシフトします。GCによって使用される追加のCPU時間が全体の持続時間を増加させることに注意してください。
GOGC \u003cinput type=”range” min=”0” max=”10” step=”0.005” value=”6.64” id=”graph1-gogc”/\
100GCは常にいくらかのCPUとピークメモリのオーバーヘッドを伴うことに注意してください。GOGCが増加すると、CPUオーバーヘッドは減少しますが、ピークメモリはライブヒープサイズに比例して増加します。GOGCが減少すると、ピークメモリ要件は追加のCPUオーバーヘッドの代償で減少します。
注:グラフはプログラムを完了するためのCPU時間を表示し、壁時計時間ではありません。プログラムが1 CPUで実行され、そのリソースを完全に活用している場合、これらは同等です。実際のプログラムは、マルチコアシステムで実行され、常にCPUを100%活用しているわけではありません。この場合、GCの壁時間への影響は低くなります。*
注:Go GCには最小合計ヒープサイズが4 MiBあるため、GOGC設定ターゲットがそれを下回る場合は、切り上げられます。このビジュアライゼーションはこの詳細を反映しています。*
もう1つの例を挙げると、もう少し動的で現実的です。再び、アプリケーションはGCなしで完了するのに10 CPU秒かかりますが、定常状態の割り当て率が途中で劇的に増加し、最初のフェーズでライブヒープサイズが少し変動します。この例は、ライブヒープサイズが実際に変化しているときの定常状態がどのように見えるか、そしてより高い割り当て率がより頻繁なGCサイクルにつながるかを示しています。
GOGC \u003cinput type=”range” min=”0” max=”10” step=”0.005” value=”6.64” id=”graph2-gogc”/\
100
メモリ制限
Go 1.19 まで、GOGC は GC の動作を変更するために使用できる唯一のパラメータでした。これはトレードオフを設定する方法としては非常に効果的ですが、利用可能なメモリが有限であることを考慮していません。ライブヒープサイズに一時的なスパイクが発生した場合に何が起こるかを考えてみてください:GC はそのライブヒープサイズに比例した合計ヒープサイズを選択するため、GOGC は ピーク ライブヒープサイズに合わせて設定する必要があります。通常の場合、より高い GOGC 値がより良いトレードオフを提供するにもかかわらずです。
以下の視覚化は、この一時的なヒープスパイクの状況を示しています。
GOGC
100
もし例のワークロードが 60 MiB 以上のメモリが利用可能なコンテナで実行されている場合、GOGC は 100 を超えて増加することはできません。これは、他の GC サイクルがその追加メモリを利用するための利用可能なメモリを持っているにもかかわらずです。さらに、一部のアプリケーションでは、これらの一時的なピークはまれで予測が難しく、時折避けられない、そして潜在的にコストのかかるメモリ不足の状態を引き起こす可能性があります。
そのため、1.19 リリースでは、Go はランタイムメモリ制限を設定するサポートを追加しました。メモリ制限は、すべての Go プログラムが認識する GOMEMLIMIT
環境変数を介して、または runtime/debug
パッケージで利用可能な SetMemoryLimit
関数を通じて構成できます。
このメモリ制限は、Go ランタイムが使用できるメモリの合計量に最大値を設定します。含まれるメモリの具体的なセットは、runtime.MemStats
の式として定義されます。
Sys
-
HeapReleased
または、runtime/metrics
パッケージの観点から同等に、
/memory/classes/total:bytes
-
/memory/classes/heap/released:bytes
Go GC は使用するヒープメモリの量を明示的に制御しているため、このメモリ制限と Go ランタイムが使用する他のメモリに基づいて合計ヒープサイズを設定します。
以下の視覚化は、GOGC セクションの同じ単相定常状態ワークロードを示していますが、今回は Go ランタイムからの追加の 10 MiB のオーバーヘッドと調整可能なメモリ制限があります。GOGC とメモリ制限の両方を移動させて、何が起こるかを見てみてください。
GOGC
100
メモリ制限
100.0 MiB
メモリ制限が GOGC によって決定されたピークメモリ(GOGC が 100 の場合は 42 MiB)を下回ると、GC はピークメモリを制限内に保つためにより頻繁に実行されることに注意してください。
以前の一時的なヒープスパイクの例に戻ると、メモリ制限を設定し、GOGC を上げることで、両方の利点を得ることができます:メモリ制限の違反がなく、より良いリソースの経済性です。以下のインタラクティブな視覚化を試してみてください。
GOGC
100
メモリ制限
100.0 MiB
いくつかの GOGC とメモリ制限の値では、ピークメモリ使用量がメモリ制限で止まりますが、プログラムの残りの実行は GOGC によって設定された合計ヒープサイズのルールに従うことに注意してください。
この観察は、もう一つの興味深い詳細につながります:GOGC がオフに設定されている場合でも、メモリ制限は依然として尊重されます!実際、この特定の構成は、メモリ制限を維持するために必要な最小 GC 周波数を設定するため、リソース経済の最大化を表します。この場合、プログラムのすべての実行はヒープサイズがメモリ制限に達するまで上昇します。
さて、メモリ制限は明らかに強力なツールですが、メモリ制限の使用にはコストが伴います、そして確かに GOGC の有用性を無効にするものではありません。
ライブヒープが大きくなりすぎて合計メモリ使用量がメモリ制限に近づくと何が起こるかを考えてみてください。上記の定常状態の視覚化で、GOGC をオフにしてから、メモリ制限をさらに下げていくと何が起こるかを見てみてください。アプリケーションがかかる合計時間が、GC が不可能なメモリ制限を維持するために常に実行されるため、無制限に増加し始めることに注意してください。
この状況は、プログラムが常に GC サイクルのために合理的な進行を妨げられることを指し、スラッシングと呼ばれます。これは特に危険で、プログラムを効果的に停止させます。さらに悪いことに、これは GOGC で回避しようとしていたのとまったく同じ状況で発生する可能性があります:十分に大きな一時的なヒープスパイクがプログラムを無限に停止させる可能性があります!一時的なヒープスパイクの視覚化でメモリ制限を減らして(約 30 MiB またはそれ以下)、最悪の動作が特にヒープスパイクから始まることに注意してください。
多くの場合、無限の停止はメモリ不足の状態よりも悪いものであり、メモリ不足は通常、はるかに早い失敗をもたらします。
この理由から、メモリ制限は ソフト と定義されています。Go ランタイムは、すべての状況でこのメモリ制限を維持することを保証しません。合理的な努力を約束するだけです。このメモリ制限の緩和は、スラッシング動作を回避するために重要です。なぜなら、GC に逃げ道を与えるからです:GC に過剰な時間を費やさせないために、メモリ使用量が制限を超えることを許可します。
内部でこれがどのように機能するかというと、GC はある時間ウィンドウ内で使用できる CPU 時間の上限を設定します(非常に短い一時的な CPU 使用量のスパイクに対していくつかのヒステリシスがあります)。この制限は現在、約 50% に設定されており、2 * GOMAXPROCS
CPU 秒のウィンドウです。GC CPU 時間を制限する結果は、GC の作業が遅延することです。一方、Go プログラムはメモリ制限を超えて新しいヒープメモリを割り当て続けることができます。
50% の GC CPU 制限の直感は、十分な利用可能なメモリを持つプログラムに対する最悪の影響に基づいています。メモリ制限の誤設定の場合、設定が低すぎると、プログラムは最大で 2 倍遅くなります。なぜなら、GC は CPU 時間の 50% 以上を奪うことができないからです。
推奨使用法
メモリ制限は強力なツールであり、Go ランタイムは誤用から最悪の動作を軽減するための手段を講じていますが、慎重に使用することが依然として重要です。以下は、メモリ制限が最も有用で適用可能な場所、そしてそれがより多くの害を引き起こす可能性がある場所に関するアドバイスのコレクションです。
- 必ず、Go プログラムの実行環境が完全に制御下にあり、Go プログラムがリソースのセットにアクセスできる唯一のプログラムである場合(すなわち、コンテナメモリ制限のようなメモリ予約の一種)、メモリ制限を活用してください。良い例は、固定量の利用可能なメモリを持つコンテナにウェブサービスをデプロイすることです。この場合、良い目安は、Go ランタイムが認識していないメモリソースを考慮して、追加の 5-10% の余裕を残すことです。
- 自由に、変化する条件に適応するためにリアルタイムでメモリ制限を調整してください。良い例は、C ライブラリが一時的にかなり多くのメモリを使用する必要がある cgo プログラムです。
- GOGC をオフにしてメモリ制限を設定しないでください。Go プログラムが他のプログラムと限られたメモリを共有する可能性があり、これらのプログラムが一般的に Go プログラムから切り離されている場合です。代わりに、メモリ制限を維持してください。これは望ましくない一時的な動作を抑えるのに役立つかもしれませんが、平均的なケースのために GOGC を小さく、合理的な値に設定してください。共存プログラムのためにメモリを「予約」しようとするのは魅力的かもしれませんが、プログラムが完全に同期されていない限り(例えば、Go プログラムがサブプロセスを呼び出し、その呼び出し先が実行される間ブロックする場合)、結果は信頼性が低くなります。Go プログラムが必要ないときにメモリを少なく使うことを許可することで、全体的により信頼性の高い結果が得られます。このアドバイスは、1 台のマシン上で実行されているコンテナのメモリ制限の合計が、マシンに実際に利用可能な物理メモリを超えるオーバーコミット状況にも適用されます。
- 制御できない実行環境にデプロイする際にメモリ制限を使用しないでください。特に、プログラムのメモリ使用量がその入力に比例する場合です。良い例は、CLI ツールやデスクトップアプリケーションです。プログラムにメモリ制限を組み込むことは、どのような入力が与えられるか、またはシステム上でどれだけのメモリが利用可能かが不明な場合、混乱を招くクラッシュやパフォーマンスの低下を引き起こす可能性があります。さらに、上級のエンドユーザーは、望む場合は常にメモリ制限を設定できます。
- プログラムがすでに環境のメモリ制限に近い場合にメモリ制限を設定して、メモリ不足の状態を回避しないでください。これは、メモリ不足のリスクを深刻なアプリケーションの遅延のリスクに置き換えることになり、これはしばしば好ましくないトレードオフです。Go がスラッシングを軽減するために行う努力があっても、そうです。この場合、環境のメモリ制限を増やす(その後、メモリ制限を設定する可能性がある)か、GOGC を減少させる方がはるかに効果的です(これはスラッシング軽減よりもはるかにクリーンなトレードオフを提供します)。
レイテンシ
この文書の視覚化は、GCが実行されている間、アプリケーションが一時停止しているとモデル化されています。このように動作するGCの実装も存在し、それらは「ストップ・ザ・ワールド」GCと呼ばれています。
しかし、GoのGCは完全にストップ・ザ・ワールドではなく、アプリケーションと並行してほとんどの作業を行います。これは主にアプリケーションのレイテンシを減少させるためです。具体的には、単一の計算ユニット(例:ウェブリクエスト)のエンドツーエンドの所要時間です。これまでのところ、この文書は主にアプリケーションのスループット(例:1秒あたりに処理されるウェブリクエスト)を考慮してきました。GCサイクルセクションの各例は、実行中のプログラムの総CPU時間に焦点を当てています。しかし、ウェブサービスにとっては、そのような所要時間はあまり意味がありません。スループットはウェブサービスにとって依然として重要ですが(すなわち、1秒あたりのクエリ)、しばしば各リクエストのレイテンシがさらに重要です。
レイテンシの観点から、ストップ・ザ・ワールドGCは、マークフェーズとスイープフェーズの両方を実行するのにかなりの時間を要する可能性があり、その間、アプリケーション、そしてウェブサービスの文脈では、進行中のリクエストはさらなる進行ができません。代わりに、GoのGCは、グローバルなアプリケーションの一時停止の長さをヒープのサイズに比例させることを避け、コアトレースアルゴリズムはアプリケーションがアクティブに実行されている間に実行されます。(一時停止は、アルゴリズム的にはGOMAXPROCSに強く比例しますが、最も一般的にはゴルーチンを停止するのにかかる時間に支配されます。)並行して収集することはコストがかからないわけではありません:実際には、同等のストップ・ザ・ワールドのガベージコレクタよりもスループットが低い設計につながることがよくあります。しかし、レイテンシが低いことは必ずしもスループットが低いことを意味しないことに注意することが重要です。また、Goのガベージコレクタのパフォーマンスは、レイテンシとスループットの両方で時間とともに着実に改善されています。
Goの現在のGCの並行性は、これまでにこの文書で議論されたことを無効にするものではありません:これまでのすべての声明はこの設計選択に依存していません。GCの頻度は、GCがスループットのためにCPU時間とメモリの間でトレードオフを行う主な方法であり、実際にはレイテンシに対してもこの役割を担っています。これは、GCのコストのほとんどがマークフェーズがアクティブな間に発生するためです。
重要なポイントは、GCの頻度を減少させることがレイテンシの
改善につながる可能性があるということです。これは、GOGCの増加やメモリ制限の変更など、GCの頻度を減少させることだけでなく、最適化ガイドで説明されている最適化にも適用されます。
しかし、レイテンシはスループットよりも理解が難しいことが多いです。なぜなら、それはプログラムの瞬間的な実行の産物であり、単なるコストの集約ではないからです。その結果、レイテンシとGCの頻度の関係はあまり直接的ではありません。以下は、さらに掘り下げたい人のためのレイテンシの可能な原因のリストです。
- 1. GCがマークフェーズとスイープフェーズの間を移行する際の短いストップ・ザ・ワールドの一時停止、
- 2. マークフェーズ中にGCがCPUリソースの25%を使用するためのスケジューリング遅延、
- 3. 高い割り当て率に応じてGCを支援するユーザーゴルーチン、
- 4. GCがマークフェーズ中に追加の作業を必要とするポインタの書き込み、
5. ルートをスキャンするために実行中のゴルーチンを一時停止する必要があります。
これらのレイテンシの原因は、実行トレースで確認できますが、追加の作業を必要とするポインタの書き込みを除きます。
追加リソース
上記の情報は正確ですが、Go GCの設計におけるコストとトレードオフを完全に理解するための詳細が不足しています。詳細については、以下の追加リソースを参照してください。
- GCハンドブック—ガベージコレクタ設計に関する優れた一般的なリソースとリファレンス。
- TCMalloc—Goのメモリアロケータが基づいているC/C++メモリアロケータTCMallocの設計文書。
- Go 1.5 GC発表—Go 1.5の並行GCを発表するブログ投稿で、アルゴリズムについてより詳細に説明しています。
- Getting to Go—2018年までのGoのGC設計の進化に関する詳細なプレゼンテーション。
- Go 1.5並行GCのペーシング—並行マークフェーズを開始するタイミングを決定するための設計文書。
- スマートなスカベンジング—Goランタイムがオペレーティングシステムにメモリを返す方法を見直すための設計文書。
- スケーラブルページアロケータ—Goランタイムがオペレーティングシステムから取得したメモリを管理する方法を見直すための設計文書。
- GCペーサーの再設計(Go 1.18)—並行マークフェーズを開始するタイミングを決定するアルゴリズムを見直すための設計文書。
- ソフトメモリ制限(Go 1.19)—ソフトメモリ制限の設計文書。
仮想メモリに関する注意
このガイドは主にGCの物理メモリ使用に焦点を当てていますが、定期的に浮上する質問は、それが正確に何を意味し、仮想メモリ(通常はtop
のように「VSS」として表示される)とどのように比較されるかです。
物理メモリは、ほとんどのコンピュータの実際の物理RAMチップに収容されているメモリです。仮想メモリは、プログラムを互いに隔離するためにオペレーティングシステムが提供する物理メモリの抽象化です。また、プログラムが物理アドレスに全くマッピングされていない仮想アドレス空間を予約することも通常は許可されています。
仮想メモリはオペレーティングシステムによって維持されるマッピングに過ぎないため、
物理メモリにマッピングされていない大きな仮想メモリの予約を行うことは通常非常に安価です。
Goランタイムは、いくつかの方法でこの仮想メモリのコストの見方に依存しています:
- Goランタイムは、マッピングされた仮想メモリを削除することは決してありません。代わりに、オペレーティングシステムが提供する特別な操作を使用して、特定の仮想メモリ範囲に関連する物理メモリリソースを明示的に解放します。この技術は、メモリ制限を管理し、Goランタイムがもはや必要としないメモリをオペレーティングシステムに返すために明示的に使用されます。Goランタイムは、もはや必要としないメモリをバックグラウンドで継続的に解放します。詳細については、追加リソースを参照してください。
- 32ビットプラットフォームでは、Goランタイムはヒープのために128 MiBから512 MiBのアドレス空間を事前に予約して、断片化の問題を制限します。
Goランタイムは、いくつかの内部データ構造の実装において大きな仮想メモリアドレス空間の予約を使用します。64ビットプラットフォームでは、これらは通常約700 MiBの最小仮想メモリフットプリントを持ちます。32ビットプラットフォームでは、そのフットプリントは無視できるものです。
その結果、
top
のような仮想メモリメトリックは、Goプログラムのメモリフットプリントを理解する上であまり役に立ちません。代わりに、物理メモリ使用をより直接的に反映する「RSS」や同様の測定に焦点を当ててください。
最適化ガイド
コストの特定
GoアプリケーションがGCとどのように相互作用するかを最適化しようとする前に、まずGCがそもそも主要なコストであることを特定することが重要です。
Goエコシステムは、コストを特定し、Goアプリケーションを最適化するためのいくつかのツールを提供しています。これらのツールの簡単な概要については、診断ガイドを参照してください。ここでは、GCの影響と動作を理解するために適用するのに合理的な順序で、これらのツールのサブセットに焦点を当てます。
- 1. CPUプロファイル
最初に始めるのに良い場所は、CPUプロファイリングです。CPUプロファイリングは、CPU時間がどこに費やされているかの概要を提供しますが、訓練を受けていない目には、特定のアプリケーションにおけるGCの役割の大きさを特定するのが難しい場合があります。幸いなことに、GCがどのようにフィットするかを理解することは、主にruntime
パッケージ内の異なる関数が何を意味するかを知ることに帰着します。以下は、CPUプロファイルを解釈するためのこれらの関数の有用なサブセットです。
注意:以下にリストされている関数はリーフ関数ではないため、pprof
ツールがtop
コマンドで提供するデフォルトには表示されない場合があります。代わりに、top -cum
コマンドを使用するか、これらの関数に直接list
コマンドを使用し、累積パーセント列に焦点を当ててください。 runtime.gcBgMarkWorker
: バックグラウンドマークワーカーゴルーチンへのエントリポイント。ここで費やされる時間は、GCの頻度とオブジェクトグラフの複雑さおよびサイズに比例します。これは、アプリケーションがマークとスキャンに費やす時間の基準を表します。
注意:これらのゴルーチン内では、runtime.gcDrainMarkWorkerDedicated
、runtime.gcDrainMarkWorkerFractional
、およびruntime.gcDrainMarkWorkerIdle
への呼び出しが見つかります。これはワーカータイプを示します。ほとんどアイドル状態のGoアプリケーションでは、Go GCはその仕事をより早く終わらせるために追加の(アイドル)CPUリソースを使用します。これはruntime.gcDrainMarkWorkerIdle
シンボルで示されます。その結果、ここでの時間は、Go GCが自由だと考えるCPUサンプルの大部分を表す可能性があります。アプリケーションがよりアクティブになると、アイドルワーカーのCPU時間は減少します。これが起こる一般的な理由の1つは、アプリケーションが完全に1つのゴルーチンで実行されているが、GOMAXPROCS
が>1である場合です。runtime.mallocgc
: ヒープメモリのためのメモリアロケータへのエントリポイント。ここで費やされる累積時間が大きい(>15%)場合、通常は多くのメモリが割り当てられていることを示します。runtime.gcAssistAlloc
: ゴルーチンがスキャンとマークを支援するために時間を譲渡するために入る関数。ここで費やされる累積時間が大きい(>5%)場合、アプリケーションがGCに対してどれだけ速く割り当てているかに関して、アプリケーションがGCを上回っている可能性が高いことを示します。これはGCからの影響が特に高いことを示し、アプリケーションがマークとスキャンに費やす時間を表します。これはruntime.mallocgc
コールツリーに含まれているため、それも膨らませます。- 2. 実行トレース
CPUプロファイルは、時間が集約的にどこに費やされているかを特定するのに優れていますが、パフォーマンスコストがより微妙でまれ、特にレイテンシに関連するものを示すのにはあまり役立ちません。一方、実行トレースは、Goプログラムの実行の短いウィンドウに対して豊かで深いビューを提供します。これらは、Go GCに関連するさまざまなイベントを含み、特定の実行パスが直接観察でき、アプリケーションがGo GCとどのように相互作用するかを示します。追跡されたすべてのGCイベントは、トレースビューワーで便利にそのようにラベル付けされています。
実行トレースの取得方法については、runtime/trace
パッケージのドキュメントを参照してください。 - 3. GCトレース
他のすべてが失敗した場合、Go GCはGCの動作に関するより深い洞察を提供するいくつかの異なる特定のトレースを提供します。これらのトレースは常にSTDERRに直接印刷され、GCサイクルごとに1行で、すべてのGoプログラムが認識するGODEBUG
環境変数を介して構成されます。これらは、GCの実装の詳細にある程度の親しみが必要なため、主にGo GC自体のデバッグに役立ちますが、それでもGCの動作をよりよく理解するために時折役立つことがあります。
コアGCトレースはGODEBUG=gctrace=1
を設定することで有効にされます。このトレースによって生成される出力は、runtime
パッケージのドキュメントの環境変数セクションに記載されています。
「ペーサートレース」と呼ばれる補足的なGCトレースは、さらに深い洞察を提供し、GODEBUG=gcpacertrace=1
を設定することで有効にされます。この出力を解釈するには、GCの「ペーサー」(追加リソースを参照)を理解する必要がありますが、これはこのガイドの範囲外です。
ヒープ割り当ての排除
GCからのコストを削減する1つの方法は、最初からGCが管理する値を減らすことです。以下に説明する技術は、GOGCセクションが示すように、Goプログラムの割り当て率がGCの頻度に大きな影響を与えるため、パフォーマンスの大きな改善をもたらす可能性があります。
ヒーププロファイリング
GCが重要なコストの源であることを特定した後、ヒープ割り当ての排除の次のステップは、ほとんどの割り当てがどこから来ているのかを見つけることです。この目的のために、メモリプロファイル(実際にはヒープメモリプロファイル)は非常に便利です。これらを開始する方法については、ドキュメントを確認してください。
メモリプロファイルは、プログラム内のヒープ割り当てがどこから来ているのかを説明し、それらを割り当てられた時点でのスタックトレースによって特定します。各メモリプロファイルは、メモリを4つの方法で分解できます。
inuse_objects
—生存しているオブジェクトの数を分解します。inuse_space
—バイト単位で使用されているメモリによって生存しているオブジェクトを分解します。alloc_objects
—Goプログラムが実行を開始して以来割り当てられたオブジェクトの数を分解します。alloc_space
—Goプログラムが実行を開始して以来割り当てられたメモリの総量を分解します。ヒープメモリのこれらの異なるビューを切り替えるには、
-sample_index
フラグをpprof
ツールに渡すか、ツールが対話的に使用されるときにsample_index
オプションを使用します。- 注意:メモリプロファイルはデフォルトでヒープオブジェクトのサブセットのみをサンプリングするため、すべてのヒープ割り当てに関する情報は含まれません。しかし、これはホットスポットを見つけるには十分です。サンプリングレートを変更するには、
runtime.MemProfileRate
を参照してください。 *
GCコストを削減する目的では、
alloc_space
が通常最も便利なビューであり、割り当て率に直接対応します。このビューは、最も利益をもたらす割り当てホットスポットを示します。- 注意:メモリプロファイルはデフォルトでヒープオブジェクトのサブセットのみをサンプリングするため、すべてのヒープ割り当てに関する情報は含まれません。しかし、これはホットスポットを見つけるには十分です。サンプリングレートを変更するには、
エスケープ分析
ヒーププロファイルの助けを借りて候補のヒープ割り当てサイトが特定された後、それらをどのように排除できますか?鍵は、Goコンパイラのエスケープ分析を活用して、Goコンパイラがこのメモリのための代替の、より効率的なストレージを見つけることです。たとえば、ゴルーチンのスタック内です。幸いなことに、Goコンパイラは、Go値がヒープにエスケープするかどうかを説明する能力を持っています。その知識を持つことで、分析の結果を変更するためにソースコードを再編成することが重要になります(これはしばしば最も難しい部分ですが、このガイドの範囲外です)。
Goコンパイラのエスケープ分析からの情報にアクセスする最も簡単な方法は、Goコンパイラが適用した最適化や適用しなかった最適化をテキスト形式で説明するデバッグフラグを使用することです。これには、値がエスケープするかどうかも含まれます。次のコマンドを試してください。ここで、[package]
はGoパッケージのパスです。
$ go build -gcflags=-m=3 [package]
この情報は、VS Codeでオーバーレイとして視覚化することもできます。このオーバーレイは、VS Code Goプラグインの設定で構成および有効化されます。
- 1.
ui.codelenses
設定をgc_details
を含めるように設定します。 2.
ui.diagnostic.annotations
をescape
を含めるように設定して、エスケープ分析のオーバーレイを有効にします。。最後に、Goコンパイラはこの情報を機械可読(JSON)形式で提供し、追加のカスタムツールを構築するために使用できます。これに関する詳細は、ソースGoコードのドキュメントを参照してください。
実装特有の最適化
Go GCは生存メモリの人口統計に敏感です。なぜなら、オブジェクトとポインタの複雑なグラフは、並行性を制限し、GCに対してより多くの作業を生成するからです。その結果、GCには特定の一般的な構造に対するいくつかの最適化が含まれています。パフォーマンス最適化に最も直接的に役立つものは以下に示されています。
- 注意:以下の最適化を適用すると、意図が不明瞭になり、Goのリリース間で維持できない可能性があるため、これらの最適化は最も重要な場所でのみ適用することをお勧めします。そのような場所は、コストの特定セクションにリストされているツールを使用して特定できます。 *
- ポインタなしの値は他の値から分離されます。
その結果、厳密に必要でないデータ構造からポインタを排除することが有利な場合があります。これにより、GCがプログラムに与えるキャッシュ圧力が減少します。その結果、ポインタ値の代わりにインデックスに依存するデータ構造は、型が不十分であるにもかかわらず、より良いパフォーマンスを発揮する可能性があります。これは、オブジェクトグラフが複雑であり、GCがマークとスキャンに多くの時間を費やしていることが明らかな場合にのみ行う価値があります。 GCは値の最後のポインタでスキャンを停止します。
その結果、構造体型の値のポインタフィールドを値の先頭にグループ化することが有利な場合があります。これは、アプリケーションがマークとスキャンに多くの時間を費やしていることが明らかな場合にのみ行う価値があります。(理論的には、コンパイラがこれを自動的に行うことができますが、まだ実装されておらず、構造体フィールドはソースコードに書かれた通りに配置されます。)さらに、GCは見るほぼすべてのポインタと相互作用する必要があるため、たとえばスライスへのインデックスを使用することは、GCコストを削減するのに役立ちます。
Linuxの透過的巨大ページ(THP)
プログラムがメモリにアクセスすると、CPUは使用する仮想メモリアドレスを物理メモリアドレスに変換する必要があります。これにより、アクセスしようとしているデータを参照します。これを行うために、CPUは「ページテーブル」と呼ばれるデータ構造を参照します。これは、オペレーティングシステムによって管理される仮想メモリから物理メモリへのマッピングを表します。ページテーブルの各エントリは、ページと呼ばれる物理メモリの不可分なブロックを表します。
透過的巨大ページ(THP)は、連続した仮想メモリ領域をサポートする物理メモリのページを透過的に置き換えるLinuxの機能です。大きなブロックを使用することで、同じメモリ領域を表すために必要なページテーブルエントリが少なくなり、ページテーブルのルックアップ時間が改善されます。ただし、大きなブロックは、システムによって巨大ページの小さな部分しか使用されない場合に、より多くの無駄を意味します。
本番環境でGoプログラムを実行する際に、Linuxで透過的巨大ページを有効にすると、追加のメモリ使用のコストでスループットとレイテンシが改善される可能性があります。小さなヒープを持つアプリケーションはTHPの恩恵を受けない傾向があり、かなりの量の追加メモリを使用する可能性があります(最大50%)。ただし、大きなヒープ(1 GiB以上)を持つアプリケーションは、あまり追加のメモリオーバーヘッド(1〜2%未満)なしでかなりの恩恵を受ける傾向があります。いずれの場合でも、THP設定を把握しておくことは役立ちますし、実験を行うことをお勧めします。
Linux環境で透過的巨大ページを有効または無効にするには、/sys/kernel/mm/transparent_hugepage/enabled
を変更します。詳細については、公式Linux管理ガイドを参照してください。Linuxの本番環境で透過的巨大ページを有効にすることを選択した場合、Goプログラムに対して以下の追加設定をお勧めします。
/sys/kernel/mm/transparent_hugepage/defrag
をdefer
またはdefer+madvise
に設定します。
この設定は、Linuxカーネルが通常のページを巨大ページにどれだけ積極的に統合するかを制御します。defer
は、カーネルに巨大ページを遅延させてバックグラウンドで統合するように指示します。より積極的な設定は、メモリ制約のあるシステムでスタールを引き起こす可能性があり、アプリケーションのレイテンシを損なうことがよくあります。defer+madvise
はdefer
のようなもので、システム上の他のアプリケーションが巨大ページを明示的に要求し、パフォーマンスのためにそれを必要とする場合に、よりフレンドリーです。/sys/kernel/mm/transparent_hugepage/khugepaged/max_ptes_none
を0
に設定します。
この設定は、Linuxカーネルデーモンが巨大ページを割り当てようとする際に、どれだけの追加ページを割り当てることができるかを制御します。デフォルト設定は最大限に攻撃的であり、しばしばGoランタイムがOSにメモリを返すために行う作業を元に戻す可能性があります。Go 1.21以前は、Goランタイムはデフォルト設定の悪影響を軽減しようとしましたが、それにはCPUコストが伴いました。Go 1.21以降およびLinux 6.2以降、Goランタイムはもはや巨大ページの状態を変更しません。
Go 1.21.1以降にアップグレードした際にメモリ使用量が増加した場合は、この設定を適用してみてください。問題が解決する可能性があります。追加の回避策として、Prctl
関数をPR_SET_THP_DISABLE
で呼び出してプロセスレベルで巨大ページを無効にするか、GODEBUG=disablethp=1
(Go 1.21.6およびGo 1.22で追加予定)を設定してヒープメモリの巨大ページを無効にすることができます。GODEBUG
設定は将来のリリースで削除される可能性があることに注意してください。
付録
GOGCに関する追加の注意
GOGCセクションでは、GOGCを2倍にするとヒープメモリのオーバーヘッドが2倍になり、GCのCPUコストが半分になると主張しました。なぜそうなるのかを数学的に分解してみましょう。
まず、ヒープターゲットは総ヒープサイズのターゲットを設定します。このターゲットは、主に新しいヒープメモリに影響を与えます。なぜなら、生存ヒープはアプリケーションの基本だからです。
ターゲットヒープメモリ = ライブヒープ + (ライブヒープ + GCルート) GOGC / 100
総ヒープメモリ = 生存ヒープ + 新しいヒープメモリ *
⇒ *
新しいヒープメモリ = (生存ヒープ + GCルート) GOGC / 100
これから、GOGCを2倍にすると、アプリケーションが各サイクルで割り当てる新しいヒープメモリの量も2倍になることがわかります。これはヒープメモリのオーバーヘッドを捉えます。生存ヒープ + GCルートは、GCがスキャンする必要があるメモリの量の近似です。
次に、GCのCPUコストを見てみましょう。総コストは、サイクルごとのコストと、ある期間TにわたるGCの頻度に分解できます。
総GC CPUコスト = (サイクルごとのGC CPUコスト) (GCの頻度) T *
サイクルごとのGC CPUコストは、GCモデルから導出できます:
サイクルごとのGC CPUコスト = (生存ヒープ + GCルート) (バイトあたりのコスト) + 固定コスト
ここでは、スイープフェーズのコストは無視されています。マークとスキャンのコストが支配的だからです。
定常状態は、一定の割り当て率と一定のバイトあたりのコストによって定義されるため、定常状態ではこの新しいヒープメモリからGCの頻度を導出できます:
GCの頻度 = (割り当て率) / (新しいヒープメモリ) = (割り当て率) / ((生存ヒープ + GCルート) GOGC / 100)
これをまとめると、総コストの完全な方程式が得られます:
総GC CPUコスト = (割り当て率) / ((生存ヒープ + GCルート) GOGC / 100) ((生存ヒープ + GCルート) (バイトあたりのコスト) + 固定コスト) T *
十分に大きなヒープ(ほとんどのケースを表す)では、GCサイクルの限界コストが固定コストを支配します。これにより、総GC CPUコストの式が大幅に簡略化されます。
総GC CPUコスト = (割り当て率) / (GOGC / 100) (バイトあたりのコスト) T *
この簡略化された式から、GOGCを2倍にすると、総GC CPUコストが半分になることがわかります。(このガイドの視覚化は固定コストをシミュレートしているため、GOGCが2倍になると報告されるGC CPUオーバーヘッドは正確に半分にはなりません。)さらに、GCのCPUコストは主に割り当て率とメモリをスキャンするためのバイトあたりのコストによって決まります。これらのコストを具体的に削減する方法については、最適化ガイドを参照してください。
注意:生存ヒープのサイズと、GCが実際にスキャンする必要があるメモリの量との間には不一致があります。同じサイズの生存ヒープでも、異なる構造を持つとCPUコストが異なり、同じメモリコストが異なるトレードオフをもたらします。これが、ヒープの構造が定常状態の定義の一部である理由です。ヒープターゲットは、GCがスキャンする必要があるメモリのより近い近似として、スキャン可能な生存ヒープのみを含むべきだと主張できますが、これは非常に小さなスキャン可能な生存ヒープがある場合に、ヒープがそれ以外に大きいときに劣化した動作を引き起こします。*