An immutable object keeps its state after construction, so callers cannot change data you expected to stay fixed. In Java, that requires attention to fields, references, and collection contents. String and the list APIs show both the benefits and the 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 existsImmutability guarantees that your code, a library, or another thread cannot change the object. Removing setters alone does not provide that guarantee. 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, so the caller can modify that list after construction and the Car's state changes.
getPreviousOwners()returns the internal List directly, so 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 used in 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.
3.1 Why Strings Are Immutable
Strings are immutable for several reasons, all of them practical:
- Security: Strings carry sensitive data such as file paths, network addresses, and 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.
- 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 by avoiding 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, such as toUpperCase(), substring(), replace(), or 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 shows the distinction between the collection and its elements: 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, saving time and memory.
- ❌ Cons: It's a view, not a snapshot. If the original list changes, the "unmodifiable" list reflects those changes. A common pitfall is that 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 an 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).
- ❌ 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
Google Guava provided ImmutableList 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
Defensive copying keeps the caller from changing your internal collection:
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, which is 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 helps with thread safety, caching, and understanding how data changes:
- 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. */. The documentation tells future maintainers to preserve immutability when adding methods. - 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 improves thread safety and makes state easier to reason about. Java provides final fields, records, List.of(), List.copyOf(), and Collections.unmodifiableList(). Use them.
Keep objects unchanged after construction. Make fields final. Copy collections defensively. Return immutable views. On Java 14+, records let the compiler generate the data-carrier methods.
Start with immutability as the default. Make things mutable only when you have a concrete reason.
Test Your Understanding
Question 1 of 5 · Score: 0/0 correct
Your first answer counts toward your score.
