You have seen this in a serialization library, a DI framework, and more than one “just for tests” helper: grab a final field, call setAccessible(true), then Field.set. The constructor said the value was settled. Reflection disagreed.
That hole has been in the platform since JDK 5. JEP 500 starts closing it. Java 26 does not throw on the mutation by default. It warns, and prepares a future JDK to make final mean final.
field.setAccessible(true);
field.set(obj, "test"); // succeeds on Java 26; warns; will fail later unless enabled
The point is integrity, not ceremony. If any code in the process can reassign a final field, neither you nor the JVM can trust it.
The problem it solves
final is a promise: assign once in the constructor (instance) or class initializer (static), then never again. The Java Memory Model has leaned on that promise since JDK 5. The JIT leans on it too — constant folding is often the first step in a chain of optimizations, and it only pays if the field cannot change underfoot.
Deep reflection (Field.setAccessible + Field.set) made the promise optional. Relatively little code uses the hole, but the existence of the hole means the JVM cannot assume any ordinary final field is actually immutable.
That is why records already closed it. Component fields of a record cannot be mutated by deep reflection, and neither can the final fields of hidden classes. JEP 500 is the same rule, rolled out to normal classes, with a warning release first so the ecosystem can move.
--add-opens is not enough to silence this. Opening a package still lets you see private members; mutating a final field is a separate permission.
When it shipped
| Release | Status | Spec |
|---|---|---|
| JDK 5 | Field.set allowed to mutate finals (serialization) | Reflection API change |
| JDK 15 | Hidden-class finals cannot be mutated via deep reflection | JEP 371 |
| JDK 16 | Record-component fields cannot be mutated via deep reflection | JEP 395 |
| JDK 24 | Started removing sun.misc.Unsafe methods that mutate finals | JEP 498 |
| Java 26 | Warnings by default | JEP 500 |
| A future JDK | deny becomes the default (IllegalAccessException) | Same JEP, later strengthening |
No preview flag. The change is in java.base reflection. You will see it on an ordinary Java 26 runtime the first time some library pokes a final field.
Note: Do not read “Java 26” as “it throws now.” The default mode is warn. Mutation still happens. The exception path is opt-in today (deny) and the planned default later.
Mental model
Two new knobs, one old one, and a rule about who is allowed to turn them.
| Knob | What it controls |
|---|---|
--enable-final-field-mutation | Who may mutate finals (modules, or ALL-UNNAMED for the classpath) |
--illegal-final-field-mutation | What happens on an illegal attempt (allow / warn / debug / deny) |
--add-opens / opens | Whether the package is even visible for deep reflection |
An attempt is legal only if all three hold: setAccessible(true) already succeeded, the field’s package is open to the caller, and final-field mutation is enabled for the caller’s module. Fail any of those and Java 26 warns (default) or throws (deny).
The application (or deployer) enables mutation, not the library. A framework that still needs the hole should tell you to pass the flag — it should not pretend --add-opens is the whole story, and it should treat the flag as a last resort.
The footgun
Here is the classic “test override” that has been copy-pasted for twenty years. It compiles on every JDK you still run.
class Config {
private final String env;
Config() {
this.env = "prod";
}
String env() {
return env;
}
}
public class MutateFinal {
public static void main(String[] args) throws Exception {
Config config = new Config();
System.out.println(config.env());
var field = Config.class.getDeclaredField("env");
field.setAccessible(true);
field.set(config, "test");
System.out.println(config.env());
}
}
java MutateFinal.java
On Java 25 and earlier you get silent mutation. On Java 26 you still get the mutation, plus a warning on stderr the first time code in that module does this:
prod
WARNING: Final field env in Config has been mutated by class MutateFinal in unnamed module (file:/...)
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
test
At most one warning per module in the default mode, so a chatty library does not flood the log. The second print still shows test. That is the whole point of a prepare JEP: you keep running, and you get a named thing to fix.
setAccessible itself did not change. The new check is on Field.set (and on MethodHandles.Lookup.unreflectSetter, which is the MethodHandle equivalent of obtaining a setter). You can still make the field accessible; writing it is what Java 26 notices.
What the modes do
--illegal-final-field-mutation is the same shape as --illegal-native-access from JDK 24.
| Mode | Mutation | Diagnostics |
|---|---|---|
allow | Proceeds | Silence |
warn | Proceeds | One warning per module (Java 26 default) |
debug | Proceeds | Warning and stack trace on every attempt |
deny | Field.set throws IllegalAccessException | Future default |
Prepare for the future by running CI on deny once you are on 26:
java --illegal-final-field-mutation=deny MutateFinal.java
prod
Exception in thread "main" java.lang.IllegalAccessException: ...
To keep a hole open on purpose — a serialization library you cannot patch this week — enable mutation for the code that does the write:
java --enable-final-field-mutation=ALL-UNNAMED MutateFinal.java
Classpath code lives in the unnamed module, so ALL-UNNAMED is the usual value. Named modules take a comma-separated list:
java --enable-final-field-mutation=com.acme.serde,com.acme.testkit -m com.acme.app/com.acme.Main
Enabling mutation does not skip opens. If the package is not open to the caller, set is still illegal. Other ways to pass the same flag: JDK_JAVA_OPTIONS, an @argfile, the Enable-Final-Field-Mutation: ALL-UNNAMED JAR manifest entry (java -jar only; other values throw), jlink --add-options, or the JNI invocation API when you embed a JVM.
Note: warn and debug will remain after deny becomes the default, for at least one release. allow is the mode that goes away. Do not build a rollout plan that depends on allow.
Finding who is mutating
The warning names a class and a module, which is enough when the caller is your code. When it is a dependency, switch to stack traces or Flight Recorder.
java --illegal-final-field-mutation=debug -jar app.jar
debug prints a stack for every illegal write, not just the first one per module. For a longer-running process, record jdk.FinalFieldMutation — the event fires when code mutates a final instance field or when Lookup.unreflectSetter is used to get a write handle on a reflected final field:
java -XX:StartFlightRecording:filename=recording.jfr -jar app.jar
jfr print --events jdk.FinalFieldMutation recording.jfr
The event names the declaring class, the field, and the stack. That is usually enough to tell a JSON deserializer from a test utility from a mocking library.
What still works
Three things people mix up with “Java 26 broke reflection”:
Ordinary reflection is fine. Reading fields, calling methods, setAccessible on non-final members, mutating non-final fields — unchanged. JEP 500 does not deprecate Field or remove setAccessible.
Serialization of Serializable types has a supported API. The original reason JDK 5 allowed this hole was third-party serializers that, like the JDK’s own, bypass constructors and fill in instance fields — including finals. Library maintainers should use sun.reflect.ReflectionFactory, which is supported for that job. It hands back a method handle to JDK-generated code that assigns instance fields the same way core serialization does. Users should not need --enable-final-field-mutation for a library that has migrated.
That API only covers classes that implement java.io.Serializable. That limit is the integrity bargain: the JVM must assume finals in Serializable objects might be written during deserialization, and it can treat finals everywhere else as permanently immutable. If you “serialize” types that are not Serializable by blasting Field.set at them, that is the code to redesign.
System.in / out / err were already special. Those finals are write-protected; you mutate them through setIn / setOut / setErr, never via deep reflection. Java 26 does not change that.
MethodHandles follow Field.set, not a separate story. Lookup.unreflectSetter on a final field is the same new check: open package plus --enable-final-field-mutation for the caller, otherwise warn now and throw later. Do not treat a VarHandle or a setter handle obtained through that path as a workaround — it is the same permission, with a different type.
The honest alternative
If you control the type, stop poking the field. Put the value in a constructor. Tests construct a Config("test"). Production constructs a Config("prod"). There is nothing for reflection to fix.
class Config {
private final String env;
Config(String env) {
this.env = env;
}
String env() {
return env;
}
}
When the type is its data, make that explicit with a record. You get the canonical constructor, accessors, and equals / hashCode for free, and deep reflection already cannot reassign the components:
public record Config(String env) {}
Config prod = new Config("prod");
Config test = new Config("test");
Constructor injection is the same move for frameworks. Most DI containers already refuse or discourage injecting into final fields. Pass collaborators into the constructor (or a compact record header) and the container never needs setAccessible on a final.
When construction has several optional pieces, a builder still assigns once, inside the constructor the builder finally calls:
public final class ServerConfig {
private final String host;
private final int port;
private ServerConfig(String host, int port) {
this.host = host;
this.port = port;
}
public static Builder builder() {
return new Builder();
}
public static final class Builder {
private String host = "localhost";
private int port = 8080;
public Builder host(String host) {
this.host = host;
return this;
}
public Builder port(int port) {
this.port = port;
return this;
}
public ServerConfig build() {
return new ServerConfig(host, port);
}
}
}
The builder’s fields are mutable on purpose. The ServerConfig fields are not. That is the line JEP 500 is asking you to keep.
Tests, clones, and native code
A unit test that does setAccessible + set on a final to “inject a stub” is using the production type as a mutable bag. Give the type a constructor (or a package-visible one) and pass the stub. If the class is not yours, wrap it rather than violating its constructor contract.
clone() is the other historical trap. super.clone() copies finals from the original, so implementations sometimes used deep reflection to overwrite them on the copy. That is another mutation that will fail under deny. Bloch’s advice still holds: prefer a static factory or a constructor. If you must implement clone, construct a new instance and copy through the constructor so finals are assigned, not patched.
JNI Set*Field / SetStatic*Field on a final is undefined behavior, not a supported mutation path. Java 26 adds diagnostics (-Xlog:jni=debug, -Xcheck:jni) so you can find those calls. A future JDK may make the JNI functions return success and do nothing. sun.misc.Unsafe field writes have no new diagnostics here; the removal of those methods already started in JDK 24.
Note: JEP 500 is about reassigning the field. A final List<String> that you add to is a different bug — the reference is stable, the object is not. Records do not magically deep-freeze either. Use unmodifiable collections (or defensive copies) when the graph needs to be immutable, not another Field.set.
Gotchas
Default is warn, not deny. Shipping on Java 26 with a quiet stderr does not mean you are done. Run deny in CI or you will meet the exception on a later JDK.
--add-opens does not enable mutation. It is necessary for deep reflection across modules and insufficient for finals. You need --enable-final-field-mutation as well, and only for the modules that still write.
setAccessible succeeding is not permission to write. On Java 26 it can be legal to call setAccessible(true) and illegal to call set. Passing that Field into another module does not transfer the right.
Module.addOpens at runtime will not create a mutation hole if neither side was enabled on the command line. The JVM already decided those finals are trustworthy. This also applies to ModuleLayer.Controller.addOpens and Instrumentation.redefineModule.
Classpath apps enable ALL-UNNAMED. Named-module lists do not cover code on the class path. The JAR manifest entry, when you use it, only accepts ALL-UNNAMED.
Do not ask users to enable mutation as the product design. Serialization libraries migrate to ReflectionFactory. DI, mocking, and test helpers migrate to constructors. The flag is a bridge, not an API.
Cheat sheet
Java 26 / JEP 500: warnings, not a hard fail
Default: --illegal-final-field-mutation=warn (mutation succeeds, 1 warning/module)
Prepare: --illegal-final-field-mutation=deny (IllegalAccessException)
Debug: --illegal-final-field-mutation=debug (stack every time)
Find: JFR event jdk.FinalFieldMutation
Enable a hole (application / deployer, last resort):
java --enable-final-field-mutation=ALL-UNNAMED ...
java --enable-final-field-mutation=M1,M2 ...
JAR manifest: Enable-Final-Field-Mutation: ALL-UNNAMED
Legal write needs all three:
1. setAccessible(true) already succeeded
2. field's package is open to the caller
3. final-field mutation enabled for the caller's module
setAccessible: unchanged
Field.set / Lookup.unreflectSetter: the new check
Serializable deserialization: sun.reflect.ReflectionFactory (supported)
Records / hidden classes: already immutable via deep reflection
System.in/out/err: already write-protected; use setIn/setOut/setErr
JNI Set*Field on finals: undefined; -Xlog:jni=debug / -Xcheck:jni
Do:
- Assign
finalfields in a constructor (or a record header / builderbuild()). - Run Java 26 CI with
--illegal-final-field-mutation=denyso the future default is a test failure, not a production surprise. - Use JFR or
debugwhen the one-line warning is not enough to name the library. - Tell serializer maintainers to move to
ReflectionFactoryinstead of handing users a flag.
Don’t:
- Treat Java 26 as a hard break — the default still mutates.
- Silence the warning with
--add-opensand call it fixed. - Mutate finals in tests, mocks, or
clone(); construct a new instance. - Use
Field.set/unreflectSetteras a deserializer for types that are notSerializable. - Enable
ALL-UNNAMEDin production and leave it there as architecture.
Wrap-up
JEP 500 is a warning release with a sharp sequel. Java 26 still lets Field.set write a final field, then tells you which module did it. A later JDK will throw unless the application enabled that module on purpose.
The integrity argument is the whole feature. If finals are optional, the Memory Model and the JIT are guessing. Records and hidden classes already refused the guess; ordinary classes now get the same rule on a delay.
Fix the call sites you own with constructors, records, and builders. Leave --enable-final-field-mutation for the one library you cannot patch this week, and make deny the CI default so that list shrinks.