
2025/11/29 22:47
Help, My Java Object Vanished (and the GC Is Not at Fault)
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
## Summary この記事では、HotSpot が JEP 450 Compact Object Headers をサポートするために更新された際、特に markWord 配列内の value‑object ビットを位置 3 から位置 7 に移動したことが、複数のアーキテクチャで約 75 回の間欠的失敗を引き起こした経緯を説明しています。エラーには単体テストのクラッシュ、Java の `AssertionError`、および `NoClassDefFoundError` メッセージが含まれます。 初期デバッグは静的プロトタイプ定数に焦点を当てましたが、未使用の static‑prototype ロジックを削除しても失敗は解消されませんでした。問題を再現するには Compact Object Headers(`-XX:-TieredCompilation`, `-XX:+UseCompactObjectHeaders` をオフ)を無効にしつつ、C2 JIT コンパイルを有効にする必要がありました。システマティックなテストケースの削減(例:`MultiReleaseJarProperties` テストの分離)や jtreg の再実行コマンドでも決定的な失敗は得られませんでした。 著者は次に自動化されたメソッドレベルでの隔離(`-XX:CompileCommand=compileonly`)を使用し、疑わしい箇所として `java.util.concurrent.ConcurrentHashMap::get` を特定しました。調査によって、C2 の `Object::hashCode` 用イントリンシックが誤ってコンパイルされていたことが判明しました。具体的には、`UseObjectMonitorTable` が無効化された際に、ObjectMonitor 監視拡張コード内の不正なビットマスクがメタデータビットではなくネイティブポインタ上で操作されていたためです。 Compact Object Headers は通常 `UseObjectMonitorTable` を true に設定し、このエラーを隠蔽します。無効化するとバグが露呈します。不正なマスクにより、軽量ロッキングのスロー・パスがスキップされ、ポインタビットがモニター値と一致した場合に null オブジェクトやクラスロード失敗が発生しました。以前はネイティブ 16 バイトメモリアラインメントが特定のビットをゼロに保つことで問題を隠していました。 修正では、マスク `markWord::inline_type_mask_in_place` を `markWord::lock_mask_in_place` に置き換え、正しい動作を回復しました。著者はまた、初期試行が無駄に見える場合でも、ターゲットを絞った質問と利用可能なツールを活用する構造化されたデバッグ手法の重要性を強調しています。
本文
本日は、OpenJDKプロジェクトでHotSpot Java Virtual Machine(JVM)の開発者として経験した最近の旅についてお話しします。新機能のテストを実行している最中に、Javaオブジェクトやクラスが無作為に消えてしまったことに気づきました!その後に起こった出来事は、私の人生(今まで)の中で最も興味深いデバッグと修正体験だったと思います(これから世界と共有したい)。
この記事は広範な(コンピュータサイエンス)読者を対象としています。JavaやJVMに関する知識は必須ではありませんが、低レベルプログラミングへの少しの好奇心は歓迎です。
執筆意図は次の通りです:
- Project Valhalla と値オブジェクト(value object)を紹介
- HotSpot の内部動作に洞察を与え、貢献したくなるよう促す
- JVM フラグが開発者にとってどのように役立つか実践的に示す
- デバッグで得た教訓を共有
- 将来の自分や同僚のためにプロセスを文書化
- 成果について語り、ASCII アートも添える
多くを書きました。各章の要約(結論は除く)を入れています。全体を読む価値があると確信していますので、ご興味のある部分だけでもご活用ください。
はじめに
何をしていたかというと?
JEP 450 が定める Project Valhalla 用フォーマットに合わせて markWord を変更していました。以下は専門用語を分解したものです。
Java 101
Java は汎用途プログラミング言語です。プラットフォーム非依存のバイトコードへコンパイルされ、Java Virtual Machine(HotSpot)が実行します。JVM は自動ガベージコレクションを行い、JIT コンパイラで頻繁に呼ばれるメソッドをネイティブコードへ変換して高速化します。
オブジェクトはヒープ上に配置され、各オブジェクトにはヘッダーにメタデータが格納されています。ここでは「ビット」が特に興味深いです。
従来のオブジェクトヘッダーは markWord と class Word(クラスポインタ)で構成され、64‑bit アーキテクチャでは 96〜128 ビットでした。JDK 24 から JEP 450:Compact Object Headers¹ が導入され、class Word を markWord に統合することでヘッダーサイズを 64 ビットに縮小しました。
markWord の情報は以下のようになります(64‑bit システム)²:
7 3 0 VVVVAAAASTT ^^----- tag bits ^------- self‑forwarding bit (GC) ^^^^-------- age bits (GC) ^^^^------------ Valhalla‑reserved bits
- TT – ロック用タグビット。01 はロックされていない、00 と 10 は軽量ロックとモニタロック。
- S – GC が使用するセルフフォワーディングビット(ここでは無視)。
- AAAA – 世代別ガベージコレクションのオブジェクト年齢追跡用ビット。
- VVVV – Valhalla 用に予約された 4 ビット(実装上は
と呼ばれる)。unused_gap_bits
Project Valhalla
Project Valhalla は「Java のエピックリファクタ」とも呼ばれ、数多くの機能セットが開発中です。ここで関係するのは JEP 401:Value Classes and Objects です。値オブジェクトはフィールドだけで区別され、ヒープフラッテン化⁴ やスカラー化⁵ といった最適化を可能にします。「通常クラス/オブジェクト」は identity クラス/オブジェクトと呼ばれます。
Valhalla は JDK のフォークです。主流の変更は頻繁に取り込むものの、自然と遅延が生じます。Compact Object Headers が Valhalla にマージされた際、11 ビットのレイアウトを少し変更して統合しました。選択した形式は次の通り(JEP 450 と対比):
7 3 0 VVVVAAAASTT <- JEP 450 VVVAAAAVSTT <- Valhalla ^------- value object ビットに注目
Valhalla のレイアウトでは、最下位の V が age bits より下に位置します。ビット 36(0‑indexed)にある V は「値オブジェクトであるか」を示すフラグです。このビットが必要なのは、HotSpot が値オブジェクトと identity オブジェクトで多くの処理を分岐させるためです。
私の変更
私の変更は簡潔でした:11 ビット(
VVVAAAAVSTT)を JEP 450 の VVVVAAAASTT に合わせて更新するだけ。つまり、値オブジェクトビットを上げ(age bits を下げる)ました。
要約
markWord はオブジェクトヘッダーの一部で、Java オブジェクトのメタデータを保持します。Project Valhalla ではフィールドだけで区別される値オブジェクトが導入され、その存在はヘッダー内のビットで示されます。Valhalla ではこのビットはインデックス 3 にあり、Compact Object Headers(JEP 450)に準拠するためにはインデックス 7 に移動させる必要がありました。
広範な失敗
変更は単純でしたが、いくつかのアーキテクチャ・プラットフォームで 75 通りのテスト失敗を引き起こしました。問題点は:
- 広範 – VM の外側にある多くのコンポーネントが影響
- 断続的 – 時には成功し、時には失敗(再現性が低い)
- 明示的でない – ほとんどのアサーションは失敗せず、アプリケーションレベルでエラーが発生
HotSpot のコードベースは約 550 KLoC⁷ と極めて複雑です。再現可能なクラッシュを特定することは非常に重要でした。
症状 A – 単体テストの失敗
GoogleTest フレームワークで書かれた単体テストの 4 つが失敗しました:
[ FAILED ] 4 tests [ FAILED ] markWord.inline_type_prototype_vm [ FAILED ] markWord.null_free_flat_array_prototype_vm [ FAILED ] markWord.nullable_flat_array_prototype_vm [ FAILED ] markWord.null_free_array_prototype_vm
症状 B – java.lang.AssertionError
java.lang.AssertionErrorいくつかのテスト(例:
java/util/jar/JarFile/mrjar/MultiReleaseJarProperties.java)が TestNG ハーネスで AssertionError を投げました:
java.lang.AssertionError: l should not be null at org.testng.ClassMethodMap.removeAndCheckIfLast(ClassMethodMap.java:55) …
ソースコード自体が
l のヌルチェックを行っているため、消えてしまうのは奇妙です。
症状 C – java.lang.NoClassDefFoundError
java.lang.NoClassDefFoundErrorsun/security/krb5 内のいくつかのテスト(例:sun/security/krb5/etype/UnsupportedKeyType.java)が NoClassDefFoundError で失敗。実際に見つからなかったクラスはテストや同じテストを複数回走らせると変わり、典型的な断続性を示します。
java.lang.NoClassDefFoundError: sun/security/krb5/internal/Krb5 at java.base/jdk.internal.loader.NativeLibraries.load(Native Method) …
Act I:意味上の困難
最も手軽に解決できたのは単体テストの失敗でした。C++ で書かれ、テスト対象関数を直接呼び出せるためです。
Valhalla 固有のマクロ(
EnableValhalla)が原因で、静的プロトタイプに関連する定数が未使用・レガシーだったため削除しました。単体テストは通過しましたが、アプリケーションレベルの失敗は残りました。
Act II:縮小・再現・リサイクル
HotSpot フラグを試し、C2 JIT コンパイラと Compact Object Headers が無効な状態で問題が発生することを突き止めました。テストケースを大幅に最小化しましたが、さらに細分化できず、ミスコンパイルの可能性が高いと推測しました。
Act III:コンパイラ解析
-XX:+PrintCompilation で全てのコンパイル済みメソッドを列挙し、-XX:CompileCommand を使ってそれらを走査するスクリプトを書きました。結果として ConcurrentHashMap::get が疑わしい対象でした。インライン展開を調べると、C2 の Object::hashCode イントリンが原因であることが判明しました。
Act IV:バグ
イントリンのソースは次のような分岐を含みます:
if (!UseObjectMonitorTable) { … }
Compact Object Headers では
UseObjectMonitorTable が true に設定されるため、問題は発生しませんでした。分岐内でビットマスク(markWord::inline_type_mask_in_place)が値オブジェクトビットの移動後に誤って構築されていました。
オブジェクトモニタが膨張すると、markWord はネイティブポインタになり、ロックビットだけが設定されます。間違ったマスクでポインタをマスクすると、スローウィーガードで偽陰性となり、同期が失敗し、結果としてヌルオブジェクトや NoClassDefFoundError が発生しました。
修正は
markWord::lock_mask_in_place を使用するだけでした。パッチを適用すると全ての症状が消えました。
Act V:事後分析
なぜ VM は私の変更前に影響しなかったか?
64‑bit ネイティブポインタは
malloc で取得されると 16 バイトアラインメントになるため、下位 4 ビットは常にゼロです(うち 2 ビットがロックメタデータによって上書きされる)。誤ったマスクはこれらの低ビットの一つしか確認しませんでした。値オブジェクトビットを移動すると、バグが顕在化しました。
閉じに & まとめ
- 堅実なメソッドと仮定への挑戦 – HotSpot のような大規模コードベースでは、デバッグ進行のために論理的思考が不可欠です。
- 適切なタイミングで正しい質問をする – 何・いつ尋ねるか、誰に聞くかを知りましょう。助けを求めることは恥ずべきことではありません。
- ツールを活用せよ –
を使えなくても、基本的なデバッギングツールに慣れておくと非常に有益です。lldb
同僚の皆さんに感謝しつつ、今回の経験が他者にも役立てば幸いです。ありがとうございました!