On the Boundaries of Final

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: Behaviour-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. It is a surprisingly approachable document, and I strongly advocate for going straight to it rather than relying on folklore.

I believe this JEP 500 is part of a larger effort in making integrity a first-order concern. For some years, the OpenJDK community has been executing a strategy known as Integrity by Default. The end goal of that larger effort is to transition Java from allowing unsafe APIs and deep reflection without asking any questions to a world where developers need to explicitly opt in to using these (potentially dangerous) features. Moreover, Valhalla, which will deliver value-classes (among others), 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.

Construction 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? The JLS §12.4.2 permits recursive initialisation 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:

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);
    // So what value did we observe?
    System.out.println(KeyStore.cachedKeyStore);
  }
}

Running this results in:

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 behaviour 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.

Construction Integrity: Multiple Threads

JLS §17 (JMM) details the Java memory model 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 behaviour 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:

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 Main {
  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();
    }
  }
}

Thanks to repeating the experiment 100k times and injecting bogus work, this more or less consistently results in:

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 behaviour.

While this behaviour is clearly defined, I believe it constitutes a data race because two threads are competing to read and write the same variable without synchronisation. Moreover, no, throwing volatile on the field will not help, as the specification disallows it. 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 emboldened to make assumptions. If the JVM can guarantee that a field is immutable, it can perform optimizations such as constant folding and dead code elimination with absolute confidence. However, code that relies on unsafe publication—like the ServiceRegistry example above-undermines this trust.

In the example, the config field effectively mutates from null to Configuration for the reading thread. If the JIT compiles the reading code under the assumption that a final field is a constant that never changes, but a data race exposes an uninitialized state, the results could be catastrophic. We might move from “merely” observing a null value to experiencing erratic behavior where the JIT elides null checks entirely, assuming the field could strictly never be null.

As we move toward a Java with stronger integrity by default, relying on the “loophole” behaviors of the JLS becomes increasingly risky. If we want the JVM to optimize our code as if it were immutable, we must ensure we construct our objects safely. Deep reflection and unsafe publication are two sides of the same coin: they both break the contract of final. JEP 500 fixes the former; it is up to developers to fix the latter.

Written on November 18, 2025