ガベージコレクション入門: Microsoft .NET Framework の自動メモリ管理 Part I
Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework
(November 2000 Vol.15 No.11)
Jeffrey Richter
Jeffrey Richter は、『Programming Application for Microsoft Windows』 (Microsoft Press刊、1999 年) の著者。.NET および Win32 のプログラミングと設計を専門としている。ソフトウェアのコンサルティングと教育を手がける Wintellect の共同設立者でもある。ホームページは http://www.JeffreyRichter.com
。
この記事は、C および C++ についての知識があることを前提に書かれています。
このアーティクルは、株式会社アスキー
より 2000 年 11 月 18 日に発刊された MSDN Magazine
No.9 (ISBN4-7561-3655-9) に収録されています。
ガベージコレクタに管理された環境を作るのは,プログラマのためにメモリ管理を単純化するためである。今回は,一般的なGCの概念と内部メカニズムをいくつか示した。本稿のGCの説明を読めば,GCがメモリの利用状況の監視,メモリの解放タイミングの把握などからプログラマを解放することに気づくことだろう。
ガベージコレクタは,メモリ内の型が表現しているリソースについての知識を持たないため,リソースの状態情報の破棄の実行方法を知ることができない。リソースを適切にクリーンアップするためには,プログラマの側でリソースを適切にクリーンアップする方法を知っているコードを書かなければならない。
Part II では,大きなオブジェクトによって管理ヒープにかかるメモリ消費負担を軽減する弱い参照機能を紹介し,管理オブジェクトの寿命を人工的に延長するメカニズムを説明し,最後にガベージコレクタの処理性能のさまざまな側面を取り上げる。また,Common Language Runtimeが公開するパフォーマンスカウンタを取り上げる。
Summary
Microsoft .NET の Common Language Runtime (言語共通ランタイム) 環境は、メモリの利用状況の監視やメモリを開放すべきタイミングの把握といったことからプログラマを完全に解放する。しかし、自動メモリ管理の仕組みは理解しておいたほうがよい。本稿は .NET ガベージ コレクションについての 2 回連載の 第 1 部で、リソースの確保、管理の仕組みを説明した上で、ガベージ コレクション アルゴリズムの動作を一歩ずつ詳しく述べる。また、ガベージ コレクタがメモリ リソースの開放を決定したときに、リソースを適切にクリーンアップする方法や、開放時にオブジェクトに強制的にクリーンアップ処理を実行させる方法についても説明する。
アプリケーションに適切なリソース管理メカニズムを実装する仕事は、面倒で難しいものになることがある。リソース管理のために、解決しようとしている本来の問題から注意力が削がれる場合もある。プログラマのために、メモリ管理という辛い仕事を単純化してくれる何らかのメカニズムがあれば、すばらしいことではないだろうか。.NETにはそれがある。ガベージコレクション (GC) である。
ちょっと考えてみよう。すべてのプログラムは、何らかのタイプのリソースを使っている。メモリバッファ、画面空間、ネットワーク接続、データベースリソースなどである。実際、オブジェクト指向環境では、すべての型は、プログラムが使える何らかのリソースを識別している。これらのリソースを使うためには、型を表現するためにメモリを確保しなければならない。リソースにアクセスするために必要なステップは、次のとおりである。
- リソースを表現する型のためにメモリを確保する。
- メモリを初期化してリソースの初期状態を設定し、リソースを使える状態にする。
- 型のインスタンスメンバーにアクセスするという形でリソースを使う (必要に応じて繰り返される)。
- リソースの状態情報を破棄してリソースをクリーンアップする。
- メモリを解放する。
プログラミングエラーの大きな原因の1つは、この一見単純なパラダイムである。もう不要になったメモリを解放し忘れたり、すでに解放したメモリを使おうとしたりしたことが今までに何回あるかを思い出してみていただきたい。
これら2つのバグは、バグによってどのような問題が起き、それらの問題がいつ起きるかが通常予測不能である分、ほかのほとんどのアプリケーションのバグよりも悪質である。ほかのバグの場合なら、アプリケーションの動作の異常に気づいたときにそれを直すだけである。しかし、これら2つのバグは、リソースリーク (メモリの過消費) やオブジェクトの破壊 (不安定化) を起こし、アプリケーションは予測不能なタイミングで予測不能な動作を起こすようになってしまう。実際、これらのタイプのバグを発見しやすくすることを目的として作られたツールは無数にある (たとえば、タスクマネージャ、システムモニタActiveXコントロール、CompuWareのBoundsChecker、RationalのPurify)。
これからのGCの説明を読めば、GCがメモリの利用状況の監視、メモリの解放タイミングの把握などからプログラマを解放することに気づくことだろう。しかし、ガベージコレクタは、メモリ内の型が表現しているリソースについての知識をいっさい持たない。そのため、ガベージコレクタは、第4ステップ (リソースの状態情報の破棄) の実行方法を知ることができない。リソースを適切にクリーンアップするためには、プログラマの側でリソースを適切にクリーンアップする方法を知っているコードを書かなければならない。.NET Frameworkでは、後述のClose、Dispose、Finalizeメソッドでこのコードを書くことになっている。ただし、あとで述べるように、ガベージコレクタは、このメソッドをいつ呼び出すべきかを自動的に判断できる。
多くの型は、クリーンアップを必要としないリソースを表現している。たとえば、矩形リソースは、型のメモリ内で管理されている左、上、幅、高さのフィールドを破棄するだけで簡単に破棄できる。その一方で、ファイルリソースやネットワーク接続リソースを表現する型では、リソースを破棄すべきときに明示的に指定されたクリーンアップコードを実行する必要がある。本稿では、これらの正しい処理方法を説明していく。しかし、まずは、メモリがどのように確保され、リソースがどのように初期化されるかを考えていこう。
リソースの確保
Microsoft .NET Common Language Runtime のもとでは、すべてのリソースを .NET 管理のヒープから確保しなければならない。これはCランタイムヒープと非常に似ているが、自分ではオブジェクトを解放しないところが異なる。オブジェクトは、アプリケーションにとって不要になったときに自動的に解放される。この説明からは当然疑問が湧いてくるだろう。管理ヒープは、オブジェクトがアプリケーションにとって不要になったタイミングというのをいったいどのようにして知るのだろうか。この疑問には、すぐあとで答えることにしよう。
今日使われているGCアルゴリズムは数種類ある。個々のアルゴリズムは、最良の処理性能を提供するために、特定の環境に合わせてチューニングされている。本稿では、Common Language Runtimeが使っているGCアルゴリズムに焦点を絞る。それでは、基本的な考え方から見ていこう。
プロセスが初期化されるときに、Common Language Runtimeはアドレス空間内の連続領域を予約するが、初期状態ではこの領域には物理メモリはいっさい割り当てられていない。このアドレス空間内領域が管理ヒープである。管理ヒープはポインタも管理している。本稿では、このポインタをNextObjPtrと呼ぶことにする。このポインタは、ヒープ内のどこから次のオブジェクトを確保すべきかを示す。初期状態では、NextObjPtrは、予約済みアドレス空間内領域の先頭アドレスにセットされている。
アプリケーションは、new演算子を使ってオブジェクトを作成する。new演算子は、まず、予約済み領域内に新オブジェクトが必要とするスペースがあるかどうかを確認する (必要に応じて、物理メモリをコミットする)。もしあれば、NextObjPtrはヒープ内の新オブジェクトを作成すべき位置を指しているので、オブジェクトのコンストラクタを呼び出し、new演算子はオブジェクトのアドレスを返す。
この時点で、NextObjPtrは、次のオブジェクトを配置すべきヒープ内の位置を指すようにオブジェクトの後ろにインクリメントされる。Figure 1 は、A、B、Cの3個のオブジェクトを含む管理ヒープを示したものである。次のオブジェクトは、NextObjPtrが指している位置 (オブジェクトCの直後) から確保される。
Figure 1 管理ヒープ
次に、Cランタイムヒープのメモリの確保方法を見てみよう。Cランタイムヒープがオブジェクトにメモリを割り当てるためには、データ構造のリストをたどる必要がある。十分なサイズを持つブロックが見つかったら、それを分割し、全体に変動が起きないようにするためにリストの節点のポインタを書き換えなければならない。それに対し、管理ヒープでは、オブジェクトの確保とは、単純にポインタへの値の加算である。比較すると、これは明白に速い。実際、管理ヒープからのオブジェクトの確保は、スレッドのスタックからのメモリの確保とほとんど同じスピードである。
今までの話では、管理ヒープは、高速で実装が単純な分、Cランタイムヒープよりもはるかに優れているように感じられるだろう。管理ヒープがこれらのメリットを手にしたのは、もちろん、アドレス空間と物理メモリが無限にあるという非常に大きな前提条件を設けているからである。この前提条件は間違いなくばかげているので、管理ヒープは、この前提条件を成り立たせるようなメカニズムを組み込まなければならない。このメカニズムをガベージコレクタという。ガベージコレクタの仕組みを見てみよう。
アプリケーションがオブジェクトを作るためにnew演算子を呼び出したとき、領域内にはオブジェクトを確保できるだけのアドレス空間が残されていない場合がある。ヒープは、NextObjPtrに新オブジェクトのサイズを加算することによりこの条件を検出する。NextObjPtrがアドレス空間領域の末尾よりも後ろに行ってしまったら、ヒープはいっぱいであり、ガベージコレクションを実行しなければならない。
実際には、ガベージコレクションは、ジェネレーション0がいっぱいになったときに発生する。簡単に言えば、ジェネレーションとは、ガベージコレクタが処理性能の向上のために実装しているメカニズムである。新しく作成されたオブジェクトは若いジェネレーションの一部となり、アプリケーションのライフサイクルの初期に作成されたオブジェクトは古いジェネレーションの一部となる。オブジェクトをジェネレーションに分割すれば、ガベージコレクタは、管理ヒープ内のすべてのオブジェクトではなく、特定のジェネレーションだけを集めれば済むようになる。ジェネレーションについては、Part II で詳しく説明する。
ガベージコレクションのアルゴリズム
ガベージコレクタは、もうアプリケーションからは使われていないオブジェクトがヒープ内にあるかどうかをチェックする。そのようなオブジェクトがあれば、それらが使っているメモリは回収できる (ヒープに使えるメモリがなければ、new演算子はOutOfMemoryExceptionを起こす)。では、ガベージコレクタは、アプリケーションがオブジェクトを使っているかどうかをどのようにして知るのだろうか。読者が想像されるように、これは簡単に答えられる問題ではない。
すべてのアプリケーションは、ルーツのセットを持っている。ルーツは記憶位置を識別するもので、管理ヒープ上のオブジェクトか、nullをセットされたオブジェクトのどちらかを参照する。たとえば、アプリケーション内のすべてのグローバルな静的オブジェクトポインタは、アプリケーションのルーツの一部であるとみなされる。さらにスレッドスタック上にあるローカル変数/引数オブジェクトポインタも、アプリケーションのルーツの一部であると考えられる。最後に、管理ヒープ内のオブジェクトを指すポインタを格納するCPUレジスタも、アプリケーションのルーツの一部とみなされる。アクティブなルーツのリストは、JIT (just-in-time) コンパイラとCommon Language Runtimeによって管理されており、ガベージコレクタのアルゴリズムからアクセスできるようになっている。
起動時のガベージコレクタは、ヒープ内のすべてのオブジェクトをごみだと考えている。言い換えれば、アプリケーションのルーツのなかには、ヒープ内のオブジェクトを参照しているものはないとみなしているのである。次に、ガベージコレクタはルーツをたどり、ルーツから手の届くすべてのオブジェクトのグラフを作る。このとき、ガベージコレクタは、たとえばヒープ内のオブジェクトを指しているグローバル変数を見つける。
Figure 2 は、いくつかの確保済みのオブジェクトを持つヒープを示したものである。このヒープでは、アプリケーションのルーツがオブジェクトA、C、D、Fを直接参照している。これらのオブジェクトはどれもグラフの一部になる。ガベージコレクタは、グラフにオブジェクトDを追加するときに、このオブジェクトがオブジェクトHを参照していることに気づく。そこで、オブジェクトHもグラフに追加される。ガベージコレクタは、すべての到達可能オブジェクトを再帰的にたどっていく。
.jpg)
Figure 2 ヒープ内に確保されたオブジェクト
グラフのその部分が完成すると、ガベージコレクタは次のルートをチェックし、オブジェクトを再びたどっていく。ガベージコレクタは、オブジェクトからオブジェクトへと移っていく過程で、以前にグラフに追加したオブジェクトを追加しようとすることがある。そのときには、その経路をそれ以上たどらない。このことには、一連のオブジェクトを一度以上たどらないので処理性能を大幅に向上させるという効果と、オブジェクトの循環リンクがあっても無限ループに陥ることを避けられるという2つの効果がある。
すべてのルーツをチェックしたら、ガベージコレクタのグラフにはアプリケーションのルーツから何らかの形で手が届くすべてのオブジェクトの集合が含まれている。グラフに含まれていないオブジェクトはアプリケーションからはアクセスできないので、ごみとみなされる。ガベージコレクタは、今度はヒープを線型にたどり、ガベージオブジェクトの連続ブロックを探す (この部分は、フリー空間とみなされる)。次に、ガベージコレクタは、ごみではないオブジェクトをメモリの下位アドレスにシフトし (誰もが大昔から知っている標準のmemcpy関数を使う) 、ヒープのなかのギャップをすべて取り除く。もちろん、メモリ内のオブジェクトをすべて移動すれば、オブジェクトを指すポインタもすべて無効になる。そのため、ガベージコレクタは、ポインタがオブジェクトの新しい位置を指すようにアプリケーションのルーツを書き換えなければならない。また、ほかのオブジェクトを指すポインタを持つオブジェクトがあれば、ガベージコレクタはこれらのポインタも修正する。Figure 3 は、ガベージコレクション終了後の管理ヒープを示したものである。
.jpg)
Figure 3 ガベージ コレクション終了後の管理ヒープ
ごみをすべて見分け、ごみ以外のすべてのオブジェクトをメモリコンパクションにかけ、すべてのポインタを修正したら、NextObjPtrは最後のごみではないオブジェクトの直後に配置される。この時点でnew演算子は再び処理を試み、アプリケーションが要求したリソースの作成は成功する。
以上からも想像されるように、GCは処理性能をかなり損なう。管理ヒープを使うときの大きなデメリットは、これである。しかし、GCが発生するのはヒープがいっぱいになったときだけであり、それまでは管理ヒープはCランタイムヒープよりもかなり高速だということを忘れないでいただきたい。Common Language Runtimeのガベージコレクタは、処理性能を大幅に向上させるメカニズムも組み込んでいる。これらの最適化については、PartⅡでジェネレーションについて論じるときに取り上げる。
ここで注意しておくべき重要なポイントがいくつかある。もはや、アプリケーションが使うリソースの寿命を管理するコードを実装する必要はない。そして、本稿の冒頭で取り上げた2つのバグが消えたことに注意していただきたい。まず、アプリケーションのルートからアクセスできないリソースはいつかの時点でガベージコレクタに収集されるため、リソースリークが発生する可能性はなくなる。手が届かなければ、アプリケーションからそのリソースにアクセスする手段はないのである。次に、手の届くリソースは解放されないので、解放されたリソースにアクセスする可能性もなくなる。Figure 4 のコードは、リソースの確保、管理方法を示す例である。
class Application {
public static int Main(String[] args) {
// ArrayList object created in heap, myArray is now a root
ArrayList myArray = new ArrayList();
// Create 10000 objects in the heap
for (int x = 0; x < 10000; x++) {
myArray.Add(new Object()); // Object object created in heap
}
// Right now, myArray is a root (on the threadユs stack). So,
// myArray is reachable and the 10000 objects it points to are also
// reachable.
Console.WriteLine(a.Length);
// After the last reference to myArray in the code, myArray is not
// a root.
// Note that the method doesnユt have to return, the JIT compiler
// knows
// to make myArray not a root after the last reference to it in the
// code.
// Since myArray is not a root, all 10001 objects are not reachable
// and are considered garbage. However, the objects are not
// collected until a GC is performed.
}
}
|
|
Figure 4 リソースの確保と管理
|
GCがそんなに偉大だというのなら、なぜANSI C++にはGCが含まれていないのだろうか。その理由は、ガベージコレクタがアプリケーションのルーツを識別するとともに、すべてのオブジェクトポインタを見つけることもできなければならないからである。C++は、ある型から別の型へのポインタのキャストを認めるため、ポインタが何を参照しているのかを知ることができない。Common Language Runtimeでは、管理ヒープはオブジェクトの本当の型を知っている。また、オブジェクトのどのメンバーがほかのオブジェクトを参照しているかは、メタデータ情報によって分かる。
完結化
ガベージコレクタには、プログラマの役に立つもう1つの機能、完結化機能がある。完結化により、ガベージコレクションにかけられたリソースが、自分自身を穏便にクリーンアップできるようになる。完結化機能があれば、ガベージコレクタがファイルやネットワーク接続を表現するメモリを解放することになっても、ファイルやネットワーク接続を表現するリソースは、ガベージコレクタがリソースのメモリを解放しようと判断したときに、自分自身を適切にクリーンアップできる。
完結化のために行なわれていることを実際よりも単純化して説明しよう。ガベージコレクタは、オブジェクトがごみになっていることを検出すると、オブジェクトのFinalizeメソッドを呼び出し (存在する場合) てから、オブジェクトのメモリを回収する。たとえば次のような型があったとする (記述にはC#を使っている)。
public class BaseObj {
public BaseObj () {
}
protected override void Finalize() {
// ここにリソースクリーンアップコードを入れる
// 例: ファイルのクローズ/ネットワーク接続の切断
Console.WriteLine("In Finalize.");
}
}
そしてプログラムは次のコードを呼び出してこのオブジェクトのインスタンスを作成する。
BaseObj bo = new BaseObj();
将来のいずれかのとき、ガベージコレクタはこのオブジェクトがごみになっていることに気づく。ガベージコレクタはこの型がFinalizeメソッドを持っていることを検出し、そのメソッドを呼び出す。コンソールウィンドウに“In Finalize.”という文字列が表示され、このオブジェクトが使っていたメモリは回収される。
C++プログラミングになれているプログラマの多くは、デストラクタとFinalizeメソッドを直接結び付けて考えようとするだろう。しかし、オブジェクトの完結化とデストラクタとでは、セマンティクスがまったく異なっているので、完結化について考えるときには、デストラクタについて持っている知識はすべて忘れたほうがよい。管理オブジェクトはデストラクタを持たない。それだけである。
型を設計するときには、Finalizeメソッドはできる限り使わないほうがよい。理由は、次に示すようにいくつもある。
- Finalizeメソッドを持つオブジェクトは、より古いジェネレーションに昇格される。その分、メモリはタイトになり、ガベージコレクタがオブジェクトをごみと判断したときにオブジェクトのメモリが回収されなくなる。また、このオブジェクトが直接、間接的に参照しているオブジェクトも、すべて昇格される。ジェネレーションと昇格については、PartⅡで取り上げる。
- Finalizeメソッドを持つオブジェクトは、確保に余分な時間がかかる。
- ガベージコレクタに強制的にFinalizeメソッドを実行させることは、処理性能に大きな悪影響を与える。オブジェクトは1つ1つ完結化されることを忘れないでいただきたい。10000個のオブジェクトの配列があれば、そのなかのオブジェクト1つ1つについてFinalizeメソッドを呼び出さなければならない。
- Finalizeメソッドを持つオブジェクトは、ほかの (Finalizeメソッドを持たない) オブジェクトを参照することによって、それらのオブジェクトの寿命を不必要に延ばすことがある。実際、1つの型を2つの型に分割することを考えたほうがよい。1つの型はほかのオブジェクトを参照しない軽量型で、Finalizeメソッドを持つ。もう1つの型は、Finalizeメソッドを持たず、ほかのオブジェクトを参照する。
- Finalizeメソッドがいつ実行されるかは、プログラムからはまったく制御できない。次にガベージコレクタが実行されるまで、オブジェクトがリソースを抱え込んでいる場合もあり得る。
- アプリケーションが終了したとき、一部のオブジェクトはまだ手が届くところにあり、Finalizeメソッドも呼び出されない。このようなことが起きるのは、バックグラウンドスレッドがオブジェクトを使っている場合や、アプリケーションのシャットダウン、AppDomainのアンロード中にオブジェクトを作成した場合である。また、アプリケーションを高速に終了させるために、デフォルトではアプリケーション終了時には、手の届かなくなったオブジェクトでもFinalizeメソッドは呼び出されない。もちろん、オペレーティングシステムリソースはすべて回収されるが、管理ヒープ内のオブジェクトは、穏便にクリーンアップ処理を行なうことができない。System.GC型のRequestFinalizeOnShutdownメソッドを呼び出せばこのデフォルトの動作は変更できるが、このメソッドを呼び出すということは、その型がアプリケーション全体のポリシーを決めるということなので、注意が必要である。
- Common Language Runtimeは、Finalizeメソッドの呼び出し順について何も保証していない。たとえば、内部オブジェクトへのポインタを持つオブジェクトがあり、ガベージコレクタが両方のオブジェクトをごみと認定したとする。さらに、内部オブジェクトのFinalizeメソッドが最初に呼ばれたとしよう。外部オブジェクトのFinalizeメソッドは内部オブジェクトへのアクセスと内部オブジェクトメソッドの呼び出しを認められているが、内部オブジェクトのFinalizeメソッドが先に呼び出されていたら、これでは結果は予測不能になってしまう。このような理由から、Finalizeメソッドは、内部、メンバーオブジェクトにアクセスすべきではない。
それでも自分の型にはFinalizeメソッドが必要だと判断したときには、できる限りそのコードを高速にすべきである。スレッド同期処理など、Finalizeメソッドをブロックするような処理は、いっさい避けなければならない。また、Finalizeメソッドから例外を発生すると、システムはFinalizeメソッドが制御を返したと判断し、ほかのオブジェクトのFinalizeメソッド呼び出しに向かってしまう。
コンストラクタのコードを生成するときに、コンパイラは自動的に基底型コンストラクタ呼び出しを挿入する。同様に、C++コンパイラは、デストラクタコードを生成するときに、自動的に基底型デストラクタ呼び出しを挿入する。しかし、すでに述べたように、Finalizeメソッドはデストラクタとは異なる。コンパイラはFinalizeメソッドについての特別な知識も持っていないので、基底型のFinalizeメソッドの呼び出しを自動的に挿入したりはしない。このような動作が必要なら (実際に、そうなることは頻繁にあるが) 、自分の型のFinalizeメソッドから基底型のFinalizeメソッドを明示的に呼び出さなければならない。
public class BaseObj {
public BaseObj() {
}
protected override void Finalize() {
Console.WriteLine("In Finalize.");
base.Finalize(); // 基底型のFinalizeを呼び出す
}
}
通常、基底型のFinalizeメソッド呼び出しは派生型のFinalizeメソッドの最後の文になることに注意していただきたい。こうすれば、基底型の寿命をできる限り引き伸ばすことができる。基底型のFinalizeメソッドの呼び出しは非常によく行なわれることなので、C#にはプログラマの仕事を楽にする構文が含まれている。C#で次のように書くと、
class MyObject {
~MyObject() {
.
.
.
}
}
コンパイラは次のようなコードを生成する。
class MyObject {
protected override void Finalize() {
.
.
.
base.Finalize();
}
}
C#のこの構文が、C++言語のデストラクタ定義構文と同じように見えることに注意していただきたい。しかし、C#がデストラクタをサポートしないことは、覚えておく必要がある。同じ構文に惑わされてはならない。
完結化の内部メカニズム
完結化は、表面上非常に単純に見える。プログラマがオブジェクトを作成し、オブジェクトがガベージコレクションにかけられると、そのFinalizeメソッドが呼び出される。しかし、実際にはこれよりも多くの仕事が行なわれている。
アプリケーションが新オブジェクトを作成するとき、new演算子はヒープからメモリを確保する。オブジェクトの型にFinalizeメソッドが含まれていたら、完結化キューにオブジェクトへのポインタがセットされる。完結化キューは、ガベージコレクタが管理する内部データ構造である。キューの個々のエントリは、メモリを回収する前にFinalizeメソッドを呼び出さなければならないオブジェクトを指している。
Figure 5 は、いくつかのオブジェクトを含むヒープを示したものである。それらのオブジェクトのなかにはアプリケーションのルーツから手の届くところにあるものもあれば、そうでないものもある。オブジェクトC、E、F、I、Jが作成されたとき、システムはこれらのオブジェクトがFinalizeメソッドを持つことを検出し、これらのオブジェクトへのポインタを完結化キューに追加している。
.jpg)
Figure 5 多くのオブジェクトを持つヒープ
GCが発生し、オブジェクトB、E、G、H、I、Jはごみと判断された。ガベージコレクタは、完結化キューからこれらのオブジェクトへのポインタを探す。ポインタが見つかったら、そのポインタは完結化キューから取り除かれ、Fリーチャブルキュー (F-reachable queue) に追加される。Fリーチャブルキューも、ガベージコレクタが管理する内部データ構造である。Fリーチャブルキューの個々のポインタは、Finalizeメソッドを呼び出してもよい状態になっているオブジェクトを識別する。
ガベージコレクションが終わったあとの管理ヒープは、Figure 6 のようになる。オブジェクトB、G、Hは、呼び出さなければならないFinalizeメソッドを持たないので、これらのオブジェクトが使っていたメモリはすでに回収されている。しかし、オブジェクトE、I、Jのメモリは、これらのオブジェクトのFinalizeメソッドがまだ呼び出されていないので、回収できない。
.jpg)
Figure 6 ガベージ コレクション実行後の管理ヒープ
Common Language Runtimeは、Finalizeメソッド呼び出しのために専用スレッドを持っている。Fリーチャブルキューが空なら (通常はそうである) 、このスレッドは眠っている。しかし、Fリーチャブルキューにエントリがセットされると、スレッドは目を覚まし、キューからエントリを1つずつ取り除いて、各オブジェクトのFinalizeメソッドを呼び出す。このような理由から、Finalizeメソッドのなかでは、コードを実行するスレッドについて何らかの前提条件を持つコードを書いてはならない。たとえば、Finalizeメソッドでは、TLSアクセスを避ける必要がある。
完結化キューとFリーチャブルキューのやり取りは非常に魅力的である。まずはFリーチャブルキューの名前の由来を説明しておこう。Fが完結化 (finalization) から取られていることは明らかである。Fリーチャブルキューに含まれるすべてのエントリは、Finalizeメソッドを呼び出してもらわなければならない。名前のリーチャブル (reachable) の部分は、オブジェクトが手の届くところにあることを意味している。言い方を換えれば、Fリーチャブルキューは、グローバルな静的変数がルーツであるのと同じように、ルーツであるとみなされているのである。そのため、Fリーチャブルキューにセットされているオブジェクトはリーチャブル (手が届く存在) であり、ごみではないのである。
一言で言えば、オブジェクトが手の届かない存在になると、ガベージコレクタはオブジェクトをごみとみなす。ガベージコレクタがオブジェクトのエントリを完結化キューからFリーチャブルキューに移したとき、オブジェクトはごみとはみなされなくなり、そのメモリは回収されない。ガベージコレクタは、この時点でごみの識別を終えている。ごみと識別されたオブジェクトのなかの一部は、再びごみではないものとして分類し直される。ガベージコレクタは回収可能メモリをコンパクションにかけ、Common Language Runtimeの専用スレッドはFリーチャブルキューの個々のオブジェクトのFinalizeメソッドを実行して、Fリーチャブルキューを空にしていく。
ガベージコレクタは、次に起動されたときに、Finalizeメソッド呼び出しを受けたオブジェクトが本物のごみになっていることを検出する。なぜなら、アプリケーションのルーツも、Fリーチャブルキューも、もうこのオブジェクトを指していないからである。このオブジェクトのメモリは単純に回収される。ここで重要なことは、Finalizeメソッドを持つオブジェクトが使っていたメモリの回収には、2回のGCが必要だということである。実際には、オブジェクトが古いジェネレーションに昇格する場合があるので、3回以上のGCが必要な場合もある。Figure 7 は、第2のGCを実行したあとの管理ヒープの様子を示したものである。
.jpg)
Figure 7 2 度目のガベージコレクションを実行したあとの管理ヒープ
復活
完結化の概念は全体的に魅力的である。しかし、完結化には、今までに述べてきた以上の内容が含まれている。前節で述べたように、アプリケーションが生きたオブジェクトにアクセスしなくなったら、ガベージコレクタはオブジェクトが死んでいるとみなす。しかし、オブジェクトが完結化を必要とする場合、Finalizeメソッドを呼び出されるまで、オブジェクトは再び生きているものとみなされ、Finalizeが呼び出されたあとに完全に死ぬ。言い換えれば、Finalizeを必要とするオブジェクトは、死んでから生き返り、もう一度死ぬのである。これは、復活と呼ばれる非常におもしろい現象である。復活は、その名のとおり、オブジェクトが死から蘇ることを可能にする。
復活の一形態についてはすでに示した。ガベージコレクタがFリーチャブルキューにオブジェクトへのポインタをセットしたとき、オブジェクトはルートから手が届く存在になり、生き返る。それでも、いずれオブジェクトのFinalizeメソッドが呼び出され、オブジェクトを指すルートはなくなり、オブジェクトは永遠に死ぬ。しかし、オブジェクトのFinalizeメソッドが、オブジェクトを指すポインタをグローバルな静的変数にセットするようなコードを実行したらどうなるだろうか。
public class BaseObj {
protected override void Finalize() {
Application.ObjHolder = this;
}
}
class Application {
static public Object ObjHolder; //デフォルトではnull
.
.
.
}
この場合、オブジェクトのFinalizeメソッドが実行されると、オブジェクトへのポインタはルートにセットされ、オブジェクトはアプリケーションのコードから手の届くところに戻る。このオブジェクトは復活し、ガベージコレクタはオブジェクトをごみとはみなさない。アプリケーションはオブジェクトを自由に使えるが、オブジェクトは完結化を経ており、そのようなオブジェクトを使うと予測不能な結果を起こすことがあることに注意しなければならない。また、BaseObjにほかのオブジェクトを指す (直接的にでも間接的にでも) メンバーが含まれている場合、それらのオブジェクトはアプリケーションのルーツから手が届く位置に移ってくるので、すべて復活される。しかし、それらのオブジェクトのなかには、すでに完結化を終了して死んでいるものが含まれていることに注意しなければならない。
実際、オブジェクト型を設計するときには、自分のあずかり知らないところでその型が完結化、復活される可能性があることを考える必要がある。このようなケースを穏便に処理できるようにコードを実装すべきである。多くの型では、オブジェクトが完結化を受けたかどうかを示すブール型のフラグで管理するとよい。完結化されたオブジェクトのメソッドが呼び出されたら、例外を発生することを検討してもよい。実際に使うべきテクニックは、作ろうとしている型によって決まるはずである。
先ほどのコードに戻り、ほかのコードがApplication.ObjHolderにnullをセットすると、オブジェクトは手の届かないところに行く。いずれガベージコレクタがオブジェクトをごみとみなし、オブジェクトのメモリを回収するだろう。完結化キューにオブジェクトポインタがセットされていないので、オブジェクトのFinalizeメソッドは呼び出されないことに注意していただきたい。
復活の優れた用途はほとんどないので、できる限り復活が起きないようにすべきである。しかし、復活が実際に使われるのは、オブジェクトが死んだときに必ず穏便なクリーンアップが行なわれるようにするためであることが多い。したがって、GC型はこれを実現するために、ReRegisterForFinalizeというメソッドを提供している。引数は、オブジェクトへのポインタだけである。
public class BaseObj {
protected override void Finalize() {
Application.ObjHolder = this;
GC.ReRegisterForFinalize(this);
}
}
このオブジェクトのFinalizeメソッドは、自分を指すルートを作成することによって、自分を復活させる。次に、Finalizeメソッドは、指定されたオブジェクト (this) のアドレスを完結化キューの末尾に追加するReRegisterForFinalizeを呼び出す。ガベージコレクタは、このオブジェクトが再び手の届かない位置に移ったことを検出すると、オブジェクトのポインタをFリーチャブルキューにセットし、Finalizeメソッドが再び呼び出されるようにする。この例は、自らを確実に復活させて決して死なないオブジェクトの作り方を示しているが、これは通常望ましくないことである。Finalizeメソッドのなかで、条件に基づいて、オブジェクトを参照するルートを設定する方法のほうがはるかに一般的である。
ReRegisterForFinalzeは、1回の復活で複数回呼び出さないように注意しなければならない。そうしなければ、オブジェクトのFinalizeメソッドが複数回呼び出されてしまう。このようなことになるのは、ReRegisterForFinalizeを呼び出すたびに、完結化キューの末尾にエントリを追加するからである。オブジェクトがごみだと判断されると、それらのエントリはすべて完結化キューからFリーチャブルキューに移される。そのため、オブジェクトのFinalizeメソッドが複数回呼び出されるようになるのである。
オブジェクトの強制クリーンアップ
可能であれば、オブジェクトはクリーンアップを必要としないように定義すべきである。しかし、多くのオブジェクトでは、このようなことは単純に不可能である。そこで、これらのオブジェクトでは、型定義の一部としてFinalizeメソッドを実装しなければならない。しかし、その型のユーザーが、必要なときにオブジェクトを明示的にクリーンアップできるようにするために、別のメソッドを追加することが推奨される。このメソッドには、CloseかDisposeという名前を付ける約束になっている。
一般に、Closeは、オブジェクトをクローズしたあとも再オープン、再利用できるときに使う。ファイルのように一般にクローズされるとみなされているオブジェクトに対してもCloseを使う。それに対し、Disposeは、捨てたあと再利用できないオブジェクトで使う。たとえば、System.Drawing.Brushオブジェクトを削除するには、そのDisposeメソッドを呼び出す。捨てたあとのBrushオブジェクトは利用できず、オブジェクトを操作するメソッドを呼び出すと、例外が発生する。ほかのBrushが必要なら、新しいBrushオブジェクトを構築しなければならない。
それでは、Close/Disposeメソッドが行なうべきことを見ていこう。System.IO.FileStream型は、ユーザーが読み書きのためにファイルをオープンできるようにする。この型の実装は、処理性能を上げるために、メモリバッファを利用している。この型がバッファの内容をファイルにフラッシュするのは、バッファがいっぱいになったときだけである。ここで新しいFileStreamオブジェクトを作成し、数バイトの情報をそこに書き込んだとする。書き込んだ内容だけではバッファがいっぱいにならなければ、バッファはディスクに書き込まれない。FileStream型はFinalizeメソッドを実装しており、FileStreamオブジェクトがガベージコレクションにかけられると、Finalizeメソッドはメモリに残っているデータをディスクにフラッシュしてファイルをクローズする。
しかし、FileStream型のユーザーにとって、この動作は完璧であるとは言えない。たとえば、最初のFileStreamオブジェクトがガベージコレクションにかけられていないのに、アプリケーションが同じディスクファイルを使って新しいFileStreamオブジェクトを作成したくなったとする。この場合、最初のFileStreamオブジェクトが排他的なアクセスを保証するようにファイルをオープンしていれば、第2のFileStreamオブジェクトはファイルのオープンに失敗する。FileStreamオブジェクトのユーザーには、メモリの最終的なディスクへのフラッシュとファイルのクローズを強制する何らかの手段を与えなければならない。
FileStream型のドキュメントを読むと、Closeというメソッドが含まれていることが分かる。このメソッドは、メモリに残っているデータをディスクにフラッシュし、ファイルをクローズする。これでFileStreamオブジェクトのユーザーは、オブジェクトの動作をコントロールできるようになる。
しかし、今度は別のおもしろい問題が持ち上がってくる。FileStreamオブジェクトがガベージコレクションにかけられたとき、FileStreamのFinalizeメソッドは、何をすべきなのだろうか。答えは当然なしである。実際、アプリケーションが明示的にCloseメソッドを呼び出していれば、FileStreamのFinalizeメソッドを実行するいわれはまったくない。Finalizeメソッドはできる限り使わないほうがよいのだから、このようなシナリオでは、システムには何もしないFinalizeメソッドを呼び出させることになる。システムがオブジェクトのFinalizeメソッドを呼び出さなくなるようにする手段も欲しい。実際、そのような手段はある。System.GC型には、SuppressFinalizeという静的メソッドが含まれている。このメソッドは、唯一の引数としてオブジェクトのアドレスを取る。
Figure 8 は、FileStreamの型実装を示したものである。SuppressFinalizeは、オブジェクトに結び付けられたあるビットフラグをオンにする。このフラグがオンになると、Common Language RuntimeはこのオブジェクトのポインタをFリーチャブルキューにセットしてはならないと判断する。かくして、オブジェクトのFinalzeメソッドは呼び出されなくなるのである。
public class FileStream : Stream {
public override void Close() {
// Clean up this object: flush data and close file
・・・
// There is no reason to Finalize this object now
GC.SuppressFinalize(this);
}
protected override void Finalize() {
Close(); // Clean up this object: flush data and close file
}
// Rest of FileStream methods go here
・・・
}
|
|
Figure 8 FileStreamでの型の実装
|
これに関連して、もう1つ別の問題もある。FileStreamオブジェクトとStreamWriterオブジェクトの併用は非常に一般的である。
FileStream fs = new FileStream("C:\\SomeFile.txt",
FileMode.Open, FileAccess.Write, FileShare.Read);
StreamWriter sw = new StreamWriter(fs);
sw.Write ("Hi there");
// 次のClose呼び出しを行ないたい
sw.Close();
// 注意:StreamWriter.Closeは、FileStreamをクローズする。
// このシナリオでは、
// FileStreamを明示的にクローズしてはならない。
StreamWriterのコンストラクタが引数としてFileStreamオブジェクトを取っていることに注意していただきたい。StreamWriterオブジェクトは、その内部でFileStreamのポインタを保存している。これらのオブジェクトはともに、ファイルへのアクセスが終わったときにファイルにフラッシュすべき内部データバッファを持っている。StreamWriterNoCloseメソッドは、FileStreamに最後のデータを書き込み、内部的にFileStreamのCloseメソッドを呼び出す。これがディスクに最後の書き込みを行なってファイルをクローズする。StreamWriterのCloseメソッドが対応するFileStreamオブジェクトをクローズするので、プログラムが自らfs.Closeを呼び出してはならない。
では、2つのClose呼び出しを取り除いたらどうなるだろうか。ガベージコレクタは、オブジェクトがごみになったことを正しく判断し、オブジェクトは完結化される。しかし、ガベージコレクタは、Finalize呼び出しの順番について何も保証していない。そこで、FileStreamが先に完結化されると、FileStreamはファイルをクローズする。そして、StreamWriterを完結化させようとすると、StreamWriterはクローズされたファイルにデータを書き込もうとして例外を生成する。もちろん、StreamWriterが先に完結化されれば、データは安全にファイルに書き込まれる。
Microsoftはこの問題をどのようにして解決したのだろうか。ガベージコレクタに特定の順番でオブジェクトを完結化させることはできない。オブジェクトは互いに相手を指すポインタを持つことができるので、ガベージコレクタがそれらのオブジェクトの完結化の順番を正しく推理することは不可能である。そこでMicrosoftは、Finalizeメソッドをまったく持たないようにStreamWriter型を実装した。もちろん、こうすると、StreamWriterオブジェクトを明示的にクローズすることを忘れたら、確実にデータが失われるということになる。Microsoftは、プログラマがデータの一貫した消失に気づき、明示的なClose呼び出しを挿入してコードをフィックスすることを期待している。
すでに述べたように、SuppressFinalizeメソッドは、オブジェクトのFinalizeメソッドを呼び出すべきではないことを示すビットフラグをセットするだけである。しかし、このフラグは、Common Language RuntimeがFinalizeメソッドを呼び出す直前にリセットされる。そのため、ReRegisterForFinalize呼び出しとSuppressFinalize呼び出しの数は同じにならない場合がある。Figure 9 のコードは、ここで言っていることの意味をはっきりと示している。
void method() {
// The MyObj type has a Finalize method defined for it
// Creating a MyObj places a reference to obj on the finalization table.
MyObj obj = new MyObj();
// Append another 2 references for obj onto the finalization table.
GC.ReRegisterForFinalize(obj);
GC.ReRegisterForFinalize(obj);
// There are now 3 references to obj on the finalization table.
// Have the system ignore the first call to this objectユs Finalize
// method.
GC.SuppressFinalize(obj);
// Have the system ignore the first call to this objectユs Finalize
// method.
GC.SuppressFinalize(obj); // In effect, this line does absolutely
// nothing!
obj = null; // Remove the strong reference to the object.
// Force the GC to collect the object.
GC.Collect();
// The first call to objユs Finalize method will be discarded but
// two calls to Finalize are still performed.
}
|
|
Figure 9 ReRegisterForFinalize と SuppressFinalize
|
ReRegisterForFinalizeとSuppressFinalizeがこのように実装されているのは、処理性能上の理由からである。1つ1つのSuppressFinalize呼び出しが対応するReRegisterForFinalize呼び出しを持てば、すべては正しく動作する。ReRegisterForFinalizeやSuppressFinalizeを続けて複数回呼び出したり、オブジェクトのFinalizeメソッドが複数回呼び出されないようにするのは、プログラマの仕事である。
まとめ
ガベージコレクタに管理された環境を作るのは、プログラマのためにメモリ管理を単純化するためである。今回は、一般的なGCの概念と内部メカニズムをいくつか示した。PartⅡでは、この議論の締めくくりとして、大きなオブジェクトによって管理ヒープにかかるメモリ消費負担を軽減する弱い参照 (WeakReference) と呼ばれる機能を紹介する。次に、管理オブジェクトの寿命を人工的に延長するメカニズムを説明し、最後にガベージコレクタの処理性能のさまざまな側面を取り上げる。また、PartⅡでは、ジェネレーション、マルチスレッドによるガベージコレクション、Common Language Runtimeが公開するパフォーマンスカウンタを取り上げる予定である。パフォーマンスカウンタを使えば、ガベージコレクタの動作をリアルタイムで監視できる。