はじめに
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 プロジェクトの中で開発が行われています。
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 もあります。
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]
まあ、UseCompressedOops
や UseCompressedClassPointers
を意識的に無効化するケースは少ないとは思いますが。
まとめ
Java Object Layout の使い方について簡単に見てきました。
細かな話はいろいろありますが、以下のようにすることで対象オブジェクトのメモリ容量を得ることができます。
GraphLayout.parseInstance(obj).totalSize()
メモリチューニング時の知識として覚えておくと良いかと思います。