Immutability is one of those concepts that sounds academic until the day you spend four hours debugging a bug caused by someone modifying an object you didn't expect to change. In this article, I'll explain what immutability actually means in Java, how to enforce it in your own classes, why String works the way it does, and all the different ways to make a List immutable — with their tradeoffs.
Table of Contents
- 1. What Is Immutability?
- 2. How to Make an Object Immutable
- 3. Strings: Java's Poster Child for Immutability
- 4. Making Collections Immutable
- 5. Records: Immutability by Default
- 6. Why Bother? The Benefits
- 7. Good Practices & Pitfalls
- 8. Conclusion
1. What Is Immutability?
An object is immutable if its state cannot be changed after it's created. Once you construct it, every field is fixed forever. No setters. No mutating methods. The object is, for the rest of its lifetime, exactly what you built.
Think of it like a stone tablet vs a whiteboard. A mutable object is the whiteboard — anyone with a reference can erase and rewrite. An immutable object is the stone tablet — chisel it once and it stays that way.
// Mutable
MutablePerson p = new MutablePerson("Alice", 30);
p.setAge(31); // ✅ allowed — state changed
// Immutable
ImmutablePerson p = new ImmutablePerson("Alice", 30);
// p.setAge(31); // ❌ doesn't compile — no setter existsThe key insight: immutability isn't just about missing setters. It's a guarantee that nothing — not your code, not a library, not a different thread — can change this object. That guarantee is what makes immutable objects so valuable in concurrent programming, caching, and reasoning about code.
2. How to Make an Object Immutable
2.1 The Five Rules
Making a class immutable follows a clear recipe. Brian Goetz lays it out in Java Concurrency in Practice, and it hasn't changed:
- Don't provide setters — no methods that modify the object's state.
- Make the class final — prevent subclasses from adding mutable behavior.
- Make all fields final — the compiler enforces that they're assigned exactly once.
- Make all fields private — prevent direct access from outside the class.
- Don't leak mutable references — if a field is a mutable object (like a Date or a List), never return it directly. Return a copy or an immutable wrapper.
2.2 Example: A Mutable Car
public class MutableCar {
private String model;
private int year;
private List<String> previousOwners;
public MutableCar(String model, int year, List<String> previousOwners) {
this.model = model;
this.year = year;
this.previousOwners = previousOwners; // ⚠️ stores the caller's reference
}
public void setModel(String model) { this.model = model; } // ⚠️ setter
public void setYear(int year) { this.year = year; } // ⚠️ setter
public String getModel() { return model; }
public int getYear() { return year; }
public List<String> getPreviousOwners() { return previousOwners; } // ⚠️ leaks mutable list
}This class has three problems:
- Setters let anyone change model and year.
- The constructor stores the caller's List reference — the caller can modify that list after construction and the Car's state changes.
getPreviousOwners()returns the internal List directly — the caller can mutate it.
List<String> owners = new ArrayList<>(List.of("Alice"));
MutableCar car = new MutableCar("Civic", 2020, owners);
owners.add("Bob"); // 💥 modifies the car's internal state!
car.getPreviousOwners().clear(); // 💥 also modifies internal state!2.3 Example: Making It Immutable
public final class ImmutableCar { // Rule 2: final class
private final String model; // Rules 3 & 4: private final
private final int year;
private final List<String> previousOwners;
public ImmutableCar(String model, int year, List<String> previousOwners) {
this.model = model;
this.year = year;
// Rule 5: defensive copy on the way in
this.previousOwners = List.copyOf(previousOwners);
}
// Rule 1: no setters — only getters
public String getModel() { return model; }
public int getYear() { return year; }
// Rule 5: return an immutable view
public List<String> getPreviousOwners() {
return previousOwners; // already immutable via List.copyOf
}
// Factory for creating modified copies (instead of setters)
public ImmutableCar withYear(int newYear) {
return new ImmutableCar(this.model, newYear, this.previousOwners);
}
public ImmutableCar withAddedOwner(String newOwner) {
List<String> newOwners = new ArrayList<>(this.previousOwners);
newOwners.add(newOwner);
return new ImmutableCar(this.model, this.year, newOwners);
}
}Notice the pattern: instead of setters, we provide wither methods that return a new object with the modified field. The original object never changes. This is the functional-programming style that underpins immutable design.
3. Strings: Java's Poster Child for Immutability
String is the most famous immutable class in Java. Every String object, once created, can never change. This is so fundamental that most Java developers don't think about it — until it bites them.
3.1 Why Strings Are Immutable
Strings are immutable for several reasons, all of them practical:
- Security: Strings carry sensitive data — file paths, network addresses, database credentials. If strings were mutable, a library could modify a file path between the security check and the actual file open (a TOCTOU attack).
- The String Pool: Java interns string literals into a shared pool. If one reference could mutate a pooled string, every reference to that string would see the change — chaos.
- Thread Safety: Immutable objects are inherently thread-safe. No synchronization needed. Given how often strings are passed between threads (logging, request handling, message passing), this matters.
- HashCode Caching: String caches its hashcode after the first computation. Since the string never changes, the hashcode is stable forever — making String an ideal HashMap key.
3.2 The String Pool
String a = "hello"; // pooled literal
String b = "hello"; // same pooled object
String c = new String("hello"); // new object, NOT in the pool
System.out.println(a == b); // true — same reference from pool
System.out.println(a == c); // false — different object
System.out.println(a.equals(c)); // true — same contentThe string pool is a JVM-internal cache of string literals. When you write "hello", the JVM checks if that string already exists in the pool. If so, it reuses it. This saves memory — no need for 10,000 copies of "id" in a JSON processing app.
You can manually intern a string with c.intern(), but don't abuse this: the pool lives in the (limited) string constant pool region of the heap, and interning dynamically-created strings can cause memory pressure.
3.3 The "Strings Are Mutable" Trap
New Java developers often write code like this and get confused:
String s = "hello";
s.toUpperCase();
System.out.println(s); // "hello" — not "HELLO"!Every method on String that "changes" it — toUpperCase(), substring(), replace(), concat() — returns a new String. The original is untouched. You need to capture the return value:
String s = "hello";
s = s.toUpperCase(); // reassign the variable
System.out.println(s); // "HELLO"This isn't a quirk; it's the contract. String methods are side-effect-free. Once you internalize this, you stop making the mistake.
4. Making Collections Immutable
Collections are where immutability gets interesting. A List<String> of immutable strings is not itself immutable. You need to make the collection immutable too. Java gives you several options, each with different tradeoffs.
4.1 List.of() and Set.of() (Java 9+)
The simplest way to create a small, fixed-content immutable list:
List<String> names = List.of("Alice", "Bob", "Charlie");
// names.add("Dave"); // ❌ UnsupportedOperationException
// names.set(0, "X"); // ❌ UnsupportedOperationException
// names.clear(); // ❌ UnsupportedOperationException- ✅ Pros: Clean syntax, null-hostile (rejects nulls), truly immutable.
- ❌ Cons: Fixed content — can't create from an existing collection without copying first. Limited to 10 elements with the overloaded factory; 11+ requires the varargs overload which has worse performance. The resulting list is a special internal implementation, not ArrayList or LinkedList.
For a List<Car> with List.of():
Car car1 = new Car("Civic", 2020);
Car car2 = new Car("Corolla", 2022);
List<Car> cars = List.of(car1, car2);
// cars.add(new Car(...)); // ❌ can't modify the list
// car1.setYear(2023); // ⚠️ BUT the Car itself might be mutable!This reveals a crucial distinction: List.of() makes the collection immutable, but not the elements. If Car is mutable, the objects inside the list can still change. True deep immutability requires the elements themselves to be immutable.
4.2 Collections.unmodifiableList()
Wraps an existing list in an unmodifiable view:
List<String> mutableList = new ArrayList<>();
mutableList.add("Alice");
mutableList.add("Bob");
List<String> unmodifiable = Collections.unmodifiableList(mutableList);
// unmodifiable.add("Charlie"); // ❌ UnsupportedOperationException
// unmodifiable.remove(0); // ❌ UnsupportedOperationException
mutableList.add("Charlie"); // ⚠️ THIS WORKS — and changes the "unmodifiable" view!
System.out.println(unmodifiable); // [Alice, Bob, Charlie]- ✅ Pros: Wraps without copying — fast, memory-efficient.
- ❌ Cons: It's a view, not a snapshot. If the original list changes, the "unmodifiable" list reflects those changes. This is the most common pitfall — developers pass around the wrapper thinking the data is frozen, but someone modifies the backing list and everything breaks.
- ❌ Cons: If the original list reference is discarded and the wrapper is the only reference, it is effectively immutable — but this relies on discipline, not enforcement.
4.3 List.copyOf() (Java 10+)
Creates a truly independent, immutable copy:
List<String> original = new ArrayList<>(List.of("Alice", "Bob"));
List<String> copy = List.copyOf(original);
// copy.add("Charlie"); // ❌ UnsupportedOperationException
original.add("Charlie"); // modifies the original
System.out.println(original); // [Alice, Bob, Charlie]
System.out.println(copy); // [Alice, Bob] — unchanged!- ✅ Pros: Guaranteed independence from the source. Null-hostile. If the input is already an immutable list, it returns the same reference (O(1) identity check) — smart optimization.
- ❌ Cons: Copies the data (O(n)), so it has a memory and time cost. Like
List.of(), it doesn't make elements immutable.
4.4 Guava's ImmutableList
If you're using Google Guava, ImmutableList has been the gold standard since before Java 9:
import com.google.common.collect.ImmutableList;
ImmutableList<String> names = ImmutableList.of("Alice", "Bob", "Charlie");
ImmutableList<String> copied = ImmutableList.copyOf(existingCollection);
// Builder pattern for incremental construction
ImmutableList<String> built = ImmutableList.<String>builder()
.add("Alice")
.add("Bob")
.add("Charlie")
.build();- ✅ Pros: Explicit type (
ImmutableListnot justList), builder pattern for complex construction, null-hostile, well-tested, available since Java 6. The type itself documents that the list won't change. - ❌ Cons: External dependency. The explicit type means you're coupled to Guava's API, which some teams avoid for standard-library purism.
4.5 Defensive Copying
The oldest technique, and still the most reliable when you don't trust the caller:
public class Garage {
private final List<Car> cars;
public Garage(List<Car> cars) {
// Defensive copy on the way in
this.cars = new ArrayList<>(cars);
}
public List<Car> getCars() {
// Defensive copy on the way out
return new ArrayList<>(cars);
}
// Or return an unmodifiable view
public List<Car> getCarsView() {
return Collections.unmodifiableList(cars);
}
}- ✅ Pros: Works with any Java version. Explicit control over what gets copied and when.
- ❌ Cons: Easy to forget. O(n) copies every time — expensive if called in a loop. Still doesn't protect against mutation of the elements themselves.
4.6 Comparison Table
| Method | Java Version | Nulls | Dependency | Independence |
|---|---|---|---|---|
List.of() | 9+ | ❌ rejects | None | N/A — creates new |
Collections.unmodifiableList() | 1.2+ | ⚠️ depends on backing list | None | ❌ view — backing list changes leak through |
List.copyOf() | 10+ | ❌ rejects | None | ✅ independent copy |
ImmutableList.of() | 6+ (Guava) | ❌ rejects | Guava | ✅ independent (copyOf) or new (of) |
Defensive new ArrayList<>(x) | All | ✅ allows | None | ✅ independent copy |
- For APIs that return lists: use
List.copyOf()(Java 10+) orCollections.unmodifiableList()with a defensive copy of the internal state. Never return the raw internal list. - For APIs that accept lists in constructors: always make a defensive copy.
- For constants and test data:
List.of(). - If you control the whole codebase: make the elements themselves immutable (use records!) and
List.copyOf()— then you get true deep immutability without Guava.
5. Records: Immutability by Default
Java 14 introduced records, which are immutable data carriers by design:
public record Car(String model, int year) {}
Car car = new Car("Civic", 2020);
System.out.println(car.model()); // "Civic" — accessor, not getter
System.out.println(car.year()); // 2020
// car.setModel("Accord"); // ❌ doesn't compile
Car same = new Car("Civic", 2020);
System.out.println(car.equals(same)); // true — auto-generated value equality
System.out.println(car.hashCode()); // consistent with equalsA record automatically generates:
- A canonical constructor
- Private final fields
- Public accessor methods (named after the field, no
getprefix) equals(),hashCode(), andtoString()
Records are shallowly immutable — the fields can't be reassigned, but if a field is a mutable type (like ArrayList), its contents can change. For deep immutability, use immutable types for all fields:
public record Garage(List<Car> cars) {
public Garage {
// Compact constructor — defensive copy before assignment
cars = List.copyOf(cars);
}
}For most DTOs, value objects, and data containers, records eliminate the boilerplate of writing immutable classes by hand.
6. Why Bother? The Benefits
Immutability isn't an academic exercise. It delivers real engineering benefits:
- Thread safety for free. No locks, no synchronized blocks, no volatile fields. Immutable objects can be shared across threads without any coordination. This alone justifies immutability in concurrent systems.
- Predictable behavior. An immutable object never surprises you. If you pass a String to a method, the method cannot corrupt it. You don't need to read the method's implementation to know if your data is safe.
- Simpler caching. Immutable objects never need invalidation. Cache them forever. Java's String pool is the classic example, but the same principle applies to any computed value.
- Atomic failure. An immutable object is either fully constructed or not at all. There's no window where the object is in an invalid halfway state — the constructor must complete before any reference to the object exists (modulo unsafe publication, which the
finalkeyword prevents). - Easier reasoning. Pure functions + immutable data = local reasoning. You don't need to trace through the entire call stack to understand what a method does to your object. This is why functional languages push immutability so hard.
7. Good Practices & Pitfalls
After working with immutable types for years across concurrent systems, here's what I've learned:
- 1. Defensive copy on the way in AND out. If your immutable class holds a mutable collection, copy it in the constructor AND wrap the returned value. One without the other is a half-measure.
- 2. Prefer composition of immutable types. A class made entirely of Strings, primitives, and immutable collections is automatically deeply immutable. No special handling needed.
- 3. Be careful with Date and Calendar. These are mutable. Use
java.time.*classes (Instant, LocalDate, LocalDateTime) instead — they're all immutable. - 4. Don't expose arrays. Even if you declare
private final String[], the reference can't change, but the contents can. Either clone arrays defensively or useList.of()instead. - 5. Withers beat setters. When you need a modified version, return a new object. This pattern (called "functional update") is what String, BigDecimal, and records encourage.
- 6. Document your immutability. Add
@Immutable(from JSR-305) or a simple Javadoc comment:/** Immutable. */. Future maintainers will thank you — and think twice before adding that setter. - 7. Watch out for inheritance. Even if your class is immutable, a subclass might not be. Mark your class
final, or at minimum make all constructorsprivateand expose static factories. Records are implicitly final, which is another reason to use them.
8. Conclusion
Immutability isn't a Java quirk — it's a design principle that pays dividends in thread safety, predictability, and maintainability. Java gives you the tools: final fields, records, List.of(), List.copyOf(), and Collections.unmodifiableList(). Use them.
The mental model is simple: don't let anyone change what you've built. Make fields final. Copy collections defensively. Return immutable views. And if you're on Java 14+, just use records and let the compiler do the heavy lifting.
Start with immutability as the default. Make things mutable only when you have a concrete reason — not the other way around. Your future self, debugging a concurrency issue at 2 AM, will be grateful.
Test Your Understanding
1 of 5What does it mean for an object to be immutable in Java?
