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:
// 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: StringIntegerLongDoubleBigIntegerBigDecimalLocalDateLocalDateTimeDurationjava.time
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.
// 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 HashMaphashCode()
// 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.
String: Java's Core Immutable Class
Stringprivate finalString
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.
// 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 ++StringBuilder
// 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
final variable: assigned once, never reassigned
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 field: must be assigned in the constructor, never changed after
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
// 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
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:
- Declare the class
(prevent subclassing).final - Make all fields
.private final - Initialise all fields in the constructor only.
- Provide no setters or mutating methods.
- For any mutable field (arrays, collections, dates), store and return defensive copies.
Here is a complete example with only primitive and String
// 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);
}
}
// 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 DateList
// 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!
// 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 ListMapDateList.copyOf()
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; }
}
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 finalequals()hashCode()toString()
// 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:
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; }
}
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 finaljava.lang.Record