Skip to content

Immutability and Defensive Copying — Optimize

Immutability looks expensive: every "change" allocates. The JIT, escape analysis, and the JDK's collection short-circuits combine to make most of that cost vanish. This file walks ten places where immutability meets the JVM at speed — record allocation in hot loops, List.copyOf short-circuits, copy-on-write trade-offs, lock-free reads via immutable snapshots, the upcoming Valhalla value classes — and shows when to keep the discipline and when to break it locally. All numbers are illustrative; verify with JMH.


1. Escape analysis turns short-lived records into stack values

C2's escape analysis (EA) proves whether an allocation escapes its method. If it doesn't, scalar replacement breaks the object into its component fields and keeps them in registers — no heap object, no GC pressure.

Records cooperate with EA because they are:

  • Implicitly final — no subclass can capture this.
  • All-final-fields — no setter can leak the reference.
  • Tiny accessors — easily inlined.
  • No synchronized / wait/notify — no monitor-related side effects.
public record Money(long cents, Currency currency) {
    public Money plus(Money other) {
        if (!currency.equals(other.currency)) throw new IllegalArgumentException();
        return new Money(cents + other.cents, currency);
    }
}

// Hot loop
Money total = new Money(0, USD);
for (var item : cart.items()) {
    total = total.plus(item.price());                  // allocates a Money per iteration
}

Naively, this allocates 10,000 Money objects for a 10,000-line cart. With EA, C2 proves each intermediate Money never leaves plus — it is consumed in the next iteration and discarded. C2 scalar-replaces: the cents and currency fields live in registers, and the loop runs at roughly the speed of two long additions per iteration.

Confirm: run with -XX:+UnlockDiagnosticVMOptions -XX:+PrintEliminateAllocations and inspect for the allocation site being eliminated.

Counter-example: if total is captured into a lambda or stored to a field after the loop, EA fails and every intermediate Money lands on the heap. The shape of the loop matters as much as the type.


2. List.copyOf returns the same instance for already-immutable input

The JDK's List.copyOf(...) has a fast-path: if the argument is already an instance of ImmutableCollections.AbstractImmutableList (returned by List.of, List.copyOf, Stream.toList, Map.entry, etc.), it returns the same instance.

List<String> immutable = List.of("a", "b", "c");
List<String> snapshot  = List.copyOf(immutable);
System.out.println(immutable == snapshot);             // true — no allocation

Practical consequence: a defensive copy in a record's compact constructor is free on subsequent rounds.

public record Order(long id, List<LineItem> items) {
    public Order {
        items = List.copyOf(items);                    // allocates only if input is mutable
    }
    public Order replaceItem(int i, LineItem replacement) {
        var copy = new ArrayList<>(items);             // allocates a mutable view for editing
        copy.set(i, replacement);
        return new Order(id, copy);                    // compact ctor: List.copyOf(copy) allocates one ImmutableList
    }
}

Inside replaceItem, the ArrayList is short-lived; EA may eliminate it. The final List.copyOf allocates one ImmutableList. Two allocations, both small. The "modification" of an immutable order costs less than you would expect.

Set.copyOf and Map.copyOf have the same fast-path. Defensive copies along a fully-immutable pipeline allocate exactly once — when the value first enters the pipeline.


3. Record compact constructor — no extra cost over a hand-written class

A common worry: "isn't the compact constructor adding overhead?" The answer: no. The bytecode generated by javac for a record with a compact constructor is structurally identical to a hand-written final class with the same constructor body and private final fields.

// Record version
public record Order(long id, List<LineItem> items) {
    public Order {
        items = List.copyOf(items);
    }
}

// Hand-written equivalent — generates near-identical bytecode
public final class Order {
    private final long id;
    private final List<LineItem> items;
    public Order(long id, List<LineItem> items) {
        items = List.copyOf(items);
        this.id = id;
        this.items = items;
    }
    public long id() { return id; }
    public List<LineItem> items() { return items; }
    @Override public boolean equals(Object o) { /* generated */ }
    @Override public int hashCode() { /* generated */ }
    @Override public String toString() { /* generated */ }
}

The only "cost" the record adds is the Record.toString / equals / hashCode machinery, generated via invokedynamic and a per-class bootstrap. After the first call site warms up, those become inline-able and roughly free.

Don't avoid records for performance. They are at least as fast as the hand-written equivalent and almost always shorter and clearer.


4. Defensive copy allocation cost — measured

A naive defensive copy of an ArrayList<String> with 16 elements allocates one new ImmutableCollections.ListN (the JDK's small-N immutable list) and one backing Object[16]. On a modern x64 JVM, that is two allocations, ~80-200 bytes total, ~20-40 ns of work.

For a per-second rate of 100k constructions:

  • 100k * 200 bytes = 20 MB/s of short-lived garbage.
  • 100k * 30 ns = 3 ms of CPU.

The garbage is short-lived: young-generation GC reclaims it on the next minor cycle with no measurable overhead. The CPU cost is in the noise for almost all application workloads.

When does the cost matter?

  • Per-event rates above ~1M/s with non-trivial list sizes.
  • Allocation-rate-sensitive workloads — low-latency trading, real-time analytics, networking where every microsecond budget is tight.
  • Memory-constrained environments — embedded, containers with small heap, anything where GC pause budget is < 1ms.

For everyone else: the allocation cost of defensive copying is invisible. The hours of debugging saved by knowing nothing can mutate the order are worth orders of magnitude more than the nanoseconds spent on List.copyOf.


5. Lock-free reads of immutable snapshots — the big win

The biggest performance argument for immutability is not allocation cost — it is lock-free reads. An immutable snapshot held in an AtomicReference is read by N threads with zero contention.

public final class ConfigHolder {
    private final AtomicReference<Config> ref = new AtomicReference<>(Config.DEFAULT);
    public Config current() { return ref.get(); }            // one volatile read
    public void publish(Config next) { ref.set(next); }      // one volatile write
}

Reader cost: one volatile read (~1-2 ns on x64). No CAS, no lock, no spin. Scales linearly with cores.

Compare with the classic mutable + lock approach:

public final class MutableConfigHolder {
    private final Config config = new Config();
    private final ReentrantLock lock = new ReentrantLock();
    public int someValue() {
        lock.lock();
        try { return config.someValue(); }
        finally { lock.unlock(); }
    }
}

Reader cost under contention: a CAS on the lock, possibly a thread park if contended. At 16 cores reading at high rate, the lock acquisition becomes the bottleneck — readers serialize on the same cache line.

For read-heavy workloads (configuration, routing tables, feature flags), the immutable-snapshot pattern is often 100x faster under contention. The "allocation cost" of building a new Config on each update is paid once per write, not once per read.


6. Copy-on-write vs persistent collections — the breakeven

CopyOnWriteArrayList (in java.util.concurrent) and the various persistent collection libraries (Vavr, PCollections, Eclipse Collections) take different approaches to "make immutable cheap to modify".

  • CopyOnWriteArrayList. Every mutation rebuilds the entire array. O(n) write, O(1) read. Excellent for read-mostly lists where the size stays small.
  • Persistent vectors (Vavr Vector, Clojure-style). O(log32 n) write via structural sharing. O(log32 n) read. Worse than ArrayList for small N but scales to large N.
  • List.copyOf. O(n) snapshot on the input; the result is read-shared but write-prohibited.

Empirical breakeven (approximate):

Op ArrayList (no sharing) CopyOnWriteArrayList Vavr Vector List.copyOf
Read by index ~1 ns ~1 ns ~10 ns ~1 ns
Append, n=16 ~10 ns ~50 ns ~30 ns ~50 ns
Append, n=1024 ~10 ns ~3000 ns ~50 ns ~3000 ns

For small immutable lists (≤ ~50 elements) List.copyOf wins. For frequent appends to large structures, persistent collections win. For read-mostly with rare writes, CopyOnWriteArrayList wins.

Default to List.copyOf until a profile shows otherwise. Persistent collections add a library dependency and an unfamiliar API; the win must justify both.


7. Mutable holders for hot paths — the rare exception

Sometimes a profiler points at an immutable allocation in a tight loop, and you have to bend. The pattern: collect into a local mutable buffer, then snapshot into immutable form at the boundary.

public Order calculateOrder(Cart cart) {
    var lines = new ArrayList<LineItem>(cart.items().size());      // mutable, local
    for (var item : cart.items()) {
        lines.add(new LineItem(item.sku(), priceFor(item)));
    }
    return new Order(cart.id(), lines);                            // record copies via List.copyOf
}

Inside the loop, the local ArrayList is mutated without allocation per element. At the boundary (new Order(...)), the compact constructor List.copyOfs it into an immutable list. The total allocation is one ArrayList + one ImmutableList, and the cost of repeatedly resizing the ArrayList is amortised O(1) per append.

Rule: mutable inside the method, immutable across the method boundary. The discipline holds at the level of public types; locally, do whatever is fast.

Stream.toList() (Java 16+) makes this even shorter:

return new Order(cart.id(),
    cart.items().stream()
        .map(item -> new LineItem(item.sku(), priceFor(item)))
        .toList());                                                // returns an immutable list

Stream.toList() returns an unmodifiable list directly, so the record's compact constructor List.copyOf short-circuits to identity. One allocation total for the list.


8. The cost of Date defensive copy versus migrating to Instant

If a class holds a java.util.Date, defensive copying costs one new Date(d.getTime()) per constructor and per getter call. The allocation is small (16 bytes), but it is per call.

Migrating to Instant:

  • No defensive copy at all (Instant is immutable).
  • Comparison via Instant.isBefore/isAfter is faster than Date.before/after.
  • Instant's nanosecond resolution beats Date's millisecond.
  • Code is clearer; intent is explicit; tests are easier.

There is essentially no scenario where defensive-copying Date outperforms migrating to Instant. The migration is mechanical:

// Before
public final class Reservation {
    private final Date checkIn;
    public Reservation(Date checkIn) { this.checkIn = new Date(checkIn.getTime()); }
    public Date checkIn() { return new Date(checkIn.getTime()); }
}

// After
public record Reservation(Instant checkIn) { }

Two methods become zero. The class is shorter, faster, and provably immutable.

For legacy boundaries where you cannot change the public API, convert at the edges and use Instant internally:

public final class Reservation {
    private final Instant checkIn;
    public Reservation(Date checkIn) { this.checkIn = checkIn.toInstant(); }
    public Date checkIn() { return Date.from(checkIn); }
}

The internal state is immutable; the boundary translates. One allocation per call survives on the API boundary, but every internal operation is allocation-free.


9. Project Valhalla — value classes change the equation

JEP 401 (preview) introduces value classes, which Valhalla has been working toward for almost a decade. The relevant change for immutability:

  • Value objects have no identity. The JVM is free to inline them into other objects, into arrays, and into registers.
  • Value class fields are implicitly final. Every value class is automatically immutable.
  • Point[] becomes a flat array of [x, y, x, y, x, y, ...] rather than an array of pointers to heap-allocated Point objects.

For immutability today, the implication is: design with records as if Valhalla were imminent. Today's record is the easiest path to tomorrow's value record. The annotation @jdk.internal.ValueBased already marks several JDK immutables (Optional, LocalDate, etc.) as ready for value-class promotion.

public value record Point(double x, double y) implements Comparable<Point> {
    public int compareTo(Point o) {
        int c = Double.compare(x, o.x);
        return c != 0 ? c : Double.compare(y, o.y);
    }
}

Today: record syntax, heap allocations, EA-and-JIT optimisations. Tomorrow: value record syntax, inline-in-array storage, no heap allocations for short-lived values.

The advice "use records and immutability" is forward-compatible. The same code that runs today with record will run dramatically faster under Valhalla without changing a line.


10. Quick rules — when allocation matters and how to fix it

A short checklist for the times performance trumps immutability purity.

  • Profile says so. Don't denormalize without a flame graph that names the call site.
  • Defensive copies in a hot path? List.copyOf short-circuits when input is already immutable — measure first; you may already be at zero cost.
  • Date defensive copy? Migrate to Instant. Always a win.
  • Record in a hot loop? EA usually eliminates intermediate allocations. Check with -XX:+PrintEliminateAllocations.
  • Many small "modifications" of a large structure? Reach for Vavr / PCollections, but only after a profile.
  • Lock contention on a read-heavy mutable? Convert to AtomicReference<ImmutableSnapshot>. Almost always a 10-100x win.
  • Mutable internal, immutable external. Build inside a method with ArrayList; return as List.copyOf (or Stream.toList()) at the boundary.
  • Document the trade. A comment like // hot path: mutable accumulator, immutable result keeps the next maintainer honest.

The general law: design immutable first, measure, then break exactly the places the profiler points at — never preemptively. Modern Java's combination of records, escape analysis, List.copyOf's short-circuit, and AtomicReference-based snapshots makes immutability essentially free for typical workloads. For the rare hot path that does show up in a flame graph, the techniques in sections 7-9 buy back most of the loss without abandoning the principle wholesale. Cross-reference optimize.md in ../../03-design-principles/01-solid-principles/ for the broader JIT story.