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
| Need | Class | Note |
|---|---|---|
| sorted concurrent map/set | ConcurrentSkipListMap / ConcurrentSkipListSet | lock-free; O(log n) |
| read-mostly list (listeners) | CopyOnWriteArrayList | every write copies the array; iteration is a snapshot, never fails |
| producer–consumer queue | ArrayBlockingQueue (bounded), LinkedBlockingQueue, PriorityBlockingQueue | put blocks when full, take when empty |
| hand-off with no buffer | SynchronousQueue | what newCachedThreadPool uses |
| delayed tasks | DelayQueue | elements become available at their time |
| lock-free queue/deque | ConcurrentLinkedQueue / ConcurrentLinkedDeque | non-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
- Can you avoid sharing? Confine to one thread, or make it immutable.
- Is it one variable? An atomic.
- Is it a map, list or queue? The concurrent collection.
- Is it a compound invariant across several fields?
synchronized, orReentrantLockif you needtryLock, conditions or fairness. - Read-heavy with slow reads?
ReadWriteLock.
In interviews, saying the order aloud is worth more than knowing every class.
Interview angle
- "
ReentrantLockversussynchronized?" Same mutual exclusion; the lock addstryLock, timeouts, interruptible waits, fairness and multiple conditions, at the cost of manualunlockinfinally. - "How does
AtomicIntegerwork?" A compare-and-swap loop — no lock;LongAdderfor hot counters. - "How is
ConcurrentHashMapdifferent fromHashtable/synchronizedMap?" Fine-grained per-bin locking, lock-free reads, atomicmerge/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
ReentrantLockwhen you needtryLock, timeouts, fairness or severalConditions;unlock()infinally, always.- Atomics are CAS loops:
incrementAndGet,compareAndSet,updateAndGet;LongAdderfor contended counters; they do not cover multi-variable invariants. ConcurrentHashMapwithmerge/computeIfAbsent/putIfAbsentreplaces every check-then-act race; no nulls, weakly consistent iteration.BlockingQueuefor producer–consumer;CopyOnWriteArrayListfor read-mostly;ConcurrentSkipListMapfor 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=3Atomics, 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=9In this module: Concurrency
← Executors, thread pools and Futures · CompletableFuture and the coordination tools →