What Happens If You Override Getter in Subclass of Immutable Class
If a class designed to be immutable is not protected against inheritance with the final modifier, a subclass can override its getter methods and return dynamically changing, ran...
🟢 Junior Level
30-Second Summary
If a class designed to be immutable is not protected against inheritance with the final modifier, a subclass can override its getter methods and return dynamically changing, random, or fabricated values.
As a result, immutability guarantees are completely shattered:
- Java’s dynamic method dispatch (
invokevirtual) routes all getter invocations to the subclass’s overridden implementation. - Any client code operating through a reference of the base class assumes the object is immutable, yet receives mutating values across successive calls.
- Thread safety invariants are destroyed, hash-based collections misplace or corrupt elements, and security validations are rendered ineffective.
Code Example: Hijacking Immutability via Getter Overriding
// ❌ DESIGN DEFECT: Class is not declared final
public class BankAccountBalance {
private final long cents; // Field is immutable in JVM heap memory
public BankAccountBalance(long cents) {
this.cents = cents;
}
public long getCents() {
return cents;
}
}
// 💥 Malicious or buggy mutable subclass:
public class FraudulentBalance extends BankAccountBalance {
private long mutableCents;
public FraudulentBalance(long initialCents) {
super(initialCents);
this.mutableCents = initialCents;
}
public void setCents(long newCents) {
this.mutableCents = newCents; // Mutator method introduced
}
@Override
public long getCents() {
// 💥 HIJACKED: Returns mutable state instead of superclass final field
return this.mutableCents;
}
}
What Happens in Application Code:
// Client accepts base immutable type:
BankAccountBalance balance = new FraudulentBalance(100_00);
System.out.println(balance.getCents()); // Prints 10000
// External caller mutates subclass state:
((FraudulentBalance) balance).setCents(999_999_00);
// Base immutable reference produces mutated output!
System.out.println(balance.getCents()); // Prints 99999900 (Invariant broken!)
🟡 Middle Level
Violation of the Liskov Substitution Principle (LSP)
The Liskov Substitution Principle (LSP) requires that subtypes must be substitutable for their base types without altering program correctness. The base type contract specifies:
- Determinism: Successive invocations of
getCents()return the exact same value across the object’s entire lifecycle. - Thread Safety: Instances can be freely shared across threads without synchronization.
The subclass
FraudulentBalanceviolates both invariants, converting a reliable value object into an unpredictable concurrency and state hazard.
Production Failure Modes
| Vulnerable Area | Mechanism of Failure | Production Impact |
|---|---|---|
HashMap / HashSet Keys |
Modifying subclass state dynamically changes hashCode() and equals() |
Entries stranded in incorrect buckets, unretrievable values (map.get() returns null), permanent memory leaks |
| Thread Safety & Visibility | Base cents field has JMM §17.5 freeze barrier protection; subclass mutable field does not |
Data races, stale reads from CPU L1/L2 caches, torn reads on 64-bit values (long/double) |
| Security Boundaries (TOCTOU) | Security layer validates balance threshold; attacker thread mutates subclass field before transfer executes | Balance fraud, privilege escalation, authorization bypass |
🔴 Senior Level
Bytecode Mechanics and Dynamic Dispatch
At the Java bytecode level, calling a non-private, non-static instance method compiles to an invokevirtual instruction:
invokevirtual #7 // Method BankAccountBalance.getCents:()J
- During execution, the CPU reads the target object’s header (Mark Word and Klass Word) to find its virtual method table (
vtable). - Because the concrete heap instance is
FraudulentBalance, the method pointer table directs execution to the subclass’s overridden method body. - Core Senior Insight: The Java Memory Model (JMM) guarantees memory visibility and initialization barriers for
finalfields, but the JMM cannot override JVM polymorphic method dispatch. If a getter is overridden, the JVM must execute the subclass bytecode.
JIT C2 Compiler Impact: Inline Cache Invalidation
When an immutable class is not final and has overriding subclasses:
- The HotSpot C2 compiler cannot perform monomorphic direct call devirtualization.
- The call site degrades into a Polymorphic Inline Cache (PIC) or megamorphic indirect
vtabledispatch. - Method inlining is disabled or burdened with speculative guard checks, degrading getter invocation throughput by 300–500% in hot execution loops.
Architectural Defenses Against Getter Hijacking
1. Declare the Class `final` (Best Practice):
public final class BankAccountBalance { ... }
2. Declare Methods `final` (If class inheritance is mandatory):
public final long getCents() { return this.cents; }
3. Use Java 16+ Records:
public record BankAccountBalance(long cents) { } // Implicitly final
4. Sealed Classes (Java 17+):
public sealed class BankAccountBalance permits VerifiedBalance { ... }
4 Tricky Questions
1. If base class methods invoke state via getters (this.getCents()) instead of reading private fields directly (this.cents), what happens if a subclass overrides that getter?
Answer:
This dramatically magnifies the vulnerability. If internal business methods of the superclass (e.g., calculateInterest(), equals(), or compareTo()) call this.getCents() rather than referencing this.cents directly, the subclass hijacks not only external consumers but the internal integrity and calculation algorithms of the superclass itself. For this reason, immutable class internals should always reference private fields directly (this.cents) or declare getters strictly final.
2. Does marking all getters final achieve complete immutability if the class itself remains non-final?
Answer: No, it does not achieve complete immutability:
- The subclass can still introduce new mutable fields and mutator methods, creating an ambiguous hybrid object.
- The subclass can override
java.lang.Objectmethods (equals(),hashCode(),toString()) unless they are also explicitly markedfinal, corrupting hash collections. - Reflection-based frameworks and type guards checking
obj.getClass() == BankAccountBalance.classwill fail or reject valid domain instances. Complete immutability requires sealing the entire class (final class).
3. If an overridden getter is called concurrently with a subclass setter, what JMM visibility guarantees apply?
Answer:
If the subclass’s mutable field is not declared volatile and access is not synchronized, there is no Happens-Before relationship (JMM §17.4) between the setter write and the getter read.
A concurrent reader thread on another CPU core may:
- Continuously read stale values from local L1/L2 CPU cache lines.
- Experience a torn read on 32-bit JVM architectures for 64-bit primitives (
longordouble), reading half new bits and half old bits. - Read default uninitialized values (
0ornull) if publication of the subclass occurred before constructor completion.
4. What is the difference in call dispatch between invokevirtual and invokeinterface when calling an overridden getter?
Answer:
invokevirtual: Used when the reference is typed as a concrete or abstract class (BankAccountBalance balance). The JVM resolves the method pointer via a fixed offset in the class’svtable, which is extremely fast and branch-predictable.invokeinterface: Used when calling through an interface (BalanceProvider balance). Because different classes may implement the interface in different orders, the method does not have a fixed offset in the class vtable. The JVM must perform an interface table (itable) lookup or rely on dynamic Inline Caching (Monomorphic/Polymorphic Inline Cache), introducing additional lookup overhead until stabilized.
🎯 Interview Cheat Sheet
Key Takeaways
- Polymorphic Dispatch: Bytecode
invokevirtualensures the subclass’s overridden getter is executed even when referenced by the base type. - Contract Breach: Violates the Liskov Substitution Principle (LSP) by swapping deterministic immutable state for mutable behavior.
- JMM Limits:
finalfields are safe in heap memory, but JMM cannot block virtual method overriding. - Collection Hazard: Dynamic getter outputs corrupt
hashCode()/equals(), losing keys insideHashMap. - Primary Remedy: Always mark immutable classes
final, or use Java 16+record.
Red Flags to Avoid
- ❌ “If the superclass field is final, the overridden getter must return that final value.” (False; an overridden getter can return any computation or subclass mutable field).
- ❌ “Polymorphism does not apply to getters returning primitives.” (False; primitive return types follow identical virtual method table dispatch).
- ❌ “Declaring fields final is sufficient; making the class final is optional.” (False; getter overriding completely bypasses the superclass fields).
Related Topics
- Why Should an Immutable Class Be final — Prohibiting subclassing
- Can You Inherit From an Immutable Class — Inheritance risks
- Is It Enough to Make All Fields final for Immutability — Field vs class immutability
- What Happens If You Modify a Mutable Key in HashMap — Key mutation failure modes