Immutability has real costs - what are they, and how do you mitigate them when state must change frequently?
answer
- Change = new object => churn + GC; arrays/collections => copy-on-modify
- String += in a loop is O(n^2); StringBuilder fixes it
- Mutable companion in hot path, freeze (toString/List.copyOf) at the end
- Persistent structures share unchanged nodes (O(log n) updates)
- Generational GC + escape analysis make young garbage cheap; measure first
basics
~20 sBecause you can't modify an immutable object, every change means building a new one. Do that in a tight loop and you create lots of short-lived objects, which costs CPU and gives the garbage collector more work. The fix is to use a mutable helper (like StringBuilder) for the hot part and produce the immutable result at the end.
solid answer
~50 sThe cost of immutability is allocation: 'changing' an immutable object actually creates a new object, so frequent updates produce object churn and extra GC pressure, and if the object holds an array or collection, each new version may copy it (copy-on-modify), costing time and memory proportional to size. The classic symptom is String concatenation in a loop - O(n^2) and a trail of garbage. Mitigations: (1) use a mutable companion for the hot path and freeze at the end - StringBuilder then toString(), or a local mutable accumulator then wrap immutably; (2) use persistent/structural-sharing data structures that share unchanged parts instead of full copies; (3) batch changes so you allocate once, not per field; (4) lean on the JVM - generational GC makes short-lived young-gen objects cheap, and escape analysis can sometimes stack-allocate or scalar-replace objects that don't escape. The discipline is immutable-by-default, with measured mutable exceptions only where profiling shows allocation hurts.
go deeper
Knows that 'changing' an immutable object makes a new one and that StringBuilder is the fix for string building in loops.
Lists the three costs (churn, copy-on-modify, GC) and applies the mutable-companion-then-freeze pattern; explains the O(n^2) string case.
Reasons about mitigations as a portfolio (builder confinement, persistent structures, batching) and invokes generational GC / escape analysis, insisting on profiling before trading away immutability.
Sets org-level defaults (immutable by default, confined mutable hot paths), weighs persistent-structure libraries vs JVM allocator behavior, and ties choices to measured latency/throughput and GC SLOs.
## Why immutability costs anything An immutable object's state is fixed at construction. So a logical 'update' - increment a counter, add an element, change a field - cannot edit the existing object; it must **produce a new object** carrying the changed value. Three concrete costs follow. ### 1. Object churn / allocation pressure If you perform N updates, you allocate N objects (the intermediate versions are garbage). Allocation in the JVM is cheap per object (bump-pointer in the young generation), but it's not free, and N can be large. The textbook case: ``` String s = ""; for (String part : parts) s = s + part; // each += builds a NEW String ``` Each `+` copies all characters so far plus the new ones into a fresh `String`, giving **O(n^2)** total character copies and n-1 throwaway Strings. The same shape appears with naive `BigInteger`/`BigDecimal` loops or rebuilding an immutable list element-by-element. ### 2. Copy-on-modify overhead When an immutable object **contains** a mutable component (array, collection), producing the next version often **copies the whole component** to keep the old version intact. `List.copyOf` / building a new `ArrayList` to make an unmodifiable list is O(size) per change. So a sequence of single-element 'adds' to an immutable list copies the entire backing array each time. ### 3. GC impact All those intermediate objects become garbage that the collector must trace and reclaim. With many short-lived objects, **minor (young-gen) GCs** run more often. Generational collectors are tuned for exactly this 'most objects die young' pattern, so it's usually cheap - but in latency-sensitive or extremely hot paths it can add pauses and CPU. ## Mitigations ### A. Mutable companion, immutable result Use a purpose-built **mutable builder** for the hot loop and convert to an immutable value once: - `StringBuilder` -> `toString()` instead of `+=`. - A local `ArrayList` you fill, then `List.copyOf(list)` at the end. - A `record` or immutable value assembled once from accumulated locals. The mutation is **confined** (thread-local, not shared), so you keep the safety benefits at the boundary while paying allocation only once. ### B. Persistent / structural-sharing structures **Persistent data structures** create a new logical version that **shares** most of its internal nodes with the previous version, copying only the path that changed (O(log n) instead of O(n)). Examples: many functional/immutable collection libraries, and Java's own copy-on-write is the opposite extreme (full copy). Structural sharing makes 'immutable but frequently updated' affordable. ### C. Batch the changes Instead of updating one field at a time (one allocation each), compute all changes and build the new object **once**. A `with`-style API or builder that takes several new values amortizes allocation. ### D. Trust the runtime where appropriate - **Generational GC**: short-lived garbage is collected cheaply in the young generation; don't pre-optimize against it without evidence. - **Escape analysis**: the JIT can detect objects that never escape a method and **scalar-replace** them (no heap allocation at all) or stack-allocate, erasing the cost in some hot, inlined paths. Measure (allocation profiling, GC logs, JFR) before trading away immutability. ## The judgment call The right rule is **immutable by default, mutable by exception**. Immutability buys thread safety, safe sharing, valid keys, and simpler reasoning - usually worth a little allocation. You introduce a mutable companion only in **localized, profiled hot spots** where churn measurably hurts, and you keep that mutability **confined** so the rest of the system still enjoys immutability's guarantees. This is precisely the design behind `String` (immutable, shared, safe) living next to `StringBuilder` (mutable, fast, local).
- Why is repeated String concatenation O(n^2)?Each '+' creates a brand-new String by copying all characters accumulated so far plus the new piece. Summing 1+2+...+n character-copies over n steps is O(n^2), and it leaves n-1 throwaway Strings as garbage. StringBuilder appends into one growable buffer and copies once at toString(), making it O(n).
- How can the JIT make some immutable allocations effectively free?Through escape analysis: if the JIT proves an object never escapes the method (isn't stored in a field, returned, or passed to unknown code), it can scalar-replace it - keep its fields in registers/stack with no heap allocation - or stack-allocate it, so the GC never sees it. This often applies to short-lived immutable temporaries in hot, inlined code.
saying these in an interview costs you the question
- Concluding 'immutability is too slow, avoid it' instead of confining mutation to hot paths
- Ignoring copy-on-modify cost for immutable objects that wrap large arrays/collections
- Premature micro-optimization against GC without profiling
- Assuming every immutable update is O(1) - building a new collection can be O(n)