Every conversation about functional programming in Java eventually arrives at the same objection: “All that copying can’t be free.”

It is not free. Immutability trades mutation for allocation, and that trade has a real price: more objects, more garbage collector work, more indirection. The objection deserves a better answer than “the JVM handles it” — and a more honest one than “immutability is always fast enough.”

This post is that answer. Where the cost actually lives, what the JVM absorbs on your behalf, what persistent data structures change, and — most importantly — how to tell whether your code is in the small set of cases where any of this matters. The stream-pipeline and List.copyOf side of the story is covered in Streams, Immutable Collections, and Efficient Data Processing; this post digs under it, into allocation, GC, memory layout, and the data structures built for immutable updates.


The Three Real Costs

Strip away the folklore and immutability has exactly three mechanical costs on the JVM.

1. Allocation rate

Every “modified” immutable value is a new object. A pipeline that transforms a record three times allocates three records where a mutable style might have patched one bean in place.

The individual allocation is close to the cheapest operation the JVM knows how to do: a thread-local bump of a pointer (the TLAB), typically a few nanoseconds, with no locking. The cost is not the allocation — it is the rate. Allocation rate determines how often the young-generation collector runs, and in allocation-heavy services that is where the price surfaces: not as slow code, but as GC frequency.

Two JVM mechanisms claw most of this back:

  • Generational collection is designed for exactly this workload. Young collectors scan live objects, not dead ones. A short-lived intermediate value that dies before the next collection costs approximately nothing to reclaim. Immutable-style code produces precisely this profile: many objects, almost all dead on arrival.
  • Escape analysis can delete the allocation entirely. When the JIT proves an object never escapes the compiled scope, it can replace the object with its fields in registers — no allocation, no GC. This is an optimization, not a guarantee: it applies to small, non-escaping, hot-path objects, and you find out whether it fired from a profiler, not from the source code.

2. Copying

Updating one field of an immutable aggregate means rebuilding the aggregate. For a record with five components, that is five field copies — negligible. For an immutable List of 100,000 elements, rebuilding the list on every update — copying a mutable working copy with List.copyOf, or re-collecting a stream — is a 100,000-element copy each time, and doing it inside a loop turns an O(n) job into O(n²). (List.copyOf itself is smart enough to pass an already-immutable list through untouched; the copy here is the price of the rebuild, not of the method.)

This is the one cost that produces genuinely catastrophic asymptotics, and it is also the most avoidable — the fix is either a builder used inside a pure function (mutation as an implementation detail) or a persistent data structure. Both are covered below.

3. Memory layout and indirection

An immutable object graph is a graph of pointers. Traversing it means chasing references, and a cache miss on a modern CPU costs on the order of a hundred nanoseconds — real money next to the sub-nanosecond arithmetic it interrupts. A long[] you index sequentially is the friendliest thing you can hand the hardware; a linked structure of boxed values is close to the least friendly.

This cost is the hardest for the JVM to remove today, and it is the one Project Valhalla exists to attack: value classes let the JVM flatten and inline immutable data, keeping the programming model and discarding the indirection.


Persistent Data Structures: Immutable Updates Without the Copy

The copying problem has a classical solution that the JDK does not ship: persistent data structures, immutable collections whose update operations return a new version that shares almost all of its structure with the old one.

The most common design — the one behind Clojure’s vectors and maps and Java libraries such as Vavr and Paguro, with Scala’s Vector and the hash-array-mapped tries elsewhere as close variations on the theme — is a wide tree: a bitmapped trie with 32-way branching. Updating one element copies only the path from the root to the affected leaf, roughly log₃₂(n) small node copies, and every other node is shared between the old and new versions.

// Vavr — updates return a new vector, structure is shared, nothing is copied wholesale
io.vavr.collection.Vector<Order> v1 = io.vavr.collection.Vector.ofAll(orders);
io.vavr.collection.Vector<Order> v2 = v1.update(3, v1.get(3).withStatus(SHIPPED));
// v1 is fully intact; v1 and v2 share all but ~log32(n) nodes

The practical consequences, stated honestly in both directions:

  • Per-update cost drops from O(n) to effectively O(log₃₂ n) — for a million elements, a handful of node copies instead of a million-element array copy. The loop that was quadratic with List.copyOf becomes near-linear.
  • Old versions stay alive without full copies — they hold only the nodes they don’t share — which is what makes them ideal for snapshots, undo histories, and handing data across threads without defensive copies.
  • The constant factors are worse than ArrayList. Indexed reads walk a shallow tree instead of hitting an array slot; iteration touches scattered nodes instead of one contiguous array. For read-heavy code over data that never changes, a plain immutable List (one array, no tree) is both simpler and faster.
  • They earn their keep in one specific situation: many successive updated versions of a large collection, where copy-on-write would be quadratic and sharing across versions (or threads) is the point. If you do not have that shape, you do not need the dependency.

What a Benchmark Will Actually Tell You

Performance claims in this area die on contact with a profiler more often than immutability does. Two ground rules before trusting any number — including the ones in this post:

Microbenchmark with JMH or not at all. A System.nanoTime() loop measures the interpreter, then the compiler warming up, then — after the JIT notices your result is unused — nothing, because dead-code elimination deleted the work. JMH exists to defeat exactly these failure modes (warmup phases, Blackhole sinks, forked JVMs). Any allocation-cost figure from a hand-rolled loop should be assumed wrong.

Profile allocation, not time, first. If immutability is costing you, the signature is allocation pressure: young-GC frequency in the GC log, allocation flame graphs from async-profiler. A CPU profile that shows your time in database drivers and JSON parsing — the usual result — is the measurement telling you the immutability conversation is over.

And keep the denominator honest: a service call that spends two milliseconds waiting on a database will not notice a few thousand extra nanoseconds of record allocation. In typical backend code, I/O dominates by three to six orders of magnitude. The cases below are the exceptions.


When It Actually Matters

The short list. Each of these is real, and each is recognizable in a profile before it is recognizable in the source code.

  1. Hot numeric loops over large data. Millions of elements, arithmetic per element, throughput-critical. Boxed immutable values and pointer-chasing structures lose to primitive arrays here, decisively. Use int[]/long[]/double[] or IntStream inside the computation — a pure function with a mutable core is still pure from the outside.
  2. Copy-on-write of a large collection inside a loop. The accidental O(n²). Fix with a builder confined to one method, or a persistent collection if you genuinely need every intermediate version.
  3. Allocation-rate-bound services. High-throughput systems where the GC log shows young collections dominating. The answer is usually surgical — reuse a buffer in the one hot path the allocation profile names — not a codebase-wide return to setters.
  4. Hard latency budgets. Low-latency trading, game frame loops, real-time audio: domains that budget microseconds and treat any GC pause as a defect. These teams preallocate and reuse everything; immutability-by-default was never the convention there, and nothing in this post argues it should be.

Outside this list, the honest engineering statement is: the cost exists, the JVM absorbs most of it, and I/O buries the remainder.


The Ledger Has Two Sides

Counting only immutability’s costs is bad accounting, because mutation is not free either — its costs are just paid in different places:

  • Defensive copies disappear. Mutable objects crossing trust boundaries get copied on suspicion, at every boundary, forever. A deeply immutable value — immutable elements in an immutable container, not just an unmodifiable List.copyOf around mutable beans — is handed over by reference, safely, every time. (In-process boundaries only: crossing to another system still means serialization and validation.) In share-heavy code, immutability can mean strictly less copying.
  • Locks disappear with them. Data that cannot change needs no synchronization to read. The mutable alternative in concurrent code — locking, or copying per thread — has its own price, paid in contention and in bugs that no profiler will ever show you. Structuring parallel work without shared state is built on exactly this.
  • The JVM optimizes what cannot change. Static finals — and record fields, which the JIT is allowed to trust — enable constant-folding; stable values enable speculation; and the platform’s own trajectory — records, frozen arrays research, Valhalla — is a decade-long bet that immutable data is the optimizable kind.

A Working Playbook

  1. Default to immutability at boundaries and in domain models. This is the cheap 95%: records, immutable collections, values across threads. The costs here are the ones the JVM absorbs.
  2. Let a profiler nominate the hot paths. Allocation profile first, GC log second, CPU profile third. Believe the tool, not the intuition — the intuition always blames the streams and it is almost always wrong.
  3. In a nominated hot path, mutate privately. A builder, a primitive array, a reused buffer — inside one method, invisible from outside. Purity is an observable property; the implementation is allowed to be pragmatic.
  4. Reach for persistent collections only for the versioned-data shape. Many versions of large collections, snapshots, undo. Otherwise the JDK’s immutable collections plus discipline are simpler and faster.
  5. Re-measure after every fix. The point of the profile is to make the conversation falsifiable in both directions.

Conclusion

The performance cost of immutability is real, mechanical, and boring: allocation rate, occasional copying, pointer indirection. None of it is mysterious, most of it is absorbed by a runtime that has been optimized for short-lived objects for twenty-five years, and nearly all of the remainder hides behind I/O that costs orders of magnitude more.

What survives scrutiny is a short, specific list — hot numeric loops, copy-on-write in loops, allocation-bound throughput, hard latency budgets — and every entry on it announces itself in a profile long before it justifies abandoning immutable design. The right posture is neither faith nor fear: immutable by default, mutable by measurement.


Further reading