JDK migration5 min read

JDK 26 Final-Field Mutation Warnings: Find and Remove Reflective Writes

Prepare for JDK 26 final-field mutation warnings, locate reflective writes with debug mode or JFR, and scope temporary module permissions safely.

  • reflection
  • final fields
  • JDK 26

JDK 26 warns when code uses deep reflection to mutate a final instance field without an explicit permission. The write still proceeds in the default warn mode, but a future JDK will deny it by default. The durable migration is to update or redesign the library that performs the write; --enable-final-field-mutation is a narrowly scoped compatibility bridge, not a general fix.

This behavior is new in JDK 26. JDK 25 and earlier do not recognize the new launcher options, so run the migration audit on the same JDK 26 runtime that will host the application.

Reproduce the dependency on deep reflection

The following class models a framework that constructs an object and then overwrites a final field:

import java.lang.reflect.Field;

public class FinalWrite {
    static final class Settings {
        final String endpoint;

        Settings(String endpoint) {
            this.endpoint = endpoint;
        }
    }

    public static void main(String[] args) throws Exception {
        var settings = new Settings("old");
        Field field = Settings.class.getDeclaredField("endpoint");
        field.setAccessible(true);
        field.set(settings, "new");
        System.out.println(field.get(settings));
    }
}

Compile it normally, then run it with JDK 26:

javac FinalWrite.java
java FinalWrite

The program prints new, and JDK 26’s default mode also warns about the first illegal final-field mutation performed by that module. The JDK 26 migration guide shows the warning format and explains that it identifies both the mutated field and the code responsible.

Calling setAccessible(true) is necessary in this example, but no longer sufficient to make the final-field write legal. Opening a package and granting final-field mutation are independent checks.

Audit with debug and deny modes

Default warn mode reports at most one illegal mutation per module, which is useful for visibility but incomplete as an inventory. Use debug during tests to receive a warning and stack trace for every mutation:

java --illegal-final-field-mutation=debug FinalWrite

JDK Flight Recorder offers a lower-noise option for a representative workload. It records jdk.FinalFieldMutation when code mutates a final instance field through reflection or obtains a final-field setter with MethodHandles.Lookup.unreflectSetter:

java -XX:StartFlightRecording:filename=final-writes.jfr FinalWrite
jfr print --events jdk.FinalFieldMutation final-writes.jfr

After updating dependencies, test the future default explicitly:

java --illegal-final-field-mutation=deny FinalWrite

An unpermitted write now fails with IllegalAccessException. Use this mode in migration CI because a passing run proves more than merely suppressing warnings.

The four JDK 26 modes are allow, warn, debug, and deny. allow lets all illegal writes proceed silently and should only be a short-lived diagnostic escape hatch. warn is the JDK 26 default. The JDK 26 java launcher specification states that deny is intended to become the default and that the --illegal-final-field-mutation option itself will eventually be removed.

Grant permission to the mutating module only

If a required library cannot yet avoid the write, enable the capability for the module containing the code that calls Field.set, not the module that declares the field. For class-path code, that module is the unnamed module:

java --enable-final-field-mutation=ALL-UNNAMED FinalWrite

For named modules, list only the modules that require the capability:

java --enable-final-field-mutation=com.example.serializer,com.example.mapper \
  --module com.example.app/com.example.app.Main

This option can name modules only in the boot module layer. It cannot grant the capability to modules created later in user-defined layers. Deployment owners can also pass it with JDK_JAVA_OPTIONS, an argument file, an executable-JAR manifest, a jlink image option, or the JNI Invocation API. Library code cannot silently grant itself this permission; the application or runtime owner must choose it.

Package openness is a separate requirement

A legal final-field mutation in JDK 26 requires both permission for the caller’s module and access to the declaring package. If the field belongs to another named module, that package must already be open to the caller in a way recognized by the final-field rules. --enable-final-field-mutation does not imply --add-opens, and --add-opens alone does not grant mutation permission.

Static final fields, record fields, and fields in hidden classes remain non-modifiable through deep reflection. The JDK 26 Field API contract lists those cases and warns that changing other final fields after publication can have unpredictable effects, including code continuing to observe the original value.

Prefer construction over post-construction mutation

The clean migration depends on why the write exists:

  • For dependency injection, use constructor or factory injection so final fields receive their values during construction.
  • For object mapping, call an appropriate constructor, builder, or static factory instead of allocating an incomplete object and patching it.
  • For cloning, construct a new instance with the desired final values rather than changing the object returned by super.clone().
  • For tests, expose a supported seam such as a constructor parameter or interface instead of rewriting private final state.

Serialization is the important compatibility case. JEP 500 allows limited-purpose serialization support to evolve separately, but ordinary libraries should not assume a permanent exemption. Inventory the actual callers, upgrade them where possible, prove the application under deny, and keep any remaining permission as narrow and visible as the dependency that requires it.