Java 26: Prepare to Make Final Mean Final (JEP 500)

The final keyword is about to mean something again. JEP 500 introduces runtime warnings in JDK 26 when reflection mutates final fields — the first step toward making final truly immutable. In a future JDK, those mutations will be blocked entirely.

Status: Standard — available in JDK 26 without any flags.

What Changed

Today, despite the final keyword, any code can still do:

Field f = obj.getClass().getDeclaredField("name");
f.setAccessible(true);
f.set(obj, "hacked");   // ← Mutates a "final" field!

In JDK 26, this still succeeds — but now emits a warning to System.err:

WARNING: java.lang.reflect: final field MyClass.name was set via reflection.
This will be blocked in a future release.

In a future JDK, these mutations will throw IllegalAccessException.

Why It Matters

The JVM and JIT compiler rely on final fields never changing. This enables powerful optimizations:

  • Constant folding — inline the field value directly, eliminating the read
  • Thread-safe publication — no need for volatile semantics
  • Escape analysis — prove the object is truly immutable

Reflective mutation of final fields silently breaks these guarantees, causing subtle concurrency bugs. JEP 500 closes this loophole gradually: warn in JDK 26, block in a future release.

What Triggers a Warning

  • Field.set*() on a final instance field
  • Field.set*() on a final static field
  • VarHandle / MethodHandle write access to final fields

What Does NOT Trigger a Warning

  • Reading final fields via Field.get() — still perfectly safe
  • Setting non-final fields via reflection — unaffected
  • Normal (non-reflective) use of final fields

Demo Highlights

1. Modify a Final Instance Field → Warning

static class UserProfile {
    private final String name;
    private final int age;

    UserProfile(String name, int age) { this.name = name; this.age = age; }
}

var profile = new UserProfile("Alice", 30);
System.out.println("Before: " + profile);  // UserProfile[name=Alice, age=30]

Field nameField = UserProfile.class.getDeclaredField("name");
nameField.setAccessible(true);
nameField.set(profile, "Bob");  // ⚠️ WARNING on stderr in JDK 26

System.out.println("After: " + profile);   // UserProfile[name=Bob, age=30]
// The mutation succeeds today — but WILL be blocked in a future JDK.

2. Modify a Final Static Field → Warning

Final static fields (constants) are even more dangerous to mutate — the JIT may have already inlined their value:

static class AppConfig {
    private static final String DEFAULT_LOCALE = "en_US";
}

Field localeField = AppConfig.class.getDeclaredField("DEFAULT_LOCALE");
localeField.setAccessible(true);
localeField.set(null, "fr_FR");  // ⚠️ WARNING on stderr — JIT may ignore this!

3. Reading Final Fields — No Warning

Only writes trigger warnings. Reading via reflection is safe and produces no warning:

Field nameField = UserProfile.class.getDeclaredField("name");
nameField.setAccessible(true);
String value = (String) nameField.get(profile);  // ✅ No warning

4. Non-Final Fields — No Warning

The warning only applies to fields declared final. Setting non-final fields via reflection is unaffected:

static class MutableSettings {
    private String theme = "light";
    private int fontSize = 14;
}

Field themeField = MutableSettings.class.getDeclaredField("theme");
themeField.setAccessible(true);
themeField.set(settings, "dark");  // ✅ No warning — field is not final

5. Proper Alternatives

Rather than mutating final fields, create new instances:

// Builder / copy-with pattern
record Config(String env, String locale) {}

var config = new Config("prod", "en_US");
// "Modify" by creating a new instance — no reflection needed
var updated = new Config(config.env(), "fr_FR");

Real-World Use Cases

The companion FinalFieldWarningsRealWorldExamples class demonstrates six patterns that will break in future JDKs and how to migrate:

JSON Deserializer Setting Final Fields

Frameworks like Jackson and Gson use reflection to set final fields during deserialization. In JDK 26 this now warns — in a future JDK it will fail.

Old way (now warns):

var user = new ImmutableUser("unknown", -1);
Field nameField = ImmutableUser.class.getDeclaredField("name");
nameField.setAccessible(true);
nameField.set(user, "Alice");  // ⚠️ WARNING

New way — use constructor-based deserialization (@JsonCreator in Jackson):

// Jackson: annotate the constructor
@JsonCreator
ImmutableUser(@JsonProperty("name") String name, @JsonProperty("age") int age) {
    this.name = name;
    this.age  = age;
}
// Gson: use GsonBuilder with ConstructorPreference

Singleton Reset for Testing

Tests often reset a singleton’s final static INSTANCE field between runs.

Old way (now warns):

Field instanceField = DatabasePool.class.getDeclaredField("INSTANCE");
instanceField.setAccessible(true);
instanceField.set(null, null);  // ⚠️ WARNING — will be blocked!

New way — expose an explicit reset method and use a non-final holder:

static class ResettableService {
    private static volatile ResettableService instance = new ResettableService();

    static ResettableService get() {
        if (instance == null) instance = new ResettableService();
        return instance;
    }

    /** Called by tests only. */
    static void resetForTesting() { instance = null; }
}

Dependency Injection into Final Fields

Spring’s @Autowired field injection and Guice’s @Inject set final fields via reflection. Spring itself has recommended constructor injection since version 4.3.

Old way (now warns):

// Framework sets this final field reflectively:
@Autowired
private final OrderService orderService = null;  // ⚠️ WARNING

New way — constructor injection:

private final OrderService orderService;

@Autowired
OrderControllerNew(OrderService orderService) {
    this.orderService = orderService;  // ✅ No reflection
}

Configuration Override for Testing

Tests override static final constants like MAX_RETRIES to speed up execution.

Old way (now warns and may be silently ignored by the JIT):

Field maxRetriesField = RetryConfig.class.getDeclaredField("MAX_RETRIES");
maxRetriesField.setAccessible(true);
maxRetriesField.setInt(null, 1);  // ⚠️ WARNING — JIT may have already inlined 5!

New way — inject a configuration object:

record FlexibleRetryConfig(int maxRetries, long timeoutMs) {}

// Production
var prodConfig = new FlexibleRetryConfig(5, 30_000);
// Testing
var testConfig = new FlexibleRetryConfig(1, 10);

Immutable DTO Copy Pattern

Some codebases “clone and change” final fields via reflection. Records with wither methods are the clean alternative:

record Product(String name, double price, boolean inStock) {
    Product withPrice(double newPrice) {
        return new Product(name, newPrice, inStock);
    }
    Product withInStock(boolean newInStock) {
        return new Product(name, price, newInStock);
    }
}

var original   = new Product("Widget", 29.99, true);
var discounted = original.withPrice(19.99);   // ✅ New instance, no reflection
var soldOut    = original.withInStock(false); // ✅ New instance, no reflection

Lazy Cache Initialization

Final fields initialized to null and later populated reflectively should instead use proper double-checked locking or StableValue (JEP 526):

static class LazyCache {
    private volatile Map<String, String> data;  // Non-final, volatile

    String get(String key) {
        if (data == null) {
            synchronized (this) {
                if (data == null) {
                    data = loadExpensiveData();  // ✅ Safe lazy init
                }
            }
        }
        return data.getOrDefault(key, "(not found)");
    }
}

Migration Checklist

If you see JDK 26 warnings in your application logs:

  1. Jackson/Gson — enable constructor binding (@JsonCreator, FieldNamingPolicy, or records)
  2. Spring — switch from @Autowired field injection to constructor injection
  3. JUnit/TestNG — replace singleton reflection resets with explicit resetForTesting() methods
  4. Test constants — replace reflective overrides with configurable objects or system properties
  5. Copy utilities — replace reflective cloning with withX() methods on records

Running the Demo

mvn compile exec:exec \
  -Dexec.mainClass=org.example.standard.FinalFieldWarningsDemo

Watch both stdout and stderr — the JDK warnings appear on stderr alongside the normal output.

Check out the full source on GitHub.