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:
finalVariable / 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.finalMethod: Prohibits method overriding in subclasses (@Overrideis rejected by the compiler).finalClass: Completely blocks inheritance (no class can extend it viaextends).
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:
- At constructor exit, the JVM executes a Freeze Action for all
finalfields. - The JIT compiler inserts a StoreStore memory barrier (e.g.,
DMB ISHSTon ARM64 or a compiler scheduling barrier on x86 TSO). - This barrier guarantees:
- All writes to
finalfields 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
finalfields without needingvolatileorsynchronizedread locks.
- All writes to
HotSpot C2 JIT Compiler Optimizations
HotSpot treats final fields with special optimization privileges:
- Compile-Time Constant Inlining: Fields declared as
static final primitive/Stringare tagged with theConstantValuebytecode attribute. The value is embedded directly as an immediate operand in native machine code, bypassing heap reads entirely. - Trust Final Fields: For instance
finalfields, 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
finalvalues.
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:
- JEP 403 (Strong Encapsulation): Invoking
setAccessible(true)onfinalfields inside JDK internal modules (java.base) throws an uncatchableInaccessibleObjectException. - 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 classstrictly forbids inheritance everywhere, including inside the same file.private constructorsforbid 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 17sealed classes.
Red Flags (DO NOT Say)
- ❌ “Declaring
final List<String> listmakes the list contents immutable.” (It only locks the reference; elements can be added or removed freely). - ❌ “Declaring fields
finalis sufficient for immutability; the class does not need to befinal.” (Subclasses can override getters and introduce mutable fields). - ❌ “The
finalkeyword 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 finalconstants automatically when a dependency JAR is swapped.” (Primitive and String constants are inlined into client bytecodes at compile time).
Related Topics
- What is an Immutable Object — Fundamental definitions
- How to Create an Immutable Class in Java — 5-step blueprint
- Is It Enough to Make All Fields final for Immutability — Shallow vs deep immutability
- Why Should an Immutable Class Be final — Subclassing vulnerabilities
- Why Are Immutable Objects Thread-Safe — JMM safe publication