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:
- A
final class extends java.lang.Record. private finalfields for each component declared in the record header.- A canonical constructor accepting all component parameters.
- Public accessor methods without the
getprefix (latitude(),longitude()). - Standard, deterministic implementations of
equals(),hashCode(), andtoString()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
- Canonical Constructor: Implicitly generated, matches the record header parameters.
- Compact Constructor: Omits parameter declarations; modifies or validates arguments before implicit assignment.
- 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:
- JVM & Language Support: A
recordis a first-class language feature recognized by the compiler, JVM bytecode (Recordclass attribute), Reflection API (Class.isRecord(),Class.getRecordComponents()), and pattern matching (JEP 440). Lombok@Valueis a compile-time hack that uses AST manipulation to generate a regular class. - Safety Guarantees: Records enforce strict immutability semantics in JVM serialization and disallow mutable state extensions. Lombok
@Valueclasses 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 finalfields, 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
getprefix (user.name(), notuser.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;
@Datagenerates a mutable class with setters; records model immutable data with JVM-level support).
Related Topics
- What is an Immutable Object — Immutability core tenets
- How to Create an Immutable Class in Java — Manual immutable class architecture
- How to Protect a Collection from Modification — Collection immutability
- Why Are LocalDate and LocalDateTime Immutable — JDK value types