CompressedOops

Oracle ACE
作成者:john.rose。最終更新者:k_v_n(2009年5月15日)

Hotspot JVMの圧縮OOP

OOPとは何か、なぜOOPを圧縮する必要があるのか

HotSpotの専門用語である"OOP"("Ordinary Object Pointer")は、オブジェクトに対する管理ポインタを表します。 これは通常、ネイティブ・マシンのポインタと同じサイズになります。つまり、LP64システムでは64ビットです。 ILP32システムでは、最大ヒープ・サイズは4GBよりやや少ない程度です。これは多くのアプリケーションにとって十分ではありません。 一方、LP64システムでは、実行時のヒープは対応するILP32システムの約1.5倍必要となることもあります(どちらのモードも同じ状況と仮定した場合)。 これは、管理ポインタのサイズが増大することが原因となっています。 メモリのコストは低いですが、最近では帯域幅およびキャッシュが不足しているため、4GBという制限を解決するためだけにヒープ・サイズが大幅に増加してしまうのは厳しいところです。

(さらに、x86チップの場合、ILP32モードで利用できるレジスタはLP64モードの半分しかありません。 SPARCはこのような影響を受けません。RISCチップはもともとレジスタ数が多く、LP64モードでは単にレジスタ幅が拡大しています。)

圧縮OOPは(JVMのすべてではないものの、多くの領域で)32ビットの値で管理ポインタを表します。この管理ポインタの参照先のオブジェクトを見つけるには、この値を8倍して64ビット・ベースのアドレスに加算する必要があります。 これによって、アプリケーションは最大40億のオブジェクト(バイトではありません)、または最大約32GBのヒープ・サイズに対応できます。 同時に、データ構造はILP32モードと匹敵するほどにコンパクトです。

ここでは、32ビットの圧縮OOPが管理ヒープ内の64ビットのネイティブ・アドレスに変換される処理を、デコードという語で表します。 この逆の処理は、エンコードです。

圧縮されるOOPの種類

ILP32モードのJVMの場合、またはLP64モードでUseCompressedOopsフラグをオフにした場合、すべてのOOPはネイティブ・マシンのワード・サイズとなります。

UseCompressedOopsがtrueの場合は、ヒープ内の次のOOPが圧縮されます。

  • すべてのオブジェクトのklassフィールド
  • すべてのOOPのインスタンス・フィールド
  • OOP配列(objArray)の全要素

Javaクラスを管理するHotspot VMのデータ構造は圧縮されません。 これらのデータ構造は一般的に、Permanent Generation(PermGen)として知られるJavaヒープ領域に存在します。

インタプリタでは、OOPが圧縮されることはありません。 このOOPには、JVMローカルおよびスタック要素、外部への呼出し引数、戻り値が含まれます。 インタプリタはヒープから読み込まれたOOPをしきりにデコードし、OOPをヒープに保存する前にエンコードします。

同様に、メソッドの呼出しシーケンスでは、インタプリタの場合でもコンパイルの場合でも、圧縮したOOPは使用しません。

コンパイル済みコードでは、各種最適化の結果によって、OOPが圧縮される場合とされない場合があります。 最適化されたコードが、管理ヒープ内のある場所から別の場所に、圧縮OOPをデコードせずに移動できる可能性があります。 同様に、チップ(x86など)でデコード処理に使用できるアドレッシング・モードがサポートされている場合は、オブジェクト・フィールドや配列要素を指定するために使用される圧縮OOPでも、圧縮OOPはデコードされない場合があります。

そのため、コンパイル済みコード内の次の構造は、圧縮OOPかネイティブ・ヒープ・アドレスのいずれかを参照できます。

  • レジスタまたはスピル・スロットのコンテンツ
  • OOPマップ(GCマップ)
  • デバッグ情報(OOPマップにリンクされているもの)
  • マシン・コードに直接埋め込まれたOOP(この機能のある、x86などの非RISCチップの場合)
  • nmethodの定数部分のエントリ(マシン・コードに影響する再配置に使用されるものを含む)

HotSpot JVMのC++コードでは、圧縮OOPとネイティブOOPの違いをC++の静的な型のシステムに反映しています。 一般的に、多くの場合OOPは圧縮されていません。 特にC++メンバー関数は、通常どおりネイティブ・マシン・ワードで表されたレシーバ(this)上で処理を行います。 JVMのいくつかの関数は、圧縮OOPとネイティブOOPのいずれでも対応できるようにオーバーロードされます。

以下の重要なC++値は圧縮されることはありません。

  • C++オブジェクト・ポインタ(this
  • 管理ポインタに対するハンドル(Handle型など)
  • JNIハンドル(jobject型)

C++コードには、圧縮OOPを操作(通常はロードまたは保存)している場所をマークするnarrowOopという型があります。

アドレッシング・モードを使用した圧縮の復元

圧縮OOPを使用するx86の命令シーケンスの例は、次のとおりです。

! int R8; oop[] R9;  // R9 is 64 bits
! oop R10 = R9[R8];  // R10 is 32 bits
! load compressed ptr from wide base ptr:
movl R10, [R9 + R8<<3 + 16]
! klassOop R11 = R10._klass;  // R11 is 32 bits
! void* const R12 = GetHeapBase();
! load compressed klass ptr from compressed base ptr:
movl R11, [R12 + R10<<3 + 8]

圧縮OOP(nullの可能性もあるもの)をデコードするSPARCの命令シーケンスの例は、次のとおりです。

! 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 is constant heap base

(注釈付きの出力は、逆アセンブル・プラグインによるものです。)

nullの処理

32ビットのゼロ値は、64ビットのネイティブのnull値にデコードされます。 このため、デコードのロジックに特別なパスを追加する必要があります。たとえば、nullにはならないと保証された圧縮OOP(klassフィールドなど)を静的に定め、よりシンプルな完全デコード/エンコード処理を使用すると効果的となります。

暗黙的なnullチェックは、インタプリタによるバイトコードでもコンパイルによるバイトコードでも、JVMのパフォーマンスにとって極めて重要です。 十分小さなオフセットをベース・ポインタに対して使用しメモリを参照すると、ベース・ポインタがnullの場合は、確実に何らかのトラップやシグナルが発生します。これは、仮想アドレス空間の最初のページなどはマップされないからです。

圧縮OOPでも同様の技術を使用できることがあります。この場合、管理ヒープに使用される仮想アドレスの最初のページなどのマップを解除します。 考え方としては、圧縮後のnullを(シフトとヒープ・ベースの加算によって)デコードした場合でも、ロードまたは保存の処理で使用できるようになります。この場合でも、コードは暗黙nullチェックを利用できます。

オブジェクト・ヘッダーのレイアウト

オブジェクト・ヘッダーは、ネイティブサイズのmarkワード、klassワード、32ビットのlengthワード(オブジェクトが配列の場合)、32ビットのgap(配置ルールで必要な場合)、さらには0個以上のインスタンス・フィールド、配列要素、またはメタデータ・フィールドで構成されています。 (興味深いトリビア: Klassメタオブジェクトには、klassワードのすぐ後にC++ vtableが含まれています。)

gapフィールドが存在すると、多くの場合、インスタンス・フィールドの保存に使用できます。

UseCompressedOopsがfalseの場合(かつ常にILP32システムにある場合)、markとklassは両方ともネイティブ・マシン・ワードです。 配列については、gapはLP64システム上では必ず存在します。また、ILP32システム上では64ビット要素の配列でのみ存在します。

UseCompressedOopsがtrueの場合、klassは32ビットです。 非配列の場合は、klassのすぐ後にgapフィールドがあり、配列の場合はklassのすぐ後にlengthフィールドが保存されます。

ゼロベースの圧縮OOP

圧縮OOPでは、narrow oop baseに対して任意のアドレスが使用されます。これは、暗黙nullチェックが動作するように、Javaヒープ・ベースから(保護された)ページ・サイズ1つ分を除算したものとして計算されます。 つまり、通常のフィールド参照は次のようになります。

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

narrow oop baseを0にできる場合(Javaヒープが実際はオフセット0で開始する必要はない)、通常のフィールド参照は単純に次のようになります。

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

理論的には、これによってヒープ・ベースの加算を節約できるようになります(現在のレジスタ・アロケータでは、レジスタを保存できません)。 また、ゼロベースによって、圧縮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はエンコード/デコードなしで使用できます。

ゼロベースの実装では、Javaヒープ・サイズおよび稼働するプラットフォームによって異なる戦略を使用して、Javaヒープを割り当てようとします。
まず、ヒープ・サイズが4GB未満の場合は、Javaヒープを4GB未満に割り当てて、デコードなしで圧縮OOPを使用するよう試みます。
これが失敗するか、ヒープ・サイズが4GBを超える場合は、32GB未満にヒープを割り当てて、ゼロベース圧縮OOPを使用するよう試みます。
さらにこれが失敗する場合は、narrow oop baseを使用した通常の圧縮OOPに切り替えます。

▲ ページTOPに戻る