skip to content

When should you replace a contended lock with a lock-free or contention-avoiding alternative, and what are the options?

level: principalimportance: should knowfreq 48%

answer

  1. Avoid contention before managing it — and only after measuring
  2. CAS = retry loop, non-blocking, no parking
  3. Immutable > confined > read-mostly > atomic > LongAdder
  4. LongAdder beats AtomicLong on hot writes (striped cells)
  5. StampedLock optimistic read for read-heavy
  6. Lock-free can't serialize a genuine multi-var invariant

basics

~20 s

When a lock is a proven hotspot, you can avoid it instead of shrinking it. Options: immutable objects (nothing to lock), thread-confined data (each thread its own copy), read-mostly locks, atomic CAS classes like AtomicLong, or LongAdder for hot counters. They reduce or remove contention.

solid answer

~50 s

Once a lock is the measured bottleneck and you've already minimized its hold time and granularity, consider removing contention rather than managing it. The toolbox, roughly in order of preference: (1) immutability — if shared data never changes, no synchronization is needed at all; (2) thread confinement — give each thread its own copy (ThreadLocal) and merge later, eliminating sharing; (3) read-mostly locks — ReadWriteLock or StampedLock (with its optimistic read) let many readers proceed concurrently; (4) lock-free atomics — Atomic* classes use a CAS (compare-and-set) loop that retries on conflict instead of blocking, great under low-to-moderate contention; (5) contention-spreading structures — LongAdder/LongAccumulator keep per-thread cells and sum on demand, beating AtomicLong under heavy write contention; and ConcurrentHashMap for maps. Lock-free isn't free: CAS loops can livelock-ish 'spin and retry' under extreme contention and don't help if the operation truly must be serialized. Choose by access pattern (read-heavy vs write-heavy, contention level), and always validate with a benchmark.

code

java · 17 lines
java
// Hot counter under heavy write contention.
// AtomicLong: every increment CASes the SAME cell -> threads collide and retry.
AtomicLong slow = new AtomicLong();
slow.incrementAndGet();   // one shared cell; degrades under heavy contention

// LongAdder: per-thread striped cells -> writers rarely collide.
LongAdder fast = new LongAdder();
fast.increment();         // updates this thread's cell, almost no contention
long total = fast.sum();  // adds the cells when you actually need the value

// Lock-free update of a single value via a CAS retry loop:
AtomicReference<BigDecimal> ref = new AtomicReference<>(BigDecimal.ZERO);
BigDecimal cur, next;
do {
    cur = ref.get();
    next = cur.add(BigDecimal.ONE);   // recompute from the fresh value
} while (!ref.compareAndSet(cur, next));  // retry on conflict, never blocks

go deeper

for a junior

Knows alternatives like immutable objects and AtomicInteger exist and avoid some locking.

for a middle

Can pick AtomicLong/ConcurrentHashMap over synchronized for simple cases and knows ReadWriteLock helps read-heavy data.

for a senior

Understands the CAS retry model, when atomics beat locks and when they thrash, and matches LongAdder/StampedLock to the access pattern.

for a principal

Drives the measured decision across the full toolbox (immutability, confinement, read-mostly, atomics, contention-spreading), knows the limits (genuine serialization, ABA, maintainability), and validates with benchmarks and tail-latency analysis before adopting non-blocking designs.

## The decision gate Lock-free engineering is **powerful and dangerous** — it's harder to write, harder to reason about, and easy to get subtly wrong. So the rule of thumb is: reach for it only when (a) a lock is a **measured** bottleneck, and (b) you've **already** narrowed the critical section and considered finer granularity. At that point, the question becomes *can I avoid the shared mutation entirely, or replace blocking with non-blocking coordination?* ## Key concept: CAS (compare-and-set) Most lock-free Java code rests on **CAS**, a single hardware instruction exposed by `java.util.concurrent.atomic`. CAS says: 'atomically, if this memory still holds the value I expect, replace it with the new value; otherwise tell me you failed.' The typical pattern is a **retry loop**: ``` long cur, next; do { cur = atomic.get(); next = compute(cur); } while (!atomic.compareAndSet(cur, next)); ``` No thread is ever *blocked* — a losing thread just **retries** with the fresh value. This is **non-blocking**: a thread can't be stuck waiting on another thread that got descheduled while holding a lock. ## The toolbox, in order of preference **1. Immutability — the best 'lock' is no lock.** If the shared object never mutates after construction, multiple threads can read it freely with zero coordination (final fields are safely published). Reframe updates as 'replace the reference with a new immutable object' (copy-on-write style). No contention because there's no shared *mutation*. **2. Thread confinement.** If data needn't be shared, don't share it. Give each thread its own copy via **`ThreadLocal`** (or per-task local variables), then **combine** the partial results once at the end (map-reduce). Zero contention during the hot phase. This is exactly what `LongAdder` does internally. **3. Read-mostly: ReadWriteLock / StampedLock.** When the data is read far more than written, a `ReadWriteLock` lets **many readers** hold the read lock simultaneously (writers still exclusive). `StampedLock` adds an **optimistic read**: read without locking, then *validate* the stamp; if a writer intervened, fall back to a real read lock. Optimistic reads have almost no contention cost when writes are rare. (Caveat: `StampedLock` is non-reentrant and easy to misuse.) **4. Lock-free atomics (Atomic*).** `AtomicInteger/Long/Reference` and friends use the CAS loop above. Under **low-to-moderate** contention they outperform locks because there's no parking/unparking. Under **very high** write contention, though, many threads keep failing the CAS and retrying — wasted CPU — so a single hot `AtomicLong` can degrade. **5. Contention-spreading structures.** `LongAdder`/`LongAccumulator` solve the hot-counter problem by keeping **per-thread (striped) cells**; each thread updates its own cell (little or no contention), and `sum()` adds them when you need the total. For a high-write counter this dramatically beats `AtomicLong`. `ConcurrentHashMap` similarly spreads contention across bins. These are the go-to for write-heavy aggregation. ## When lock-free is the WRONG choice - **The operation must truly be serialized** (a real invariant across multiple variables that can't be expressed as one atomic CAS). Then you need a lock (or a single-writer design); a CAS loop can't make a genuinely sequential operation parallel. - **Extreme contention on one cell** makes CAS loops thrash — prefer contention-spreading (`LongAdder`) or back off to a different design. - **Complex multi-word updates** (lock-free linked structures, ABA hazards) are expert territory; a well-placed lock is often safer and fast enough. - **Readability/maintainability** matters: a clear lock that isn't a bottleneck beats a clever lock-free structure nobody on the team can safely modify. ## Choosing by access pattern | Pattern | Reach for | |---|---| | Never mutated | Immutability | | Per-thread accumulation | ThreadLocal + merge / LongAdder | | Read-heavy, rare writes | StampedLock optimistic read / ReadWriteLock | | Single value, moderate writes | AtomicLong/Reference (CAS) | | Hot counter, heavy writes | LongAdder / LongAccumulator | | Concurrent map | ConcurrentHashMap | | Genuine multi-var invariant | A (narrow, possibly striped) lock | ## Always validate Non-blocking does **not** automatically mean faster — it depends on contention level and access pattern. Prove the win with a **JMH benchmark** at realistic thread counts before and after, and re-check tail latency, not just throughput. The principal-level skill is matching the tool to the measured access pattern and resisting cleverness where a simple lock suffices.

  • Why does LongAdder outperform AtomicLong for a frequently incremented counter, and what's its trade-off?
    LongAdder keeps multiple per-thread striped cells, so concurrent increments hit different cells and rarely collide, avoiding the CAS retry storm a single shared AtomicLong cell suffers under heavy write contention. The trade-off: sum() must add all cells, so reads are more expensive and not a perfectly atomic point-in-time snapshot — ideal for write-heavy, read-rarely counters.
  • What is a situation where lock-free atomics cannot replace a lock?
    When an operation must keep an invariant across multiple variables atomic (e.g. transfer that decrements one account and increments another as one indivisible step), or any genuinely sequential operation. A single CAS only covers one word; you'd need a lock, an immutable swap of a combined object, or a single-writer design.

saying these in an interview costs you the question

  • Assuming lock-free is always faster than locking
  • Using a single AtomicLong as a hot counter instead of LongAdder
  • Trying to make a true multi-variable invariant lock-free with a single CAS
  • Reaching for lock-free before measuring or before narrowing the section
  • Ignoring StampedLock's non-reentrancy and misuse hazards

context