Skip to content
Java strings 6 min read

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:

RuleWhat to do
1. Declare the class finalPrevents subclasses from adding mutable state or overriding methods.
2. Make all fields private and finalprivate hides them; final forces assignment in the constructor only.
3. No setter methodsNever expose a way to change field values after construction.
4. Initialize all fields in the constructorThe constructor is the only place state is set.
5. Return deep copies of mutable fieldsIf 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.Instant or java.time.LocalDateTime (from the Date/Time API) over java.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 of new 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, and toString for free. Consider record 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() and hashCode() are overridden
  • String Immutability — why String itself 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 final applies to variables, methods, and classes.
  • Java Memory Model — the final field visibility guarantee that makes immutable objects thread-safe.
  • HashMap — why stable hashCode() from immutable keys is so important.
Last updated June 13, 2026
Was this helpful?