Loading content...
In the previous page, we established that immutable objects are inherently thread-safe because concurrent reads cannot conflict. But how exactly does immutability solve the specific problems we've encountered throughout our concurrency journey?
This page demonstrates immutability as a design-level solution to concurrency hazards. Rather than managing race conditions with locks, we eliminate the possibility of races. Rather than reasoning about memory visibility, we create structures that need no such reasoning.
We'll revisit the core concurrency problems—race conditions, visibility issues, atomicity violations—and show how immutability addresses each. By the end, you'll see immutability not as an alternative to synchronization, but as a way to remove the need for synchronization entirely.
By the end of this page, you will understand: how immutability eliminates race conditions at their root, why visibility guarantees are automatic with proper immutability, how compound operations become irrelevant without mutable state, the copy-on-write paradigm for 'modifying' immutable objects, and performance considerations that make immutability practical.
A race condition occurs when the correctness of a program depends on the relative timing of concurrent operations. The canonical example is check-then-act: checking a condition and acting on it non-atomically, allowing another thread to invalidate the condition between check and act.
The Root Cause: Race conditions require at least one write operation. If all operations are reads, there's nothing to race against—all readers will see the same consistent state.
Immutability's Solution: By eliminating writes, immutability eliminates the possibility of races.
Let's see a concrete example:
1234567891011121314151617181920212223242526272829303132
// MUTABLE: Race condition in user session managementpublic class MutableUserSession { private String userId; private String role; private Set<String> permissions; private Instant lastAccess; private boolean valid; // Multiple threads might call these concurrently: public void invalidate() { this.valid = false; // Write operation this.permissions.clear(); // Another write } public boolean canAccess(String resource) { // CHECK if (!valid) return false; // ACT - but between check and act, another thread could invalidate! lastAccess = Instant.now(); // Write return permissions.contains(resource); // Read of possibly-modified set } public void grantPermission(String permission) { // What if this runs while canAccess is running? permissions.add(permission); // Concurrent modification! }} // Thread 1: session.canAccess("admin-panel") // Checking valid, reading permissions// Thread 2: session.invalidate() // Clearing permissions// Thread 1: ConcurrentModificationException or worse, silent corruption!Why This Works:
With immutability, the session object that Thread 1 is using cannot change. Thread 2's call to invalidated() doesn't modify the existing session—it creates a brand new ImmutableUserSession object. Thread 1 continues reading from its original reference, which remains consistent.
This is a fundamental paradigm shift:
| Mutable Approach | Immutable Approach |
|---|---|
| Objects are containers that change over time | Objects are snapshots of state at a moment |
| Multiple threads contend over the same object | Each thread may hold different snapshots |
| Requires synchronization to prevent races | Race conditions are structurally impossible |
| Updates modify in place | Updates produce new objects |
When using immutable objects, you typically have a mutable reference (often atomic) pointing to the current immutable state. Updates atomically swap the reference to point to a new immutable object. This localizes the synchronization concern to a single atomic reference update, while the majority of code works with guaranteed-immutable data.
In multithreaded programming, visibility refers to whether changes made by one thread are seen by other threads. Without proper synchronization, a write in one thread might not be visible to readers in other threads—they might see stale cached values indefinitely.
The classic visibility problem:
123456789101112131415161718192021222324
// Classic visibility problempublic class MutableFlag { private boolean running = true; // Not volatile! public void stop() { running = false; // Thread 1 writes } public void runLoop() { while (running) { // Thread 2 reads - might never see the update! doWork(); } // This loop might run FOREVER even after stop() is called! // Thread 2's CPU might cache 'running = true' and never refresh. }} // The fix for mutable objects requires volatile or synchronization:public class FixedMutableFlag { private volatile boolean running = true; // Now visible across threads public void stop() { running = false; } public void runLoop() { while (running) { doWork(); } }}Why Immutable Objects Don't Have This Problem:
Immutable objects establish visibility at construction time. In Java, final fields have special memory model guarantees:
When a constructor completes, all final fields are guaranteed to be visible to any thread that obtains a reference to the object.
This visibility extends transitively—if a final field references another object, all fields of that object (at construction time) are also visible.
Since immutable objects never change after construction, this one-time visibility guarantee is all that's needed.
123456789101112131415161718192021222324252627282930313233343536373839404142
// Immutable object with guaranteed visibilitypublic final class ImmutableConfig { private final String serverHost; private final int serverPort; private final boolean debugMode; private final List<String> allowedOrigins; // Also immutable public ImmutableConfig(String host, int port, boolean debug, List<String> origins) { this.serverHost = host; this.serverPort = port; this.debugMode = debug; this.allowedOrigins = List.copyOf(origins); // Immutable copy // At this point (end of constructor), the Java Memory Model guarantees // that any thread that sees a reference to this object will see // all final fields with their correctly initialized values. } // All getters just read final fields - always visible correctly public String getServerHost() { return serverHost; } public int getServerPort() { return serverPort; } public boolean isDebugMode() { return debugMode; } public List<String> getAllowedOrigins() { return allowedOrigins; }} // Usage with safe publication:public class ConfigManager { // AtomicReference provides atomic updates to the reference itself private final AtomicReference<ImmutableConfig> currentConfig = new AtomicReference<>(); public void updateConfig(ImmutableConfig newConfig) { // Atomic swap - the new immutable config is safely published currentConfig.set(newConfig); // Any thread calling getConfig() after this point will see // the new config with all its fields properly visible. } public ImmutableConfig getConfig() { return currentConfig.get(); }}The Java Memory Model describes final field behavior as 'freezing' the field's value at the end of construction. This freeze creates a happens-before relationship: all actions before the freeze (setting final fields) are visible to any action after reading a reference to the constructed object. This is why construction must complete before the reference escapes.
A compound action is a sequence of operations that must execute atomically to be correct. With mutable objects, ensuring atomicity of compound actions requires careful synchronization. With immutable objects, the problem largely disappears.
The Classic Example: Transfer Between Accounts
12345678910111213141516171819202122232425
// MUTABLE: Requires careful synchronization for compound actionspublic class MutableAccount { private String id; private BigDecimal balance; private List<Transaction> history; public synchronized void transfer(MutableAccount target, BigDecimal amount) { // Must lock BOTH accounts to prevent deadlock and race conditions // But what order? Need consistent ordering to prevent deadlock! MutableAccount first = this.id.compareTo(target.id) < 0 ? this : target; MutableAccount second = this.id.compareTo(target.id) < 0 ? target : this; synchronized (first) { synchronized (second) { if (this.balance.compareTo(amount) >= 0) { this.balance = this.balance.subtract(amount); target.balance = target.balance.add(amount); this.history.add(new Transaction("DEBIT", amount)); target.history.add(new Transaction("CREDIT", amount)); } } } } // Complex, error-prone, and deadlock-susceptible!}Why This is Better:
No Lock Ordering: We don't need to coordinate multiple locks because we're not locking objects—we're computing new state.
No Partial Updates: If the transfer computation fails (e.g., insufficient funds), no state has been modified. We simply don't publish the new objects.
Natural Rollback: Since we're creating new objects, the old state remains unchanged. Rollback is simply not using the new objects.
Easy Testing: The transfer function is a pure function—deterministic, no side effects. Test with any inputs, verify outputs.
Single Synchronization Point: All complexity is localized to the atomic reference update in AccountStore.
The updateAndGet method on AtomicReference uses compare-and-swap internally. If another thread modifies the reference concurrently, it will retry with the new value. This is typically faster than blocking locks but means your update function might be called multiple times. With immutable objects and pure functions, this is safe—the function just computes the same result again.
If objects never change, how do we represent change in our programs? The answer is copy-on-write: when we need to 'modify' an immutable object, we create a new object with the desired changes.
The Conceptual Model:
Think of immutable objects as values, not containers:
[1, 2, 3] doesn't 'become' [1, 2, 3, 4]. Instead, appending 4 produces a new list [1, 2, 3, 4].1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768
// Immutable class with "fluent" copy-on-write methodspublic final class ImmutablePerson { private final String firstName; private final String lastName; private final int age; private final String email; private final Address address; // Also immutable! public ImmutablePerson(String firstName, String lastName, int age, String email, Address address) { this.firstName = Objects.requireNonNull(firstName); this.lastName = Objects.requireNonNull(lastName); this.age = age; this.email = email; this.address = address; } // "With" methods return new instances with specified changes public ImmutablePerson withFirstName(String firstName) { return new ImmutablePerson(firstName, lastName, age, email, address); } public ImmutablePerson withLastName(String lastName) { return new ImmutablePerson(firstName, lastName, age, email, address); } public ImmutablePerson withAge(int age) { return new ImmutablePerson(firstName, lastName, age, email, address); } public ImmutablePerson withEmail(String email) { return new ImmutablePerson(firstName, lastName, age, email, address); } public ImmutablePerson withAddress(Address address) { return new ImmutablePerson(firstName, lastName, age, email, address); } // Fluent chaining for multiple updates: // ImmutablePerson updated = person // .withFirstName("Jane") // .withEmail("jane@example.com") // .withAge(31); // Getters public String getFirstName() { return firstName; } public String getLastName() { return lastName; } public int getAge() { return age; } public String getEmail() { return email; } public Address getAddress() { return address; }} // Modern Java (14+) with records - copy-on-write is even easier:public record Person(String firstName, String lastName, int age, String email, Address address) { public Person withFirstName(String firstName) { return new Person(firstName, lastName, age, email, address); } public Person withAge(int age) { return new Person(firstName, lastName, age, email, address); } // Or use new record "with" pattern (preview feature in recent Java): // Person updated = person with { firstName = "Jane", age = 31 };}Sophisticated immutable data structures (like those in Clojure or libraries like Immutable.js) use structural sharing: unchanged portions of the data structure are shared between old and new versions. This dramatically reduces memory overhead and makes copy-on-write operations nearly as efficient as mutation.
Defensive copying is a technique used with mutable objects to protect against unintended modifications: when receiving a mutable object, copy it so the caller's reference can't affect your internal state; when returning a mutable object, copy it so the caller can't modify your internal state.
With immutable objects, defensive copying becomes unnecessary—the objects can't be modified regardless of who holds a reference.
12345678910111213141516171819202122232425262728293031
// WITH MUTABLE OBJECTS// Defensive copies everywhere! public class Report { private final Date createdAt; private final List<String> items; public Report(Date created, List<String> items) { // Defensive copy on input this.createdAt = new Date( created.getTime()); this.items = new ArrayList<>( items); } public Date getCreatedAt() { // Defensive copy on output return new Date( createdAt.getTime()); } public List<String> getItems() { // Defensive copy on output return new ArrayList<>(items); }} // Every get/set involves copying!// Memory overhead, CPU cycles wasted// Easy to forget a copy -> bug!12345678910111213141516171819202122232425262728293031
// WITH IMMUTABLE OBJECTS// No defensive copying needed! public class Report { private final Instant createdAt; private final List<String> items; public Report(Instant created, List<String> items) { // Instant is immutable this.createdAt = created; // Make immutable copy once this.items = List.copyOf(items); } public Instant getCreatedAt() { // Direct return - Instant // is immutable return createdAt; } public List<String> getItems() { // Direct return - list // is immutable return items; }} // No copies on access!// Lower memory, faster access// Can't forget - no copies to make!The Hidden Costs of Defensive Copying:
Memory Allocation: Every defensive copy allocates new memory, increasing GC pressure.
CPU Cycles: Copying takes time, especially for large collections or complex objects.
Error Prone: Forgetting a defensive copy introduces subtle bugs that may not manifest until production.
Code Bloat: Constructors and getters become filled with copying logic.
Deep Copy Complexity: For nested mutable objects, you need recursive deep copying—extremely error-prone.
Immutability's Advantage:
With immutable objects, you make one copy at construction (to ensure you own the data), and thereafter you can freely share references. This is both faster and safer.
A common concern with immutability is performance: "Don't you have to copy everything all the time?" The reality is more nuanced, and in many cases immutability actually improves performance.
12345678910111213141516171819202122232425262728293031323334353637383940
// Example: Hash code caching in immutable objectspublic final class ImmutablePoint { private final int x; private final int y; private int cachedHashCode; // Lazily computed, but safe! public ImmutablePoint(int x, int y) { this.x = x; this.y = y; // Don't compute hash code yet - lazy initialization } @Override public int hashCode() { int result = cachedHashCode; if (result == 0) { // Compute once, cache forever - safe without synchronization! // Why? Because: // 1. If two threads compute simultaneously, they compute the same value // 2. Once written, the value never changes // 3. Reading a stale 0 just means we re-compute (harmless) result = 31 * x + y; if (result == 0) result = 1; // Avoid re-computing for hash of 0 cachedHashCode = result; } return result; } @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof ImmutablePoint)) return false; ImmutablePoint that = (ImmutablePoint) o; return x == that.x && y == that.y; }} // This is exactly how java.lang.String caches its hash code!// The slight races possible in lazy initialization are benign// because the computed value is always the same.In-place mutation can be faster for tight loops over large arrays, algorithms with many incremental updates, or memory-constrained environments. The key is knowing when these situations apply. For most application code—business logic, domain models, API request handling—immutability's safety and simplicity outweigh any micro-performance concerns.
| Scenario | Mutable | Immutable | Winner |
|---|---|---|---|
| Concurrent reads | Requires locks or volatile | No synchronization needed | Immutable ✅ |
| Returning internal state | Defensive copy required | Direct reference return | Immutable ✅ |
| Use as hash key | Must be careful, may corrupt | Safe, can cache hash | Immutable ✅ |
| Tight numeric loop | In-place update fastest | Object creation overhead | Mutable ✅ |
| Building up collection | Incremental adds efficient | Copy each step (naive) | Mutable ✅ |
| Shared state reasoning | Complex lock analysis | No analysis needed | Immutable ✅ |
Let's examine common patterns for using immutable objects in concurrent systems:
Hold a mutable atomic reference to an immutable state object. Updates atomically swap the reference.
123456789101112131415161718192021222324252627
public class GameState { private final AtomicReference<ImmutableGameState> state; public GameState(ImmutableGameState initial) { this.state = new AtomicReference<>(initial); } // Readers get consistent snapshot public ImmutableGameState getState() { return state.get(); } // Atomic update using CAS loop public void applyMove(Move move) { state.updateAndGet(current -> current.withMove(move)); } // Conditional update public boolean tryApplyMoveIfValid(Move move) { return state.updateAndGet(current -> { if (current.isValidMove(move)) { return current.withMove(move); } return current; // No change }) != state.get(); // Returns true if changed }}We've seen how immutability addresses concurrency challenges at the design level rather than through runtime synchronization. Let's consolidate:
What's Next:
Now that we understand how immutability provides thread safety, the next page covers the how-to: practical techniques for designing deeply immutable objects that work correctly in concurrent environments. We'll cover construction patterns, the builder pattern for complex immutable objects, and strategies for dealing with inherently mutable data.
You now understand how immutability solves specific concurrency problems: eliminating race conditions, providing automatic visibility, simplifying compound actions, enabling safe sharing, and often improving performance. Next, we'll learn how to design immutable objects correctly.