On the Boundaries of Final
A deep dive into JEP 500 and the Java Memory Model to understand the formal boundaries of 'final'. We examine JLS §17, unsafe publication, and why immutable fields can still appear mutable in concurrent environments.
In my research group, Luke Cheeseman is tackling the complexities of concurrency and data races—work that is brilliantly detailed in his paper, When Concurrency Matters: behavior-Oriented Concurrency. Following recent discussions with Luke and the announcement of JEP 500: Prepare to Make Final Mean Final targeting JDK 26, I was inspired to examine the Java Language Specification (JLS) to explore the formal boundaries of final. The JLS is a surprisingly approachable document, and I strongly advocate for going straight to it rather than relying on folklore.
JEP 500 is part of a larger effort. The end-goal is Integrity by Default. This strategy ensures that powerful features like deep reflection (e.g. setAccessible) remain available, but shifts the default from implicit access to explicit opt-in. Moreover, Valhalla, which will deliver value-classes (among other things), requires all fields to be final so understanding the fine print for final may be more important than ever.
Setting the Stage: What Kind of Final Are We Talking About?
In Java, there is the keyword final, which is a modifier that may be applied to variables, fields, classes, etc. For this blog post, we focus on fields and variables.
The Java Language Specification (JLS) says the following:
- 4.12.4. final Variables: A variable can be declared final. A final variable may only be assigned to once.
- 8.3.1.2. final Fields: A field can be declared final (§4.12.4).
In essence, it is a modifier that encodes shallow immutability — the value of the variable may never be reassigned, but does not transitively apply it “deeply inside objects”.
Immutability is a powerful tool for safety and performance optimization: if a shared state cannot mutate, entire classes of concurrency bugs simply vanish, and when the JIT compiler can prove that a value is constant, more efficient code can be generated.
Consider these examples of how we can use final to encode immutability:
1
2
3
4
5
6
class User {
private String username;
public User(String username) { this.username = username; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
}
Adding the final modifier for a variable will cause the following to fail at compile-time, adhering to JLS §4.12.4:
1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
final User user = new User("Mr. Duke");
user = new User(); // Illegal
}
}
However, note that it is only the value of user (the reference) that is immutable. The non-final fields inside the User instance remain mutable:
1
2
3
4
5
6
public class Main {
public static void main(String[] args) {
final User user = new User("Mr. Duke");
user.setUsername("Mr. Final"); // Legal!
}
}
However, there is nothing that stops a developer from adding final to private String username inside the class definition, making the reassignment impossible. This construct gives a developer fine-grained control over what should and should not be immutable.
Towards Integrity: Single Thread
The JLS needs to balance the risk of deadlocks during initialization with the scope of final, i.e., at exactly what point should final mean final? JLS §12.4.2 permits recursive initialization to access fields before the construction has finished:
If the Class object for C indicates that initialization is in progress for C by the current thread, then this must be a recursive request for initialization. Release LC (Lock) and complete normally.
This rule allows for a deterministic, single-threaded execution where a final field is observed in a pre-assigned state:
Copy below to BootSequence.java and run with java BootSequence.java or in-browser.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class BootSequence {
public static class Bootloader {
public static final KeyStore keyStore = new KeyStore();
static {
// Call to trigger KeyStore's static initializer
keyStore.observeFinalVariable();
}
}
public static class KeyStore {
public static final KeyStore cachedKeyStore;
static {
cachedKeyStore = Bootloader.keyStore;
}
public void observeFinalVariable() {}
}
public static void main(String[] args) {
// Trigger static initializer in Bootloader which
// cascades to trigger KeyStore's static initializer
System.out.println(Bootloader.keyStore); // Current value
// So what value did we observe?
System.out.println(KeyStore.cachedKeyStore); // Value that KeyStore saw
}
}
Running this results in:
1
2
BootSequence$KeyStore@28a418fc // Current value
null // Value that KeyStore saw
So, we have managed to observe two states of something that is admittedly immutable! Nevertheless, this is also perfectly legal Java code. It is not a data race, as only a single thread is involved.
The immutability guarantee only applies after the value has been assigned. This behavior is a deliberate design trade-off that enables a language that avoids deadlocks while supporting complex initialization logic. Immutability in this context means assigning once, not necessarily being visible immediately.
Towards Integrity: Multiple Threads
JLS §17 details the Java memory model (also known as the JMM) and the semantics of final. To this end, it specifically names freeze as an action of the point where the final value has been published and is visible to all threads.
However, the JLS anticipates that code might introduce reads before this freeze point. JLS §17.5.2 defines this behavior explicitly:
If the read occurs after the field is set in the constructor, it sees the value the final field is assigned, otherwise it sees the default value.
We can construct a trivial example where this race occurs, often referred to as unsafe publication, and confirm the specification holds:
Copy below to Runner.java and run with java Main.java or in-browser.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class Configuration {}
class ServiceRegistry {
ServiceComponent cachedServiceComponent;
void register(ServiceComponent component) {
this.cachedServiceComponent = component;
}
}
class ServiceComponent {
final Configuration config;
ServiceComponent(ServiceRegistry registry) {
registry.register(this);
// Inject some work to increase likelihood
// of an observable data race
for (int i = 0; i < 1_000_000; i++) {
Thread.yield();
}
this.config = new Configuration();
}
}
public class Runner {
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 100_000; i++) {
ServiceRegistry registry = new ServiceRegistry();
Thread t1 = new Thread(() -> {
new ServiceComponent(registry);
});
Thread t2 = new Thread(() -> {
if (registry.cachedServiceComponent != null &&
registry.cachedServiceComponent.config == null) {
System.out.println("The cachedServiceComponent introduced an observable race!");
System.exit(0);
}
});
t1.start();
t2.start();
t1.join();
t2.join();
}
}
}
Because we are repeating the experiment 100k times and injecting bogus work, this more or less consistently results in:
1
The cachedServiceComponent introduced an observable race!
This race occurs because we leak the pointer to ServiceComponent when passing this to ServiceRegistry before initialisation completes. The fundamental difference here compared to the previous example is that multiple threads are involved, and for that, JLS §17 defines the specified behavior.
While this behavior is clearly defined, it has a data race because two threads are competing to read and write the same variable without synchronization. The keyword volatile might come to mind here, as it is a keyword for which the Java Memory Model ensures that all threads would observe a consistent value. But no, adding volatile on the field will not help, as the specification disallows combining volatile and final. Given that a final field is immutable after initialization, imposing volatile semantics—which force a memory reload on every access—would incur an unnecessary performance penalty for a value that never changes after initialization.
Conclusion
This brings us back to JEP 500. While the immediate goal of the JEP is to prevent the modification of final fields via reflection, the broader philosophy is to allow the JVM to trust its own invariants fully. In a world where “Final Means Final”, the JIT compiler is empowered to make assumptions. If the JVM can guarantee that a field is immutable, it can perform optimizations like bake the value directly into the machine code (constant folding). However, code that relies on unsafe publication—like the ServiceRegistry example above—may block such optimisations.
Ultimately, the Java memory model is designed to permit optimization in pursuit of maximum performance and safety. This architectural freedom comes with complexity: it allows transient execution artifacts—like partially constructed objects—to become visible. While JEP 500 moves to strictly enforce integrity against external modification (reflection), the Java memory model reminds us that integrity in a concurrent context remains the developer’s responsibility. We must respect the boundaries of safe publication to ensure that the optimizations which make Java fast do not undermine the logic of our applications.