.NETにおけるマネージヒープとガベージコレクション

  • 73
    Like
  • 0
    Comment
More than 1 year has passed since last update.

この記事ではマネージヒープのオブジェクトの生存期間とガベージコレクタの動作についての説明を行う

パワーポイント等については下記からダウンロードできる。
http://needtec.sakura.ne.jp/doc/managedheap.pdf

マネージヒープにオブジェクトを割り当てる

オブジェクトをマネージヒープに格納する場合、下記の情報が格納されるようにする。

オーバヘッドフィールド (32bitプロセス:8byte, 64bitプロセス:16Byte)
 ・型オブジェクトポインター
  ...オブジェクトの実際の型を現す型構造体へのポインタ
 ・同期ブロックインデックス
  ...ロックとかCOMで利用する
型のフィールド

次の例ではマネージヒープにオブジェクトを追加していく例を図で表したものである。

heap.png

この状態でオブジェクトAを割り当てようとする。
この際、オーバヘッドフィールドとフィールドが入るようにNextObjPtrは加算される。

heap.png

オブジェクトBを割り当てる場合も同様に、NextObjPtrの直後からオブジェクトBが割り当てられる。
heap.png

このようにオブジェクトの割り当ては単なるポインタの加算なので非常に速い。

しかし、オブジェクトを割り当て続けると以下のようにマネージヒープに格納できない状況に陥る。
heap.png

このとき、アプリケーションがアクセスしなくなったヒープ内のオブジェクトを削除するためにガベージコレクションを行う。

ガベージコレクション

ガベージコレクションの手順

ガベージコレクションの手順は以下の通りである。

1.全てのスレッドを一時停止
  ガベージコレクション終わるまですべてのスレッドがオブジェクトにアクセスできない
2.マーキングフェイズ
  使ってないオブジェクトを探す
3.コンパクションフェイズ
  不要なオブジェクトを削除して圧縮する

マーキングフェイズ

使ってないオブジェクトを探し、同期ブロックインデックスフィールドに含まれているビットにマークを付与していく作業をマーキングフェイズという。

以下にその例を図で表す。

heap.png

クラスの静的またはインスタンスフィールド、 メソッドの実引数、ローカル変数などで 参照型の変数を総称してルートと呼ぶ。

この例だと以下のオブジェクトがルートから参照されている
A,C,D,F

そして、オブジェクトDからオブジェクトGを参照されている。

まず、全オブジェクトの同期ブロックインデックスフィールドに含まれているビットに0を指定する。これはすべてのオブジェクトを削除するという意味である。

heap.png

ルートから直接参照されているオブジェクトをマークする。
heap.png

マークをする際に、もし他のオブジェクトを 参照している場合は、そのオブジェクト もマークする。
heap.png

この例だとオブジェクトDが参照しているオブジェクトGをマークする。

すべてのオブジェクトにマークをつけたらマーキングフェイズは終了である。マークされたオブジェクトは到達可能 といい。マークされていないのは到達不能という。

コンパクションフェイズ

マークされているオブジェクトを移動させて メモリ上に連続するようする。

heap.png

オブジェクトを移動させる際に、 オブジェクトを参照しているルートなどは移動した分のバイト数を引く必要がある。

マークされているすべてのオブジェクトに対して、同様の処理を行う。
heap.png

これにより、メモリの空容量を連続的にし、マネージヒープ上でメモリの断片化がなくなる。

ガベージコレクションを行ってもメモリが足りない場合

ガベージコレクションを行ってもメモリが足りない場合はOutOfMemoryExceptionの例外が発生する。 アプリケーションはその例外をキャッチして回復を試みることができるが、ほとんどのアプリケーションは、その例外をハンドリングしていないのでプロセスが終了して、OSがプロセスが使用していたメモリを解放する。

メソッド内のオブジェクトの生存期間について

メソッド内のオブジェクトの生存期間は最後に参照したところまでである。
メソッドの終了時までではない。

heap.png

なおDebugでビルドした場合、JITコンパイラが生存期間を恣意的にメソッドの最後まで伸ばしている。
このことは、ReleaseビルドとDebugビルドで動作が異なることを意味する。

世代別ガベージコレクタ

CLRのGCは世代別ガレージコレクタを採用している。
世代別ガベージコレクタには以下の前提がある。

・オブジェクトが新しいほど、その生存期間は短い
・オブジェクトが古いほど、その生存期間は長い
・ヒープの一部分の回収はヒープ全体の回収より高速である

以下にその例を図であらわす。
まず、新しく追加されるオブジェクトは常に 世代0に追加される。
heap.png

オブジェクトCとEが到達不能となり、その後、ガベージコレクションが発生したとする。
heap.png

ガベージコレクションの後に世代0で生き残ったオブジェクトが世代1に移動して世代0は空になる。
heap.png

新しいオブジェクトは世代0に割り当てられていく。
世代0の予約サイズを超えた場合に ガベージコレクションが実行される。
heap.png

世代0のオブジェクトのみが検査され、生き残ったオブジェクトは世代1に移動する。 世代1は検査していないので、オブジェクトBは 生き残る
heap.png

ガベージコレクションを実行していくとこのように世代1が徐々に増加していく。
heap.png

世代1のサイズが上限を超えた時にガベージコレクションが発生したとする。
heap.png

この時は、世代1~世代0のオブジェクトを検査する。
結果以下のようになる。
・世代1の到達可能オブジェクトは世代2となる。
・世代0の到達可能オブジェクトは世代1となる

heap.png

このように、世代別ガベージコレクタでは、すべてのオブジェクトを検査せずに必要な世代のみ検査している。

GCの起動要因

GCの起動要因は以下の通りである。

・世代0の使用量が予約サイズを超えた
 予約サイズはCLRが動的に決める
・System.GCをコードで実行
・Windowsが空容量低下の状況を報告

ラージオブジェクト

CLRは個々のオブジェクトをスモールオブジェクトかラージオブジェクトのどちらかであると見なす。現在85,000バイト以上をラージオブジェクトとみなしている。(※ただし、変更される可能性ある)

ラージオブジェクトはスモールオブジェクトと違うアドレス空間に割り当てられる
•ラージオブジェクト⇒Large Object Heap(LOH)
•スモールオブジェクト⇒Small Object Heap (SOH)

GCはラージオブジェクトに対してコンパクションを行わない。

ラージオブジェクトは割り当て後、すぐに世代2の一部とみなされる。
世代2でGCが実行される時じゃないとラージオブジェクトに対してGCは行われない。

ラージオブジェクトにコンパクションを行わないということはメモリの断片化(フラグメンテーション)が発生することを意味する。

これらの動きを次に図で表す。
heap.png

オブジェクトB,Cが到達不能になったとする。
heap.png

ガベージコレクション発生後、到達不能の オブジェクトは解放され、1つの空き容量を作成する。この際、コンパクションは行わない。
heap.png

オブジェクトEの割り当て要求に対応するために 作成した空き容量が使用できる。
heap.png

このようにコンパクションしないで使い続けると次のように、メモリが断片化する。
heap.png

メモリが断片化すると、トータルの空き容量としては十分に足りていても、メモリが割り当てられずOutOfMemoryExceptionがスルーされる可能性がある。

.NETFramework別のLOHの断片化

Andrew Hunterは、メモリの断片化に関しての最悪の場合の事例を再現したテストプログラムを公開している。

The Dangers of the Large Object Heap
https://www.simple-talk.com/dotnet/.net-framework/the-dangers-of-the-large-object-heap/

実験コード:The Dangers of the Large Object Heap より

using System;
using System.Collections.Generic;
using System.Text;
using System.Runtime;


namespace TestLOH
{
    class Program
    {
        /// https://www.simple-talk.com/dotnet/.net-framework/the-dangers-of-the-large-object-heap/
        /// Static variable used to store our 'big' block. This ensures that the block is always up for garbage collection.
        /// </summary>
        static byte[] bigBlock;

        /// <summary>
        /// Allocates 90,000 byte blocks, optionally intersperced with larger blocks
        /// </summary>
        static void Fill(bool allocateBigBlocks, bool grow, bool alwaysGC)
        {
            // Number of bytes in a small block
            // 90000 bytes, just above the limit for the LOH
            const int blockSize = 90000;

            // Number of bytes in a larger block: 16Mb initially
            int largeBlockSize = 1 << 24;

            // Number of small blocks allocated
            int count = 0;

            try
            {
                // We keep the 'small' blocks around
                // (imagine an algorithm that allocates memory in chunks)
                List<byte[]> smallBlocks = new List<byte[]>();
                for (; ;)
                {
                    // Write out some status information
                    if ((count % 1000) == 0)
                    {
                        Console.CursorLeft = 0;
                        Console.Write(new string(' ', 20));
                        Console.CursorLeft = 0;
                        Console.Write("{0}", count);
                        Console.CursorLeft = 0;
                    }

                    // Force a GC if necessaryry
                    if (alwaysGC)
                    {
                        GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
                        GC.Collect();
                    }

                    // Allocate a larger block if we're set up to do soso
                    if (allocateBigBlocks)
                    {
                        bigBlock = new byte[largeBlockSize];
                    }


                    // The next 'large' block will be just slightly largerer
                    if (grow) largeBlockSize++;

                    // Allocate a new block
                    smallBlocks.Add(new byte[blockSize]);

                    count++;
                }
            }
            catch (OutOfMemoryException)
            {
                // Force a GC, which should empty the LOH again
                bigBlock = null;

                GC.Collect();
                // Display the results for the amount of memory we managed to allocate
                Console.WriteLine("{0}: {1}Mb allocated"
                                  , (allocateBigBlocks ? "With large blocks" : "Only small blocks")
                                  + (alwaysGC?", frequent garbage collections":"")
                                  + (grow?"":", large blocks not growing")
                                  , (count * blockSize) / (1024 * 1024));

            }
        }

        static void Main(string[] args)
        {

            // Display results for cases both with and without the larger blocks
            Fill(true, true, false);
            Fill(true, true, true);
            Fill(false, true, false);
            Fill(true, false, false);

            Console.ReadLine();
        }
    }
}

テスト環境:
VisualStudio2013 Expressでx86のアプリケーションを作成
Windows7 SP1 64bit
メモリ 4.00GB

結果:

Console 2.0 3.0 3.5 4.0 4.5
With large blocks 19Mb 19Mb 20Mb 1546Mb 1546Mb
With large blocks, frequent garbage collection 23Mb 23Mb 23Mb 1570Mb 1570Mb
Only small blocks 1572Mb 1620Mb 1588Mb 1643Mb 1643Mb
With large blocks, large blocks not growing 1375Mb 1596Mb 1419Mb 1636Mb 1620Mb

この結果より、.NET4.0より前ではLOHの断片化の状況によっては数十MB程度しか確保できなくなる場合があることが確認できる。

このことは、LOHを扱うアプリケーションを作成する場合は、.NET4.0以降を選択した方がのぞましいことを意味している。

なお、.NET4.5.1ではLOH領域に対してコンパクションを行う命令が追加されている。

Using System;
Using System.Runtime;

// LOHに対するコンパクションを要求
GCSettings.LOHCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;

// GCが発生してLOHがコンパクションされる
GC.Collect():

Finalization

オブジェクトがGCにより回収対象になった後で、オブジェクトのメモリが解放される前に何らかのコードを実行できる。

Finalizeメソッドの例:

class Hoge{
  // Finalizeメソッド
  ~Hode() {
  // }
}

C++のデストラクタと似ていますが、動作は異なる。
C++ではスコープを外れた時に、確実に呼ばれる。
C#ではFinalizeメソッドが実行されるタイミングは全く制御できない。

Finalizationの動作について以下に図であらわす。
heap.png

Finalizeメソッドが定義してあるオブジェクトを 割り当てる際、型インスタンスコンストラクターが呼ばれる前にファイナライゼーションリストにオブジェクトのポインタが配置される。

オブジェクトC、D、Fが参照されなくなったとする。
heap.png

その後、ガベージコレクションを実行すると次のようになる。
heap.png

FinalizeメソッドのないオブジェクトDは削除され、 Finalizeメソッドのあるオブジェクト、C、FはFリーチャブルキューへ参照が移動する。

Fリーチャブルキューにデータが入ると、 Finalize用のスレッドが動作してキューからデータを 取り出して、Finalizeメソッドを実行する。
この結果、Fリーチャブルキューから参照が消えて、C,Fオブジェクトは到達不能となる

heap.png

その後、ガベージコレクションを実行した後で オブジェクトは削除される
heap.png

つまり、Finalizeを使うオブジェクトは削除されるまでに、最低2回のガベージコレクションが必要になる。

参考

プログラミング .NET Framework 第4版
http://www.amazon.co.jp/dp/4822294951

CLR オブジェクトヘッダーの構造
http://shoutakenaka.blogspot.jp/2009/09/clr.html

The Dangers of the Large Object Heap
https://www.simple-talk.com/dotnet/.net-framework/the-dangers-of-the-large-object-heap/