スタック実装の詳細-パート2

画像 意味のあるタイプについての以前の投稿のコンテキストで、なぜ意味のあるタイプがスタックに配置されているのに参照型が配置されないのかという理由で、何人かから私に尋ねられました。



要するに、「できるから」。 そしてなぜなら スタック「安い」場合、可能な限りスタックに配置します。



長い答えは...長いです。



まず、一般的な用語で、ヒープと呼ぶものとスタックと呼ぶものを定義します。 まずは束。



CLRヒープ*は、非常に詳細なエンジニアリングの驚異です。 以下の説明は、ヒープが実際にどのように機能するかではありませんが、一般的なアイデアを得るには十分です。



考え方は、参照型のインスタンスに割り当てられたメモリの大きなブロックがあるということです。 これらのメモリブロックには「穴」がある場合があります。これは、一部のメモリブロックが「生きている」オブジェクトによって占有されており、一部は新しいオブジェクトの下で使用できるためです。 理想的なケースでは、占有されているすべてのメモリを連続したブロックの1か所に配置し、残りのアドレススペースはすべて解放します。



このような状況では、メモリの新しい部分を割り当てるときに、占有メモリの上限に必要な量だけポインタを移動し、以前に解放されたメモリの一部を「使い果たし」ます。 この新しく予約されたメモリは、新しく作成されたオブジェクトに使用されます。 このような操作は非常に安価です。必要に応じて、ポインターを移動し、メモリをゼロで埋めます。



「穴」がある場合、「フリーシート」-フリーセクションのリストを保持する必要があります。 次に、このリストで適切なサイズの無料の場所を探して記入します。 リストが検索されるため、この操作は少し高価です。 最適ではないので、このような状況は避けたいと思います。



ガベージコレクションは、マーキング、コレクション、圧縮の3段階で行われます**。 「マークアップ」フェーズでは、すべてのオブジェクトが「デッド」であると想定しています(ルートから約到達できません)。 CLRは、アセンブリの開始時にどのオブジェクトが生きていることが保証されているかを認識し、それらを生きているとマークします。 それらが参照するすべてのオブジェクトは、ライブなどとしてもマークされます。 生きているオブジェクトのすべての推移的な閉鎖がマークされるまで。 組み立て段階では、すべての「死んだ」オブジェクトが「穴」に変わります。 圧縮フェーズでは、生きているオブジェクトが「穴」のない連続したメモリブロックを構成するように、ブロックが再編成されます。



説明されているモデルは、このような3つの領域があるという事実によって複雑になっています。CLRコレクターは世代を実装します。 最初は、オブジェクトは「寿命が短い」ヒープ内にあります。 ***が生き残った場合、時間の経過とともに平均寿命でヒープに転送され、そこで十分に長く生き残った場合、それらは長い寿命でヒープに転送されます。 GCは、寿命が短いヒープで実行されることが非常に多く、寿命が長いヒープで実行されることはほとんどありません。 アイデアは、それらがまだ生きているかどうかに関係なく、長生きするオブジェクトの継続的なチェックを保存することです。 しかし、短命のオブジェクトがメモリをすばやく解放することも必要です。 GCには、高パフォーマンスを実現するためのさまざまな厳密に調整されたポリシーがあります。 彼らは、記憶がスイスチーズに似ている状態と圧縮段階で費やされる時間とのバランスをとっています。 非常に大きなオブジェクトは、圧縮ポリシーがまったく異なる特別なヒープに格納されます。 等 など すべての詳細がわからないので、幸いなことにこれは必要ありません。 (そしてもちろん、「バインディングオブジェクト」****、ファイナライズ、ウィークリンクなど、この記事に関係のない詳細で問題を複雑にすることはありませんでした。)



これをスタックと比較します。 スタックは、ヒープと同様に、上限へのポインターを持つメモリの大きな塊です。 しかし、実際にこのメモリをスタックとするのは、スタックの一番下のメモリが常にスタックの一番上のメモリよりも長く生きることです。 スタックは厳密に順序付けられています。 最初に死ぬべきオブジェクトは上に、最後に死ぬべきオブジェクトは下にあります。 これに基づいて、スタックに穴ができることはなく、圧縮が必要になることもありません。 また、スタック上のメモリは常に最上部から解放され、空きセクターのリストを維持する必要がないこともわかっています。 下のスタックのすべてが生きていることが保証されており、何もマークして収集する必要はありません。



スタック上のメモリの割り当ては、ポインタを移動するだけです。ヒープにメモリを割り当てるときの最良の(そしてかなり典型的な)場合とまったく同じです。 しかし、スタックのこれらすべてのプロパティのため、メモリの解放も単なるポインタの移動です! そして、まさにここで多くの時間を節約できます。 私は、多くの人がスタック上の割り当ては安く、ヒープ上の割り当ては安いと考えているという意見を得ました。 しかし、実際には、これらは通常、ほぼ同じ時間操作です。 しかし、メモリを解放するプロセスは、メモリを解放し、オブジェクトを世代間で最適化し、移動します。これらはすべて、スタック上で見られるものと比較して、メモリブロックの非常に重要な動きです。



明らかに、可能であれば、スタックよりもスタックを使用する方が適切です。 しかし、いつできますか? スタックが機能するためのすべての条件が満たされた場合のみ。 重要なタイプのローカル変数とパラメーターは、すべての条件が満たされている「最も甘い」ケースです。 スタックの最下部にある呼び出し関数のローカルデータは、呼び出された関数のスタックの最上部にあるローカルデータよりも長く保証されています。 重要な型のローカル変数は、参照ではなく値によって渡されます。これにより、ローカル変数のみが特定のメモリを指し、オブジェクトの寿命を判断するために計算する必要がなくなります。 また、重要なローカル変数への参照を渡す方法は1つしかありません。これらはrefまたはoutであり、スタック上の上記の関数に渡されます。 下にあるローカル変数は、上流の関数が制御を返すまで有効です。そのため、参照によって渡されるオブジェクトの寿命は変わりません。



いくつかの追加:

上記の段落は、ref int型のフィールドを作成できない理由を説明しています。 寿命の長いオブジェクトへの参照を長生きするオブジェクトのフィールド内に保持できた場合、突然発生した場合、スタックはその利点を失い、重要な型はガベージコレクションを必要とする別の種類の参照型になります。



匿名関数のクロージャーと演算子のブロックのクロージャー、コンパイラーは非表示クラスのフィールドを介して実装します。 これで、ref変数とout変数を閉じることが禁止されている理由を理解できたと思います。



もちろん、「refとoutを介して渡される関数パラメーターを除き、クロージャーで任意のローカル変数を使用できます」のようない奇妙なルールを作成したくありませんでした。 しかし、以来 スタックに値を配置して最適化を使用したかったため、このような一見奇妙な制限を言語に追加せざるを得ませんでした。 しかし、これは、いつものように、妥協の芸術です。



ところで、CLRではref型を返すことができます。 理論的には、整数変数への参照を返すメソッド「ref int Foo(){...}」を作成できます。 何らかの奇妙な理由でC#でこれを許可することにした場合、コンパイラを調整し、返されたリンクがヒープ上の変数または下のスタックにある変数に割り当てられていることを確認する必要があります。



羊に戻りましょう。 ローカル変数は、スタック上にあるため、スタック上にあります。 1-「通常の」ローカル変数は厳密に定義されたライフタイムを持ち、2-有効な型は常に値によってコピーされ、3-ローカルライフタイムよりも長いライフタイムを持つコンテナにのみローカル変数へのリンクを保存できるため変数。 対照的に、参照タイプの有効期間は、参照によってコピーされるライブリンクの数によって決定され、これらのリンクはどこにでも保存できます。 これは、より複雑で高価なガベージコレクション戦略と引き換えに時間を要求するときに参照型が提供する追加の自由です。



しかし、再び、これらは実装の詳細です。 ローカル変数にスタックを使用することは、CLRが行う最適化にすぎません。 重要な型の主な機能は、そのような型のオブジェクトが値によってコピーされることであり、ランタイムによってメモリを最適化できることではありません。



(*)トランスレーターから:.netには内部CLRオブジェクトのヒープがまだありますが、通常は考慮されないため、この場合はGCが収集し、ユーザーが作成したオブジェクトのインスタンスが格納されるヒープを意味します。

(**)翻訳者から:このコンテキストでの圧縮はデフラグと同等です

(***)翻訳者から:ガベージコレクション中に収集されません

(****)翻訳者から:元のピン留め-GCがメモリ内で移動しないオブジェクト。 詳細はこちら: msdn.microsoft.com/en-us/library/f58wzh21%28VS.80%29.aspx




All Articles