🔒 Section 13 · Question #17

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:

  1. Java’s dynamic method dispatch (invokevirtual) routes all getter invocations to the subclass’s overridden implementation.
  2. Any client code operating through a reference of the base class assumes the object is immutable, yet receives mutating values across successive calls.
  3. 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 FraudulentBalance violates 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
  1. During execution, the CPU reads the target object’s header (Mark Word and Klass Word) to find its virtual method table (vtable).
  2. Because the concrete heap instance is FraudulentBalance, the method pointer table directs execution to the subclass’s overridden method body.
  3. Core Senior Insight: The Java Memory Model (JMM) guarantees memory visibility and initialization barriers for final fields, 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 vtable dispatch.
  • 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:

  1. The subclass can still introduce new mutable fields and mutator methods, creating an ambiguous hybrid object.
  2. The subclass can override java.lang.Object methods (equals(), hashCode(), toString()) unless they are also explicitly marked final, corrupting hash collections.
  3. Reflection-based frameworks and type guards checking obj.getClass() == BankAccountBalance.class will 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 (long or double), reading half new bits and half old bits.
  • Read default uninitialized values (0 or null) 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’s vtable, 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 invokevirtual ensures 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: final fields are safe in heap memory, but JMM cannot block virtual method overriding.
  • Collection Hazard: Dynamic getter outputs corrupt hashCode()/equals(), losing keys inside HashMap.
  • 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).