1. 概要

このチュートリアルでは、JVMがオブジェクトと配列をヒープ内にどのようにレイアウトするかを見ていきます。

まず、少し理論から始めましょう。 次に、さまざまな状況でのさまざまなオブジェクトと配列のメモリレイアウトについて説明します。

通常、ランタイムデータ領域のメモリレイアウトはJVM仕様の一部ではなく、実装者裁量に任されています。 したがって、各JVM実装には、メモリ内のオブジェクトと配列をレイアウトするための異なる戦略がある場合があります。 このチュートリアルでは、特定のJVM実装であるHotSpotJVMに焦点を当てています。

また、JVMとHotSpotJVMの用語を同じ意味で使用する場合もあります。

2. 通常のオブジェクトポインタ(OOP)

HotSpot JVMは、Ordinary Object Pointers(OOPS)と呼ばれるデータ構造を使用して、オブジェクトへのポインターを表します。 JVM内のすべてのポインター(オブジェクトと配列の両方)は、と呼ばれる特別なデータ構造に基づいています oopDesc。  oopDesc 次の情報を使用してポインタを記述します。

マークワードは、オブジェクトヘッダーを表します。 HotSpot JVMはこの単語を使用して、IDハッシュコード、バイアスされたロックパターン、ロック情報、およびGCメタデータを格納します。 

さらに、マークワードの状態には uintptr_tのみが含まれるため、そのサイズは32ビットアーキテクチャと64ビットアーキテクチャでそれぞれ4〜8バイトの間で変化します。また、マークワードバイアスされたオブジェクトと通常のオブジェクトは異なります。 ただし、Java 15はバイアスロックを廃止するため、通常のオブジェクトのみを考慮します。

さらに、 klass word は、クラス名、その修飾子、スーパークラス情報などの言語レベルのクラス情報をカプセル化します。

instanceOop として表されるJavaの通常のオブジェクトの場合、オブジェクトヘッダーは、マークとクラスの単語に加えて、可能な配置パディングで構成されます。 オブジェクトヘッダーの後に、インスタンスフィールドへの参照が0個以上ある場合があります。 つまり、64ビットアーキテクチャでは、マークが8バイト、クラスが4バイト、パディング用にさらに4バイトあるため、少なくとも16バイトになります。

アレイの場合、次のように表されます arrayOop オブジェクトヘッダーには、マーク、クラス、およびパディングに加えて、4バイトの配列長が含まれます。 繰り返しになりますが、マークが8バイト、クラスが4バイト、配列の長さがさらに4バイトであるため、これは少なくとも16バイトになります。

理論について十分に理解できたので、実際にメモリレイアウトがどのように機能するかを見てみましょう。

3. JOLの設定

JVM内のオブジェクトのメモリレイアウトを検査するために、Javaオブジェクトレイアウト( JOL )をかなり広範囲に使用します。 したがって、jol-core依存関係を追加する必要があります。

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.10</version>
</dependency>

4. メモリレイアウトの例

まず、VMの一般的な詳細を見てみましょう。

System.out.println(VM.current().details());

これは印刷されます:

# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

これは、参照が4バイト、 booleanbyteが1バイト、 shortcharが2バイトかかることを意味します。 、 intsとfloatは4バイトを取り、最後に longsとdoubleは8バイトを取ります。 興味深いことに、配列要素として使用すると、同じ量のメモリを消費します。

また、-XXを介して圧縮参照を無効にすると、-UseCompressedOops、参照サイズのみが8バイトに変更されます。

# Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

4.1. 基本

SimpleIntクラスについて考えてみましょう。

public class SimpleInt {
    private int state;
}

クラスレイアウトを印刷する場合:

System.out.println(ClassLayout.parseClass(SimpleInt.class).toPrintable());

次のようなものが表示されます。

SimpleInt object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12     4    int SimpleInt.state                           N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

上に示したように、オブジェクトヘッダーは12バイトで、8バイトのマークと4バイトのクラスが含まれます。 その後、int状態用に4バイトがあります。 合計で、このクラスのオブジェクトは16バイトを消費します。

また、インスタンスレイアウトではなくクラスレイアウトを解析しているため、オブジェクトヘッダーと状態の値はありません。

4.2. アイデンティティハッシュコード

hashCode()は、すべてのJavaオブジェクトに共通のメソッドの1つです。 hashCode()を宣言しない場合クラスのメソッドであるJavaは、そのクラスのIDハッシュコードを使用します。 

IDハッシュコードは、オブジェクトの存続期間中は変更されません。 したがって、 HotSpot JVMは、計算されると、この値をマークワードに格納します。

オブジェクトインスタンスのメモリレイアウトを見てみましょう。

SimpleInt instance = new SimpleInt();
System.out.println(ClassLayout.parseInstance(instance).toPrintable());

HotSpot JVMは、IDハッシュコードを遅延計算します。

SimpleInt object internals:
 OFFSET  SIZE   TYPE DESCRIPTION               VALUE
      0     4        (object header)           01 00 00 00 (00000001 00000000 00000000 00000000) (1) # mark
      4     4        (object header)           00 00 00 00 (00000000 00000000 00000000 00000000) (0) # mark
      8     4        (object header)           9b 1b 01 f8 (10011011 00011011 00000001 11111000) (-134145125) # klass
     12     4    int SimpleInt.state           0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

上に示したように、マークワードは現在、まだ重要なものを何も保存していないようです。

ただし、オブジェクトインスタンスで System.identityHashCode()または Object.hashCode()を呼び出すと、これは変わります。

System.out.println("The identity hash code is " + System.identityHashCode(instance));
System.out.println(ClassLayout.parseInstance(instance).toPrintable());

これで、マークワードの一部としてIDハッシュコードを見つけることができます。

The identity hash code is 1702146597
SimpleInt object internals:
 OFFSET  SIZE   TYPE DESCRIPTION               VALUE
      0     4        (object header)           01 25 b2 74 (00000001 00100101 10110010 01110100) (1957831937)
      4     4        (object header)           65 00 00 00 (01100101 00000000 00000000 00000000) (101)
      8     4        (object header)           9b 1b 01 f8 (10011011 00011011 00000001 11111000) (-134145125)
     12     4    int SimpleInt.state           0

HotSpot JVMは、マークワードに「25b27465」としてIDハッシュコードを格納します。 JVMはその値をリトルエンディアン形式で格納するため、最上位バイトは65です。 したがって、ハッシュコード値を10進数(1702146597)で復元するには、「25b27465」バイトシーケンスを逆の順序で読み取る必要があります。

65 74 b2 25 = 01100101 01110100 10110010 00100101 = 1702146597

4.3. アラインメント

デフォルトでは、JVMはオブジェクトに十分なパディングを追加して、そのサイズを8の倍数にします。

たとえば、SimpleLongクラスについて考えてみます。

public class SimpleLong {
    private long state;
}

クラスレイアウトを解析する場合:

System.out.println(ClassLayout.parseClass(SimpleLong.class).toPrintable());

次に、JOLはメモリレイアウトを出力します。

SimpleLong object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12     4        (alignment/padding gap)                  
     16     8   long SimpleLong.state                          N/A
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

上に示したように、オブジェクトヘッダーと longstateは合計で20バイトを消費します。 このサイズを8バイトの倍数にするために、JVMは4バイトのパディングを追加します。

-XX:ObjectAlignmentInBytesチューニングフラグを使用してデフォルトの配置サイズを変更することもできます。たとえば、同じクラスの場合、 -XX:ObjectAlignmentInBytes =16のメモリレイアウトは次のようになります。

SimpleLong object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12     4        (alignment/padding gap)                  
     16     8   long SimpleLong.state                          N/A
     24     8        (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 4 bytes internal + 8 bytes external = 12 bytes total

オブジェクトヘッダーとlong変数は、まだ合計20バイトを消費します。 したがって、16の倍数にするために、さらに12バイトを追加する必要があります。

上に示したように、4つの内部パディングバイトを追加して、 long 変数をオフセット16で開始します(より整列されたアクセスを可能にします)。 次に、long変数の後に残りの8バイトを追加します。

4.4. フィールドパッキング

クラスに複数のフィールドがある場合、JVMは、パディングの無駄を最小限に抑えるような方法でそれらのフィールドを分散する場合があります。 たとえば、 FieldsArrangement クラス:

public class FieldsArrangement {
    private boolean first;
    private char second;
    private double third;
    private int fourth;
    private boolean fifth;
}

フィールド宣言の順序とメモリレイアウトでの順序は異なります。

OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
      0    12           (object header)                           N/A
     12     4       int FieldsArrangement.fourth                  N/A
     16     8    double FieldsArrangement.third                   N/A
     24     2      char FieldsArrangement.second                  N/A
     26     1   boolean FieldsArrangement.first                   N/A
     27     1   boolean FieldsArrangement.fifth                   N/A
     28     4           (loss due to the next object alignment)

この背後にある主な動機は、パディングの無駄を最小限に抑えることです。

4.5. ロック

JVMは、マークワード内のロック情報も保持します。 これを実際に見てみましょう:

public class Lock {}

このクラスのインスタンスを作成すると、そのメモリレイアウトは次のようになります。

Lock object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 
      4     4        (object header)                           00 00 00 00
      8     4        (object header)                           85 23 02 f8
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes

ただし、このインスタンスで同期する場合:

synchronized (lock) {
    System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}

メモリレイアウトが次のように変更されます。

Lock object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           f0 78 12 03
      4     4        (object header)                           00 70 00 00
      8     4        (object header)                           85 23 02 f8
     12     4        (loss due to the next object alignment)

上記のように、モニターロックを保持していると、マークワードのビットパターンが変化します。

4.6. 年齢と在職期間

オブジェクトを古い世代に昇格させるには(もちろん、世代別GCで)、JVMは各オブジェクトの存続数を追跡する必要があります。 前述のように、JVMはこの情報もマークワード内に保持します。

マイナーGCをシミュレートするために、オブジェクトを volatile 変数に割り当てることにより、大量のガベージを作成します。 このようにして、JITコンパイラによるデッドコードの除去の可能性を防ぐことができます。

volatile Object consumer;
Object instance = new Object();
long lastAddr = VM.current().addressOf(instance);
ClassLayout layout = ClassLayout.parseInstance(instance);

for (int i = 0; i < 10_000; i++) {
    long currentAddr = VM.current().addressOf(instance);
    if (currentAddr != lastAddr) {
        System.out.println(layout.toPrintable());
    }

    for (int j = 0; j < 10_000; j++) {
        consumer = new Object();
    }

    lastAddr = currentAddr;
}

ライブオブジェクトのアドレスが変更されるたびに、これはおそらくマイナーなGCとサバイバースペース間の移動が原因です。変更ごとに、新しいオブジェクトレイアウトも印刷して、エージングオブジェクトを確認します。

マークワードの最初の4バイトが時間の経過とともにどのように変化するかを次に示します。

09 00 00 00 (00001001 00000000 00000000 00000000)
              ^^^^
11 00 00 00 (00010001 00000000 00000000 00000000)
              ^^^^
19 00 00 00 (00011001 00000000 00000000 00000000)
              ^^^^
21 00 00 00 (00100001 00000000 00000000 00000000)
              ^^^^
29 00 00 00 (00101001 00000000 00000000 00000000)
              ^^^^
31 00 00 00 (00110001 00000000 00000000 00000000)
              ^^^^
31 00 00 00 (00110001 00000000 00000000 00000000)
              ^^^^

4.7. 偽共有と@Contended

jdk.internal.vm.annotation.Contended アノテーション(またはJava8ではsun.misc.Contended )は、JVMがを回避するためにアノテーション付きフィールドを分離するためのヒントです。偽共有

簡単に言うと、 Contended アノテーションは、各アノテーション付きフィールドの周囲にいくつかのパディングを追加して、各フィールドを独自のキャッシュラインで分離します。 したがって、これはメモリレイアウトに影響を与えます。

これをよりよく理解するために、例を考えてみましょう。

public class Isolated {

    @Contended
    private int v1;

    @Contended
    private long v2;
}

このクラスのメモリレイアウトを調べると、次のようになります。

Isolated object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12   128        (alignment/padding gap)                  
    140     4    int Isolated.i                                N/A
    144   128        (alignment/padding gap)                  
    272     8   long Isolated.l                                N/A
Instance size: 280 bytes
Space losses: 256 bytes internal + 0 bytes external = 256 bytes total

上に示したように、JVMは各注釈付きフィールドの周りに128バイトのパディングを追加します。 最近のほとんどのマシンのキャッシュラインサイズは約64/128バイトであるため、128バイトのパディングです。もちろん、 Contendedパディングサイズは-XX:ContendedPaddingWidthで制御できます。 チューニングフラグ。

Contended アノテーションはJDK内部であるため、使用を避ける必要があることに注意してください。

また、 -XX:-RestrictContended調整フラグを使用してコードを実行する必要があります。 そうしないと、注釈が有効になりません。 基本的に、デフォルトでは、このアノテーションは内部のみの使用を目的としており、 RestrictContended を無効にすると、パブリックAPIのこの機能のロックが解除されます。

4.8. 配列

前述のように、配列の長さも配列oopの一部です。たとえば、3つの要素を含む boolean配列の場合:

boolean[] booleans = new boolean[3];
System.out.println(ClassLayout.parseInstance(booleans).toPrintable());

メモリレイアウトは次のようになります。

[Z object internals:
 OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
      0     4           (object header)                           01 00 00 00 # mark
      4     4           (object header)                           00 00 00 00 # mark
      8     4           (object header)                           05 00 00 f8 # klass
     12     4           (object header)                           03 00 00 00 # array length
     16     3   boolean [Z.<elements>                             N/A
     19     5           (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 5 bytes external = 5 bytes total

ここでは、8バイトのマークワード、4バイトのクラスワード、および4バイトの長さを含む16バイトのオブジェクトヘッダーがあります。 オブジェクトヘッダーの直後に、3つの要素を持つブール配列の3バイトがあります。

4.9. 圧縮された参照

これまでのところ、この例は、圧縮された参照が有効になっている64ビットアーキテクチャで実行されています。

8バイトのアラインメントでは、圧縮された参照で最大32GBのヒープを使用できます。 この制限を超えるか、圧縮された参照を手動で無効にすると、klassワードは4バイトではなく8バイトを消費します。

圧縮されたoopsが-XX:-UseCompressedOops Tuningフラグで無効になっている場合の、同じ配列の例のメモリレイアウトを見てみましょう。

[Z object internals:
 OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
      0     4           (object header)                           01 00 00 00 # mark
      4     4           (object header)                           00 00 00 00 # mark
      8     4           (object header)                           28 60 d2 11 # klass
     12     4           (object header)                           01 00 00 00 # klass
     16     4           (object header)                           03 00 00 00 # length
     20     4           (alignment/padding gap)                  
     24     3   boolean [Z.<elements>                             N/A
     27     5           (loss due to the next object alignment)

約束どおり、klassワードにはさらに4バイトあります。

5. 結論

このチュートリアルでは、JVMがオブジェクトと配列をヒープ内にどのようにレイアウトするかを見ました。

より詳細な調査については、JVMソースコードのoopsセクションを確認することを強くお勧めします。 また、AlekseyShipilëvには、この分野ではるかに多くの詳細な記事があります。

さらに、JOLのより多くの例がプロジェクトソースコードの一部として利用可能です。

いつものように、すべての例はGitHubから入手できます。