Concurrency, Immutability, and Shared State

Learn how shared mutable objects race, when immutability helps, and how ownership, locks, and atomic operations keep concurrent application state correct.

published: reading time: 11 min read author: GeekWorkBench
Quick Summary

Concurrent correctness starts with ownership: decide whether one task can confine changing state, readers can use immutable snapshots, or multiple tasks need synchronized access. The examples separate visibility, atomicity, and safe publication, then show how atomics, locks, or transactions protect different kinds of invariants. Production scenarios cover lost updates, mutable cache values, retry side effects, and lock contention. Use the decision table and checklists to choose a mechanism that fits the state and authoritative boundary.

Concurrency, Immutability, and Shared State

Introduction

A race condition often starts with an ordinary object: a counter, a cache entry, or a list of pending jobs. The problem appears when several tasks can reach that object and at least one can change it. Each task may have sensible code on its own; the combined sequence can still lose updates or expose a half-finished change.

Immutability helps because a value that cannot change after construction is easier to share. It is one part of a concurrency design, though. It does not make a multi-step operation atomic, synchronize publication, or protect mutable objects hidden inside a supposedly immutable wrapper. You still need a clear ownership rule or a synchronization mechanism for state that changes.

A shared counter makes the difference concrete: a plain read-modify-write can lose an update, while AtomicInteger provides an atomic increment.

count = count + 1;                 // separate read and write
atomicCount.incrementAndGet();    // one atomic operation

The Object-Level Race

Consider a request counter incremented by two worker threads:

final class Counter {
    private int value;

    void increment() {
        value = value + 1;
    }

    int value() {
        return value;
    }
}

value + 1 is a read-modify-write sequence. If both workers read 7 before either writes, both store 8. Two increments happened, but the result is 8, not 9. The field is the shared mutable state; the race exists even though each method call looks small.

A related visibility problem occurs when one thread writes a field and another reads it without a happens-before relationship. The reader is not guaranteed to observe the write promptly or in the expected order. The Java Memory Model guide explains those Java-specific visibility rules.

graph LR
    A[Worker A reads 7]
    B[Worker A computes 8]
    C[Worker B reads 7]
    D[Worker B computes 8]
    E[Worker A writes 8]
    F[Worker B writes 8]
    G[Final value is 8; one update is lost]
    A --> B
    B --> E
    E --> G
    C --> D
    D --> F
    F --> G

The diagram shows one possible interleaving. A race does not need to reproduce on every run. A different schedule may produce the expected result by chance, which is why low-load testing can miss it.

Immutability Narrows the Problem

An immutable value has no externally visible state changes after it is created. A Money, Point, or request configuration object can then be passed to many tasks without coordinating writes to its fields.

record AccountSnapshot(String accountId, long balanceCents) {}

This record is a shallow immutable value because its components are immutable types. A record containing a mutable List is not deeply immutable just because its fields are final. The list can still change through another reference. Copy mutable inputs or expose immutable views when callers must not mutate the underlying collection.

Immutability also does not make this operation safe:

if (inventory.available(itemId) > 0) {
    inventory.decrement(itemId);
}

The check and decrement can interleave with another task. Each returned snapshot could be immutable while the inventory operation still violates its invariant. Protect the whole check-and-update operation with a lock, a transaction, or an atomic conditional update.

Choose an Ownership Rule

Before adding locks, ask who can access an object while it changes. Common designs include:

  • Confinement: Keep mutable state inside one thread, actor, request, or task. Other code sends messages instead of retaining a reference.
  • Ownership transfer: Give one component exclusive responsibility for an object. Transfer it through a queue, and stop using the old reference after handoff.
  • Shared state with synchronization: Let multiple tasks access the object, but require every relevant read and write to follow the same lock or concurrency protocol.
  • Immutable snapshots: Publish a replacement value when state changes, so readers can retain a stable version.

I reach for confinement first when ownership already follows a worker or queue; its boundaries are easier to explain than a lock passed through unrelated code. It is not always practical: caches, registries, and account balances may need shared updates. In that case, make the synchronization boundary match the invariant, not merely the field.

Synchronization Must Cover the Invariant

For the counter, Java offers an atomic increment:

AtomicInteger counter = new AtomicInteger();
int current = counter.incrementAndGet();

This makes the increment indivisible and visible according to the atomic type’s memory guarantees. A volatile int would provide visibility for individual reads and writes, but counter++ would still be a multi-step operation and could lose updates.

If state consists of multiple fields, use one lock around the operation that must remain consistent:

final class Inventory {
    private final Object lock = new Object();
    private int available;

    boolean reserveOne() {
        synchronized (lock) {
            if (available == 0) return false;
            available--;
            return true;
        }
    }
}

The lock protects the check and decrement together. Every code path that changes available must use this same lock. A lock held around only the decrement would leave the check-then-act race intact. Conversely, holding it while performing network I/O would make unrelated callers wait and invite latency problems.

Atomic references can publish immutable snapshots. The update function below should be pure because the atomic operation may retry its function if another update wins first:

record Settings(int retries, boolean enabled) {}
AtomicReference<Settings> settings =
    new AtomicReference<>(new Settings(3, true));

settings.updateAndGet(old -> new Settings(old.retries() + 1, old.enabled()));

Do not send email, charge a card, or increment an external metric inside that update function. A retry could repeat the side effect. Keep side effects outside the retryable state transformation, or use a transaction/outbox design suited to the system.

Language-Specific Limits

Concurrency rules come from a language’s memory model, runtime, and libraries. Java’s synchronized, volatile fields, atomics, and concurrent collections provide different guarantees; choosing one requires understanding the operation being protected. See the concurrent collections guide and atomics and VarHandles overview for Java APIs.

Python’s traditional CPython Global Interpreter Lock (GIL) does not make a sequence of application operations logically atomic. Threads can interleave between bytecode operations, and I/O releases the GIL. Python implementations and configurations also differ, so rely on documented synchronization primitives rather than assumptions about the GIL.

JavaScript usually runs a task on one event loop at a time, but asynchronous functions can interleave at await points. Shared memory workers add explicit synchronization concerns. Rust’s ownership and borrowing rules reject many unsynchronized aliasing patterns at compile time; shared mutation still uses tools such as Mutex or atomics and can still deadlock or implement the wrong invariant.

A language feature can narrow the set of possible bugs. It cannot decide what the correct business invariant is for you.

When to Use

Use immutable values for data that many tasks read, such as configuration, parsed messages, and snapshots. Prefer confinement or message passing when one worker can own a changing object. Use atomics for a single independent value with a supported atomic operation. Use a lock or transaction when several reads and writes must succeed as one invariant-preserving action.

When NOT to Use

Do not convert every class into an immutable copy if the state is large, frequently updated, or naturally owned by a single actor. Rebuilding large object graphs can increase allocation and garbage collection pressure. Do not reach for a lock-free algorithm before measuring contention; its correctness and maintenance costs are real. And do not treat final, const, a read-only interface, or a concurrent map as proof that all objects reachable through it are immutable or every compound operation is atomic.

Production Failure Scenarios

  • Lost inventory update: Two requests both observe one remaining item, then both decrement it. One request succeeds when the stock should have admitted only one reservation. Protect the conditional update in the database or in one synchronized inventory boundary.
  • Mutable cache value escapes: A cache returns the same List to several callers. One caller sorts or clears it while another renders the response. Return a defensive copy or an immutable value, and define whether snapshots may become stale.
  • Retry repeats side effects: An atomic update callback sends an event, then retries after a competing update. The event is sent twice even though the value changes once. Keep retryable functions pure and publish effects after a successful state transition.
  • Lock scope grows: A service adds remote I/O inside a critical section. Throughput drops, requests queue behind a slow dependency, and timeouts amplify load. Keep the lock around the smallest invariant-preserving local operation.

Trade-Off Table

Approach Good fit Cost or limit
Immutable values Read-heavy data and snapshots Replacing large values may allocate and copy
Thread/task confinement State with one natural owner Requires message passing or ownership discipline
Atomic variable One value with a supported atomic transition Does not protect a multi-field invariant
Mutex or monitor Compound operations on shared state Contention, deadlock risk, and lock-order rules
Database transaction State shared across service instances Latency, isolation choices, retries, and database load

Observability Checklist

  • Track lock wait time, lock hold time, and contention on hot paths.
  • Record atomic or optimistic-update retry counts where the library exposes them.
  • Alert on invariant violations, such as negative inventory or duplicate active reservations.
  • Include request or operation IDs in logs so concurrent attempts can be reconstructed.
  • Measure queue depth and task latency when ownership is coordinated through a queue.
  • Stress test with controlled concurrency and verify outcomes, not only absence of exceptions.

Security and Compliance Notes

Race conditions can affect more than performance. A lost authorization update or double-spent balance can violate access or financial rules. Enforce important invariants at the authoritative boundary, often a database constraint or transaction, because in-process locks do not coordinate separate service instances. Avoid logging sensitive object contents while debugging races; use identifiers and redacted state. For regulated data, retain audit events for committed changes and ensure retries do not create duplicate external actions.

Common Pitfalls and Anti-Patterns

  • Assuming immutable wrappers make mutable nested objects safe.
  • Marking a field volatile and expecting compound updates to become atomic.
  • Locking a writer but leaving readers or alternate writers outside the same protocol.
  • Using a concurrent map while assuming a sequence of get, check, and put is one atomic operation.
  • Calling a retryable atomic update function that performs I/O or another side effect.
  • Treating a GIL, event loop, or test run as a substitute for the language’s documented memory model.
  • Sharing a lock across a broad service method, including slow I/O and callbacks.

Quick Recap Checklist

  • Can more than one task reach this object while it changes?
  • What complete invariant must remain true after each operation?
  • Can one owner confine the state, or can readers use immutable snapshots?
  • If state is shared, does one synchronization boundary cover every relevant access?
  • Are retryable operations free of side effects?
  • Does the authoritative database or service enforce invariants across processes?

Interview Questions

1. Does immutability eliminate data races?

It eliminates races caused by concurrent mutation of that immutable object's own state. It does not make nested mutable references immutable, make a multi-step operation atomic, or guarantee safe publication under every language's rules. Use ownership and synchronization for changing state that remains shared.

2. Why is a volatile counter not enough for increments?

Volatile reads and writes provide visibility and ordering guarantees, but an increment is a read-modify-write sequence. Two threads can read the same old value and overwrite one another. Use an atomic increment or protect the whole operation with a lock.

3. When would you choose confinement over a mutex?

Choose confinement when one task or actor can own the mutable state and other callers can communicate through messages. It removes concurrent access to that object. Use a mutex when multiple callers must share it directly, and keep the lock around the full invariant-preserving operation.

Further Reading

Conclusion

Summary

Immutability makes values easier to share, but shared mutable state still needs a deliberate concurrency strategy. Start by identifying the object and the invariant, then decide whether one owner can confine it. If several tasks must change it, use an atomic operation, lock, or transaction whose boundary covers the whole operation. Measure contention and test interleavings; the right mechanism is the one that preserves correctness at the boundary where the state is authoritative.

Category

Related Posts

Java Concurrent Collections: ConcurrentHashMap, BlockingQueue

Java concurrent collections deep dive: ConcurrentHashMap, BlockingQueue, CopyOnWriteArrayList, ConcurrentLinkedQueue, and choosing the right structure.

#java #jvm #concurrency

Java Synchronization Primitives: synchronized, wait/notify, and Locks

Understanding Java synchronization: synchronized blocks, wait/notify, ReentrantLock, ReadWriteLock, StampedLock, and common concurrency patterns.

#java #jvm #concurrency

Java Atomics and VarHandle: Low-Level Concurrency

Understanding Java atomic operations: AtomicInteger, AtomicReference, VarHandle, compareAndSet, atomics vs locks, and lock-free programming patterns.

#java #jvm #concurrency