🔒 Section 13 · Question #20

What is Record and How Does It Help Create Immutable Classes

A record (introduced as preview in Java 14 and finalized in Java 16 via JEP 395) is a specialized class declaration designed to act as a "transparent carrier for immutable data".


🟢 Junior Level

30-Second Summary

A record (introduced as preview in Java 14 and finalized in Java 16 via JEP 395) is a specialized class declaration designed to act as a “transparent carrier for immutable data”.

A single-line record declaration replaces 30+ lines of verbose boilerplate:

public record GeoPoint(double latitude, double longitude) {}

The javac compiler automatically emits the following in bytecode:

  1. A final class extends java.lang.Record.
  2. private final fields for each component declared in the record header.
  3. A canonical constructor accepting all component parameters.
  4. Public accessor methods without the get prefix (latitude(), longitude()).
  5. Standard, deterministic implementations of equals(), hashCode(), and toString() based on all components.

Code Comparison: Traditional Immutable Class vs. Record

// ❌ BEFORE Java 16: Verbose boilerplate (25+ lines)
public final class PointOld {
    private final int x;
    private final int y;

    public PointOld(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int getX() { return x; }
    public int getY() { return y; }

    @Override
    public boolean equals(Object o) { ... }
    @Override
    public int hashCode() { ... }
    @Override
    public String toString() { ... }
}

// ✅ IN Java 16+: Concise, standardized, first-class construct
public record Point(int x, int y) {}

🟡 Middle Level

The Critical Caveat: Shallow Immutability

A record strictly guarantees reference immutability (component references cannot be reassigned). However, if a record component references a mutable object (such as ArrayList, Date, or an array), its internal state can still be mutated from the outside:

public record UserGroup(String groupName, List<String> members) {}

// ⚠️ SHALLOW IMMUTABILITY HAZARD:
List<String> mutableList = new ArrayList<>();
mutableList.add("Alice");

UserGroup group = new UserGroup("Admins", mutableList);
mutableList.add("Hacker"); // 💥 Mutating original list corrupts record state!

group.members().clear();    // 💥 Mutating via accessor clears internal state!

The Solution: Compact Constructor Defensive Copying

Records introduce the compact constructor syntax, where the parameter list is omitted and assignment to fields (this.x = x) occurs automatically at the end:

public record UserGroup(String groupName, List<String> members) {
    // Compact constructor for validation and defensive copying:
    public UserGroup {
        Objects.requireNonNull(groupName, "Group name must not be null");
        // Defensive copy ensures deep collection immutability:
        members = (members == null) ? List.of() : List.copyOf(members);
    }
}

Constructor Flavors in Records

  1. Canonical Constructor: Implicitly generated, matches the record header parameters.
  2. Compact Constructor: Omits parameter declarations; modifies or validates arguments before implicit assignment.
  3. Custom / Secondary Constructors: Must explicitly delegate to the canonical constructor via this(...):
    public record Point(int x, int y) {
        public Point(int value) {
            this(value, value); // Mandatory delegation to canonical constructor
        }
    }
    

🔴 Senior Level

Safe Deserialization Architecture (JEP 395)

Traditional Java Object Serialization (implements Serializable) is historically one of the most prolific sources of Remote Code Execution (RCE) and integrity vulnerabilities:

  • For standard classes, deserialization bypasses constructors entirely using native reflection backdoors (ReflectionFactory.newConstructorForSerialization), directly injecting raw bytes into fields.
  • Records Revolutionize Deserialization Safety: During record deserialization, the JVM strictly invokes the canonical constructor. All validation invariants, range checks, and defensive copies configured in the compact constructor are guaranteed to execute during deserialization. An attacker cannot craft a corrupted record state via serialized byte tampering.

Record Patterns & Pattern Matching (Java 21, JEP 440)

In Java 21, records serve as the foundation for structural deconstruction in pattern matching:

public sealed interface Shape permits Circle, Rectangle {}
public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}

// Deconstruction inside switch expressions without manual accessor calls:
public static double calculateArea(Shape shape) {
    return switch (shape) {
        case Circle(double r) -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
    };
}

Language Rules and Architectural Constraints

Constraint Architectural Rationale
No Class Inheritance Records implicitly extend java.lang.Record; Java forbids multiple class inheritance
Implicitly final Prohibits subclassing to preserve immutable state contracts
No Extra Instance Fields All instance state must be declared in the header; static fields and constants are permitted
Interfaces Permitted Records can implement any interface (implements Serializable, Comparable<T>)

Architectural Comparison: Record vs. Lombok @Value vs. Standard final class

Feature Java record Lombok @Value Standard final class
Boilerplate Zero Minimal (Annotation) High (Manual methods)
Runtime Dependency Built-in JDK standard External compile-time agent None
Pattern Matching (JEP 440) Built-in native support None None
Deserialization Safety Mandatory constructor execution Bypasses constructor Bypasses constructor
Additional Instance Fields ❌ Forbidden ✅ Allowed ✅ Allowed
Custom Class Inheritance ❌ Forbidden ✅ Allowed ✅ Allowed

4 Tricky Questions

1. Why is Java Record deserialization fundamentally safer than traditional class deserialization?

Answer: For standard classes, Java deserialization bypasses constructors entirely, allocating uninitialized memory and populating fields directly from the stream using native hooks. Malicious streams can bypass input validation or instantiate inconsistent object graphs. For record classes, the JVM serialization protocol mandates that deserialization must invoke the canonical constructor with components read from the stream. All validation logic, null checks, and defensive copies defined in the compact constructor are executed before the object is published.


2. Can you override an accessor method (getter) in a record? What architectural risk does this introduce?

Answer: Yes, accessor methods can be explicitly overridden in the record body:

public record User(String name) {
    @Override
    public String name() {
        return name == null ? "Anonymous" : name.trim();
    }
}

However, the Java language specification emphasizes that records must remain transparent carriers of data. The auto-generated equals(), hashCode(), and toString() methods read the underlying fields directly, not through the accessor methods. If an accessor modifies or masks field values, it creates a discrepancy between accessor outputs and hash-equality comparisons, violating data transparency.


3. Can a record be declared locally inside a method? Does it capture local method variables?

Answer: Yes, starting with Java 16, records can be declared locally inside a method (Local Records). However, a local record is implicitly static. Unlike local anonymous inner classes, a local record cannot capture non-static variables from the enclosing method scope. All state processed by the record must be explicitly passed into its constructor, eliminating accidental stack frame memory leaks.


4. What is the fundamental JVM architectural difference between a Record and Lombok’s @Value?

Answer:

  1. JVM & Language Support: A record is a first-class language feature recognized by the compiler, JVM bytecode (Record class attribute), Reflection API (Class.isRecord(), Class.getRecordComponents()), and pattern matching (JEP 440). Lombok @Value is a compile-time hack that uses AST manipulation to generate a regular class.
  2. Safety Guarantees: Records enforce strict immutability semantics in JVM serialization and disallow mutable state extensions. Lombok @Value classes can be circumvented via deserialization backdoors and lack JVM-level pattern deconstruction.

🎯 Interview Cheat Sheet

Key Takeaways

  • Standardized: Introduced in Java 14, finalized in Java 16 (LTS).
  • Core Role: Pure, transparent carrier for immutable data with zero boilerplate.
  • Bytecode Artifacts: final class, private final fields, canonical constructor, accessors (x()), equals, hashCode, toString.
  • Shallow by Default: Mutable components (e.g. lists) require defensive copies via compact constructors (List.copyOf()).
  • Accessor Syntax: Component accessors do not use the get prefix (user.name(), not user.getName()).
  • Pattern Matching: Deeply integrated with Java 21+ Record Patterns and switch expressions.

Accessor Naming Comparison

Conventional JavaBeans Java Record Accessor
user.getName() user.name()
account.getBalance() account.balance()
item.isActive() item.active()

Red Flags to Avoid

  • ❌ “Records automatically make mutable collections immutable.” (False; records provide shallow reference immutability only; defensive copying via List.copyOf() is mandatory).
  • ❌ “Record accessor methods are prefixed with get (e.g., getX()).” (False; accessors match component names directly: x()).
  • ❌ “A record can extend an abstract class.” (False; all records implicitly extend java.lang.Record).
  • ❌ “Records are just syntactic sugar for Lombok @Data.” (False; @Data generates a mutable class with setters; records model immutable data with JVM-level support).