JOL で Java オブジェクトのメモリ情報を取得する

f:id:Naotsugu:20210425200010p:plain


はじめに

Java にはオブジェクトが使用するメモリ容量を得る演算子がありません。

例えば、C や C++ における sizeof であったり、Rust における std::mem::sizeof を使えばオブジェクトのサイズを照会でき、メモリ効率に関する情報を得ることができます。

一方、Java ではオブジェクトのメモリ使用量を得ることは簡単ではありません。 Java ではポインタ演算を使用しないため、オブジェクトのメモリ構造を意識する必要は現実的には少ないです。 しかし、例えばオブジェクトをキャッシュした場合のメモリ使用量については、具体的なヒープメモリへのインパクトを見積もりたいといった場合もあるでしょう。

Java オブジェクトのメモリ使用量の見積もりが難しいのは、ランタイムデータ領域のメモリレイアウトは、JVM仕様上定められておらず、実装依存になるためです。 プリミティブ型であれば、そのサイズは言語仕様によって規定されます。しかしオブジェクトや配列をメモリにレイアウトする戦略は、JVMの実装ごとに異なる場合があるためです。

さらに、圧縮オブジェクトポインターオプション -XX:+UseCompressedOops などにより、オブジェクトのサイズが圧縮して管理される場合を考慮する必要があるなどもあり、簡単な問題ではありません。

そのため、アプリケーションのメモリ効率を把握するには、ベンチマークを行い、ヒープ使用量を泥臭い形で計測することで見積もる形を取ることが多いのです。

このような難しい問題を扱いやすくするツールに Java Object Layout (JOL) があります。


Java Object Layout (JOL)とは

JOL は、JVM 内のオブジェクト・レイアウトを解析するためのツールで、現在は、Open JDK プロジェクトの中で開発が行われています。

github.com

JOL は以下の機能を利用することで、オブジェクト・レイアウト、フットプリント、参照などの情報を提供します。

  • Unsafe
    • JDKに含まれるAPIで、メモリ管理の低レベルアクセスを提供する
  • JVMTI(Java Virtual Machine Tool Interface)
    • デバッグ・プロファイリングに有用なJava仮想マシンのインタフェースであり、JVM 上の様々な情報を入手できる
  • SA(Serviceability Agent)
    • 実行中のJavaプロセスやコア・ファイルの調査を行う API およびツールのセット

このような様々な機能より情報提供を行うため、オブジェクトのメモリ構造に関する、より正確な情報を得ることができます。


JOL の導入

Java Object Layout の利用は簡単で、jar をダウンロードして以下のように使うことができます。

$ java -jar jol-cli.jar internals java.util.HashMap


また、Gradle や Maven などで導入し、

implementation 'org.openjdk.jol:jol-core:0.15'
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.15</version>
</dependency>

以下のように API を経由して利用することもできます。

System.out.println(
    ClassLayout.parseInstance(String.class).toPrintable()
);


IntelliJ IDEA JOL Plugin もあります。

github.com


JOL の使い方

JOL では以下のクラスからメモリレイアウトに関する情報を取得できます。

クラス 説明
org.openjdk.jol.info.GraphLayout オブジェクトグラフに関する情報
org.openjdk.jol.info.ClassLayout クラスに関する情報
org.openjdk.jol.info.FieldLayout クラスのフィールドに関する情報
org.openjdk.jol.vm.VM 仕様しているJVMのメモリに関する情報

よく使うのは GraphLayout になると思います。


以下のようなクラスを対象に考えます。

public class Data {
    long id;
    String str;
    public Data(long id, String str) {
        this.id = id;
        this.str = str;
    }
}

以下のようにすることで、対象インスタンスの情報を出力することができます。

System.out.println(
    GraphLayout.parseInstance(new Data(1, "ab")).toPrintable()
);

実行結果は以下のようになります。

# WARNING: Unable to get Instrumentation. Dynamic Attach failed. You may add this JAR as -javaagent manually, or supply -Djdk.attach.allowAttachSelf
# WARNING: Unable to attach Serviceability Agent. You can try again with escalated privileges. Two options: a) use -Djol.tryWithSudo=true to try with sudo; b) echo 0 | sudo tee /proc/sys/kernel/yama/ptrace_scope
jol.Data@27716f4d object externals:
          ADDRESS       SIZE TYPE             PATH                           VALUE
        787f4a5e0         24 Data                                            (object)
        787f4a5f8         24 java.lang.String .str                           (object)
        787f4a610         24 [B               .str.value                     [97, 98]

Addresses are stable after 1 tries.

Data というクラス定義は 24 バイト、String クラスの定義で 24 バイト、格納した ab という文字列の保持で 24 バイト を消費しているということになります。


# WARNING と出力されていますが、気になる場合は起動オプションに -Djdk.attach.allowAttachSelf -Djol.tryWithSudo=true を付けると良いでしょう。

Gradle の application プラグインの場合は以下のようになります(Kotlin DLS の例では)。

application {
    applicationDefaultJvmArgs = listOf(
        "-Djdk.attach.allowAttachSelf",
        "-Djol.tryWithSudo=true")
}

-Djol.tryWithSudo=true とした場合は以下のワーニングになるかもしれません。

# WARNING: Unable to attach Serviceability Agent. Unable to attach even with escalated privileges: sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is required

Serviceability Agent で sudo にパスワードが必要という内容で、sudo の設定入れれば解消できると思いますが、まぁ、無視しておいて良いと思います。


さらに、以下のワーニングが出力される場合もあります。

WARNING: An illegal reflective access operation has occurred
WARNING: Illegal reflective access by org.openjdk.jol.info.AbstractGraphWalker$ReferenceFieldsClassValue (file:.../jol-core-0.15.jar) to field java.lang.String.value
WARNING: Please consider reporting this to the maintainers of org.openjdk.jol.info.AbstractGraphWalker$ReferenceFieldsClassValue
WARNING: Use --illegal-access=warn to enable warnings of further illegal reflective access operations
WARNING: All illegal access operations will be denied in a future release

Java9 からのモジュールシステムで、外部からのリフレクション(この場合はStringクラスに対して)を使用しているというワーニングです。 --add-opens にて外部からのリフレクションアクセスを許可すればワーニングを消すことができます。

今回の例では String に対するものなので、Gradle の application プラグインの場合は以下のようになります(Kotlin DLS の例では)。

applicationDefaultJvmArgs = listOf(
        "-Djdk.attach.allowAttachSelf",
        "-Djol.tryWithSudo=true",
        "--add-opens=java.base/java.lang=ALL-UNNAMED")

こちらも面倒なので、今のところは無視しておいて問題ないかと思います。


GraphLayout

GraphLayout.parseInstance() にインスタンスを渡し、GraphLayout を生成します。

var obj = new Data(1, "hello");
System.out.println(
    GraphLayout.parseInstance(obj).toPrintable()
);

GraphLayout.toPrintable() で以下のような出力が得られます。

jol.Data@f6f4d33d object externals:
          ADDRESS       SIZE TYPE             PATH                           VALUE
        787fe09f0         24 jol.Data                                        (object)
        787fe0a08         24 java.lang.String .str                           (object)
        787fe0a20         24 [B               .str.value                     [104, 101, 108, 108, 111]


GraphLayout.totalSize() では消費メモリをバイト数で得ることができます。

System.out.println(
    GraphLayout.parseInstance(obj).totalSize()
);

以下のような出力を得ることができます。

72

その他、GraphLayout.totalCount() でインスタンスの数、GraphLayout.toFootprint() でフットプリントテーブルを得ることができます。

メモリ上のレイアウトをPNG画像で出力する GraphLayout.toImage() や差分を得る GraphLayout.subtract() なんていうものもあります。



ClassLayout

ClassLayout はクラスの定義情報を扱います。

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

GraphLayout.toPrintable() で以下のような出力が得られます。

Data object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     N/A
  8   4                    (object header: class)    N/A
 12   4   java.lang.String Data.str                  N/A
 16   8               long Data.id                   N/A
Instance size: 24 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

インスタンスからクラス情報を得る GraphLayout.parseInstance() もあります。

jol.Data object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     0x0000000000000001 (non-biasable; age: 0)
  8   4                    (object header: class)    0x0017214a
 12   4   java.lang.String Data.str                  (object)
 16   8               long Data.id                   1
Instance size: 24 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

出力内容はほぼ同じですが、VALUE の値が実際のインスタンスから設定されています。

GraphLayout の場合と同様に headerSize() instanceSize() といった個別の情報を得るメソッドが提供されています。 instanceSize() はあくまでもクラス自体のインスタンスサイズで、格納されている内容に応じたものではないので注意してください。


クラスに含まれるフィールドに関する情報は以下のように取得することができます。

Set<FieldLayout> fields = ClassLayout.parseInstance(obj).fields();
for (FieldLayout field : fields) {
    System.out.println(
            field.classShortName() + ", " +
            field.shortFieldName() + ", " +
            field.size()
    );
}

以下のような出力を得ることができます。

Data, Data.str, 4
Data, Data.id, 8
24


VM

現在利用している VM に関する情報は以下のように取得することができます。

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

出力は以下のようになります。

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# WARNING | Compressed references base/shifts are guessed by the experiment!
# WARNING | Therefore, computed addresses are just guesses, and ARE NOT RELIABLE.
# WARNING | Make sure to attach Serviceability Agent to get the reliable addresses.
# 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]

Using compressed oop with 3-bit shift というメッセージが出力されています。

oop とは Ordinary Object Pointers で、オブジェクトへの参照ポインタを表すデータ構造です。

64bit 版のJVMでは、オブジェクトの参照は64bitで管理されることになりますが、ヒープサイズが32Gバイト未満の場合には、この参照を32bitとして管理することで、メモリ使用量を削減する機能があります。 これを行うオプションが UseCompressedOops です。

試しに -XX:-UseCompressedOops で圧縮を無効にすれば以下の結果が得られます。

# Running 64-bit HotSpot VM.
# Using compressed klass with 3-bit shift.
# WARNING | Compressed references base/shifts are guessed by the experiment!
# WARNING | Therefore, computed addresses are just guesses, and ARE NOT RELIABLE.
# WARNING | Make sure to attach Serviceability Agent to get the reliable addresses.
# Objects are 8 bytes aligned.
# 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]

Field sizes by type の最初の値が参照ポインタです。8バイト(64bit)に増えていますね。

なお、Field sizes by type の出力は以下の順序になります。

out.printf("# %-19s: %d, %d, %d, %d, %d, %d, %d, %d, %d [bytes]%n",
        "Field sizes by type",
        oopSize,
        sizes.booleanSize,
        sizes.byteSize,
        sizes.charSize,
        sizes.shortSize,
        sizes.intSize,
        sizes.floatSize,
        sizes.longSize,
        sizes.doubleSize
);


ついでに Using compressed klass with 3-bit shift というメッセージですが、これは UseCompressedClassPointers オプションによるものです。

UseCompressedOops が有効であり、かつUseCompressedClassPointers が有効になっている場合、クラス・メタデータに使用できる領域(CompressedClassSpace) が別途確保されます(この領域のサイズは CompressedClassSpaceSize で指定されます)。

-XX:-UseCompressedClassPointers としてオプションを無効化すると、以下のような出力になります。

# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
# 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]

まあ、UseCompressedOopsUseCompressedClassPointers を意識的に無効化するケースは少ないとは思いますが。


まとめ

Java Object Layout の使い方について簡単に見てきました。

細かな話はいろいろありますが、以下のようにすることで対象オブジェクトのメモリ容量を得ることができます。

GraphLayout.parseInstance(obj).totalSize()

メモリチューニング時の知識として覚えておくと良いかと思います。