AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

17 Concurrency / Lesson 5 of 6

Locks, atomics and the concurrent collections

15 min · +10 XP · 2 exercises · 6 questions

Read the text15 min
Exercises0/2
Quiz6 q

Quick answer

What is the difference between ReentrantLock and synchronized?

ReentrantLock and synchronized give the same mutual exclusion and visibility in Java, and both are reentrant. ReentrantLock adds tryLock() and timed waits for backing out of deadlock, interruptible waiting, an optional fair ordering and several Condition queues per lock. The cost is manual release: unlock() must go in a finally block. Without those extras, synchronized is shorter.

synchronized is a blunt instrument: one lock, no timeout, no fairness, no way to try and back off. java.util.concurrent provides sharper ones. ReentrantLock is synchronized with options; ReadWriteLock lets many readers share; the atomic classes update a single variable without any lock at all, using the CPU's compare-and-swap; and the concurrent collections — ConcurrentHashMap above all — bake thread-safety into the data structure so most code never needs an explicit lock. This lesson is the toolbox, and the rule for reaching into it: the highest-level thing that fits.

ReentrantLock: synchronized with a manual

private final ReentrantLock lock = new ReentrantLock();     // or new ReentrantLock(true) for fairness

void transfer(Account from, Account to, int amount) {
    lock.lock();
    try {
        from.debit(amount); to.credit(amount);
    } finally {
        lock.unlock();                                       // ALWAYS in finally — an exception must not leak the lock
    }
}

What it adds over synchronized: tryLock() (return false instead of waiting — the way out of deadlock), tryLock(timeout, unit), lockInterruptibly() (a waiting thread can be interrupted), fairness (longest-waiting thread gets it next; slower, but no starvation), and multiple Conditions per lock (lock.newCondition(): separate "not empty" and "not full" queues instead of one notifyAll waking everyone). What it costs: you must unlock by hand, so the try/finally is not optional. When you need none of the extras, synchronized is shorter and the JIT optimises it well.

Deadlock avoidance with tryLock:

while (true) {
    if (a.lock.tryLock()) {
        try { if (b.lock.tryLock()) { try { doBoth(); return; } finally { b.lock.unlock(); } } }
        finally { a.lock.unlock(); }
    }
    Thread.onSpinWait();   // back off and retry (add a small random sleep in real code)
}

ReadWriteLock: many readers, one writer

private final ReadWriteLock rw = new ReentrantReadWriteLock();
V get(K k) { rw.readLock().lock(); try { return map.get(k); } finally { rw.readLock().unlock(); } }
void put(K k, V v) { rw.writeLock().lock(); try { map.put(k, v); } finally { rw.writeLock().unlock(); } }

Any number of readers may hold the read lock together; a writer needs the write lock exclusively. It pays when reads greatly outnumber writes and the guarded operation is not trivial; for a simple map, ConcurrentHashMap beats it. StampedLock (Java 8) is the faster, non-reentrant variant with optimistic reads for experts.

Atomics: lock-free single-variable updates

AtomicInteger hits = new AtomicInteger();
hits.incrementAndGet();                        // atomic ++, returns the new value
hits.addAndGet(5);
hits.compareAndSet(expected, newValue);        // the primitive everything is built on: CAS
hits.updateAndGet(x -> Math.max(x, candidate));       // any pure function, retried on contention
hits.accumulateAndGet(v, Integer::sum);
AtomicReference<Node> head = new AtomicReference<>();  // lock-free stacks and queues start here
AtomicLong, AtomicBoolean, AtomicIntegerArray

An atomic operation is a compare-and-swap loop: read the value, compute the new one, write it only if nothing changed in between, otherwise retry. No lock, no blocking, no deadlock; under heavy contention many threads retry, which is where LongAdder comes in — it keeps per-thread cells and sums them on sum(), so a hot counter (requests served) scales linearly. Use AtomicInteger when you need the current value after each update; LongAdder when you increment often and read rarely.

Atomics fix count++; they do not fix compound invariants across two variables (if (a.get() > 0) b.incrementAndGet() is still a race). For that, a lock.

ConcurrentHashMap: the workhorse

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge(word, 1, Integer::sum);                       // atomic per key: the thread-safe frequency count
counts.computeIfAbsent(key, k -> expensive(k));            // atomic: expensive() runs once per key
counts.putIfAbsent(k, v);
counts.getOrDefault(k, 0);
long total = counts.reduceValuesToLong(1, Integer::longValue, 0, Long::sum);   // parallel bulk ops

Internally the table is split so that updates to different buckets do not block each other (Java 8 replaced the old segment locks with per-bin CAS/synchronisation), reads never lock, and iteration is weakly consistent — it never throws ConcurrentModificationException, reflecting some state between start and end. The compound operations (merge, compute*, putIfAbsent) are atomic per key, which is exactly what makes the check-then-act races of a synchronised HashMap disappear. It rejects null keys and values, and size() is an estimate under concurrent updates. Never do if (!map.containsKey(k)) map.put(k, v) on it — the atomic method exists for that.

Collections.synchronizedMap(new HashMap<>()) is the old alternative: one lock around every call, no atomic compound ops, and iteration requires you to hold the lock yourself. Use it only to wrap something ConcurrentHashMap cannot replace (a TreeMap → ConcurrentSkipListMap exists for that too).

The other concurrent collections

NeedClassNote
sorted concurrent map/setConcurrentSkipListMap / ConcurrentSkipListSetlock-free; O(log n)
read-mostly list (listeners)CopyOnWriteArrayListevery write copies the array; iteration is a snapshot, never fails
producer–consumer queueArrayBlockingQueue (bounded), LinkedBlockingQueue, PriorityBlockingQueueput blocks when full, take when empty
hand-off with no bufferSynchronousQueuewhat newCachedThreadPool uses
delayed tasksDelayQueueelements become available at their time
lock-free queue/dequeConcurrentLinkedQueue / ConcurrentLinkedDequenon-blocking; poll returns null when empty

BlockingQueue is the backbone of the producer–consumer pattern (next lesson): producers put, consumers take, the queue does all the waiting and waking that wait/notify used to.

Choosing

  1. Can you avoid sharing? Confine to one thread, or make it immutable.
  2. Is it one variable? An atomic.
  3. Is it a map, list or queue? The concurrent collection.
  4. Is it a compound invariant across several fields? synchronized, or ReentrantLock if you need tryLock, conditions or fairness.
  5. Read-heavy with slow reads? ReadWriteLock.

In interviews, saying the order aloud is worth more than knowing every class.

Interview angle

  • "ReentrantLock versus synchronized?" Same mutual exclusion; the lock adds tryLock, timeouts, interruptible waits, fairness and multiple conditions, at the cost of manual unlock in finally.
  • "How does AtomicInteger work?" A compare-and-swap loop — no lock; LongAdder for hot counters.
  • "How is ConcurrentHashMap different from Hashtable/synchronizedMap?" Fine-grained per-bin locking, lock-free reads, atomic merge/compute*, weakly consistent iteration; not one global lock.
  • "Is ConcurrentHashMap.size() exact?" An estimate under concurrent modification.
  • "When CopyOnWriteArrayList?" Many reads, very few writes — listener lists.

Key takeaways

  • ReentrantLock when you need tryLock, timeouts, fairness or several Conditions; unlock() in finally, always.
  • Atomics are CAS loops: incrementAndGet, compareAndSet, updateAndGet; LongAdder for contended counters; they do not cover multi-variable invariants.
  • ConcurrentHashMap with merge/computeIfAbsent/putIfAbsent replaces every check-then-act race; no nulls, weakly consistent iteration.
  • BlockingQueue for producer–consumer; CopyOnWriteArrayList for read-mostly; ConcurrentSkipListMap for sorted.
  • Highest-level tool that fits: confine → immutable → atomic → concurrent collection → lock.

Common questions

How does AtomicInteger work in Java?

Through a compare-and-swap loop instead of a lock: read the value, compute the new one, write it only if nothing changed in between, and retry otherwise. There is no blocking and no deadlock; for a heavily contended counter that is read rarely, LongAdder scales better.

How is ConcurrentHashMap different from Hashtable and synchronizedMap?

Hashtable and Collections.synchronizedMap put one lock around every call. ConcurrentHashMap locks per bin, so updates to different buckets do not block each other; reads never lock, iteration never throws ConcurrentModificationException, and merge, compute and putIfAbsent are atomic per key.

Does ConcurrentHashMap allow null keys or values?

No: ConcurrentHashMap rejects null keys and null values with a NullPointerException. Its size() is also only an estimate while other threads are updating the map.

When should I use CopyOnWriteArrayList?

When reads vastly outnumber writes, as with a list of listeners. Every write copies the whole backing array, and every iteration walks a snapshot that never throws ConcurrentModificationException.

When should I use a ReadWriteLock in Java?

A ReadWriteLock lets any number of readers hold the read lock together while a writer holds the write lock alone. It pays when reads greatly outnumber writes and the guarded operation is not trivial; for a simple map, ConcurrentHashMap beats it.

Mark as readSign in to keep your place.

Java ReentrantLock, Atomics and ConcurrentHashMap Explained

Java's ReentrantLock adds tryLock, timeouts and fairness to synchronized, and AtomicInteger uses compare-and-swap. How ConcurrentHashMap works.

  • Course: Java study plan
  • Module: Concurrency
  • Kind: Lesson
  • Reading time: 15 min
  • Runtime: OpenJDK 18

What is the difference between ReentrantLock and synchronized?

ReentrantLock and synchronized give the same mutual exclusion and visibility in Java, and both are reentrant. ReentrantLock adds tryLock() and timed waits for backing out of deadlock, interruptible waiting, an optional fair ordering and several Condition queues per lock. The cost is manual release: unlock() must go in a finally block. Without those extras, synchronized is shorter.

Lesson

synchronized is a blunt instrument: one lock, no timeout, no fairness, no way to try and back off. java.util.concurrent provides sharper ones. ReentrantLock is synchronized with options; ReadWriteLock lets many readers share; the atomic classes update a single variable without any lock at all, using the CPU's compare-and-swap; and the concurrent collections — ConcurrentHashMap above all — bake thread-safety into the data structure so most code never needs an explicit lock. This lesson is the toolbox, and the rule for reaching into it: the highest-level thing that fits.

ReentrantLock: synchronized with a manual

private final ReentrantLock lock = new ReentrantLock();     // or new ReentrantLock(true) for fairness

void transfer(Account from, Account to, int amount) {
    lock.lock();
    try {
        from.debit(amount); to.credit(amount);
    } finally {
        lock.unlock();                                       // ALWAYS in finally — an exception must not leak the lock
    }
}

What it adds over synchronized: tryLock() (return false instead of waiting — the way out of deadlock), tryLock(timeout, unit), lockInterruptibly() (a waiting thread can be interrupted), fairness (longest-waiting thread gets it next; slower, but no starvation), and multiple Conditions per lock (lock.newCondition(): separate "not empty" and "not full" queues instead of one notifyAll waking everyone). What it costs: you must unlock by hand, so the try/finally is not optional. When you need none of the extras, synchronized is shorter and the JIT optimises it well.

Deadlock avoidance with tryLock:

while (true) {
    if (a.lock.tryLock()) {
        try { if (b.lock.tryLock()) { try { doBoth(); return; } finally { b.lock.unlock(); } } }
        finally { a.lock.unlock(); }
    }
    Thread.onSpinWait();   // back off and retry (add a small random sleep in real code)
}

ReadWriteLock: many readers, one writer

private final ReadWriteLock rw = new ReentrantReadWriteLock();
V get(K k) { rw.readLock().lock(); try { return map.get(k); } finally { rw.readLock().unlock(); } }
void put(K k, V v) { rw.writeLock().lock(); try { map.put(k, v); } finally { rw.writeLock().unlock(); } }

Any number of readers may hold the read lock together; a writer needs the write lock exclusively. It pays when reads greatly outnumber writes and the guarded operation is not trivial; for a simple map, ConcurrentHashMap beats it. StampedLock (Java 8) is the faster, non-reentrant variant with optimistic reads for experts.

Atomics: lock-free single-variable updates

AtomicInteger hits = new AtomicInteger();
hits.incrementAndGet();                        // atomic ++, returns the new value
hits.addAndGet(5);
hits.compareAndSet(expected, newValue);        // the primitive everything is built on: CAS
hits.updateAndGet(x -> Math.max(x, candidate));       // any pure function, retried on contention
hits.accumulateAndGet(v, Integer::sum);
AtomicReference<Node> head = new AtomicReference<>();  // lock-free stacks and queues start here
AtomicLong, AtomicBoolean, AtomicIntegerArray

An atomic operation is a compare-and-swap loop: read the value, compute the new one, write it only if nothing changed in between, otherwise retry. No lock, no blocking, no deadlock; under heavy contention many threads retry, which is where LongAdder comes in — it keeps per-thread cells and sums them on sum(), so a hot counter (requests served) scales linearly. Use AtomicInteger when you need the current value after each update; LongAdder when you increment often and read rarely.

Atomics fix count++; they do not fix compound invariants across two variables (if (a.get() > 0) b.incrementAndGet() is still a race). For that, a lock.

ConcurrentHashMap: the workhorse

ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
counts.merge(word, 1, Integer::sum);                       // atomic per key: the thread-safe frequency count
counts.computeIfAbsent(key, k -> expensive(k));            // atomic: expensive() runs once per key
counts.putIfAbsent(k, v);
counts.getOrDefault(k, 0);
long total = counts.reduceValuesToLong(1, Integer::longValue, 0, Long::sum);   // parallel bulk ops

Internally the table is split so that updates to different buckets do not block each other (Java 8 replaced the old segment locks with per-bin CAS/synchronisation), reads never lock, and iteration is weakly consistent — it never throws ConcurrentModificationException, reflecting some state between start and end. The compound operations (merge, compute*, putIfAbsent) are atomic per key, which is exactly what makes the check-then-act races of a synchronised HashMap disappear. It rejects null keys and values, and size() is an estimate under concurrent updates. Never do if (!map.containsKey(k)) map.put(k, v) on it — the atomic method exists for that.

Collections.synchronizedMap(new HashMap<>()) is the old alternative: one lock around every call, no atomic compound ops, and iteration requires you to hold the lock yourself. Use it only to wrap something ConcurrentHashMap cannot replace (a TreeMap → ConcurrentSkipListMap exists for that too).

The other concurrent collections

NeedClassNote
sorted concurrent map/setConcurrentSkipListMap / ConcurrentSkipListSetlock-free; O(log n)
read-mostly list (listeners)CopyOnWriteArrayListevery write copies the array; iteration is a snapshot, never fails
producer–consumer queueArrayBlockingQueue (bounded), LinkedBlockingQueue, PriorityBlockingQueueput blocks when full, take when empty
hand-off with no bufferSynchronousQueuewhat newCachedThreadPool uses
delayed tasksDelayQueueelements become available at their time
lock-free queue/dequeConcurrentLinkedQueue / ConcurrentLinkedDequenon-blocking; poll returns null when empty

BlockingQueue is the backbone of the producer–consumer pattern (next lesson): producers put, consumers take, the queue does all the waiting and waking that wait/notify used to.

Choosing

  1. Can you avoid sharing? Confine to one thread, or make it immutable.
  2. Is it one variable? An atomic.
  3. Is it a map, list or queue? The concurrent collection.
  4. Is it a compound invariant across several fields? synchronized, or ReentrantLock if you need tryLock, conditions or fairness.
  5. Read-heavy with slow reads? ReadWriteLock.

In interviews, saying the order aloud is worth more than knowing every class.

Interview angle

  • "ReentrantLock versus synchronized?" Same mutual exclusion; the lock adds tryLock, timeouts, interruptible waits, fairness and multiple conditions, at the cost of manual unlock in finally.
  • "How does AtomicInteger work?" A compare-and-swap loop — no lock; LongAdder for hot counters.
  • "How is ConcurrentHashMap different from Hashtable/synchronizedMap?" Fine-grained per-bin locking, lock-free reads, atomic merge/compute*, weakly consistent iteration; not one global lock.
  • "Is ConcurrentHashMap.size() exact?" An estimate under concurrent modification.
  • "When CopyOnWriteArrayList?" Many reads, very few writes — listener lists.

Key takeaways

  • ReentrantLock when you need tryLock, timeouts, fairness or several Conditions; unlock() in finally, always.
  • Atomics are CAS loops: incrementAndGet, compareAndSet, updateAndGet; LongAdder for contended counters; they do not cover multi-variable invariants.
  • ConcurrentHashMap with merge/computeIfAbsent/putIfAbsent replaces every check-then-act race; no nulls, weakly consistent iteration.
  • BlockingQueue for producer–consumer; CopyOnWriteArrayList for read-mostly; ConcurrentSkipListMap for sorted.
  • Highest-level tool that fits: confine → immutable → atomic → concurrent collection → lock.

Common questions

How does AtomicInteger work in Java?

Through a compare-and-swap loop instead of a lock: read the value, compute the new one, write it only if nothing changed in between, and retry otherwise. There is no blocking and no deadlock; for a heavily contended counter that is read rarely, LongAdder scales better.

How is ConcurrentHashMap different from Hashtable and synchronizedMap?

Hashtable and Collections.synchronizedMap put one lock around every call. ConcurrentHashMap locks per bin, so updates to different buckets do not block each other; reads never lock, iteration never throws ConcurrentModificationException, and merge, compute and putIfAbsent are atomic per key.

Does ConcurrentHashMap allow null keys or values?

No: ConcurrentHashMap rejects null keys and null values with a NullPointerException. Its size() is also only an estimate while other threads are updating the map.

When should I use CopyOnWriteArrayList?

When reads vastly outnumber writes, as with a list of listeners. Every write copies the whole backing array, and every iteration walks a snapshot that never throws ConcurrentModificationException.

When should I use a ReadWriteLock in Java?

A ReadWriteLock lets any number of readers hold the read lock together while a writer holds the write lock alone. It pays when reads greatly outnumber writes and the guarded operation is not trivial; for a simple map, ConcurrentHashMap beats it.

Exercises

Concurrent word count

Read an integer n and n words, then a thread count k. Thread i handles the words at indexes i, i + k, i + 2k, … and counts each with ConcurrentHashMap.merge(word, 1, Integer::sum) — atomic per key, no explicit lock. Join all threads, then print <word>=<count> for every word in natural order, and distinct=<map size>.

Example: 6 then b a b c a b, then 2 →

a=2
b=3
c=1
distinct=3

Atomics, no locks

Read k (threads), m (rounds), then k integers — one value per thread. Every thread performs m rounds; in each round it calls count.incrementAndGet() on a shared AtomicInteger, sum.add(value) on a shared LongAdder, and max.accumulateAndGet(value, Math::max) on a shared AtomicInteger. Join all, then print count=<k×m> sum=<m × Σvalues> max=<largest value>.

Example: 3 4 then 5 -2 9 →

count=12 sum=48 max=9

In this module: Concurrency

← Executors, thread pools and Futures · CompletableFuture and the coordination tools →