🔒 Section 13 · Question #7

What is the final Keyword and How Does It Help Create Immutable Classes

The final keyword in Java expresses the programmatic intent of immutability and restriction of modification. Its precise behavior depends on the target context:


🟢 Junior Level

30-Second Summary

The final keyword in Java expresses the programmatic intent of immutability and restriction of modification. Its precise behavior depends on the target context:

  1. final Variable / Field: Value can be assigned exactly once. For primitive types (int, double), the numerical value is locked. For reference types, the heap object reference (pointer) is locked, but not the internal state of the referenced object.
  2. final Method: Prohibits method overriding in subclasses (@Override is rejected by the compiler).
  3. final Class: Completely blocks inheritance (no class can extend it via extends).

In the construction of immutable classes, final serves two indispensable functions:

  • At the Class Level: Guarantees that external callers cannot create a mutable subclass with setters or altered getter behaviors.
  • At the Field Level: Locks internal state upon construction and triggers the Safe Publication guarantee under the Java Memory Model (JMM §17.5).

Practical Example: Immutable GeoPoint

// 1. final class: inheritance is prohibited
public final class GeoPoint {
    // 2. final fields: initialized once in constructor
    private final double latitude;
    private final double longitude;

    public GeoPoint(double latitude, double longitude) {
        this.latitude = latitude;
        this.longitude = longitude;
    }

    public double getLatitude() {
        return latitude;
    }

    public double getLongitude() {
        return longitude;
    }
}

🟡 Middle Level

1. Blank Final Fields and Definite Assignment

A final field does not need to be initialized at declaration time. Such a field is termed a blank final:

public final class OrderItem {
    private final String sku;          // Blank final
    private final int quantity;        // Blank final
    private final long createdAt = System.currentTimeMillis(); // Initialized at declaration

    public OrderItem(String sku, int quantity) {
        // Every constructor must assign every blank final field along all execution paths
        this.sku = Objects.requireNonNull(sku);
        this.quantity = quantity;
    }
}

If any execution path in any constructor fails to assign a blank final field, javac emits a compile-time error: variable sku might not have been initialized.


2. The Reference vs. State Trap

Declaring a reference field final protects only the reference pointer, providing zero protection against mutation of the underlying object:

public final class MutableStateTrap {
    // The pointer 'list' cannot point to a different ArrayList...
    private final List<String> list = new ArrayList<>();

    public void mutate() {
        // ...but the contents of the referenced ArrayList can be freely modified!
        list.add("Danger!");
        list.clear();
    }
}

[!IMPORTANT] To achieve true immutability, reference fields must either point to deeply immutable types or be safeguarded with defensive copying on input and output using unmodifiable collections (e.g., List.copyOf()).


3. Preventing Polymorphic State Pollution

Why must an immutable class be declared final? If GeoPoint were not final, a malicious or careless developer could extend it:

public class MutableGeoPoint extends GeoPoint {
    private double mutableLatitude;

    public MutableGeoPoint(double lat, double lon) {
        super(lat, lon);
        this.mutableLatitude = lat;
    }

    public void setLatitude(double newLat) {
        this.mutableLatitude = newLat;
    }

    @Override
    public double getLatitude() {
        return this.mutableLatitude; // Violates base class immutability contract!
    }
}

Client code expecting a thread-safe GeoPoint would receive a MutableGeoPoint that can be mutated concurrently, causing race conditions. Declaring the class final eliminates this vulnerability.


🔴 Senior Level

JMM §17.5: Final Field Semantics & Freeze Action

Declaring fields final activates hardware-level Safe Publication guarantees:

  1. At constructor exit, the JVM executes a Freeze Action for all final fields.
  2. The JIT compiler inserts a StoreStore memory barrier (e.g., DMB ISHST on ARM64 or a compiler scheduling barrier on x86 TSO).
  3. This barrier guarantees:
    • All writes to final fields are flushed from CPU store buffers into L1/L2 cache before the reference to the newly constructed object becomes visible to other threads.
    • Any thread reading the published reference is guaranteed to observe the fully initialized values of all final fields without needing volatile or synchronized read locks.

HotSpot C2 JIT Compiler Optimizations

HotSpot treats final fields with special optimization privileges:

  • Compile-Time Constant Inlining: Fields declared as static final primitive/String are tagged with the ConstantValue bytecode attribute. The value is embedded directly as an immediate operand in native machine code, bypassing heap reads entirely.
  • Trust Final Fields: For instance final fields, C2 relies on the language guarantee that values never change:
    • Loop Invariant Hoisting: Moves field reads completely outside high-frequency loops.
    • Common Subexpression Elimination: Combines redundant evaluations.
    • Dead Code Elimination: Eliminates branches proven unreachable based on constant final values.

Reflection Attacks & JEP 403 Defense (Java 17+)

Historically, reflection could overwrite private final fields:

Field f = GeoPoint.class.getDeclaredField("latitude");
f.setAccessible(true);
f.set(point, 99.99); // Worked in Java 8!

In modern Java:

  1. JEP 403 (Strong Encapsulation): Invoking setAccessible(true) on final fields inside JDK internal modules (java.base) throws an uncatchable InaccessibleObjectException.
  2. JIT Inlining Desynchronization: In application code, if C2 has already compiled and inlined the getter as a constant, mutating the heap memory via reflection has no effect on the compiled code—the method continues returning the original inlined constant value!

🎯 Interview Cheat Sheet

The 3 Contexts of the final Keyword

| Context | What It Prohibits | Architectural Role in Immutability | | :— | :— | :— | | final class | Subclassing (extends) | Prevents creation of mutable or rogue subclasses | | final method | Overriding (@Override) | Protects individual method contracts and invariant checks | | final field | Reassignment (=) | Locks state + guarantees JMM §17.5 Safe Publication |


4 Tricky Interview Questions

1. What is the difference between a compile-time constant (public static final primitive/String) and a runtime final field? What versioning problem does this cause?

Answer: Compile-time constants (public static final int MAX = 100;) are tagged with the bytecode attribute ConstantValue. When compiling a client class referencing MAX, javac copies the literal value 100 directly into the client’s bytecode (sipush 100), removing the getstatic call entirely. Versioning Hazard: If the library updates MAX = 200 and the JAR is replaced without recompiling client classes, clients will continue executing with the old hardcoded value 100.

2. Can you read a final field before it has been initialized in a constructor? What value is returned?

Answer: Yes, via a polymorphic call to an overridable method from a base constructor:

class Base {
    public Base() { print(); }
    public void print() {}
}
class Child extends Base {
    private final int x = 42;
    @Override public void print() { System.out.println("x = " + x); }
}

Instantiating new Child() prints x = 0. The base constructor executes before the Child field initializer runs; memory is zeroed out, so x returns its default primitive value 0. This is why calling non-final methods from constructors is a serious anti-pattern.

3. Why might mutating a private final field via reflection fail to alter the value returned by a getter in production under C2 JIT?

Answer: When HotSpot C2 compiles a hot getter, it relies on final field semantics and performs constant folding or register caching. Overwriting heap RAM via reflection changes the memory cell, but the JIT-compiled assembly code reads from a CPU register or has the original value hardcoded in machine instructions. The application enters an inconsistent state where direct reflection reads the new value, while regular getter calls return the old value.

4. How does declaring a class final differ from making all its constructors private?

Answer: Both prevent external subclassing, but with different architectural trade-offs:

  • final class strictly forbids inheritance everywhere, including inside the same file.
  • private constructors forbid subclassing from external classes, but allow nested static subclasses within the same source file. This was the classic pattern for implementing closed class hierarchies (Algebraic Data Types) prior to Java 17 sealed classes.

Red Flags (DO NOT Say)

  • ❌ “Declaring final List<String> list makes the list contents immutable.” (It only locks the reference; elements can be added or removed freely).
  • ❌ “Declaring fields final is sufficient for immutability; the class does not need to be final.” (Subclasses can override getters and introduce mutable fields).
  • ❌ “The final keyword is only a compiler check and has no runtime effect.” (It enforces JMM §17.5 memory barriers and unlocks deep C2 JIT optimizations).
  • ❌ “Clients always pick up updated public static final constants automatically when a dependency JAR is swapped.” (Primitive and String constants are inlined into client bytecodes at compile time).