RU | EN | DE

What is an Immutable Object and Why is it Needed?

Immutable object is an object whose state cannot be changed after construction (creation). Pros:

  • Default thread safety (without synchronization).
  • Simple invariants: if the object is valid in the constructor, it is valid always.
  • Safe sharing/caching (can be freely passed between threads, placed in caches).
  • Convenient as keys in Map/elements in Set (stable hashCode).
  • Easier to test and reason about.

Rules for Constructing an Immutable Class

  1. Make the class final (or all fields private final + forbid inheritance “logically”).
  2. All fields are private final.
  3. Initialization only in the constructor (no setters afterwards).
  4. Don’t “leak this from the constructor (don’t pass a reference to itself outside after initialization).
  5. Defensive copy for:
  • Incoming mutable arguments (arrays, collections, Date, any mutable types);
  • Returned values (getters should not return “live” internal references).
  1. Deep immutability: if a field is a collection/array/object, take care of deep protection (see below).

Basic Example (Correct)

import java.util.List;
import java.util.Objects;
public final class Money {
  private final String currency;  // immutable type (String is immutable)
  private final long minorUnits;  // cents
  private final List<Integer> tags;  // NOTE: collection is mutable by nature
 
  public Money(String currency, long minorUnits, List<Integer> tags) {
    this.currency  = Objects.requireNonNull(currency);
    this.minorUnits = minorUnits;
    // defensive copy + immutable wrapper
    this.tags = List.copyOf(tags);  // Java 10+: essentially an unmodifiable snapshot
  }
  public String currency()  { return currency; }
  public long  minorUnits() { return minorUnits; }
 
  public List<Integer> tags() {
    // can return tags directly, because copyOf made an immutable snapshot
    return tags;
  }
  // functional style "with change": create a NEW object with a different value
  public Money withMinorUnits(long newMinorUnits) {
    return new Money(this.currency, newMinorUnits, this.tags);
  }
  @Override public boolean equals(Object o) { /* standard on two fields + tags */ }
  @Override public int hashCode()  { /* consistent with equals */ }
  @Override public String toString()  { /* convenient for logs */ }
}

Explanation:

  • List.copyOf(...) returns an immutable snapshot of the current content (and throws NullPointerException for null elements).
  • If deep immutability is needed (elements of the collection are themselves mutable), copy the elements (see “depth of immutability”).

Working with Arrays and Collections

Arrays

Arrays are mutable. Therefore:

  • In the constructor: this.arr = Arrays.copyOf(arr, arr.length);
  • In the getter: return Arrays.copyOf(arr, arr.length);

Collections

  • For shallow immutability: List.copyOf, Set.copyOf, Map.copyOf (Java 10+) or Collections.unmodifiableXxx(...).
  • unmodifiableList only prevents mutation through the wrapper, but if you hold a reference to the original mutable collection, the changes are visible. Therefore:
    • either create a new collection and wrap it,
    • or use copyOf (makes a snapshot).
  • For deep immutability: copy the elements (if they are mutable).

Common Mistakes (Interview Loves Them)

  1. Returning a live reference to an internal collection/array:
  • An “immutable” class that returns getList() and it can be modified – it’s not immutable.
  1. No defensive copy in the constructor:
  • You passed a list, saved the reference as is → the external side will change the list after the constructor – the class “broke”.
  1. Mutable types inside immutable wrapper:
  • Storing java.util.Date (mutable) without a copy → changes through the reference. Use java.time (immutable).
  1. Inheritance from an immutable class:
  • A subclass can add mutable state/methods – it breaks the guarantee. Result: make the base class final (or constructor private + factories).
  1. Violation of equals/hashCode during “mutation”:
  • If the object is a key in HashMap, its hashCode must remain stable. Therefore – immutability.

Immutability and record (Java 16+)

record is syntactic sugar for “data”. Example:

public record Point(int x, int y) {}

Features:

  • All components are final, getters are generated, equals/hashCode/toString are already ready.
  • But: immutability is shallow. If a field is a collection/array/any mutable type, you need a defensive copy in the compact constructor:
public record Team(String name, List<String> members) {
  public Team {
    name = Objects.requireNonNull(name);
    members = List.copyOf(members); // make a snapshot
  }
}

Immutability and Lombok

  • @Value → makes the class final, fields private final, getters, equals/hashCode.
  • Still manually provide defensive copy for arrays/collections/Date.
@lombok.Value
public class UserDto {
  String name;
  List<String> roles; // in the constructor, make List.copyOf(...)
}

”Functional Updates”: with* methods

In the immutable model, changes are made by creating a new object:

public Money withCurrency(String newCurrency) {
  if (this.currency.equals(newCurrency)) return this; // micro-optimization
  return new Money(newCurrency, this.minorUnits, this.tags);
}

This is convenient and safe for multithreading.

Serialization of Immutable Classes

  • Serialized/deserialized normally (Java/JSON/Protobuf).
  • If a singleton is needed – add readResolve().
  • For JSON deserialization (Jackson) use:
    • fully-argument constructor or
    • @JsonCreator + @JsonProperty.

Why String is Immutable in Java

  1. Security: Strings are often used as identifiers, class names, resource names, ClassLoader parameters, SQL strings, etc. If someone could change a string “in place”, it would open up a massive vulnerability (path substitution, class name substitution, SQL injection at the object level, etc.).
  2. String Pool (interning): String literals are stored in a pool: identical literals refer to one object for memory savings and faster == comparison between literals. The pool is possible only if strings are immutable (otherwise changing a string would change all references to it).
  3. Caching hashCode: String.hashCode() is calculated once and cached in a field. If a string could be changed, the cache would become invalid → violation of Map/Set contracts.
  4. Thread safety and sharing: The same string can be freely shared between threads without synchronization.
  5. JVM/JIT optimizations: Immutable objects are easier to optimize (escape analysis, constant inlining, constant folding).

Conclusion: immutability is the foundation of String for security, performance, and predictability.

Mini-examples: Correct/Incorrect

❌ Incorrect (leaking internal array)

public final class Bad {
  private final int[] data;
  public Bad(int[] data) { this.data = data; }  // no copy!
  public int[] getData() { return data; }  // returned a live reference!
}

✅ Correct

public final class Good {
  private final int[] data;
  public Good(int[] data) { this.data = data.clone(); }
  public int[] data()  { return data.clone(); }
}

✅ Correct for dates: use java.time

public final class Booking {
  private final java.time.Instant from;
  private final java.time.Instant to;
  public Booking(Instant from, Instant to) {
  this.from = from; this.to = to; // Instant is immutable
  }
}

Depth of Immutability

If a field is a list of Address objects, and Address is mutable, then:

  • Make immutable Address (preferably), or
  • Perform a deep copy of the elements and wrap the list in List.copyOf.
public record Address(String city, String street) {} // immutable
 
public final class Person {
  private final List<Address> addresses;
  public Person(List<Address> addresses) {
  this.addresses = List.copyOf(addresses); // elements are already immutable
  }
  public List<Address> addresses() { return addresses; }
}

Best Practices (short checklist)

  • Make the class final + all fields private final.
  • No setters. “Changes” are made through with* methods, creating a new object.
  • Defensive copy for arrays/collections/mutable types on input and output.
  • Use java.time instead of Date/Calendar.
  • For collections – List.copyOf/Map.copyOf (or Collections.unmodifiableXxx + preliminary copy).
  • Avoid leaking this in the constructor (no callbacks/listener registration there).
  • Store invariants in one place – in the constructor/factory, check requireNonNull, ranges, etc.
  • For performance, you can cache derived values (e.g., hashCode) – this is safe if the object is immutable.