LabHub

ブログ

JDK 26 の final フィールド変更警告 — JEP 500 が実際に変えたこと、そして今確認しておくべきこと

한국어English日本語中文

はじめに — ある日 stderr に3行増えていた

JDK 26 にアップグレードしてアプリケーションを起動すると、何も変えていないのに標準エラーにこう出力されます。

WARNING: Final field finalField in class TestFinalFieldModification$ClassWithFinalField has been mutated reflectively by class com.google.gson.internal.bind.ReflectiveTypeAdapterFactory$2 in unnamed module @59fd97a8 (file:/.../gson-2.13.2.jar)
WARNING: Use --enable-final-field-mutation=ALL-UNNAMED to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled

これは私が作った例ではなく、Gson のイシュートラッカーに実際に上がった再現報告です。再現コードは驚くほど平凡です — final String フィールドを一つ持つクラスに new Gson().fromJson(...) を実行しただけです。JDK 25 では黙って動いていたコードが、JDK 26 では警告を吐きます。

犯人は JEP 500: Prepare to Make Final Mean Final です。Ron Pressler と Alex Buckley が書いたこの JEP は、2026年3月17日に GA した JDK 26 に Closed/Delivered として収録されました。名前のとおり「final が本当に final を意味するようにするための準備」段階であり、今回のリリースでやることはただ一つ — 警告を出すことです。

final はこれまで final ではなかった

JEP の言葉をそのまま移すとこうなります。「final フィールドが再代入され得ないという期待は偽である(false)。」なぜなら、JDK 5 以降リフレクション API がそれを許してきたからです。

// final フィールドを持つ普通のクラス
class C {
    final int x;
    C() { x = 100; }
}

// 1. C の final フィールドにディープリフレクション
java.lang.reflect.Field f = C.class.getDeclaredField("x");
f.setAccessible(true);      // C の final フィールドを変更可能にする

// 2. インスタンス生成
C obj = new C();
System.out.println(obj.x);  // 100 と出力

// 3. final フィールドを変更
f.set(obj, 200);
System.out.println(obj.x);  // 200 と出力
f.set(obj, 300);
System.out.println(obj.x);  // 300 と出力

これがなぜ許されていたかというと、デシリアライズのためです。JDK 自身のプラットフォームシリアライゼーションはコンストラクタを迂回してストリームの値をフィールドに直接書き込みますが、サードパーティのシリアライズライブラリも同等の機能を提供するには同じことが必要でした。そこで JDK 5 でリフレクション API を変更し、final フィールドを変更できるようにしたのです。JEP はこの判断を「振り返れば、整合性(integrity)を犠牲にした悪い選択」だったと書いています。

問題は、これが二つのことを同時に壊すという点です。

第一に、正確性の推論が不可能になります。final フィールドの不変性は JDK 5 以来 Java メモリモデルで中核的な役割を果たしてきました — マルチスレッドコードにおけるオブジェクトの安全な初期化(safe initialization)はこれに乗っています。Lucene のイシューに付いたあるコメントがこの点を正確に突いています。リフレクションで final フィールドを変更することは「許可」されていただけで、一貫して正しく動作したことは一度もない、というのです — 変更した値がすべてのスレッドから見えるとは限らなかった、という観察です。つまりこれは JDK 26 が新しく作った問題ではなく、もともと壊れていたものを今になって告げ始めた、というほうが近いのです。

第二に、JIT が最適化できません。代表例が定数畳み込み(constant folding)です。final フィールドが絶対に変わらないと信じられて初めて、定数式を毎回ではなく一度だけ評価でき、そこから先の最適化の連鎖が始まります。ところが「どんなコードでも、いつでも final フィールドを変更できる」のであれば、JVM はすべての final フィールドを疑ってかからねばなりません。JEP の言葉を借りれば、そうした API が存在するという事実だけで、実際に final フィールドを変更するコードがごく一部であるにもかかわらず、「あらゆるプログラム」の安全性と性能が同時に削られてしまいます。

この方向性自体は突然出てきたものではありません。JDK 15 の隠しクラスと JDK 16 のレコードは最初からディープリフレクションによる final フィールド変更をブロックしており、JDK 17 では JDK 内部が強くカプセル化され、JDK 24 では sun.misc.Unsafe の該当メソッドを削除する手続きが始まりました。JEP 500 は同じ「デフォルトで整合性(integrity by default)」路線の次の一手です。

JEP 500 が JDK 26 で実際に変えたこと

核心は Field::set の挙動変更です。f.set(...) が呼ばれ、そのフィールドが final であるとき、変更が成立するには次の3条件がすべて真でなければなりません。

  1. f.setAccessible(true) がすでに成功しており、
  2. フィールドを宣言したクラスのパッケージが呼び出し元モジュールに open されており、
  3. 呼び出し元モジュールに対して final フィールド変更が有効化されている。

2番目と3番目が JDK 26 で新しく加わった条件です。ここでよく誤解される点が一つあります — setAccessible の挙動自体は変わっていません。つまり f.setAccessible(true) は今でも成功する一方で、f.set(...) は不正になり得ます。両者はもはや別々の関門です。

条件が満たされなかったときにランタイムが何をするかは、新オプション --illegal-final-field-mutation が決めます。JDK 9 の --illegal-access(JEP 261)、JDK 24 の --illegal-native-access(JEP 472)と同じ系譜です — JEP はこの二つを直接、先例として挙げています。

動作
allow警告なしで変更を許可
warn変更は許可するが警告する。JDK 26 のデフォルト
debugwarn と同じだが、変更のたびに警告 + スタックトレース
denyField::setIllegalAccessException を投げる。将来のデフォルト

JEP が明示するロードマップはこうです — 将来のリリースで deny がデフォルトになり、そのとき allow は削除され、warndebug は最低もう1リリース残ります。warn モード自体も最終的には段階的に廃止されます。

警告のフォーマットは JEP にこう書かれています。

WARNING: Final field f in p.C has been [mutated/unreflected for mutation] by class com.foo.Bar.caller in module N (file:/path/to/foo.jar)
WARNING: Use --enable-final-field-mutation=N to avoid a warning
WARNING: Mutating final fields will be blocked in a future release unless final field mutation is enabled

ここに落とし穴があります。警告は、リフレクションを行うモジュールごとに最大1回しか出ません。アプリケーション全体がクラスパス上にあれば(たいていそうです)全部が一つの unnamed module にまとめられるので、違反が十箇所あろうと百箇所あろうと警告はきっかり1回だけ出て終わりです。つまりこの警告を見て「うちは1箇所しか引っかかっていない」と読んではいけません。数えるための道具ではなく、ただのアラームです。

変わったのは Field::set だけではありません。MethodHandles.Lookup::unreflectSetter も同じ形で変わり、実行時にパッケージを開く Module::addOpens 系列も影響を受けます — コマンドラインで誰も有効化していないパッケージについては、JVM はそのパッケージの final フィールドを信頼することに決め、その後 addOpens を呼んでも final フィールドを変更する権限は生まれません。

逆に変わっていないものも明確にしておきます。SystemsetIn/setOut/setErr は JDK 26 で何も変わっていません。これらのフィールドはもともと write-protected でディープリフレクションでは変更できず、専用のメソッドでしか変更できませんでした。

新しいフラグ、そして --add-opens では足りない理由

警告と(将来の)例外を避けるには、新しい恒久オプション --enable-final-field-mutation を使います。

# クラスパス上のすべてのコードに対して許可
$ java --enable-final-field-mutation=ALL-UNNAMED ...

# モジュールパス上の特定モジュールにのみ許可
$ java --enable-final-field-mutation=M1,M2 ...

ランチャーに直接渡す以外にも経路がいくつかあります — JDK_JAVA_OPTIONS 環境変数、java @config のような引数ファイル、実行可能 JAR のマニフェストへの Enable-Final-Field-Mutation 追加(ただしサポートされる値は ALL-UNNAMED のみで、他の値を入れると例外になります)、jlink の --add-options、そして JNI Invocation API で JVM を組み込むネイティブアプリケーション。ただしこのオプションはブートモジュールレイヤーのみを対象とし、ユーザー定義レイヤーには適用できません。

ここで実務者が最もつまずきやすい点を指摘します。--add-opens だけではこの警告は消えません。JEP が明示的に釘を刺している一文です。そしてその逆も真です — --enable-final-field-mutation を有効にしたからといってディープリフレクションが無条件にできるわけではなく、変更したい final フィールドがそのコードに対して open されている必要があります。先に見た条件2と3は AND です。だから実務ではこの二つを一緒に使うことになる場面が多くなります。

もう一つ — このトレードオフを決める主体はアプリケーション開発者(あるいはその助言を受けた配布者)であり、ライブラリ開発者ではありません。これは JEP が方針として明確に釘を刺している部分です。ライブラリが自分のユーザーに「このフラグを付けてください」と要求するのは最後の手段であるべきです。なぜなら、フラグはそのライブラリだけに効くのではなくモジュール単位で効き、クラスパスであれば結局アプリケーション全体に効いてしまうからです。

実際に何が引っかかるか — イシュートラッカーが語ること

推測の代わりに、GA 前後で実際に上がったイシューを見てみます。

Gson。先ほど見たイシュー #2991です。deny で動かすと警告ではなく例外に変わります。興味深いのはメンテナーの回答です — final フィールドを変更することが Gson に「深く根付いている(deeply ingrained)」ため、今すぐできる最善は例外メッセージを JEP 500 に合わせて直し、代替策を文書化することくらいだと述べています。挙げられた代替策は、フィールドから final を外す、レコードにする、Kotlin の data class + 専用 TypeAdapterFactory を使う、問題のあるクラスごとにカスタム TypeAdapterJsonDeserializer を書く、の4つです。加えて、リフレクションベースのシリアライズはこの問題とは別にそもそも良くないとも述べています — クラスの private フィールドから公開 JSON API が導かれてしまい、しかも Gson 用に設計されたクラスに限らずどんなクラスにもそれが起きるからです。この記事を書いている時点でイシューはまだ開いており、上がっている PR も例外メッセージの改善にとどまります。

Spring Securityイシュー #19127です。spring-security-acl 7.0.5 で BasicLookupStrategyAclImpl の final フィールド aces をリフレクションで変更します。上と同じ3行の警告がそのまま出ます。

Trinoイシュー #28207が最も教訓的です。テストをそのまま --illegal-final-field-mutation=deny で回した結果ですが、引っかかったのは Trino 自身のコードではありません。picocli が ClientOptionspublic final なリスト型フィールドに値を入れられず、Jackson databind の FieldProperty が別のライブラリ(hoverfly)の final フィールドに書き込めません。エラーメッセージは非常に率直です。

class com.fasterxml.jackson.databind.deser.impl.FieldProperty (in unnamed module @359df09a)
cannot set final field io.specto.hoverfly.junit.api.view.HoverflyInfoView.upstreamProxy
(in unnamed module @359df09a), unnamed module @359df09a is not allowed to mutate final fields

教訓はこうです — これは多くの場合、あなたのコードの問題ではなく依存関係の問題です。しかも、あなたには直せないコードで起きます。

Luceneイシュー #15482は逆側の事例です。Lucene は forbiddenapis で自分のコードの setAccessible をもともと禁止してきたため、今度はテストランナーに deny を渡して依存関係のふるまいまで捕まえようという計画です。自分の家がきれいであれば、このフラグは負担ではなく武器になります。

今できること — deny で試し、JFR で犯人を捜す

JEP の勧告は明快です。「将来に備えるなら、既存コードを deny モードで動かし、ディープリフレクションで final フィールドを変更しているコードを見つけ出せ。」

警告がモジュールごとに1回しか出ず数えにくいという問題は、二つの方法で回避します。

# 1) 変更のたびに警告 + スタックトレース
$ java --illegal-final-field-mutation=debug -jar app.jar

# 2) JFR でイベントを収集する(JFR が有効なら自動記録される)
$ java -XX:StartFlightRecording:filename=recording.jfr ...
$ jfr print --events jdk.FinalFieldMutation recording.jfr

jdk.FinalFieldMutation イベントは、final インスタンスフィールドを変更したとき、あるいは Lookup.unreflectSetter で書き込み可能な MethodHandle を取得したときに記録され、フィールドを宣言したクラス、フィールド名、そしてスタックトレースを含みます。スタックトレースがあることが重要です — 上の Gson の例のように、実際の呼び出し元がフレームワークの奥深くにあるとき、これがなければ見つけられません。

実務的な手順はこうなります。

  1. CI のテストジョブを JDK 26 + --illegal-final-field-mutation=deny で一度回してリストを出す(Trino と Lucene がまさにこれをやっています)。
  2. 出てきた項目を「自分のコード / 直せる依存関係 / 直せない依存関係」に分ける。
  3. 自分のコードは直す — たいていコンストラクタに移すだけで終わります。
  4. 直せないものだけ残し、本番ランタイムでは --enable-final-field-mutation で明示的に開けておく。これは逃げではなく記録です。どこに負債があるかがコマンドラインに残ります。

ライブラリ側の代替策、そして ReflectionFactory という中途半端な抜け道

JEP と Avoiding Final Field Mutation が示す代替策は、結局一つに収束します — コンストラクタを使え、ということです。

問題の根は、「まず空のオブジェクトを作り、後からフィールドを埋める(construct-first-assign-later)」パターンです。プラットフォームのデシリアライズがそうであり、JavaBean がそうであり、昔ながらの DI もそうでした。このパターンは final と真っ向からぶつかるため、強制的な代入が必要になります。しかしこれは final の問題以前に、それ自体として良くありません — クラスの不変条件は通常コンストラクタで確立されますが、コンストラクタを迂回すると不変条件を満たさないインスタンスが作られ得ます。Inside Java の記事にある ReflectionFactory の例がこれをあからさまに示しています。コンストラクタを迂回するので検証も迂回され、age-5 がそのまま入ってしまいます。

DI 陣営はすでにこの道を歩んできました。フィールド注入からコンストラクタ注入へと移行し、大半のフレームワークは今や final フィールドへの注入を禁止するか推奨していません。シリアライズライブラリであれば、レコードに限定する、レコードをプロキシとして使う、あるいはユーザーにコンストラクタや静的ファクトリを指定させる、といった選択肢があります。クローンであれば super.clone() の代わりにコピーコンストラクタや静的ファクトリを使います(Effective Java の古い助言がそのまま有効です)。

では既存のシリアライズライブラリはどうすればよいのでしょうか。JEP の公式な答えは sun.reflect.ReflectionFactory です。jdk.unsupported モジュールにある critical internal API であり、ここから得たメソッドハンドルは JDK 自身のシリアライズと同じ権限を持ちます。コマンドラインフラグは不要で、deny の下でも警告やエラーは出ません。

しかし、ここに決定的な制約がありますReflectionFactoryjava.io.Serializable を実装したクラスのデシリアライズしかサポートしません。これはバグではなく意図的に引かれた線です。JEP の説明は明快です — JVM は Serializable オブジェクトの final フィールドは変更され得ると仮定せざるを得ませんが、それ以外のすべてのオブジェクト(圧倒的多数)の final フィールドについては永続的に不変だと仮定できるようにしたい、というわけです。定数畳み込みのような最適化の余地を守るための取引です。

そのため、この抜け道は Gson にはあまり役立ちません。Gson は Serializable の実装を要求していないからです。イシューで指摘されているとおりです。メンテナーの反応もシニカルなだけに的確です — 値クラスに Serializable を付けるように直すくらいなら、いっそレコードにしたほうがましではないか、というのです。Inside Java の記事も同じ警告を添えています。ReflectionFactory は「臆病な人向けの道具ではない」鋭い道具であり、プラットフォームシリアライズのあらゆる難点(readObject/readResolve プロトコルの再実装、セキュリティ、Serializable の伝播)をそのまま引き受けることになります。

正直なトレードオフ — そして今すぐ急ぐ必要がない理由

ここからはトーンを落とすべき部分です。

性能面の利得に数字がありません。JEP は「より安全で、潜在的により速い(potentially faster)」とだけ書いています。Oracle の JDK 26 の主要変更点ドキュメントも同じ表現を使っています。メカニズム(定数畳み込みとその先の最適化の連鎖)は説明されていますが、「何%速くなる」というベンチマークはどこにもありません。この記事でも数字を作り出すことはしません。今の時点で正直な要約は、「将来の最適化のための余地を確保する変更であり、その利得はまだ数値としては示されていない」です。

「なぜ投機的最適化ではダメなのか」という反論に JEP 自身が答えている部分は、むしろ興味深いところです。JIT がいつもするように「final は変わらないと楽観的に仮定し、変わったら脱最適化(deoptimize)する」でよいのではないか、という反論に対し、JEP の答えはそれだけでは足りないかもしれない、というものです。今後計画されている最適化の一部は、プロセスの寿命内での不変性だけでなく、ある実行から次の実行へと引き継がれる不変性に依存し得るからです。AOT キャッシュや Leyden 系の作業を思い浮かべると、この一文の重みが読めてきます。実行時に脱最適化できることと、前回の実行で焼き込んだものを信じることは、別の問題です。

そしてタイムラインです。これが実務者にとって最も重要です。JEP 500 は deny がデフォルトになるリリースを明示していません。ただ「将来のリリース」とだけ書かれています。そして 2026年9月15日 GA 予定の JDK 27 は、すでに Rampdown Phase One を過ぎて機能セットが凍結されており("No further JEPs will be targeted to this release")、確定した JEP のリストに final フィールドの強制に関するものはありません。つまり JDK 27 でこれがエラーになることはありません。早くてもその次です。

そのため、今この警告に対する合理的な反応はこうなります。

気にすべき場合

まだ気にしなくてよい場合

ただし、反射的に --illegal-final-field-mutation=allow を突っ込むのは避けたほうがよいでしょう。JEP は明示的に述べています — deny がデフォルトになる日、allow は削除されます(warndebug は最低もう1リリース残ります)。つまり有効期限付きの値であり、その日このフラグを握っているチームは、いずれにせよもう一度手を入れなければなりません。今すぐ静かにさせる必要があるなら、--enable-final-field-mutation のほうが正解です。こちらは廃止予定のない恒久オプションであり、何より「ここで final を変更している」ということをコマンドラインに残すという点で正直です。

最後に死角を一つ。ここまでの診断はすべて Java コードの話です。ネイティブコードが JNI の Set<type>Field 系で final フィールドを変更するのは未定義動作(undefined behavior)であり、JEP はその結果として配列の境界チェックが壊れたりプロセスが落ちたりし得るとまで書いています。診断は -Xlog:jni=debug-Xcheck:jni でしか得られません。そして sun.misc.Unsafe での変更には診断すらありません。静かに奇妙なバグや JVM クラッシュとして現れることがあります。

おわりに

まとめるとこうです。JDK 26 はディープリフレクションによる final フィールド変更に警告を出し始めました。挙動はまだそのままで、デフォルトは warn であり、警告はモジュールごとに1回しか出ないため、実際の規模を知るには deny か JFR が必要です。将来 deny がデフォルトになりますが、それがいつかは JEP も言っておらず、少なくとも JDK 27 ではありません。

この変更の本当のメッセージは、フラグそのものではなくその下にあります。Java は20年以上「final は不変である」と言いながら、それを破る道具も一緒に手渡してきており、その上にエコシステムのかなりの部分が乗っています。JEP 500 は、その請求書を今になって分割して発行し始めたということです。警告の一行は小さいですが、その裏にある問いは小さくありません — あなたのオブジェクトはコンストラクタで完成しますか、それとも誰かが後からフィールドを埋めますか。

参考資料

コメント

まだコメントはありません。

ログインするとコメントできます