Java Record Immutability: Guarantees and Limitations
java record immutability: Explore what Java records guarantee about immutability, where the guarantees end, and how to handle mutable fields, validation, and serializa...
When you declare a Java record, the compiler generates a final class with private final fields, a canonical constructor, accessor methods, equals, hashCode, and toString. The immutability of records is a central design goal, but it is important to understand exactly what that immutability means in practice. Java record immutability is enforced at the language level for the fields you declare, yet it does not automatically make every object graph deeply immutable.
What the Compiler Enforces
A record declaration like this:
public record Point(int x, int y) {}
produces a class with two private final fields, x and y. The canonical constructor assigns these fields exactly once. There are no setters, and the accessor methods x() and y() simply return the field values. Because the fields are final and the class is final, no subclass can add mutable state or override the accessors to return something different. The compiler also generates equals, hashCode, and toString based on the component values, which are consistent with the immutable state.
This means that once a record instance is created, its component values cannot be reassigned. You cannot change point.x or point.y after construction. Any attempt to modify a field through reflection will fail under the standard Java module system unless you explicitly open the package. This is a stronger guarantee than a typical mutable class with getters and setters.
Where Immutability Ends: Mutable Components
The guarantee applies to the references stored in the record's fields, not to the objects those references point to. If a record component is an array, a List, a Map, or any other mutable object, the record itself is only shallowly immutable. For example:
public record Names(List<String> names) {}
The names field is final, but the List instance it references can be modified after the record is created. Callers who obtain the list through names() can add or remove elements. This is a common source of confusion because the record's immutability is often mistaken for deep immutability.
To protect against this, you need to make defensive copies in the constructor and in the accessor. The canonical constructor can copy the incoming list, and the accessor can return an unmodifiable view. However, this is manual work and is not automatically generated by the record syntax.
Defensive Copying in Records
Consider a record that should hold a list of tags:
public record TaggedItem(String id, List<String> tags) { public TaggedItem { tags = List.copyOf(tags); } @Override public List<String> tags() { return List.copyOf(tags); } }
The compact constructor reassigns the tags parameter to an immutable copy. The accessor also returns a fresh immutable copy each time it is called. This ensures that the internal list cannot be modified by the caller and that the caller does not receive a reference to the internal list. The cost is that every call to tags() creates a new list, which may be acceptable for small collections but could be a performance concern for large ones.
An alternative is to store an immutable list type, such as List.copyOf or an unmodifiable list, and return it directly if you are certain the caller will not attempt to modify it. But returning the internal reference still exposes the object; if it is truly immutable, that is safe. The key is to decide whether the component type is itself immutable.
Arrays and Records
Arrays are inherently mutable, even if the reference is final. A record with an array component is problematic because the array elements can be changed. For example:
public record Matrix(int[][] values) {}
Here, values is a final reference to an array, but the inner arrays and the elements can be modified. To make this record truly immutable, you would need to deep-copy the entire array structure in the constructor and accessors, which is tedious and error-prone. In practice, it is often better to use immutable collection types or to avoid exposing arrays directly.
If you must use an array, consider storing a clone and returning a clone from the accessor. But be aware that this only provides a shallow copy for multi-dimensional arrays. Deep immutability requires recursive copying.
Validation in the Canonical Constructor
The canonical constructor is the only place where you can validate the state of a record before it is created. Because the fields are final, you cannot validate later. This is a natural fit for enforcing invariants. For example:
public record Temperature(double celsius) { public Temperature { if (celsius < -273.15) { throw new IllegalArgumentException("Temperature below absolute zero"); } } }
The compact constructor syntax allows you to add validation without explicitly declaring the parameter list. The compiler implicitly assigns the validated values to the fields. This keeps the validation logic in one place and prevents invalid instances from ever existing.
Records vs Traditional Immutable Classes
Before records, you would write an immutable class manually with private final fields, a constructor, getters, and override equals, hashCode, and toString. Records reduce that boilerplate and make the intent explicit. However, traditional immutable classes give you more flexibility. You can hide the constructor behind a factory method, cache instances, or perform complex initialization. Records have a fixed constructor signature and no inheritance, which can be limiting.
For simple data carriers, records are usually the better choice. For objects with complex behavior or that need to participate in an inheritance hierarchy, a traditional class may be more appropriate. The decision should be based on whether the record's constraints align with your design.
Serialization and Reflection
Records interact with Java serialization in a specific way. When a record is serialized and deserialized, the canonical constructor is invoked, not the default deserialization mechanism. This means validation in the constructor is preserved during deserialization. This is a significant advantage over traditional classes where serialization can bypass constructor checks.
Reflection on records also respects immutability. The Record class provides methods to access components, but you cannot set them. The java.lang.reflect.Field for a record component is final, so attempts to set it via reflection will throw an IllegalAccessException unless you use setAccessible(true) and the module is open. This makes records a solid foundation for value-based programming.
Performance and Memory Considerations
The immutability of records has runtime implications. Because fields are final, the JVM can apply optimizations such as constant folding and better escape analysis. Records also have a compact memory footprint compared to a hand-written class with the same fields, because the generated code is minimal. However, the equals and hashCode methods are generated based on the components, which can be expensive if components are large objects. If you need high-performance equality checks, you may want to override these methods, but that is rarely necessary.
The main performance concern is defensive copying. If you copy collections in the constructor and accessors, you add allocation overhead. For read-mostly scenarios, this may be negligible. For high-throughput code, consider using immutable collection types like those from Java's List.of and Map.of, which are already immutable and can be returned directly without copying.