Immutability in Java

An immutable object is one whose state cannot be changed after it is created. Java's String, Integer, and all other wrapper types are immutable by design. Understanding immutability is essential because it underpins thread safety, reliable hashCode behaviour, and predictable APIs, making your code simpler to reason about and safer to share across threads without synchronisation.

Contents

What is Immutability?

An object is immutable if its observable state, the values exposed through its public API, cannot change after construction. No setter, no mutation method, no way to put it in a different state.

Mutable and immutable objects differ in a fundamental way:

Java
// Mutable — state can change after creation
StringBuilder sb = new StringBuilder("hello");
sb.append(" world");  // Same object, new state
System.out.println(sb);  // hello world

// Immutable — every "modification" returns a new object
String s = "hello";
String s2 = s.concat(" world");  // New String; s unchanged
System.out.println(s);   // hello  (original untouched)
System.out.println(s2);  // hello world

Java has many built-in immutable types: String, Integer, Long, Double, BigInteger, BigDecimal, LocalDate, LocalDateTime, Duration, and all other java.time types.

Why Immutability Matters

Immutability provides several important guarantees that directly affect correctness and simplicity:

Thread safety without synchronisation

An immutable object can be shared freely between threads. Since its state never changes, there is nothing to synchronise. Multiple threads can read it simultaneously without any locks.

Java
// String is safely shared between threads — no synchronisation needed
public class Config {
    // Fine to share — String is immutable
    private final String host = "db.example.com";
    private final int    port = 5432;

    public String getHost() { return host; }
    public int    getPort() { return port; }
}

Safe use as HashMap keys

A HashMap stores entries by the key's hashCode() at insertion time. If the key were mutable and its hash changed after insertion, the entry would become unfindable. Immutable keys eliminate this class of bug entirely.

Java
// String keys are safe in HashMap because String is immutable
Map<String, Integer> cache = new HashMap<>();
cache.put("user:42", 100);

String key = "user:42";
// key cannot change — the hash stays constant
System.out.println(cache.get(key));  // 100, always reliable

Freedom from defensive copies

When you pass an immutable object to a method or return it from a method, the caller cannot modify it. No need to copy it defensively before sharing.

Immutable objects make great value objects: dates, money amounts, coordinates, identifiers. Any time a concept has no identity beyond its value, make it immutable.

String: Java's Core Immutable Class

String is the most visible immutable class in Java. Its internal character array is private final and never exposed. Every method that appears to modify a String actually returns a brand-new one.

Java
String original = "Hello";

// These all produce NEW strings — 'original' is never changed
String upper    = original.toUpperCase();   // "HELLO"
String trimmed  = "  Hi  ".trim();         // "Hi"
String replaced = original.replace("l", "r");  // "Herro"
String sub      = original.substring(1, 3);    // "el"

System.out.println(original);  // Still "Hello"

Because of immutability, the JVM can safely intern strings and reuse instances across the entire application via the String pool.

Java
// String literals are pooled — same content → same object
String a = "Java";
String b = "Java";
System.out.println(a == b);       // true  — same pooled instance

// new String(...) bypasses the pool
String c = new String("Java");
System.out.println(a == c);       // false — different object
System.out.println(a.equals(c));  // true  — same content

String pooling is only safe because strings are immutable. If one reference could mutate the shared instance, every other reference would silently see a different value.

String concatenation in loops: use StringBuilder

Because each + on strings produces a new object, building a string inside a loop with + is O(n²). Use StringBuilder for repeated concatenation.

Java
// BAD — creates a new String object on every iteration
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i;  // new String each time
}

// GOOD — StringBuilder is mutable by design, built for this
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result2 = sb.toString();  // Single String at the end

The final Keyword

final is Java's way of signalling "this cannot change". It applies in three distinct contexts, each with different meaning:

final variable: assigned once, never reassigned

Java
final int MAX = 100;
// MAX = 200;  // Compile error: cannot assign a value to final variable MAX

// For references, final means the reference cannot be reassigned,
// but the object it points to can still be mutated if it is mutable
final List<String> list = new ArrayList<>();
list.add("Java");   // OK — mutating the list object is allowed
list.add("Python"); // OK
// list = new ArrayList<>();  // Compile error — cannot reassign the reference
final on a reference variable does NOT make the object immutable. It only prevents the variable from pointing to a different object. If you need a truly immutable list, use List.of() or Collections.unmodifiableList().

final field: must be assigned in the constructor, never changed after

Java
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;  // Must be assigned here (or in field initialiser)
        this.y = y;
    }

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

    // No setX / setY — the object cannot be changed after construction
}

final class: cannot be subclassed

Java
// String itself is declared as: public final class String { ... }
// You cannot extend it:
// class MyString extends String { }  // Compile error

// Marking your class final prevents subclasses from overriding
// methods in ways that break immutability guarantees
public final class Money {
    private final long   amount;
    private final String currency;

    public Money(long amount, String currency) {
        this.amount   = amount;
        this.currency = currency;
    }

    public long   getAmount()   { return amount;   }
    public String getCurrency() { return currency; }
}

final method: cannot be overridden in subclasses

Java
public class Base {
    public final String getId() {
        return "fixed-id";  // Subclasses cannot change this behaviour
    }
}

Building a Custom Immutable Class

There are five rules for making a class properly immutable in Java:

  1. Declare the class final (prevent subclassing).
  2. Make all fields private final.
  3. Initialise all fields in the constructor only.
  4. Provide no setters or mutating methods.
  5. For any mutable field (arrays, collections, dates), store and return defensive copies.

Here is a complete example with only primitive and String fields (no defensive copies needed yet: that is covered in the next section):

Java
// Rules 1-4 in action
public final class ImmutablePoint {

    private final double x;
    private final double y;
    private final String label;

    public ImmutablePoint(double x, double y, String label) {
        this.x     = x;
        this.y     = y;
        this.label = label;  // String is already immutable — no copy needed
    }

    public double getX()     { return x;     }
    public double getY()     { return y;     }
    public String getLabel() { return label; }

    // "Modification" returns a new instance — original unchanged
    public ImmutablePoint translate(double dx, double dy) {
        return new ImmutablePoint(x + dx, y + dy, label);
    }

    @Override
    public String toString() {
        return label + "(" + x + ", " + y + ")";
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof ImmutablePoint other)) return false;
        return Double.compare(x, other.x) == 0
            && Double.compare(y, other.y) == 0
            && label.equals(other.label);
    }

    @Override
    public int hashCode() {
        return java.util.Objects.hash(x, y, label);
    }
}
Java
// Usage
ImmutablePoint p1 = new ImmutablePoint(1.0, 2.0, "A");
ImmutablePoint p2 = p1.translate(3.0, 4.0);  // New object

System.out.println(p1);  // A(1.0, 2.0)  — unchanged
System.out.println(p2);  // A(4.0, 6.0)  — new instance

Defensive Copies for Mutable Fields

When an immutable class holds a reference to a mutable object (an array, a Date, a List), you must take a defensive copy both in the constructor and in the getter. Otherwise a caller can reach in and mutate the object's state through the reference.

Java
// BROKEN — the internal array leaks out, allowing mutation
public final class BrokenRange {
    private final int[] values;

    public BrokenRange(int[] values) {
        this.values = values;        // Stores original reference — WRONG
    }

    public int[] getValues() {
        return values;               // Returns internal reference — WRONG
    }
}

// Caller can break immutability:
int[] data = {1, 2, 3};
BrokenRange r = new BrokenRange(data);
data[0] = 99;                        // Mutates r.values through original reference!
r.getValues()[1] = 88;               // Mutates r.values through the getter!
Java
// CORRECT — defensive copy in constructor AND getter
public final class SafeRange {
    private final int[] values;

    public SafeRange(int[] values) {
        this.values = values.clone();  // Copy on the way IN
    }

    public int[] getValues() {
        return values.clone();         // Copy on the way OUT
    }

    public int size() { return values.length; }
}

// Now the caller cannot reach the internal array
int[] data = {1, 2, 3};
SafeRange r = new SafeRange(data);
data[0] = 99;                    // Does NOT affect r.values
r.getValues()[1] = 88;           // Does NOT affect r.values (got a copy)
System.out.println(r.getValues()[0]);  // 1 — still the original value

The same principle applies to List, Map, and Date fields. For collections, prefer copying via List.copyOf() or constructor copies:

Java
public final class ImmutableOrder {
    private final String        id;
    private final List<String> items;

    public ImmutableOrder(String id, List<String> items) {
        this.id    = id;
        this.items = List.copyOf(items);  // Unmodifiable copy on the way IN
    }

    public String        getId()    { return id; }
    // List.copyOf result is already unmodifiable — safe to return directly
    public List<String> getItems() { return items; }
}
List.copyOf() (Java 10+) creates an unmodifiable snapshot. Returning it directly from the getter is safe because the caller cannot mutate it and it does not reference the original list.

Records: Immutable Data Carriers (Java 16+)

Java 16 introduced records as a concise way to declare immutable data-holding classes. A record automatically provides: private final fields, a canonical constructor, equals(), hashCode(), and toString(), everything a hand-written immutable class needs.

Java
// A record replaces ~40 lines of hand-written immutable class boilerplate
public record Point(double x, double y) {}

// Usage is identical
Point p1 = new Point(1.0, 2.0);
Point p2 = new Point(1.0, 2.0);

System.out.println(p1.x());        // 1.0   — accessor (no "get" prefix)
System.out.println(p1.equals(p2)); // true  — value-based equality
System.out.println(p1);            // Point[x=1.0, y=2.0]

Records can have compact constructors for validation, and can add extra methods, but they cannot add mutable state:

Java
public record Money(long amount, String currency) {

    // Compact constructor — validates without repeating field assignments
    public Money {
        if (amount < 0)          throw new IllegalArgumentException("Amount cannot be negative");
        if (currency == null || currency.isBlank())
                                  throw new IllegalArgumentException("Currency required");
        currency = currency.toUpperCase();  // Normalise before assignment
    }

    // Extra utility methods are fine
    public Money add(Money other) {
        if (!currency.equals(other.currency))
            throw new IllegalArgumentException("Currency mismatch");
        return new Money(amount + other.amount, currency);
    }

    public boolean isZero() { return amount == 0; }
}
Java
Money price    = new Money(1000, "usd");
Money tax      = new Money(80,   "usd");
Money total    = price.add(tax);

System.out.println(price);   // Money[amount=1000, currency=USD]
System.out.println(total);   // Money[amount=1080, currency=USD]
System.out.println(price);   // Still Money[amount=1000, currency=USD] — unchanged

Records are implicitly final and cannot extend other classes (they implicitly extend java.lang.Record). They are the recommended way to create immutable value objects in modern Java.

Records with mutable component types (arrays, lists) are NOT automatically deep-immutable. Apply the same defensive copy rules from the previous section in the compact constructor and consider returning unmodifiable views from accessors.