更新履歴

(2005.11.18) 脚注*2を加筆。

(2005.11.17) 文章を推敲。

(2005.11.14) NMT bit の read barrier について嘘を書いていたので修正。



Azul Sysmtes (米日) は Java や .NET に特化した専用計算機 Network Attached Processing (NAP) を提唱し、 製品として Azul Compute Appliance を開発した。 Azul Compute Appliance は、 すでに稼動中の Solaris/Linux の J2SE/J2EE システムの Java VM を Azul Systems が提供するスタブ JVM に置き換えるだけで、 元のマシンで実行されていたプログラム(バイトコード) が Azul Compute Appliance に転送・実行されるようになる リモート計算機だ (*1)。

Azul Systems は専用の CPU (Vega) を一から設計・実装し、 その上で動く Azul Virtual Machine も独自開発した(*2)。 そのためふつ〜のプロセッサの Java VM にはない独自機構を数多く備えている。 このページでは NAP の特殊な機構のうち、 JavaOne Tokyo 2005 で 開発者の Dr. Cliff Click から聞いた GC アルゴリズム部分を紹介する。

ガーベージコレクション(GC) の分類方法には Stop-The-World 型 (STW) と On-the-fly 型 の 2 つに分ける方法がある。

STW は GC が起動する前にアプリケーションスレッド(Java の場合は Java スレッド) を「時よ止まれ」と全て停止ささせ、 その後にガーベージとなったオブジェクトを回収し、 回収が終わったらアプリケーションスレッドを再開するタイプの GC だ。 そのためどうしても GC ポーズ時間 が生じる。 このポーズ時間の間、 タイマー処理もネットワークの待ち受けも停止して何もできない状態が続く。 そのためポーズ時間が長すぎるといろいろ破綻が起きる。

一方 On-the-fly 型 GC の場合、アプリケーションスレッドと GC スレッドが平行に動作し、 アプリケーションスレッドを完全に停止させることなく GC スレッドがガーベージを再利用可能にしてゆく。

上の説明だけを読むと On-the-fly 型の方が優れていると思うかもしれないが、 GC 以外も含めた全体性能・信頼性を比較すると On-the-fly 型 GC は STW 型 GC に負けることが多い。 そのため各種言語・ランタイムの GC アルゴリズムを見ると、 STW 型を採用する方が多い。

ただ GC 採用に先鞭をつけた Java システムは、 エンタープライズシステムの世界で使用されることが多くなってきた。 STW 型 GC の多くはメモリの使用量に比例して GC ポーズ時間が伸びてゆく傾向があるので、 Java 仮想マシンにギガバイト単位のメモリが与えられると、 GC ポーズ時間は数秒〜数分に到達するようになる。 これではたまらんので最近の Sun、IBM の Java 仮想マシンは STW 型と On-the-fly 型の折衷である Mostly Concurrent Mark & Sweep を採用したりして、GC ポーズ時間の増大を凌ごうとしている。 しかし mostly という単語がつくことから分かるように この GC は完全な無停止 GC ではなく、 やはり使用メモリ量に比例したGC停止時間が必要となる。

ここから本題。 Azul Sytems の NAP は Pauseless GC という一風変わった GC アルゴリズムを採用している。 Pauseless GC は理論上 STW がない。 にも関わらず性能もあまり悪くないというキ印もの。

Pauseless GC は、 まず以下の 3 つのフェーズを持つ GC である。

GC スレッドは marking → relocation → remap の順に GC を実行し、 その間にアプリケーションスレッドも平行(concurrent) に動作する。 また 1回目の GC の remap phase と 2 回目の GC の makring phase も concurrent に重ね合わせることが可能だ。

注目すべきは Pauseless GC は「オブジェクトの移動」をやるタイプの GC だという点。 GC の別の分類方法では、 Mark & Sweep GC のようにオブジェクトを割り当てたら移動させず空き領域はフリーリストで管理する GC と、 Copying GC や Mark & Compaction GC のようにオブジェクトを移動させて 連続した空き領域を作る GC に分類することができる。

前者のオブジェクトの移動をしないタイプの方が GC 時間は短くなるし concurrent にもしやすいが、 どうしてもメモリ空間の断片化が避けられない。 生きているオブジェクトが飛び石のようにヒープ中に散らばるので、 プログラムが長い間 稼動していると 「100MBのメモリがあって 90% はフリーなのに 10K バイトのオブジェクトが割り付けられない」というような事態に陥る。 それじゃ困るので実用を考えると メインの GC が非移動型でも フラグメンテーションが進んだ場合の奥の手として移動型 GC を用意せざるえない。

一方、 後者のオブジェクト移動型 GC は メモリ空間に隙間がなくなりフリーな領域を有効に使用できるし、 空間内のオブジェクトの充填率が高い分 キャッシュヒット率も上げることができる。 ただしふつ〜のアーキテクチャでの GC 実装では、 relocation と remap は Java スレッドを停止させた状態で同時に行う必要がある(*3)。 そのため 1回の GC ポーズ時間がどうしても長くなる傾向にある。 しかし Pauseless GC の場合 ここも concurrent に動作させることができる のが凄いのだ。

もう一点すごいのは marking が完全に concurrent な点だ。 Mostly concurrent ではない。 この実現方法が凄いと言うか、インチキというか... 鍵になっているのは ハードウェア・サポート だ。

Marking を Java スレッドと GC スレッドで並列にやるのは難しい。 2つ問題がある。

1つは、 marking をしている間に Java スレッドがプログラムを進めてしまうと、 それまで生きていたオブジェクトが死んでしまう可能性があること。 そのため concurrent marking は「本当はガーベージなんだけど生きているとマークをつけてしまう」可能性がある。 ただこういう漏れガーベージは、効率が悪くなるがエラーにはならないので、無視することは可能だ。

もう1つはより深刻で、 「本当は生きているんだけどマークがつけられない」可能性だ。 マーキングした後のオブジェクトを Java スレッドが書き換えてしまうと問題が発生する。 図1のようなパターンは、 GC スレッドと Java スレッドがコンテキストスイッチによってタイムスライスしながら動いていると考えて欲しい。 GC スレッドが Step1 まで処理を進めた後で停止し、Java スレッドが Step2 の処理をしてしまうと、 Step3 で GC スレッドが再開してもオブジェクト E が未マークのまま放置されることになる。 誤ってガーベージと判定されてしまうのだ。 オブジェクト E を回収してしまうと、 エラーが発生することになる。

このマーク漏れの問題を Mostly Concurrent Marking は、 concurrent marking phase と serial makring phase の 2つに分けることで解決した。 最初、GC スレッドが concurrent marking phase を処理している間は Java スレッドに動作させることができるが、 concurrent marking phase が終了すると Java スレッドは停止させて GC スレッドだけが serial marking phase で塗り洩らしの解決をする。 つまり serial marking phase 中は STW だ。

塗り残しとなるのは、 Java スレッドによって参照が書き換えられたオブジェクトである。 そのため Java スレッドは write barrier を導入し、 参照の書き換えが起こったオブジェクトに専用のタグを打っていく。 Serial marking phase では、 「マーク済みであり」かつ「書き換えタグ」がついたオブジェクトを起点にして 2度目のマーキングを行う。 処理が完了すれば、生きているオブジェクトの全てがマーク済みになる。

だが mostly concurrent marking には serial makring phase でどうしても STW が必要になってしまう。 経験的には concurrent marking phase で大部分のマーキングを済ませることができ serial marking phase は残りの部分だけを行うので 大概の場合には STW 時間は短い。 ただしオブジェクトのリンク状態によっては serial marking phase に時間がかかることもある。 このため「最悪」の GC ポーズ時間は短くならない。

Pauseless GC は別のアプローチを使う。 それは巡回したオブジェクトにマークをつけると同時に、 巡回に使った参照にもチェックをつけるという方法だ。 Java の参照は実装的にはメモリ上のオブジェクトの位置を指す「ポインタ」として実装されている。 Java オブジェクトは 64 ビット境界にあわせて配置されるので 「ポインタ」の下位3ビットは常に 0 になる。 常に 0 になっているビットというのは冗長なので、 このビットの一つを Not-Marked-Through (NMT) bit というタグに転用する。 GC スレッドは marking 中に巡回した参照(ポインタ)の NMT ビットを立て、 巡回・未巡回を区別していく。

同時に Java スレッドも concurrent marking に協力する。 Java スレッドがオブジェクトの参照をする時に read barrier を行う。 NMT bit がセットされていない参照(ポインタ)をロードすると、 Java スレッドは発見した未巡回ポインタを GC スレッドの queue に突っ込み、 (後で同じトラップに引っかからないように) NMT bit を立てて元の処理に戻る。 C++ であらわすと LoadReferenceWithReadBarrier のようになる。

Object* LoadReferenceWithReadBarrier(Object** address) { intptr_t value = (intptr_t)(*address); if (value & NMT_MASK) { return (Object*)(~7 & value); // 下位3ビットのクリア } else { Object* pointer = (Object*)value; push_marking_queue(pointer); // marking queue *address = (Object*) (value | NMT_MASK); // NMT bit をセット return pointer; } } // メモリ上にあるオブジェクトのポインタ pointer を使用する時は LoadReferenceWithReadBarrier を通してから extern Object* pointer; Object* p = LoadReferenceWithReadBarrier(&pointer); int fieldValue = p->getField(fieldDescriptor);