John Roest

Immutability in java

Understanding Immutability in Java

An immutable object's state cannot change after it is created. That constraint sounds simple, but its consequences are significant—particularly in multi-threaded systems.

Why Immutability Matters#

  1. Thread safety: Immutable objects can be shared across threads without synchronization. There is no state to race over.
  2. Predictability: Code that passes an immutable object can rely on that object not changing between calls.
  3. Security: Immutable objects cannot be modified by code that receives a reference to them, eliminating a class of unintended side effects.
  4. Caching: Immutable objects can be cached and reused freely without risk of inconsistent state.

In concurrent code, mutable shared state requires locks. Locks introduce contention, deadlock potential, and complexity. Immutability removes the need for locks entirely.

Example 1: Immutable Class#

public final class Person {
    private final String name;
    private final int age;

    public Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }
}

Three rules make this class immutable: fields are final, the class is final (preventing subclasses from adding mutable state), and there are no setters. Once constructed, a Person instance is fixed. Any code holding a reference to it knows the values will not change.

Example 2: Unmodifiable Collections#

To expose a list without allowing callers to modify it, use Collections.unmodifiableList:

import java.util.Collections;
import java.util.List;

public class EmployeeService {
    private final List<String> employees = List.of("Alice", "Bob", "Charlie");

    public List<String> getEmployees() {
        return Collections.unmodifiableList(employees);
    }
}

List.of(...) already returns an immutable list, but the wrapper makes the intent explicit for dynamically constructed lists. Callers receive a read-only view; mutations throw UnsupportedOperationException at runtime rather than silently corrupting internal state.

The same pattern applies to sets and maps via Collections.unmodifiableSet and Collections.unmodifiableMap.

Example 3: Java Records#

Java 16 introduced records as first-class immutable data carriers:

public record Product(String name, double price) {}

A record's fields are implicitly final. The compiler generates the constructor, accessors, equals, hashCode, and toString automatically. Records are the idiomatic choice for value objects and data transfer objects in modern Java—they enforce immutability through the language rather than through developer discipline.

Example 4: Defensive Copying#

When a class must hold a reference to a mutable type, defensive copying prevents external code from corrupting the internal state:

public final class SecureContainer {
    private final byte[] data;

    public SecureContainer(byte[] data) {
        this.data = data.clone();
    }

    public byte[] getData() {
        return data.clone();
    }
}

Without the clone on input, the caller could retain the original reference and modify the array after construction. Without the clone on output, the caller could modify the returned array and indirectly alter the container's internal state. Both copies are required to establish a true immutability guarantee.

Conclusion#

Prefer immutability by default. For new classes, start with final fields and no setters. Use records for data-only types. Return unmodifiable views from collection accessors. Apply defensive copies when mutable types cannot be avoided.

Immutability is not a theoretical preference—it eliminates real categories of bugs in concurrent and distributed systems, simplifies reasoning about code, and produces stronger API contracts. Java has evolved to make it progressively easier to write immutable code. Use the tools available.