skip to content

What techniques make a read-modify-write operation (like an increment) atomic in Java, and what are the trade-offs between them?

level: seniorimportance: should knowfreq 64%

answer

  1. Atomicity = exclusion + visibility (JMM)
  2. Locks: general, blocking, can deadlock
  3. Atomic classes = lock-free CAS, single variable only
  4. LongAdder for hot counters; merge/compute for per-key
  5. Multi-field invariant => must use a lock

basics

~20 s

Make the read, change, and write happen as one indivisible step. You can use a synchronized block or a Lock, or an atomic class like AtomicInteger with incrementAndGet, or a concurrent collection's atomic methods like compute. Each guarantees no other thread interleaves in the middle.

solid answer

~40 s

A read-modify-write (e.g. `count++`) must be made atomic so no thread sees or changes the value mid-operation. Three main tools: (1) **Locks** — `synchronized` or `ReentrantLock` give mutual exclusion plus the memory visibility you need; simple and general, but they serialize access and can contend. (2) **Atomic classes** — `AtomicInteger.incrementAndGet`, `AtomicReference.compareAndSet`, `LongAdder` — use lock-free CAS (compare-and-swap) hardware instructions; great for single-variable counters and high contention (`LongAdder` scales best for hot counters). (3) **Concurrent-collection atomic methods** — `ConcurrentHashMap.merge`/`compute`/`computeIfAbsent` make a per-key read-modify-write atomic. Choose by scope: a single number → atomic class; a per-key update → the map's compute method; an invariant spanning multiple fields → a lock. CAS can degrade under extreme contention (retries), which is why `LongAdder` (striped counters) often wins for hot counters.

code

java · 13 lines
java
// Three atomic ways to do a safe increment:

// 1) Lock-based (general, but serializes)
private int a;
synchronized void incLock() { a++; }

// 2) Lock-free CAS (single variable)
private final AtomicInteger b = new AtomicInteger();
void incAtomic() { b.incrementAndGet(); }

// 3) Per-key in a concurrent map (atomic for that key)
private final ConcurrentHashMap<String,Integer> c = new ConcurrentHashMap<>();
void incKey(String k) { c.merge(k, 1, Integer::sum); }

go deeper

for a junior

Knows you can wrap the increment in a synchronized block or use AtomicInteger.incrementAndGet to make it safe.

for a middle

Distinguishes locks from atomic classes, knows volatile alone doesn't fix an increment, and uses ConcurrentHashMap.merge/compute for per-key updates.

for a senior

Explains CAS, picks the primitive by scope (single var vs per-key vs multi-field invariant), knows LongAdder for hot counters and that compute functions must be short.

for a principal

Reasons about contention and scalability trade-offs (CAS retry/livelock, false sharing, striping), the ABA problem, and the memory-model guarantees each primitive provides, and designs data structures to minimize shared mutable hot spots.

## The problem to solve A **read-modify-write (RMW)** operation reads a value, derives a new value from it, and writes it back — `count++`, `balance -= amount`, `map.put(k, map.get(k)+1)`. For correctness, those three steps must be **atomic**: indivisible, so no other thread can read or write the same data in the middle. Atomicity here has two parts that often get conflated: - **Mutual exclusion / indivisibility** — only one thread performs the RMW on that data at a time. - **Visibility** — once a thread writes, other threads actually *see* the new value (governed by the **Java Memory Model**, the rules for when one thread's writes become visible to another). A fix that gives exclusion but not visibility is still broken. ### Tool 1: Locks (synchronized / ReentrantLock) ```java private int count; public synchronized void inc() { count++; } // whole RMW under the monitor lock ``` Entering a `synchronized` block acquires a **monitor** (a lock associated with an object); only one thread holds it at a time, so the RMW is exclusive. Releasing the monitor also **flushes** the write so the next acquirer sees it (visibility for free). `ReentrantLock` is the explicit version with extras (tryLock, timeouts, fairness, interruptibility). - **Pros:** general — protects *any* compound action, even one spanning multiple fields or objects; simple to reason about. - **Cons:** **blocking** — contending threads wait; coarse locks **serialize** otherwise-parallel work; risk of deadlock if you take multiple locks in inconsistent orders; context-switch cost under contention. ### Tool 2: Atomic classes (lock-free CAS) ```java private final AtomicInteger count = new AtomicInteger(); public void inc() { count.incrementAndGet(); } ``` These use **CAS (compare-and-swap)**: a single hardware instruction that atomically says "if this memory still holds value X, set it to Y; otherwise fail." `incrementAndGet` loops: read current `v`, try `compareAndSet(v, v+1)`; if another thread changed it in between, the CAS fails and it retries. No thread ever *blocks* — it's **lock-free** (the system as a whole always makes progress). - **Pros:** non-blocking, no deadlock, low overhead at low/moderate contention; ideal for a single counter, flag, or reference (`compareAndSet` is the atomic check-then-act for one variable). - **Cons:** only covers a **single variable** — you can't CAS two fields together (use a lock or an immutable holder you swap atomically). Under **very high contention** the retry loop spins and wastes CPU. The **ABA problem**: a value can change X→Y→X and a naive CAS won't notice; use `AtomicStampedReference` when identity-by-version matters. ### Tool 2b: LongAdder / LongAccumulator (striped counters) For a **hot counter** that many threads bump, a single `AtomicLong` becomes a contention bottleneck (all threads CAS the same cell). `LongAdder` keeps an array of **cells**, each thread updates a different cell, and `sum()` adds them up on read. - **Pros:** scales far better for write-heavy counters. - **Cons:** uses more memory; `sum()` isn't an atomic snapshot. Use it when writes vastly outnumber reads. ### Tool 3: Concurrent-collection atomic methods For a per-key RMW in a map, the map provides atomic compound operations: ```java map.merge(key, 1, Integer::sum); // atomic get-or-default + add + put map.compute(key, (k, v) -> (v == null ? 1 : v + 1)); map.computeIfAbsent(key, k -> expensiveLoad(k)); ``` These execute the read-modify-write **atomically for that key**, internally locking only the relevant bin, so concurrency on *other* keys is preserved. - **Pros:** scoped, high-concurrency, no manual locking. - **Cons:** the function must be **short and side-effect-free** (it runs while holding the bin lock; doing I/O or calling back into the same map can deadlock or stall other threads). ### How to choose | Scope of the RMW | Best tool | |---|---| | One number, moderate contention | `AtomicInteger`/`AtomicLong` | | One number, very hot (write-heavy) | `LongAdder` | | One reference / flag, check-then-set | `AtomicReference.compareAndSet` | | Per-key update in a map | `ConcurrentHashMap.merge`/`compute` | | Invariant spanning multiple fields/objects | a lock (`synchronized`/`ReentrantLock`) | ### Mental model Locks give you *blocking mutual exclusion over arbitrary code*; CAS atomics give you *non-blocking atomicity over a single slot*; concurrent collections give you *atomicity scoped to a key*. The smaller and more specific the atomic primitive you can use, the better it scales — but only a lock can keep an invariant that spans *multiple* variables consistent. Always remember the second half: whichever you pick must also provide the memory **visibility** guarantee, which all three of these do.

  • Why is `volatile int count; count++;` still not thread-safe?
    volatile guarantees visibility and ordering for individual reads and writes, but count++ is still a separate read, add, and write. Two threads can both read the same value and both write back, losing an update. volatile fixes visibility, not the atomicity of the compound RMW.
  • What is the ABA problem and which tool addresses it?
    ABA is when a value goes X to Y back to X between your read and your CAS; the CAS succeeds even though the state was modified in between, which can break algorithms that care about that. AtomicStampedReference (or AtomicMarkableReference) attaches a version stamp so the CAS also checks the stamp, detecting the change.

saying these in an interview costs you the question

  • Making the field volatile and thinking count++ is now safe (volatile gives visibility, not atomicity of RMW)
  • Trying to CAS two related fields independently and expecting a consistent pair
  • Using AtomicLong for a hyper-hot counter where LongAdder scales better
  • Doing I/O or blocking inside ConcurrentHashMap.compute
  • Assuming lock-free always beats a lock regardless of contention

context