- Published on
The Final Field Mutation Warning in JDK 26 — What JEP 500 Actually Changed, and What to Check Now
- Authors

- Name
- Youngju Kim
- @fjvbn20031
- Introduction — One Day, Three New Lines Showed Up in stderr
- final Has Never Really Been final
- What JEP 500 Actually Changed in JDK 26
- New Flags, and Why --add-opens Doesn't Cut It
- What Actually Breaks — What the Issue Trackers Say
- What You Can Do Now — Flip to deny, Find the Culprit with JFR
- The Library-Side Alternative, and ReflectionFactory as a Half-Measure Escape Hatch
- An Honest Tradeoff — And Why This Isn't Urgent Right Now
- Closing
- References
Introduction — One Day, Three New Lines Showed Up in stderr
You upgrade to JDK 26, start the application, and even though you changed nothing, this shows up on standard error.
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
This isn't an example I made up — it's an actual reproduction filed on the Gson issue tracker. The repro code is surprisingly mundane — a class with one final String field, and running new Gson().fromJson(...) on it is the whole story. Code that quietly worked on JDK 25 now spits out a warning on JDK 26.
The culprit is JEP 500: Prepare to Make Final Mean Final. Written by Ron Pressler and Alex Buckley, this JEP shipped as Closed/Delivered in JDK 26, which GA'd on March 17, 2026. True to its name, it's the preparation stage for making final actually mean final, and the one thing this release does is emit a warning.
final Has Never Really Been final
To carry over the JEP's own wording: "the expectation that final fields cannot be reassigned is false." That's because the reflection API has allowed exactly this since JDK 5.
// An ordinary class with a final field
class C {
final int x;
C() { x = 100; }
}
// 1. Deep reflection on C's final field
java.lang.reflect.Field f = C.class.getDeclaredField("x");
f.setAccessible(true); // Make C's final field mutable
// 2. Create an instance
C obj = new C();
System.out.println(obj.x); // prints 100
// 3. Mutate the final field
f.set(obj, 200);
System.out.println(obj.x); // prints 200
f.set(obj, 300);
System.out.println(obj.x); // prints 300
The reason this was ever allowed is deserialization. The JDK's own platform serialization bypasses the constructor and writes stream values straight into fields, and third-party serialization libraries needed to do the same thing to offer equivalent functionality. So JDK 5 changed the reflection API to allow mutating final fields. The JEP calls this decision, in hindsight, "a bad choice that traded away integrity."
The trouble is that this breaks two things at once.
First, it makes correctness reasoning impossible. The immutability of final fields has played a core role in the Java Memory Model since JDK 5 — safe initialization of objects in multithreaded code rests on it. A comment on the Lucene issue nails this exactly: mutating final fields via reflection was only ever "permitted," never consistently correct — the observation is that a mutated value isn't necessarily visible on every thread. In other words, this isn't a problem JDK 26 created; it's closer to JDK 26 finally saying out loud something that was already broken.
Second, the JIT can't optimize. Constant folding is the textbook case. Only if the JIT can trust that a final field never changes can it evaluate a constant expression once instead of every time, and that's where a whole chain of further optimizations starts. But if "any code, at any time, can mutate a final field," the JVM has to treat every final field as suspect. In the JEP's words, the mere existence of such an API degrades the safety and performance of every program — even though the code that actually mutates final fields is a tiny minority.
This direction isn't something that came out of nowhere. Hidden classes in JDK 15 and records in JDK 16 blocked final-field mutation via deep reflection from the start, JDK 17 strongly encapsulated JDK internals, and JDK 24 began the process of removing the relevant methods from sun.misc.Unsafe. JEP 500 is the next step on that same integrity-by-default line.
What JEP 500 Actually Changed in JDK 26
The core of it is a behavior change to Field::set. When f.set(...) is called and the field is final, the mutation now goes through only if all three of the following are true.
f.setAccessible(true)has already succeeded,- the package of the class that declared the field is open to the caller's module, and
- final field mutation is enabled for the caller's module.
Conditions 2 and 3 are new in JDK 26. Here's a spot where people commonly get confused: the behavior of setAccessible itself hasn't changed. So f.setAccessible(true) can still succeed while f.set(...) is illegal. The two are now separate gates.
What the runtime does when a condition fails is governed by the new --illegal-final-field-mutation option. It's in the same lineage as --illegal-access (JEP 261) from JDK 9 and --illegal-native-access (JEP 472) from JDK 24 — the JEP cites both directly as precedent.
| Value | Behavior |
|---|---|
allow | Allows the mutation, no warning |
warn | Allows the mutation but warns. JDK 26's default |
debug | Same as warn, but with a warning plus a stack trace on every mutation |
deny | Field::set throws IllegalAccessException. The future default |
The roadmap the JEP lays out is this — in a future release deny becomes the default, at which point allow is removed, while warn and debug stick around for at least one more release. warn mode itself is eventually phased out too.
The warning format, as written in the JEP, looks like this.
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
Here's the trap. The warning fires at most once per module that does the reflecting. If your whole application sits on the classpath (which is the common case), it all gets bundled into a single unnamed module, so whether there are ten violations or a hundred, you get exactly one warning and that's it. So you should not read this warning as "only one spot is affected." It's an alarm, not a counter.
Field::set isn't the only thing that changed. MethodHandles.Lookup::unreflectSetter changed the same way, and the Module::addOpens family that opens packages at runtime is affected too — for a package that nobody enabled on the command line, the JVM commits to trusting that package's final fields, and calling addOpens afterward does not grant permission to mutate them.
Worth nailing down what didn't change, too. System's setIn/setOut/setErr are completely unchanged in JDK 26. Those fields were already write-protected and could never be changed via deep reflection — only through those dedicated methods.
New Flags, and Why --add-opens Doesn't Cut It
To avoid the warning — and the (eventual) exception — you use the new, permanent --enable-final-field-mutation option.
# Allow it for all code on the classpath
$ java --enable-final-field-mutation=ALL-UNNAMED ...
# Allow it only for specific modules on the module path
$ java --enable-final-field-mutation=M1,M2 ...
There are a few paths besides passing it straight to the launcher — the JDK_JAVA_OPTIONS environment variable, an argument file like java @config, adding Enable-Final-Field-Mutation to an executable JAR's manifest (though the only supported value there is ALL-UNNAMED; any other value throws an exception), jlink's --add-options, and native applications that embed the JVM through the JNI Invocation API. That said, this option only targets the boot module layer and cannot be applied to a custom layer.
This is where practitioners trip up most often: --add-opens alone does not make this warning go away. The JEP states this explicitly. And the reverse is also true — turning on --enable-final-field-mutation doesn't unconditionally enable deep reflection either; the final field you're trying to mutate still has to be open to that code. Conditions 2 and 3 from earlier are an AND, not an OR. In practice, that means you often end up using both together.
One more thing — the party who gets to make this tradeoff is the application developer (or a deployer acting on their advice), not the library developer. This is a policy point the JEP nails down explicitly. A library telling its users "turn on this flag" should be a last resort, because the flag doesn't get turned on just for that library — it's turned on per module, and on the classpath that ends up meaning the whole application.
What Actually Breaks — What the Issue Trackers Say
Instead of guessing, let's look at issues actually filed around GA.
Gson. This is issue #2991 from earlier. Run under deny and it turns into an exception instead of a warning. What's interesting is the maintainer's response — mutating final fields is "deeply ingrained" in Gson, so the best that can be done right now is to fix the exception message to match JEP 500 and document the alternatives. The alternatives offered are: drop final from the field, use a record, use a Kotlin data class plus a dedicated TypeAdapterFactory, or write a custom TypeAdapter or JsonDeserializer per problem class. On top of that, the maintainer notes that reflection-based serialization is bad independent of this problem too — a public JSON API ends up derived from a class's private fields, and not even for classes designed for Gson, but for any class. As of this writing the issue is still open, and the PR that's been filed is scoped only to improving the exception message.
Spring Security. Issue #19127. In spring-security-acl 7.0.5, BasicLookupStrategy mutates AclImpl's final field aces via reflection. It prints exactly the same three-line warning shown above.
Trino. Issue #28207 is the most instructive one. It comes from running the test suite outright under --illegal-final-field-mutation=deny, and what breaks isn't Trino's own code. picocli can't write to ClientOptions's public final list field, and Jackson databind's FieldProperty can't write to a final field belonging to yet another library (hoverfly). The error message is blunt.
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
The lesson here: this is, for the most part, not a problem in your code but in your dependencies' — and it happens in code you can't fix.
Lucene. Issue #15482 is the flip side of the story. Because Lucene had already banned setAccessible in its own code via forbiddenapis, the plan is to now pass deny to the test runner and catch what its dependencies get up to as well. If your own house is clean, this flag turns from a burden into a weapon.
What You Can Do Now — Flip to deny, Find the Culprit with JFR
The JEP's recommendation is unambiguous: "to prepare for the future, run existing code in deny mode to find code that mutates final fields via deep reflection."
The problem that the warning only fires once per module, making it hard to count, has two workarounds.
# 1) A warning plus a stack trace on every mutation
$ java --illegal-final-field-mutation=debug -jar app.jar
# 2) Collect events with JFR (recorded automatically if JFR is on)
$ java -XX:StartFlightRecording:filename=recording.jfr ...
$ jfr print --events jdk.FinalFieldMutation recording.jfr
The jdk.FinalFieldMutation event is recorded when a final instance field is mutated or when Lookup.unreflectSetter produces a writable MethodHandle, and it carries the declaring class, the field name, and a stack trace. Having the stack trace matters — like the Gson case above, when the real caller is buried deep inside a framework, you can't find it without one.
In practice, the sequence looks like this.
- Run your CI test job once under JDK 26 with
--illegal-final-field-mutation=denyto pull a list (this is exactly what Trino and Lucene do). - Split what comes out into "my code / a dependency I can fix / a dependency I can't fix."
- Fix your own code — usually moving the assignment into the constructor is the whole fix.
- For what's left that you can't fix, explicitly open it up in the production runtime with
--enable-final-field-mutation. This isn't running away — it's a record. Where the debt lives stays visible on the command line.
The Library-Side Alternative, and ReflectionFactory as a Half-Measure Escape Hatch
The alternatives that the JEP and Avoiding Final Field Mutation offer converge on one thing in the end — use the constructor.
The root of the problem is the construct-first-assign-later pattern: build an empty object first, fill in the fields later. Platform deserialization does this, JavaBeans do this, and old-style DI did this. The pattern collides head-on with final, which is why forced assignment becomes necessary. But setting the final problem aside, this pattern is bad on its own terms — a class's invariants are normally established in the constructor, and bypassing the constructor can produce an instance that doesn't satisfy them. The ReflectionFactory example in the Inside Java article shows this bluntly: bypassing the constructor bypasses validation too, and -5 goes straight into age with nothing to stop it.
The DI world has already walked this path — it moved from field injection to constructor injection, and most frameworks now either forbid or discourage injecting into final fields. For a serialization library, the options include restricting yourself to records, using a record as a proxy, or letting users designate a constructor or static factory. For cloning, use a copy constructor or static factory instead of super.clone() — the old Effective Java advice still holds exactly as it did.
So what about existing serialization libraries? The JEP's official answer is sun.reflect.ReflectionFactory. It's a critical internal API living in the jdk.unsupported module, and method handles obtained from it carry the same privileges as the JDK's own serialization. No command-line flag is needed, and it produces no warning or error even under deny.
But there's a decisive restriction here. ReflectionFactory only supports deserializing classes that implement java.io.Serializable. This isn't a bug — it's a deliberate line. The JEP's explanation is crisp: the JVM has to assume that a Serializable object's final fields might change, but for every other object (the overwhelming majority), it wants to be able to assume the final fields are permanently immutable. It's a trade to preserve room for optimizations like constant folding.
So this escape hatch doesn't help Gson much, because Gson doesn't require implementing Serializable — exactly the point raised in the issue. The maintainer's reaction is cynically accurate too: if you're going to fix this by slapping Serializable on your value classes, wouldn't it be better to just make them records? The Inside Java article carries the same warning — ReflectionFactory is a sharp tool "not for the faint of heart," and it inherits every headache of platform serialization wholesale: reimplementing the readObject/readResolve protocol, security, and the spread of Serializable.
An Honest Tradeoff — And Why This Isn't Urgent Right Now
Here's where the tone needs to come down a notch.
There's no number behind the performance gain. The JEP writes that this is safer and "potentially faster." Oracle's Significant Changes in JDK 26 document uses the same wording. It explains the mechanism (constant folding and the optimization chain that follows), but nowhere is there a benchmark saying "X% faster." I won't invent one here either. As of now, the honest summary is: this is a change that clears room for future optimizations, and the payoff hasn't been presented as a measurement yet.
What's actually interesting is how the JEP directly answers the objection "why not just do speculative optimization instead?" — the JIT could, as it usually does, optimistically assume final never changes and deoptimize if it does. The JEP's answer is that this might not be enough, because some of the optimizations planned down the road may lean on immutability that carries from one execution to the next, not just immutability within a single process's lifetime. Think of the AOT cache and the Leyden line of work, and the weight of that sentence becomes clear. Being able to deoptimize at runtime and trusting something baked in from a previous run are two different problems.
And the timeline — this is what matters most to practitioners. JEP 500 does not specify which release makes deny the default. It just says "a future release." And JDK 27, due to GA on September 15, 2026, has already passed Rampdown Phase One and had its feature set frozen ("No further JEPs will be targeted to this release"), and nothing related to enforcing final fields is on its confirmed JEP list. In other words, this does not become an error in JDK 27. At the earliest, it's the release after that.
So a reasonable response to this warning, right now, looks like this.
When you should care
- You've upgraded to JDK 26 or later, and your stack has reflection-based serialization (Gson, etc.) or old-style field injection.
- You're a library or framework maintainer. This is real homework — the JEP's position is that requiring a flag from your users is a last resort, and reworking architecture takes time.
- Your tests are already clean enough to run under
deny— in that case, turning it on now to guard against regressions is a clear win.
When you don't need to care yet
- You're still on JDK 21 or the 25 LTS. This change starts at JDK 26, a non-LTS release on the six-month cadence. Most teams will first meet this through the next LTS.
- All that's happening is a warning, the cause is a library someone else wrote, and you're busy putting out a different fire right now. Silencing it with
--enable-final-field-mutation=ALL-UNNAMEDand writing it into the backlog is a perfectly reasonable choice. This is a permanent option, not a temporary grace period.
That said, it's worth avoiding the reflex of just slapping in --illegal-final-field-mutation=allow. The JEP says explicitly that on the day deny becomes the default, allow is removed (warn and debug stick around for at least one more release). In other words, it's a value with an expiration date, and whichever team is still holding this flag that day will have to go touch it again one way or another. If you have to silence this quietly right now, --enable-final-field-mutation is the right call — it's a permanent option with no planned removal, and more importantly, it's honest about it: it leaves "we are mutating final fields here" written on the command line.
One last blind spot. Everything diagnosed here is about Java code. Native code mutating a final field through JNI's Set<type>Field family is undefined behavior, and the JEP goes so far as to say the result can be array-bounds-check corruption or a process crash. You can only get diagnostics for that through -Xlog:jni=debug or -Xcheck:jni. And mutating via sun.misc.Unsafe gets no diagnostics at all — it can just quietly show up as a strange bug or a JVM crash.
Closing
To sum up: JDK 26 started warning about final-field mutation via deep reflection. Behavior is still unchanged, the default is warn, and because the warning fires only once per module, you need deny or JFR to see the real scope. deny becomes the default at some future point, but the JEP doesn't say when, and it's at least not JDK 27.
The real message of this change sits underneath the flag, not in it. For more than twenty years, Java has said "final is immutable" while handing out the tool to break that promise, and a substantial part of the ecosystem built itself on top of that tool. JEP 500 is where that bill starts getting issued, in installments. One line of warning is small, but the question behind it isn't: is your object complete once the constructor returns, or does someone fill in the fields later?
References
- JEP 500: Prepare to Make Final Mean Final (Ron Pressler, Alex Buckley — JDK 26, Closed/Delivered)
- JDK 26 — feature list and GA schedule (2026/03/17)
- JDK 27 — Rampdown Phase One, confirmed JEP list (GA due 2026/09/15)
- Quality Outreach Heads-up — JDK 26: Warnings About Final Field Mutation (Nicolai Parlog, 2026/05/15)
- Avoiding Final Field Mutation (Nicolai Parlog, 2026/04/27) — alternatives by scenario, plus the ReflectionFactory example
- Significant Changes in JDK 26 Release — Oracle migration guide
- google/gson #2991 — Prepare for final field mutation warning under Java 26
- spring-projects/spring-security #19127 — Final field mutation reported on Java 26
- trinodb/trino #28207 — illegal-final-field-mutation violations (tests run under deny)
- apache/lucene #15482 — When running tests on Java 26 disallow modification of final fields