Can You Inherit From an Immutable Class
From a Java syntax perspective, you can extend a class if the author forgot to mark it final. However, from an architectural and security perspective, doing so is a critical des...
🟢 Junior Level
30-Second Summary
From a Java syntax perspective, you can extend a class if the author forgot to mark it final. However, from an architectural and security perspective, doing so is a critical design flaw and dangerous anti-pattern:
If an immutable class is not protected from subclassing:
- A subclass can introduce mutable fields and setter methods.
- A subclass can override getter accessors to return dynamic or fraudulent state.
- Code expecting a thread-safe, immutable base type can receive a mutable subtype, completely destroying thread safety, cache coherence, and application predictability.
Practical Example: Subclassing Destroys Immutability
// ❌ DESIGN FLAW: Author forgot the `final` keyword
public class CurrencyAmount {
private final long cents;
public CurrencyAmount(long cents) {
this.cents = cents;
}
public long getCents() {
return cents;
}
}
// 💥 Subclass turns the "immutable" class into a mutable time-bomb:
public class MutableCurrencyAmount extends CurrencyAmount {
private long extraBonusCents = 0;
public MutableCurrencyAmount(long cents) {
super(cents);
}
public void setExtraBonusCents(long bonus) {
this.extraBonusCents = bonus; // Injected mutator setter!
}
@Override
public long getCents() {
// Violates deterministic invariance: return value changes dynamically!
return super.getCents() + extraBonusCents;
}
}
Real-World Analogy
Consider a government-issued passport:
- A genuine passport is a strictly immutable document.
- If passports could be “subclassed,” any citizen could paste extra pages with custom counterfeit stamps and present them at a bank as legitimate government identity.
🟡 Middle Level
Violation of the Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) states that objects of a superclass must be replaceable with objects of a subclass without affecting the correctness of the program.
When client code accepts a parameter of type CurrencyAmount, it relies on immutable invariants:
- Invocations of
getCents()are pure, idempotent functions that always return the exact same value. - The instance can be shared across dozens of concurrent threads without synchronization.
- Values returned by
hashCode()andequals()are immutable over time, making instances safe for use as keys inHashMaporHashSet.
When MutableCurrencyAmount is substituted:
- Multi-threaded readers experience Data Races.
- Hash tables lose keys permanently because mutated hash codes point to wrong buckets.
- The LSP contract is severely violated.
2 Architectural Alternatives to Subclassing
1. Composition Over Inheritance (Effective Java Item 18)
Instead of extending, wrap the immutable object as a private final field:
public final class AuditedCurrencyAmount {
private final CurrencyAmount amount; // Composition
private final String auditComment;
public AuditedCurrencyAmount(CurrencyAmount amount, String auditComment) {
this.amount = Objects.requireNonNull(amount);
this.auditComment = Objects.requireNonNull(auditComment);
}
public CurrencyAmount getAmount() { return amount; }
public String getAuditComment() { return auditComment; }
}
2. Private Constructors with Static Factories (Effective Java Item 17)
To permit specialized internal implementations within a class file while blocking external subclassing:
public class SecureToken {
private final String value;
// Private constructor: external code cannot invoke super()
private SecureToken(String value) {
this.value = value;
}
public static SecureToken of(String value) {
return new SecureToken(value);
}
}
🔴 Senior Level
Sealed Hierarchies (Java 17+ JEP 409)
When your domain architecture genuinely requires polymorphism across immutable structures (Algebraic Data Types / ADTs), use sealed interfaces and classes:
// Bounded type hierarchy:
public sealed interface OrderEvent permits OrderCreated, OrderCancelled, OrderShipped {}
// All implementations are inherently final and immutable:
public record OrderCreated(String orderId, long timestamp) implements OrderEvent {}
public record OrderCancelled(String orderId, String reason) implements OrderEvent {}
public record OrderShipped(String orderId, String trackingNumber) implements OrderEvent {}
Architectural Advantages:
- Guaranteed Isolation: Third-party modules or attackers cannot inject unauthorized subclasses (
MaliciousOrderEvent). - Exhaustive Pattern Matching (Java 21+): The compiler validates that all subtypes are covered in
switchexpressions without needing a fallbackdefaultbranch.
HotSpot C2 JIT Compiler Optimizations: CHA & Devirtualization
Marking an immutable class final directly impacts the machine code generated by HotSpot C2:
- On
non-finalclasses, method calls compile toinvokevirtual. - C2 uses Class Hierarchy Analysis (CHA) to inspect loaded subclasses. If no subclasses are currently loaded, C2 optimistically inlines the method, but must insert a deoptimization trap (
uncommon trap). - If an application dynamically loads a new subclass at runtime (via
ClassLoader.loadClass()), HotSpot is forced to trigger JIT deoptimization, discarding native machine code and falling back to virtual method table (vtable) dispatches. - Declaring the class
finaleliminates subclassing by language specification. C2 permanently devirtualizes method calls, compiling them into direct jumps or inlined assembly with zero deoptimization overhead and zero branch misprediction penalty.
🎯 Interview Cheat Sheet
Rules for Subclassing Immutable Classes
- Golden Rule: An immutable class must always prevent uncontrolled inheritance.
- How to Prevent Subclassing:
finalmodifier on the class (standard approach).privateconstructors + public static factory methods.package-privateconstructors (allows subclasses only within the same package).sealedinterface/class (Java 17+, explicit subclass whitelist viapermits).
- Subclassing Hazards: Injected mutator setters, overridden dynamic getters, broken
equals/hashCode, and LSP violations. - Performance Benefit:
finalclasses allow HotSpot C2 to execute aggressive devirtualization and inlining via Class Hierarchy Analysis (CHA).
4 Tricky Interview Questions
1. How does Class Hierarchy Analysis (CHA) in HotSpot C2 optimize method invocations on final classes compared to non-final classes?
Answer: For non-final classes, C2 performs speculative devirtualization: if only one implementation exists in the current JVM, it inlines the call but sets an uncommon trap. If another subclass is loaded later via a dynamic ClassLoader, C2 must deoptimize the compiled code back to interpreted mode and rebuild vtables. For final classes, the JVM specification forbids subclasses entirely. C2 devirtualizes and inlines the method permanently without deoptimization checks or vtable overhead.
2. Can an abstract class be used to design immutable data structures? How do you protect it from external mutable subclasses?
Answer: Yes. An abstract class can hold shared immutable state and template logic (e.g., AbstractImmutableList). To block external mutable subclasses, declare its constructor package-private:
public abstract class AbstractDataNode {
private final String id;
AbstractDataNode(String id) { this.id = id; } // Package-private constructor
public String getId() { return id; }
}
External packages cannot invoke super(), preventing external extension. In Java 17+, the preferred modern idiom is public sealed abstract class ... permits SubA, SubB.
3. What happens if a subclass overrides equals() and hashCode() to include mutable fields? How does this break HashSet?
Answer: If a subclass adds a mutable field status and includes it in hashCode(), mutating status changes the object’s hash code while it is already stored in a HashSet or HashMap. The hash table will search for the entry in the bucket matching the new hash code, while the entry remains sitting in the bucket determined by the original hash code. Calling set.contains(obj) will return false, and set.remove(obj) will fail to remove it, causing permanent memory leaks and corrupted state.
4. Why did Java allow inheriting from java.util.Date, and what security vulnerabilities in Java SecurityManager did it cause?
Answer: java.util.Date was designed in Java 1.0 without final and with mutable mutators (setTime()). In early JDK SecurityManager implementations, system APIs accepted Date validUntil parameters to check cryptographic certificates. Attackers passed malicious subclasses (MaliciousDate extends Date) that returned a valid past date during the permission check (Time-of-Check), but returned an expired or future date during actual resource execution (Time-of-Use). This TOCTOU exploit prompted the JDK to design all modern java.time classes (JSR-310: LocalDate, Instant) as strictly final.
Red Flags (DO NOT Say)
- ❌ “Subclassing an immutable class is fine as long as the subclass does not add setter methods.” (The subclass can still override getters to return non-deterministic values or add mutable fields).
- ❌ “Using the
finalkeyword on classes violates good OOP design and polymorphism.” (Value objects and domain data carriers must befinalto ensure security and thread safety). - ❌ “A Java
recordcan extend an immutable class.” (Records implicitly extendjava.lang.Recordand are implicitlyfinal; they cannot extend any class). - ❌ “Method inlining works identically for
finaland non-final classes.” (Non-final classes require speculative CHA inlining with deoptimization trap overhead).
Related Topics
- Why Should an Immutable Class Be final — Subclassing defense
- What Happens If You Override Getter in Subclass of Immutable Class — Getter override hazards
- How to Create an Immutable Class in Java — 5-step blueprint
- What is Record and How Does It Help Create Immutable Classes — Java Records
- Why is String Class Immutable — JDK case study