Why Should an Immutable Class Be final
An immutable class must be declared with the final modifier to strictly prohibit inheritance at the compiler and JVM language specification level.
🟢 Junior Level
30-Second Summary
An immutable class must be declared with the final modifier to strictly prohibit inheritance at the compiler and JVM language specification level.
If an immutable class is not marked final:
- Any developer or malicious actor can create a subclass (
class Child extends ImmutableClass). - The subclass can introduce mutable fields and setter methods.
- The subclass can override getter methods to return dynamically mutating or fabricated values.
- Clients expecting an immutable reference receive an instance of the mutable subclass. This shatters thread safety invariants, corrupts cache integrity, breaks hash-based collections, and introduces non-deterministic production defects.
Code Example: Exploiting a Non-Final “Immutable” Class
// ❌ ARCHITECTURAL FLAW: Class is not declared final
public class Coordinates {
private final int x;
private final int y;
public Coordinates(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
}
// 💥 Subclass breaks superclass immutability:
public class MutableCoordinates extends Coordinates {
private int mutableX;
public MutableCoordinates(int x, int y) {
super(x, y);
this.mutableX = x;
}
public void setX(int newX) {
this.mutableX = newX; // Added mutator method
}
@Override
public int getX() {
return mutableX; // Overridden getter returns mutable state!
}
}
Real-World Analogy
Think of a wax-sealed diplomatic envelope:
- Marking a class
finalis the wax seal. The contents cannot be altered, pages cannot be swapped, and no external text can be appended. - Without
final, anyone can place an identical letterhead on a forged document, insert mutable contents, and transmit it under your name.
🟡 Middle Level
3 Critical Production Risks of Non-Final Classes
| Risk Vector | Mechanism of Failure | Production Impact |
|---|---|---|
| TOCTOU Vulnerability (Security) | Security validator checks an object, but a concurrent thread mutates subclass fields between validation and execution | Authorization bypass, privilege escalation, unauthorized financial transactions |
Hash-Map Corruption (HashMap, HashSet) |
Subclass overrides hashCode() and equals() based on its mutable state |
Hash bucket corruption, unlocatable entries, severe heap memory leaks |
| Thread Safety Invalidation | Superclass fields enjoy JMM §17.5 freeze barrier protection, but subclass fields do not | Concurrency data races, visibility stale reads (0, null, or partially constructed state) |
JDK Precedents
Standard JDK architects declare all core value-based classes strictly final:
java.lang.String- All primitive wrappers:
Integer,Long,Double,Boolean, etc. - Arbitrary-precision numbers:
BigDecimal,BigInteger - Modern date-time API (
java.time.*):LocalDate,LocalDateTime,Instant,Duration,Period - Records (
record, Java 16+) are implicitly compiled with thefinalmodifier in bytecode.
Framework Interactions: Spring AOP and Mockito
Historically, the final modifier caused friction with bytecode generation and mocking tools:
- Spring AOP / CGLIB: CGLIB generates runtime proxy subclasses (
Proxy extends TargetClass). CGLIB throws an exception when attempting to subclassfinalclasses. In modern Spring Boot 3+, use JDK Dynamic Proxies via interfaces. - Mockito 2/3: Could not mock
finalclasses or methods without custom configuration extensions. - Mockito 5+ (Spring Boot 3+): Seamlessly mocks
finalclasses and methods out of the box using themock-maker-inlineengine (powered by the ByteBuddy Java Agent), transforming bytecode directly in JVM memory rather than creating subclasses.
🔴 Senior Level
HotSpot C2 JIT Optimizations: Devirtualization and Monomorphic Inlining
The final modifier on a class serves as a definitive optimization hint for the HotSpot C2 JIT compiler:
- Devirtualization:
- Standard non-final method invocations generate an
invokevirtualbytecode instruction, which requires inspecting the object header’s virtual method table (vtable). - For
finalclasses, C2 knows no subclasses can ever exist. It convertsinvokevirtualdirectly into a static direct CPU jump, eliminating memory indirection andvtablepointer lookups.
- Standard non-final method invocations generate an
- Aggressive Monomorphic Inlining:
- Simple accessor methods (
getX()) are completely inlined into the caller assembly. The method call overhead drops to zero CPU cycles (direct register assignment or memory offset load).
- Simple accessor methods (
- Eliminating Deoptimization Traps (Uncommon Traps):
- For non-final classes, C2 relies on Class Hierarchy Analysis (CHA) speculatively. If a new subclass is dynamically loaded later via
ClassLoader.loadClass(), C2 must deoptimize compiled native code, invalidate machine code frames, and drop back into the interpreter. Withfinal, CHA guarantees zero risk of subclass-triggered deoptimization.
- For non-final classes, C2 relies on Class Hierarchy Analysis (CHA) speculatively. If a new subclass is dynamically loaded later via
Architectural Alternatives to final
When domain modeling requires prohibiting subclassing without using the final class modifier directly:
- Private Constructor Pattern (Effective Java, Item 17):
public class SecureConfig { private final String apiKey; // Private constructor blocks external super() invocation private SecureConfig(String apiKey) { this.apiKey = apiKey; } public static SecureConfig of(String key) { return new SecureConfig(key); } } - Package-Private Constructor: Allows subclassing exclusively within the trusted package, defending against external extensions.
- Sealed Classes (
sealed, Java 17+):public sealed class PaymentMethod permits CreditCard, BankTransfer { ... }Permits a strictly enumerated, closed hierarchy of subtypes while blocking open-ended inheritance.
4 Tricky Questions
1. How does declaring a class final help the C2 compiler eliminate speculative inlining and deoptimization traps?
Answer:
When a class is not final, C2 optimizes method calls speculatively using Class Hierarchy Analysis (CHA). If only one implementation of a method currently exists in the JVM, C2 inlines it but inserts an uncommon trap (a hidden dependency check). If a new class is loaded at runtime via dynamic class loading (ClassLoader.loadClass()) or OSGi/plugin bundles that extends this class, the assumption is invalidated. The JVM executes a deoptimization trap, discards the JIT-compiled native code, deconstructs call frames back into interpreted bytecode, and repays profiling costs.
Declaring the class final provides a bytecode-level guarantee that subclasses can never exist. C2 compiles direct static calls and inlines methods permanently without runtime dependency checks or deoptimization risks.
2. If all methods in a non-final class are declared final, does it provide the exact same safety as declaring the class final?
Answer: No, it does not provide equivalent safety:
- Subclasses can still declare new mutable fields and mutator methods, introducing side-channel mutations.
- Subclasses can override non-final methods inherited from
java.lang.Object(such asequals(),hashCode(), andtoString()) unless explicitly declaredfinal, corrupting hash collections. - Type checks using reflection or pattern matching (
obj.getClass() == Base.class) will fail unexpectedly when subclasses are passed. - C2 cannot perform global object layout and scalar replacement optimizations that depend on closed-type guarantees.
3. Why are JPA (Hibernate) entities rarely declared final, and how does entity immutability differ from domain object immutability?
Answer:
Hibernate relies on runtime bytecode generation (ByteBuddy) to implement Lazy Loading. When accessing a lazily-fetched relationship (@ManyToOne(fetch = FetchType.LAZY)), Hibernate generates a dynamic proxy extending the entity class (Entity$HibernateProxy$xyz extends Entity). If an entity is marked final, Hibernate cannot create a proxy subclass and throws a startup exception (or is forced to eagerly initialize associations, destroying query performance).
Furthermore, JPA entities are intrinsically mutable and state-tracked via dirty checking inside the EntityManager persistence context. While Hibernate provides @org.hibernate.annotations.Immutable to disable dirty checking and update SQL generation, the Java class remains non-final to preserve proxy generation capability.
4. How does Mockito 5+ mock final classes if the JVM forbids subclassing at the bytecode level?
Answer:
Mockito 5+ utilizes the mock-maker-inline engine based on the Java Instrumentation API (java.lang.instrument and ByteBuddy):
- Mockito attaches a virtual Java Agent during JVM bootstrapping or test runner startup.
- When
mock(FinalClass.class)is invoked, Mockito does not generate a subclass. - Instead, the agent dynamically redefines the bytecode of the target
FinalClassdirectly in JVM memory, injecting an interception dispatcher hook at the entry point of each method. - When a method is called on the mock instance, the dispatcher checks whether an active stubbing exists (
when(...).thenReturn(...)). If so, it returns the stubbed value; otherwise, it delegates to the real method or returns the default value.
🎯 Interview Cheat Sheet
Key Takeaways
- Contract Integrity: Prevents subclasses from injecting mutator methods, hijacking getters, or breaking
equals/hashCode. - Security Defense: Blocks TOCTOU (Time-of-Check to Time-of-Use) validation bypass attacks in security boundaries.
- JIT Acceleration: Empowers C2 JIT with Class Hierarchy Analysis (CHA), direct devirtualization, and monomorphic inlining.
- JDK Standard: Core JDK types (
String,Integer,BigDecimal,LocalDate,record) are strictlyfinal. - Alternatives: Use the private constructor pattern with static factory methods or Java 17+
sealedclasses.
Red Flags to Avoid
- ❌ “If all fields are private final, making the class final is redundant.” (Subclasses can override getters or introduce new mutable state).
- ❌ “Marking classes final hurts performance because the JVM does extra checks.” (It improves performance via devirtualization and inlining).
- ❌ “Avoid final classes because Mockito cannot mock them.” (Outdated since Mockito 2.x; Mockito 5+ mocks
finalclasses natively). - ❌ “Making all methods final is identical to making the class final.” (A subclass can still add new mutable fields and override
Objectmethods).
Related Topics
- What is the final Keyword and How Does It Help Create Immutable Classes — The
finalkeyword - Can You Inherit From an Immutable Class — Inheritance risks
- What Happens If You Override Getter in Subclass of Immutable Class — Getter hijacking mechanics
- What is Record and How Does It Help Create Immutable Classes — Records and immutability