- 武田大輝
- 約 4,700 文字
はじめに
Java連載2026 の2本目です。
Java 27 は 2026年9月15日にリリースされました。この記事では、Java 27 本体に入った JEP 534: Compact Object Headers by Default を取り上げます。HotSpot JVM がすべてのオブジェクトに付けているヘッダを 12 バイトから 8 バイトに縮める機能、Compact Object Headers をデフォルトで有効にする JEP です。
同じ連載で取り上げた JEP 500 と違って、この変更で警告や例外が増えることはありません。Java 27 に上げれば、コードを 1 行も変えずにヒープの使用量が減ります。ただ、「ヘッダが 4 バイト減る」と聞いて、自分のアプリケーションのヒープでどれくらい減るのかを答えられる人は、そう多くないでしょう。オブジェクトによっては 1 バイトも減りません。手元で Java 25 と Java 27 を並べて測ったので、その結果から見ていきます。
そもそもオブジェクトヘッダとは何か
Java のオブジェクトは、ヒープメモリ上では連続したひとかたまりのバイト列として置かれます。その先頭に JVM の使う領域があり、フィールドの値はその後ろに並びます。この先頭の領域がオブジェクトヘッダです。開発者が書くコードからは見えませんが、new で作ったオブジェクトには必ず付き、大きさはクラスや中身によらず一定です。
入っているのは、JVM がそのオブジェクトを扱うために必要な情報で、用途は大きく 4 つあります。
- 型:どのクラスのオブジェクトか(メソッド呼び出し、リフレクション、キャストの判定に使う)
- ハッシュ:
System.identityHashCodeが返す値(一度計算したら変わらない) - GC:何回の GC を生き延びたか、移動したならどこへ移したか
- ロック:
synchronizedが取っているロックの状態
100 万個のオブジェクトがあれば、ヘッダも 100 万個分がヒープを占めます。1 個あたり数バイトの差が、全体では大きな量になります。
ヘッダはどう変わったのか
Java 25 までのヘッダは 2 つに分かれます。前半は mark word と呼ばれ、さきほどの 4 つのうちハッシュ、GC、ロックの 3 つが入ります。後半は型を指すクラスポインタです。mark word が 64 ビット、圧縮クラスポインタが 32 ビットで、合わせて 96 ビット、12 バイトになります。
mark word の 64 ビットに何がどの順で入るかは、HotSpot のソースの markWord.hpp にコメントで書かれています。内訳は次のとおりです。
| ビット | 長さ | 用途 |
|---|---|---|
| 42〜63 | 22 | 未使用 |
| 11〜41 | 31 | 識別ハッシュコード |
| 7〜10 | 4 | Project Valhalla のための予約 |
| 3〜6 | 4 | GC が数える世代 |
| 2 | 1 | self-forwarded タグ(GC がコピーに失敗した印) |
| 0〜1 | 2 | ロック状態のタグ |
名前の付いたフィールドは合計 42 ビットで、上位の 22 ビットは空いています。それでも 64 ビットあるのは、mark word がマシンのポインタと同じ大きさだからです。
JOL(Java Object Layout)という JVM 内のオブジェクト・レイアウトを解析するためのツールを使用して new Object() の内訳を表示すると、Java 25 では次のようになります。OFF は先頭から何バイト目か、SZ はその要素が占めるバイト数を表します。
java.lang.Object object internals: |
Object にはフィールドがないので、中身はヘッダの 12 バイトだけです。それでも合計 16 バイトあるのは、JVM がオブジェクトの大きさを 8 の倍数にそろえるからです。切り上げで余った 4 バイトの隙間が、出力の alignment gap にあたります。同じコードを Java 27 で動かすと次のようになります。
java.lang.Object object internals: |
class の行が消えて、mark word だけの 8 バイトになりました。クラスポインタがなくなったわけではありません。さきほど空いていた上位 22 ビットに、さらに圧縮した形で入っています。ほかのフィールドは、1 ビットも動いていません。
では、その 22 ビットはなぜ空いたままだったのでしょうか。mark word は、丸ごと別の値で上書きされることのある場所だったからです。ロックを取るときや、GC がオブジェクトを移動するとき、JVM はこの 64 ビットを作業用の置き場として使っていました。クラスポインタをここに置いていたら、上書きのたびに型が分からなくなります。この上書きをやめる書き直しが Java 24 までに済み、22 ビットが使えるようになりました。
どのオブジェクトが小さくなるのか
ヘッダが 4 バイト減れば、オブジェクトも 4 バイト減るとは限りません。代表的なクラスについて、オブジェクト 1 個あたりのサイズを GC のクラスヒストグラム(jcmd の GC.class_histogram と同じ情報)から求めてみます。
| クラス | Java 25 | Java 27 |
|---|---|---|
| Object | 16 | 8 |
| record Point(int x, int y) | 24 | 16 |
| HashMap.Node | 32 | 24 |
| Integer | 16 | 16 |
| String | 24 | 24 |
Integer と String は 1 バイトも変わっていません。ここでも 8 バイト境界が働いています。Integer はヘッダ 12 バイトに int の 4 バイトで、ちょうど 16 バイトに収まっていました。ヘッダが 8 バイトになると 12 バイトですが、8 の倍数に切り上げられて 16 バイトに戻ります。String はフィールドが合計 10 バイトです。ヘッダを足した 22 バイトと 18 バイトは、どちらも 24 バイトに切り上げられます。小さくなるのは、フィールドの合計が 8 の倍数か、8 の倍数まで 1〜3 バイト足りないクラスです。
それでも、実際のデータ構造では減ります。100 万件の HashMap<Integer, String> を作って GC した直後のヒープ使用量を測ってみます。
void main() { |
Java 25: entries=1000000 usedHeap=112 MiB |
16 MiB、14% の減少。1 エントリは 4 つのオブジェクトでできています。HashMap の Node、キーの Integer、値の String、そして文字の並びを持つ byte 配列です。小さくなったのは Node と byte 配列で、それぞれ 8 バイトずつ、合わせて 1 エントリあたり 16 バイト減りました。Integer と String は、さきほど説明したとおり変わっていません。
JEP 450 を見ると、実アプリケーションでも 10%〜20% の減少が報告されています。
Early adopters of Project Lilliput who have tried it with real-world applications confirm that live data is typically reduced by 10%–20%.
この減り幅は、オブジェクトの大きさから説明がつきます。同じ JEP によれば、ワークロードの平均的なオブジェクトは 32〜64 バイトです。ヘッダの 12 バイトは、その 19%〜38% を占めます。そこから 4 バイト削り、8 バイト境界への切り上げも重なると、全体で 10%〜20% 減る計算になります。
Java 27 でデフォルトになった経緯
Compact Object Headers そのものは新しい機能ではありません。Java 24 の JEP 450 で実験的機能として入り、Java 25 の JEP 519 で正式な機能になりました。有効にするオプションは Java 24 では -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders の 2 つ、Java 25 では -XX:+UseCompactObjectHeaders の 1 つです。JEP 534 では、このオプションのデフォルト値が true になっただけです。
JEP 534 がデフォルトにしてよい根拠として挙げているのは、Java 24 以降の実績です。Oracle はテストスイートで、Amazon は本番のサービスで、SAP は自社の OpenJDK ディストリビューションのデフォルトとして、それぞれ使ってきました。
Since JDK 24, compact object headers have proven their stability and performance. They have been tested at Oracle by running the full JDK test suite. They have also been tested at Amazon and SAP. Amazon runs hundreds of services in production with compact object headers, most of them using backports of the feature to JDK 21 and JDK 17. SAP has already switched to compact object headers by default in their downstream OpenJDK fork, the SapMachine; they run a large suite of tests daily and have a large customer base.
注意点として Java 25 まで使えた -XX:-UseCompressedClassPointers は、Java 27 では指定しても無視されます。これは、圧縮クラスポインタで表せるクラス数の上限(約 400 万個)を超える場合に圧縮をやめるためのオプションですが、Compact Object Headers は圧縮クラスポインタを前提にしているため、この回避策がなくなりました。
OpenJDK 64-Bit Server VM warning: Ignoring option UseCompressedClassPointers; support was removed in 27.0 |
JEP 450 は、400 万個のクラスをロードするアプリケーションはまだ見たことがない、と書いています。
このアップデートをうけて何をすべきか
基本的には「何もしなくてよい」です。そのうえで、自分のアプリケーションで試すなら、3 つポイントがあります。
1 つ目は、現在 Java 25 を使っているなら今日から試せることです。JEP 519 で正式な機能になっているので、-XX:+UseCompactObjectHeaders を付けるだけで有効になります。実際にこのオプションを付けた Java 25 でさきほどのクラスヒストグラムを取ると、Java 27 と同じ数字になりました。Java 27 へバージョンアップする前に、自分のアプリケーションで減り幅を測っておくことができます。
2 つ目は、減った分の扱いです。ヒープが 14% 減ったからといって、コンテナのメモリ制限をそのまま 14% 削るのは早計でしょう。減り幅はオブジェクトの形で決まり、上の表のとおりクラスによってはゼロです。GC の回数が減る効果は、ヒープを小さくすると相殺されます。まず現行の設定で GC ログを見て、それから決めるのが順当です。
3 つ目は、戻す手段です。何か問題が起きたら -XX:-UseCompactObjectHeaders で従来のレイアウトに戻せます。ただし Java 27 のリリースノートは、このオプションを将来非推奨にして削除する計画だと書いています。恒久的なオプションではなく、問題が発生した際に原因を調べるための暫定的なオプションです。
おわりに
JEP 534 は、Java 27 にバージョンアップするだけで有効になる変更です。
サンプルの HashMap では減り幅は 14% でしたが、Integer や String が大半を占めるヒープなら数字はもっと小さくなります。この機能は Java 25 から試せるので、まず自分のアプリケーションで測ってみることをお勧めします。
この記事で動かしたサンプルコードは GitHub で公開しています。