🔒 Section 13 · Question #16

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:

  1. Any developer or malicious actor can create a subclass (class Child extends ImmutableClass).
  2. The subclass can introduce mutable fields and setter methods.
  3. The subclass can override getter methods to return dynamically mutating or fabricated values.
  4. 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 final is 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 the final modifier in bytecode.

Framework Interactions: Spring AOP and Mockito

Historically, the final modifier caused friction with bytecode generation and mocking tools:

  1. Spring AOP / CGLIB: CGLIB generates runtime proxy subclasses (Proxy extends TargetClass). CGLIB throws an exception when attempting to subclass final classes. In modern Spring Boot 3+, use JDK Dynamic Proxies via interfaces.
  2. Mockito 2/3: Could not mock final classes or methods without custom configuration extensions.
  3. Mockito 5+ (Spring Boot 3+): Seamlessly mocks final classes and methods out of the box using the mock-maker-inline engine (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:

  1. Devirtualization:
    • Standard non-final method invocations generate an invokevirtual bytecode instruction, which requires inspecting the object header’s virtual method table (vtable).
    • For final classes, C2 knows no subclasses can ever exist. It converts invokevirtual directly into a static direct CPU jump, eliminating memory indirection and vtable pointer lookups.
  2. 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).
  3. 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. With final, CHA guarantees zero risk of subclass-triggered deoptimization.

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:

  1. Subclasses can still declare new mutable fields and mutator methods, introducing side-channel mutations.
  2. Subclasses can override non-final methods inherited from java.lang.Object (such as equals(), hashCode(), and toString()) unless explicitly declared final, corrupting hash collections.
  3. Type checks using reflection or pattern matching (obj.getClass() == Base.class) will fail unexpectedly when subclasses are passed.
  4. 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):

  1. Mockito attaches a virtual Java Agent during JVM bootstrapping or test runner startup.
  2. When mock(FinalClass.class) is invoked, Mockito does not generate a subclass.
  3. Instead, the agent dynamically redefines the bytecode of the target FinalClass directly in JVM memory, injecting an interception dispatcher hook at the entry point of each method.
  4. 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 strictly final.
  • Alternatives: Use the private constructor pattern with static factory methods or Java 17+ sealed classes.

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 final classes natively).
  • ❌ “Making all methods final is identical to making the class final.” (A subclass can still add new mutable fields and override Object methods).