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 afinalinstance fieldField.set*()on afinalstatic fieldVarHandle/MethodHandlewrite access tofinalfields
What Does NOT Trigger a Warning
- Reading
finalfields viaField.get()— still perfectly safe - Setting non-final fields via reflection — unaffected
- Normal (non-reflective) use of
finalfields
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 warning4. 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 final5. 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"); // ⚠️ WARNINGNew 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 ConstructorPreferenceSingleton 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; // ⚠️ WARNINGNew 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 reflectionLazy 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:
- Jackson/Gson — enable constructor binding (
@JsonCreator,FieldNamingPolicy, or records) - Spring — switch from
@Autowiredfield injection to constructor injection - JUnit/TestNG — replace singleton reflection resets with explicit
resetForTesting()methods - Test constants — replace reflective overrides with configurable objects or system properties
- Copy utilities — replace reflective cloning with
withX()methods on records
Running the Demo
mvn compile exec:exec \
-Dexec.mainClass=org.example.standard.FinalFieldWarningsDemoWatch both stdout and stderr — the JDK warnings appear on stderr alongside the normal output.
Check out the full source on GitHub.