Create an Immutable Class
An immutable object is one whose state cannot change after it is created. Java’s String is the most famous example — once you write "hello", that value is locked forever. Building your own immutable classes is a powerful technique that makes code safer, simpler to reason about, and naturally thread-safe.
Why Immutability Matters
Mutable objects are convenient to build but tricky to use safely — you never know who else might be changing the data underneath you. Immutable objects sidestep that problem entirely.
Key benefits:
- Thread safety — multiple threads can read the same object without synchronization.
- Safe sharing — you can pass an immutable object anywhere without defensive copying at the call site.
- Reliable hash codes — because the state never changes,
hashCode()always returns the same value, making immutable objects perfect keys in a HashMap. - Easier reasoning — no side effects mean fewer bugs.
Note: Java’s
String,Integer,LocalDate, and all other wrapper/value types are immutable for exactly these reasons.
The Five Rules for an Immutable Class
Follow all five rules consistently:
| Rule | What to do |
|---|---|
1. Declare the class final | Prevents subclasses from adding mutable state or overriding methods. |
2. Make all fields private and final | private hides them; final forces assignment in the constructor only. |
| 3. No setter methods | Never expose a way to change field values after construction. |
| 4. Initialize all fields in the constructor | The constructor is the only place state is set. |
| 5. Return deep copies of mutable fields | If a field holds a mutable object (like an array or Date), return a copy — not the original — from getters, and store a copy in the constructor. |
Rules 1–4 are straightforward. Rule 5 is the one developers most often forget.
A Simple Immutable Class
Here is a clean, correct immutable class representing a 2D point:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() { return x; }
public int getY() { return y; }
@Override
public String toString() {
return "Point(" + x + ", " + y + ")";
}
}
Point p = new Point(3, 7);
System.out.println(p.getX()); // 3
// p.x = 10; → compile error — field is final
Output:
3
Because int is a primitive, there is nothing to copy — primitives are always passed by value. Life is simple here.
Defensive Copying — The Critical Step
The challenge arises when a field holds a mutable object, such as a java.util.Date or an array. Simply marking the field final is not enough — final only prevents reassigning the reference; it does nothing to stop the object itself from being mutated.
Wrong — no defensive copy
import java.util.Date;
public final class Meeting {
private final String title;
private final Date start; // ← mutable!
public Meeting(String title, Date start) {
this.title = title;
this.start = start; // ← stores the caller's reference
}
public Date getStart() {
return start; // ← returns the internal reference
}
}
Date d = new Date(0);
Meeting m = new Meeting("Sync", d);
d.setTime(999999); // mutates through the original reference
System.out.println(m.getStart().getTime()); // 999999 — broken!
Correct — defensive copy in constructor AND getter
import java.util.Date;
public final class Meeting {
private final String title;
private final Date start;
public Meeting(String title, Date start) {
this.title = title;
this.start = new Date(start.getTime()); // copy on the way IN
}
public String getTitle() { return title; }
public Date getStart() {
return new Date(start.getTime()); // copy on the way OUT
}
@Override
public String toString() {
return title + " @ " + start;
}
}
Date d = new Date(0);
Meeting m = new Meeting("Sync", d);
d.setTime(999999); // no effect on m
System.out.println(m.getStart().getTime()); // 0 — correct!
Output:
0
Tip: Modern Java code should prefer
java.time.Instantorjava.time.LocalDateTime(from the Date/Time API) overjava.util.Date— they are already immutable, so you never need to copy them.
Immutable Class with a List Field
Lists are another common trap. Here is an immutable class that holds a list of tags:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class Article {
private final String title;
private final List<String> tags;
public Article(String title, List<String> tags) {
this.title = title;
this.tags = new ArrayList<>(tags); // defensive copy on the way in
}
public String getTitle() { return title; }
public List<String> getTags() {
return Collections.unmodifiableList(tags); // read-only view on the way out
}
@Override
public String toString() {
return title + " " + tags;
}
}
List<String> t = new ArrayList<>(List.of("java", "oop"));
Article a = new Article("Immutability", t);
t.add("hacked"); // no effect on a
System.out.println(a.getTags()); // [java, oop]
// a.getTags().add("x"); // throws UnsupportedOperationException
Output:
[java, oop]
Collections.unmodifiableList wraps the internal list in a read-only view so callers cannot reach inside and modify it.
Note: In Java 9+ you can use
List.copyOf(tags)instead ofnew ArrayList<>(tags)— it creates an unmodifiable copy in one step and is slightly more memory efficient.
Immutability and equals() / hashCode()
Because an immutable object’s state never changes, its hashCode() is stable. Always override equals() and hashCode() together for immutable value types:
public final class Color {
private final int r, g, b;
public Color(int r, int g, int b) {
this.r = r; this.g = g; this.b = b;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Color c)) return false;
return r == c.r && g == c.g && b == c.b;
}
@Override
public int hashCode() {
return 31 * (31 * r + g) + b;
}
}
With a stable hashCode(), Color objects work perfectly as HashMap keys or HashSet elements.
Tip: Java 16+ Records give you an immutable data class with
equals,hashCode, andtoStringfor free. Considerrecord Color(int r, int g, int b) {}as a modern alternative when the class is purely data.
Under the Hood
final fields and the Java Memory Model
The Java Memory Model gives final fields a special guarantee: once a constructor completes, any thread that obtains a reference to the object is guaranteed to see the correctly initialized values of all final fields — without any additional synchronization. This is what makes immutable objects inherently thread-safe.
String Pool analogy
Java’s String Pool is only possible because String is immutable. The JVM can safely deduplicate literals because nobody can change them. You can apply the same idea to your own immutable types — caching or interning instances becomes safe because the state can never drift.
Performance note
Immutable objects can create garbage — every “change” produces a new object. For hot paths that mutate data frequently (e.g., building a string in a loop), a mutable builder pattern (like StringBuilder) is more efficient. The typical design is:
- Mutable builder for construction:
StringBuilder,Stream.Builder, etc. - Immutable value once construction is done:
String, your immutable class.
Quick Checklist
Before shipping an immutable class, run through this checklist:
- Class is declared
final - All fields are
private final - No setters exist
- Constructor makes defensive copies of mutable parameters
- Getters return defensive copies (or unmodifiable views) of mutable fields
-
equals()andhashCode()are overridden
Related Topics
- String Immutability — why
Stringitself is immutable and how that shapes the language. - String Pool & intern() — how the JVM caches immutable string literals to save memory.
- Records — Java 16+ shorthand for immutable data classes with generated boilerplate.
- final Keyword — how
finalapplies to variables, methods, and classes. - Java Memory Model — the
finalfield visibility guarantee that makes immutable objects thread-safe. - HashMap — why stable
hashCode()from immutable keys is so important.