HotspotVMの圧縮oop

原文

翻訳:中村 成洋

更新 2010/09/19 るとさんからのご指摘で誤訳を修正

更新 2010/09/18 nminoruさんと、Dominion525さんのご指摘で誤訳を修正

oopってなに? なんで圧縮する必要があるの?

Hotspotの専門用語である「oop(ordinary object pointer:通常のオブジェクトポインタ)」はオブジェクトへの管理ポインタのことだ。 通常はネイティブなポインタサイズと同じになる。つまり、LP64の環境であれば64ビットになるということだ。 ILP32の環境では、最大ヒープサイズは4GBより幾分か少なくなる。これは多くのアプリケーションにとって不十分だ。 じゃあ、LP64の環境ではというと、ほとんどの場合のヒープは、対応するIPL32の環境と比べて約1.5倍になってしまう(どちらも同じ状況と仮定)。 管理ポインタのサイズが増大してしまうことが原因となっている*1。 メモリはとても安いものの、近頃は帯域幅とキャッシュが不足しているため、4GBの制限を越えるためだけにヒープサイズが著しく増加してしまうのは厳しい。 そのため、大量のヒープサイズ増加をしようとするときに4GBの制限に引っかかるのは辛い。

(さらに、x86CPUだとILP32で使えるレジスタはLP64のそれの半分くらいしか提供されない。一方、SPARCはそのような影響はない。RISCチップはレジスタ数が最初からもっと多かったが、LP64 モード化の際は(レジスタ数は変わらずに)レジスタ幅だけが広がった。)

「圧縮oop」は32ビットの値で管理ポインタを表す(管理ポインタの多くが圧縮されているが、すべてではない)。 圧縮oopを8倍したものに、64ビットのベースアドレスに加算してオブジェクトへの参照とする。 これによってアプリケーションが40億オブジェクト(バイトじゃない)への参照を許し、ヒープサイズは32GBが上限となる。 または、同時にデータ構造をILP32と同じぐらい小さくできる。

(以降の文章では)我々は「復号」という用語を、32 ビットの圧縮oopを管理ヒープ内の 64 ビットのネイティブのアドレスに変換する操作を指す用語として使用する。逆は「符号化」である。

どのoopが圧縮されるか?

ILP32モード、もしくは、LP64モードで UseCompressesOops フラグがオフの場合は、すべてのoopはネイティブなワードのサイズとなる。

もしUseCompressesOopsがtrueであれば、ヒープ内にある以下のoopが圧縮される。

Javaのクラスに対するHotspotVMのデータ構造は圧縮されない。 これらは大抵Javaヒープのパーマネント領域という所にある。

インタプリタ内部ではoopは圧縮されていることはない。「インタプリタ内部」とはJVMのlocalsやスタック要素、外部の呼び出し引数、そして戻り値を含む*2。インタプリタはヒープからoopをロードするときに先に復号を行い、またヒープに格納するときに符号化を行う。

同様に、インタプリタでも翻訳コードでも、メソッド呼び出し列では圧縮したoopを使用しない。

翻訳コードは、各種最適化の結果次第で oop が圧縮されるか否かが決まる。 翻訳コードがヒープ上の圧縮oopを別の位置に移動させる場合には、復号なしで済ますことができるかもしれない*3。 同様に CPU が x86 のように復号化に使えるアドレッシングモードをサポートしているなら、フィールドやオブジェクト型配列の要素を指定するために使われる圧縮oopであっても、その符号化を省くことができる*4

そのため、コンパイル済みのコードの以下の構造が、「圧縮oop」か「ネイティブなヒープアドレス」のどちらかを参照できる。

Hotspot JVMのC++コード内では、圧縮oopと素のoopの両方の違いをC++の静的な型に反映している。 ほとんどのoopは圧縮されてないだろう。 特に、C++のメンバ関数はレシーバ(this)に関しては通常通りネイティブなマシンワードとして表されたものを扱う。 JVMのいくつかの関数は、圧縮oopと素のoopのどちらでも対処できるようにオーバロードがされていることがある。

以下の重要なC++の値は決して圧縮されない。

C++コードでは圧縮oopを扱う(通常、読み取りや格納する)「narrowOop*6」と呼ばれる型をもっている。

アドレッシングモードを利用した復号

以下に圧縮oopを使用する際のx86の命令列を示す。

! int R8; oop[] R9;  // R9は64ビット
! oop R10 = R9[R8];  // R10は32ビット
! 「64ビットの拡張ベースポインタ」から「圧縮されたポインタ」を読み込み:
movl R10, [R9 + R8<<3 + 16]
! klassOop R11 = R10._klass;  // R11は32ビット
! void* const R12 = GetHeapBase();
! 「圧縮されたベースポインタ」から「圧縮されたklassのポインタ」を読み込み:
movl R11, [R12 + R10<<3 + 8]

以下にSPARCの圧縮oopの復号の命令列を示す。

! java.lang.Thread::getThreadGroup@1 (line 1072)
! L1 = L7.group
ld  [ %l7 + 0x44 ], %l1
! L3 = decode(L1)
cmp  %l1, 0
sllx  %l1, 3, %l3
brnz,a   %l3, .+8
add  %l3, %g6, %l3  ! %g6はヒープベースの定数

(上記の注釈付きの出力はディスアセンブリプラグインによるもの)

Nullの処理

32ビットの0の値は64ビットのネイティブなnullの値に復号される。 nullにならないと保証されている圧縮oop(klassフィールドなど)についてはnullを考慮しないシンプルな復号、符号化を行いたいので、少々汚いが、復号処理には特別なパスを設ける必要がある。

「暗黙のnullのチェック」はJVMのパフォーマンス(インタラプトやバイトコードコンパイルどちらも)にとって、とても重要だ。 もしベースポインタがnullだった場合、十分小さなオフセットをベースポインタに対して使用してメモリを参照すると、シグナルとかトラップとかいったものを引き起こす。なぜなら、最初のページや仮想アドレス空間の最初の部分はマッピング*7されていないからだ*8

圧縮済みoopについても管理ヒープが使う最初のページや仮想アドレスの最初の部分をアンマップすると、同じようなトリックを使える場合がある。考えかたとしては、もし圧縮済みのnullを(シフトとヒープベースの加算によって)復号した場合でも、そのまま読み込みや書き込みの処理に使ってもよいということである。この場合でもコードは暗黙のnullチェックの恩恵を受けられる*9

オブジェクトヘッダの構成

オブジェクトヘッダには、ネイティブなサイズのmarkワード、klassワード、32ビットのlengthワード(オブジェクトが配列の場合)、32ビットのgap(隙間:アラインメントに必要な場合がある)、そして、0からそれ以上のインスタンスフィールド、配列要素かメタデータフィールド(面白いトリビア:klassのメタオブジェクトはklassワードの直後にC++のvtableを埋め込んでいる*10)がある。

もしgapフィールドがあれば、ほとんどの場合、インスタンスフィールドには何かを格納可能である。

UseCompressesOopsがfalse(またILP32のシステム)の場合、markとklassはどちらもネイティブなワードサイズになる。配列の場合、LP64のシステムはgapはいつも定義される。ILP32のシステムにおいては配列の要素がすべて64ビットである場合にのみ定義される。

UseCompressesOopsがtrueの場合、klassは32ビットだ。非配列はgapフィールドをklassの直後に持つが、配列はlengthフィールドをklassの直後に持つ。

ゼロベースの圧縮oop

圧縮oopは不明瞭なアドレス(narrow-oop-base)を使う。 narrow-oop-baseは暗黙のnullチェックを行うためにJavaヒープベースから1つの(保護された)ページサイズを引いたものとして計算される。 つまり、通常のフィールドの参照は次のような意味になる。

<narrow-oop-base> + (<narrow-oop> << 3) + <field-offset>.

もしnarrow oop baseをゼロとできるならば*11(javaのヒープが実際にオフセット0から始まる必要はない)、通常のフィールド参照は次のようになる。

(<narrow-oop << 3) + <field-offset>

理論的には、ヒープベースの加算を節約できるということである(現在のレジスタアロケータはレジスタを保存することを許容していない)*12。ゼロベースだと圧縮oopのnullチェックは不要だ。 圧縮したoopの復号化のコードは現在以下のようになっている。

if (<narrow-oop> == NULL)
    <wide_oop> = NULL
else
    <wide_oop> = <narrow-oop-base> + (<narrow-oop> << 3)

ゼロのnarrow-oop-baseの場合はとてもシンプルだ。 圧縮oopの復号/符号化にはシフト演算だけを必要する。

<wide_oop> = <narrow-oop> << 3

また、もしjavaのヒープサイズが4GB未満であり、ヒープを低い仮想アドレス(4GB未満)に持っていけるならば、圧縮oopは符号化・復号無しで使える*13

ゼロベースの実装では、Javaヒープの実装に対して、ヒープサイズとプラットフォームによって異なる戦略を採用する。 まず、最初に「ヒープサイズ < 4GB」であるならば、圧縮oopを復号無しで使うために4GB以下にヒープサイズを割り当てようとする。 それが失敗する、あるいは、「ヒープサイズ > 4GB」である場合、ゼロベースの圧縮oopを使うために32GB以下にヒープを割り当てようとする。 これも失敗するならば、narrow-oop-baseを使った通常の圧縮oopを使用する。

翻訳者コメント

翻訳は、中村 成洋<authornari _at_ gmail.com> が行ないました。

実装を見ないとよく分からない所がいっぱいありますので注意した方がいいです。 補足記事をどこかで書くかもしれません。

もしも、誤植や誤訳などがありましたらお気軽に authornari _at_ gmail.comまでお知らせ ください。

Copyright (C) 2010 中村成洋


*1訳注:LP64は64ビット、ILP32は32ビットとなる
*2訳注:その次の文と合わせて、ヒープの値は圧縮されている場合があるが、localsやスタック要素、外部の呼び出し引数、そして戻り値は必ず復号されているという意味
*3訳注:インタプリタでは、単純なコピーであってもヒープからスタックに展開して読み込み、改めて圧縮してから別の場所に書き込む
*4訳注:x86における「ベースレジスタの値 + インデックスレジスタ * スケール + 定数のアドレス」とうようなアドレッシングを利用して、ベースレジスタにベースアドレスを入れ、インデックスレジスタに圧縮oopを入れ、スケールを8にして定数をフィールドのオフセットとすると圧縮oopを手動で復号しなくてもよい。フィールドや配列アクセスもできる点を強調しているのは、フィールドや配列は32bit境界でアラインされているかもしれないので、圧縮したままのoopの操作だけではアクセスできず、「定数」部分や「ベースレジスタ」の値を変えないといけないため
*5訳注:native methodの略。Javaのメソッドがコンパイルされたもの
*6訳注:naroow(狭い)は32ビットのoopを表す。逆にwide(広い)は64ビットのoopだ。
*7訳注:物理メモリに対して
*8訳注:普通最初のページは決してマッピングしないので必ずシグナルが発行される(なので十分小さなオフセットでアクセスしようとしたことを、事前のチェック無しで感知できる)
*9訳注:ベースアドレスを含むページをアンマップしておけば、圧縮済みのnullを展開したもの(ネイティブのアドレスとしてはnullではない)にアクセスしたときにシグナルが発行されるので、nullアクセスを感知できるということ
*10訳注:http://d.hatena.ne.jp/authorNari/20100917/1284736413#c1284746524を参照
*11訳注:javaのヒープが0x0000000010000000から始まるとして、0x0000000010001000を指すアドレスを圧縮する際にヒープの開始位置からのオフセット0x00001000ではなく、0からのオフセット0x10001000を使うということ
*12訳注:実際に節約できるかどうかはCPUの実装次第なので、「理論的には」と言っていると思われる。その次の括弧はおそらく、使わなくなったベースレジスタを計算用に使うとかはまだ実装していないという意味
*13訳注:ヒープが0x0x0000000010000000から0x000000001FFFFFFFであった場合、ネイティブのアドレスをシフト演算無しで32bitに切り詰めたものを圧縮oopとして使える。当然アクセスする場合も復号は必要ない