Concurrent Utilities

Locks & Conditions

15 min Lesson 8 of 13

Locks & Conditions

The synchronized keyword has served Java developers since 1995, but it has real limitations: you cannot time out while waiting for a lock, you cannot interrupt a waiting thread, and a single monitor object supports only one wait-set. The java.util.concurrent.locks package, introduced in Java 5, solves all of these problems by exposing locking as an explicit object you can program against.

Why Not Just Use synchronized?

Before looking at the new APIs, it helps to be precise about what synchronized cannot do:

  • No timed acquisition — a thread that calls a synchronized method blocks indefinitely.
  • No interruptible lock waits — you cannot cancel a thread that is waiting to acquire a monitor.
  • One condition per lockwait()/notifyAll() are tied to the object itself; you cannot have separate "not full" and "not empty" wait-sets on the same lock.
  • No read/write distinction — every access is exclusive, even pure reads.

ReentrantLock — The Direct Replacement

ReentrantLock behaves like synchronized but gives you programmatic control. The most important rule: always release the lock in a finally block so it is released even if an exception is thrown.

import java.util.concurrent.locks.ReentrantLock; public class Counter { private final ReentrantLock lock = new ReentrantLock(); private int count = 0; public void increment() { lock.lock(); try { count++; } finally { lock.unlock(); // ALWAYS in finally } } public int get() { lock.lock(); try { return count; } finally { lock.unlock(); } } }
Never forget the finally block. If an exception escapes between lock.lock() and the try body, the lock is never released and every other thread blocks forever. Always pair lock.lock() with try { ... } finally { lock.unlock(); }.

Timed and interruptible acquisition are where ReentrantLock clearly outperforms synchronized:

import java.util.concurrent.TimeUnit; import java.util.concurrent.locks.ReentrantLock; public class ResourceManager { private final ReentrantLock lock = new ReentrantLock(); // Returns false instead of blocking forever public boolean tryProcess() throws InterruptedException { boolean acquired = lock.tryLock(500, TimeUnit.MILLISECONDS); if (!acquired) { System.out.println("Could not acquire lock in time — skipping"); return false; } try { // do the work return true; } finally { lock.unlock(); } } // Respects Thread.interrupt() public void interruptibleWork() throws InterruptedException { lock.lockInterruptibly(); try { // do the work } finally { lock.unlock(); } } }

The fairness flag is another lever: new ReentrantLock(true) grants the lock to the longest-waiting thread. This prevents starvation but reduces throughput — threads queue up instead of competing.

ReadWriteLock — Separating Reads from Writes

Many data structures are read far more often than they are written. ReentrantReadWriteLock maintains two locks from a single object: a read lock that multiple threads can hold simultaneously, and a write lock that is exclusive.

import java.util.HashMap; import java.util.Map; import java.util.concurrent.locks.ReadWriteLock; import java.util.concurrent.locks.ReentrantReadWriteLock; public class CachedData { private final Map<String, String> cache = new HashMap<>(); private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); public String get(String key) { rwLock.readLock().lock(); // many readers allowed at once try { return cache.get(key); } finally { rwLock.readLock().unlock(); } } public void put(String key, String value) { rwLock.writeLock().lock(); // exclusive — blocks all readers and writers try { cache.put(key, value); } finally { rwLock.writeLock().unlock(); } } }
When does ReadWriteLock pay off? It wins when reads are frequent and writes are rare, and when the work inside the lock is non-trivial (so the overhead of two lock objects is worthwhile). For very short critical sections, a plain ReentrantLock or even synchronized can be faster because ReadWriteLock has more internal bookkeeping.

StampedLock — The Modern Alternative

Java 8 introduced StampedLock, which adds an optimistic read mode. An optimistic read does not block writers at all — it reads without acquiring any lock, then validates a stamp to check whether a write happened during the read. Only if validation fails does it fall back to a full read lock.

import java.util.concurrent.locks.StampedLock; public class Point { private double x, y; private final StampedLock sl = new StampedLock(); public void move(double deltaX, double deltaY) { long stamp = sl.writeLock(); try { x += deltaX; y += deltaY; } finally { sl.unlockWrite(stamp); } } public double distanceFromOrigin() { long stamp = sl.tryOptimisticRead(); // no lock acquired double curX = x, curY = y; if (!sl.validate(stamp)) { // a write happened — retry with a real lock stamp = sl.readLock(); try { curX = x; curY = y; } finally { sl.unlockRead(stamp); } } return Math.sqrt(curX * curX + curY * curY); } }

Conditions — Multiple Wait-Sets on One Lock

A Condition is what you get instead of wait()/notify() when you use explicit locks. You create one per logical predicate; a single lock can have many conditions, which eliminates spurious wakeups of threads that were not waiting for the predicate that just became true.

import java.util.ArrayDeque; import java.util.Queue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; public class BoundedQueue<T> { private final Queue<T> queue = new ArrayDeque<>(); private final int capacity; private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); public BoundedQueue(int capacity) { this.capacity = capacity; } public void put(T item) throws InterruptedException { lock.lock(); try { while (queue.size() == capacity) { notFull.await(); // releases lock while waiting } queue.add(item); notEmpty.signal(); // wake exactly one waiting consumer } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } T item = queue.poll(); notFull.signal(); // wake exactly one waiting producer return item; } finally { lock.unlock(); } } }
Always re-check the predicate in a loop. Even with Condition.await(), spurious wakeups are possible. The canonical pattern is while (!condition) { await(); }, never if (!condition) { await(); }.

Choosing the Right Tool

  • synchronized — simple critical sections with no timeout, cancellation, or multiple conditions needed. Prefer it for its clarity and JVM optimizations (biased locking, lock elision).
  • ReentrantLock — when you need timed or interruptible lock acquisition, or multiple Condition objects on the same lock.
  • ReadWriteLock — read-heavy workloads with infrequent writes and meaningful work inside the lock.
  • StampedLock — maximum throughput on read-dominated data, when you are comfortable with the more complex API and the fact that it is not reentrant.

Summary

ReentrantLock gives you everything synchronized does plus timed acquisition, interruptibility, and multiple Condition objects. ReadWriteLock boosts concurrency when reads dominate. StampedLock pushes further with optimistic reads. Use explicit locks only when the simpler tools are not sufficient — the added flexibility comes with added responsibility to release locks correctly.