JEP 500 and Final Field Integrity: How Java Is Closing a Reflection Loophole That’s Been Open Since JDK 1.0
For decades, developers, frameworks, and serialization libraries have quietly mutated final fields at runtime through deep reflection. JDK 26 is now putting a timer on that behaviour — and a hard stop is coming.
If you have ever used Jackson, Mockito, Hibernate, or any modern Java serialization library, there is a good chance that at some point the JVM quietly broke one of its own rules on your behalf. Specifically, it mutated a field that was explicitly declared final — a field that, by Java’s own specification, should never change after the object is constructed. This loophole has been open since the very early days of the language, and JEP 500, targeted for JDK 26, is finally beginning to close it.
This is not a breaking change yet — but it is the starting gun. Understanding what is happening, why it was ever allowed, and how to prepare your codebase now will save you from a very unpleasant surprise when the default flips from a warning to an exception in a future release.
1. What Does “Final” Actually Mean?
At the language level, final on a field is a simple promise: once the field is assigned during construction, its value will never change. It is the foundation of immutability in Java. It is why String objects are safe to share across threads without synchronisation. It is why record types feel trustworthy. It is also the basis for a critical JVM optimisation called constant folding, where the JIT compiler can inline a field’s value directly into machine code, knowing it will never need to re-read it from memory.
The problem is that the Java reflection API has, since JDK 5, provided a way to violate that promise entirely. By calling Field.setAccessible(true) and then Field.set(object, newValue) — a technique commonly known as deep reflection — any piece of code could overwrite a final field at runtime, regardless of the developer’s intent.
Deep reflection refers specifically to using
java.lang.reflect.Field‘ssetAccessibleandsetmethods to bypass Java’s access modifiers and, crucially, thefinalconstraint at runtime.
2. Why Was This Ever Allowed? A Brief History
This is not the result of an oversight. It was a deliberate, if imperfect, decision. The reflection API was broadened in JDK 5 primarily because of Java serialisation. When you deserialise an object using the standard Java Serialisation protocol, you need to reconstruct it without calling a constructor — you essentially assign fields directly into a freshly allocated, uninitialized object. For non-final fields, that was manageable. For final fields, it was not. Rather than redesign the serialisation protocol from scratch, the team modified the reflection API to allow it.
In retrospect, the OpenJDK team has acknowledged this openly: offering such unconstrained mutation was, as they put it in the JEP, “a poor choice because it sacrificed integrity.” Furthermore, the mere existence of an API that can mutate any final field at any time forces the JVM’s JIT compiler to make defensive assumptions — it cannot safely inline a value it knows might be changed later. As a result, constant folding benefits that should apply to final fields have been significantly limited.
2.1 The Gradual Tightening — A Timeline
- JDK 5 (2004)Reflection API changed to allow deep mutation of
finalfields, enabling Java serialisation. - JDK 15 (2020)Hidden classes introduced. Deep reflection blocked from mutating
finalfields in hidden classes. - JDK 16 (2021)Record classes introduced.
finalfields in records explicitly protected from deep reflection mutation. - JDK 17 (2021 LTS)Strong encapsulation of JDK internals via JEP 396 and 403 — deep reflection into JDK modules further restricted.
- JDK 24 (2024)
sun.misc.Unsafemethods that allowedfinalfield mutation began the process of removal. - JDK 26 (2026)JEP 500:Deep reflection mutations of
finalfields in all normal classes now emit warnings by default. The countdown begins. - Future JDK (TBD)Default changes from warn to deny —
IllegalAccessExceptionthrown.finaltruly means final.
3. What JEP 500 Actually Changes in JDK 26
It is important to be precise here, because the change in JDK 26 is a preparation step, not the full restriction. In JDK 26, the behaviour of Field.setAccessible(true) is unchanged. What changes is what happens when you call Field.set(object, value) on a field that is final.
The new behaviour is controlled by a command-line option: --illegal-final-field-mutation, which defaults to warn in JDK 26. Concretely, this means:
| Flag Value | Behaviour in JDK 26 | Use Case |
|---|---|---|
warn Default | Mutation proceeds but a warning is printed once per module that offends | Production: buy time to fix |
deny Recommended | Throws IllegalAccessException immediately when mutation is attempted | CI/CD: find all offenders now |
allow | Mutation proceeds silently, no warning issued | Emergency bypass only — not for new code |
debug | Like warn but also prints a full stack trace for every mutation, not just the first | Investigation and audit |
Additionally, JEP 500 introduces a permanent escape hatch for modules that genuinely need to mutate final fields: --enable-final-field-mutation=MODULE_NAME. This is intentionally more surgical than the temporary --illegal-final-field-mutation blanket option. The idea is that as you migrate, you can disable the blanket option and selectively enable mutation only for the modules that still need it, giving you a clear audit trail of what still depends on the old behaviour.
Important: In JDK 26, you can no longer silence these warnings simply by using
--add-opens. Opening a module for deep reflection is a necessary but no longer sufficient condition for mutating itsfinalfields. The mutation permission is now a separate, explicit opt-in.
JEP 500 — Enforcement Severity
4. Who Is Affected? The Ecosystem Impact
The short answer is: more projects than most teams realise. The longer answer depends on whether you are the user of a framework or a maintainer of one. Frameworks and libraries are far more likely to be performing the actual mutation — often invisibly, buried several layers deep in their internals. As a consequence, most application developers will see warnings originating from their dependencies, not from their own code.
That said, some tools are more directly implicated than others. Here is a realistic breakdown:
| Tool / Library | Reason for Impact | Migration Path | Risk |
|---|---|---|---|
| Java Serialisation | The original reason the loophole existed — deserialises into final fields | Use ReflectionFactory deserialization protocol instead of direct field assignment | Medium |
| Jackson (ObjectMapper) | Deserialises JSON into fields that may be final | Use constructor-based binding or builder pattern; avoid immutable classes without explicit creator | Medium |
| Mockito (inline mocking) | Mocking of classes with final fields uses subclassing or byte-buddy manipulation | Upgrade to Mockito 5.x; prefer interface-based design; use constructor injection | High |
| Hibernate / JPA | Proxy generation for lazy loading may interact with final fields | Avoid final fields on persistent entity classes; use non-final accessors | Medium |
| Spring DI (Field Injection) | Field injection bypasses constructors — final fields cannot be injected this way | Switch to constructor injection — already the recommended pattern since Spring 4.3 | Low |
| Custom Serialisation Libs | Any library using Field.set on arbitrary objects | Audit with --illegal-final-field-mutation=debug to identify callers | High |
As JVM Weekly noted at the end of 2025, practically every framework that generated or modified classes — Spring, Hibernate, Mockito, Byte Buddy, testing tools, profilers — has historically relied on reflection-based introspection in ways that bump up against this restriction. The good news is that the most mature projects have been aware of this direction for some time, and many have already updated their internals to use safer alternatives.
Migration Effort Estimate
5. How to Migrate Safely: A Practical Guide
The migration strategy has three clear phases, and the most important thing is to start now — during the warning window — rather than waiting for the hard stop in a future JDK.
Phase 1: Discover What You Are Dealing With
Before you change a single line of code, run your full test suite and application startup with the deny flag enabled. This turns warnings into exceptions, which means your CI pipeline will fail loudly the moment anything tries to mutate a final field. Start with:
java --illegal-final-field-mutation=deny -jar your-application.jar
If you want a stack trace for every occurrence without stopping execution, use the debug value instead:
java --illegal-final-field-mutation=debug -jar your-application.jar
Additionally, JDK 26 integrates this event with JDK Flight Recorder (JFR). You can observe the jdk.FinalFieldMutation event to get a full production-safe audit of every mutation attempt, which is particularly useful in environments where you cannot simply restart with a different flag.
Phase 2: Address the Root Causes
Once you know which modules and libraries are offending, you have several paths forward, depending on the source of the mutation.
For your own serialisation code: The JEP explicitly recommends migrating to sun.reflect.ReflectionFactory. This API allows you to execute an object’s deserialization protocol in a way the JVM can trust — it reconstructs the object through its constructor chain, so final fields are assigned during construction rather than overwritten afterwards. This approach requires no extra command-line flags and will continue to work even after the hard cutoff.
For dependency injection: If you are using field injection (@Autowired directly on a field), switch to constructor injection. This is not just a JEP 500 concern — constructor injection is safer, more testable, and has been Spring’s recommended approach for years. It also works naturally with final fields, since the injected value is provided during construction.
For mocking in tests: If your test suite uses Mockito’s inline mock-maker to mock classes with final fields, the best long-term fix is to design those classes to be mockable without reflection-based tricks — typically by targeting interfaces rather than concrete classes. Upgrading to Mockito 5.x is also worth doing, as the team has been actively updating the library’s internals to align with the new JDK restrictions.
Phase 3: Contain What You Cannot Yet Fix
In reality, some third-party dependencies will not be updated immediately. For those cases, you can use the permanent per-module escape hatch:
java --enable-final-field-mutation=com.legacy.library -jar your-application.jar
Or, if the offending code is in the unnamed module (which covers most classpath-based legacy code):
java --enable-final-field-mutation=ALL-UNNAMED -jar your-application.jar
You can also embed this in a JAR manifest to avoid polluting every startup script. Add the following to your executable JAR’s MANIFEST.MF:
Enable-Final-Field-Mutation: ALL-UNNAMED
Recommended approach: Start with
--illegal-final-field-mutation=denyin your CI environment from day one. Fix the issues that surface. Use--enable-final-field-mutationonly as a temporary, documented exception while you wait for upstream library fixes. Document every exception so you can track them down when the hard stop arrives.
6. The Performance Case: Why This Matters Beyond Correctness
Security and correctness aside, there is a compelling performance argument for closing this loophole. As InfoQ noted in December 2025, the JVM’s JIT compiler has historically had to make conservative assumptions about final fields because of this API. In particular, constant folding — where the JIT replaces a field read with an inlined constant value — is only safe if the field’s value is truly guaranteed never to change.
Once the mutation loophole is fully closed, the JVM gains the ability to treat every final field in a class as a genuine compile-time constant from the JIT’s perspective, which opens the door to more aggressive inlining and optimisation across the board. This is especially meaningful for highly concurrent applications and any code that reads final fields in tight loops.
Furthermore, this change directly benefits Project Valhalla and the work on value classes. Value classes, by design, require deep immutability guarantees. Making final semantics trustworthy at the JVM level is a foundational prerequisite for the kind of layout and optimisation work Valhalla depends on.
7. What the Warning Actually Looks Like
If you upgrade to JDK 26 and run your application without any additional flags, and something in your stack mutates a final field, you will see output like this in your logs:
WARNING: Final field value in com.example.Config has been mutated by
class com.fasterxml.jackson.databind.deser.BeanDeserializer
in module com.fasterxml.jackson.databind
(file:/path/to/jackson-databind.jar)
WARNING: Use --enable-final-field-mutation=com.fasterxml.jackson.databind to avoid a warning
WARNING: Mutating final fields will be blocked in a future release
unless final field mutation is enabled
Note the specificity of the message — it tells you exactly which field, which class caused it, and which module it came from. This is intentionally actionable. The JDK team has designed this transition to be as observable as possible before pulling the trigger on hard enforcement.
8. What We Have Learned
JEP 500 is the formal beginning of the end for one of Java’s longest-standing integrity compromises. Since JDK 5, the reflection API quietly allowed any code to overwrite final fields at runtime — a capability originally introduced to support Java serialisation but subsequently exploited by a wide range of frameworks, mocking tools, and dependency injection containers. Over the years, the OpenJDK team has been methodically tightening the rules: first for hidden classes, then for records, then for JDK internals, and now for all normal classes via JEP 500 in JDK 26.
In JDK 26, the change is a warning — not yet a hard stop. But the direction is unmistakable and the tooling to prepare is already in place. The smart move is to enable --illegal-final-field-mutation=deny in CI today, audit every warning that surfaces, and address each one either by updating to a library version that uses safer alternatives, redesigning your own code to avoid deep reflection, or — as a documented last resort — explicitly enabling mutation for the specific modules that still need it. By the time the default flips to deny in a future JDK, your codebase should barely notice.



