Lesson 25 of 71
Locks and Synchronizers
The java.util.concurrent.locks package and the synchronizers in java.util.concurrent give control that synchronized does not: timed and interruptible lock attempts, several wait queues per lock, shared read access, and coordination between groups of threads. This lesson builds on Threads, synchronized, wait and notify. After it, a reader can pick the right tool for a coordination problem and explain how it is built.
| Tool | Purpose |
|---|---|
ReentrantLock |
Mutual exclusion with tryLock, timeouts, fairness, interruptible waits |
Condition |
Named wait queues attached to a Lock |
ReentrantReadWriteLock |
Many readers or one writer |
StampedLock |
Read/write lock with optimistic reads, not reentrant |
| AQS | The framework most of the above are built on |
Semaphore |
Limit the number of concurrent holders of a permit |
CountDownLatch |
Wait until N events happen, one-shot |
CyclicBarrier |
N threads wait for each other, reusable |
Phaser |
Barrier with a changing number of parties and phases |
Every example joins its threads, or waits on a synchronizer, before printing results, so the output does not depend on scheduling.
Example 1: ReentrantLock as a replacement for synchronized
A ReentrantLock is a mutual-exclusion lock that the same thread can acquire again without blocking itself. The unlock call always goes in a finally block, because unlike synchronized the lock is not released when an exception leaves the block.
import java.util.concurrent.locks.ReentrantLock;
public class LockCounter {
private final ReentrantLock lock = new ReentrantLock();
private long count;
void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public static void main(String[] args) throws InterruptedException {
LockCounter c = new LockCounter();
Runnable work = () -> { for (int i = 0; i < 100_000; i++) c.increment(); };
Thread a = new Thread(work), b = new Thread(work);
a.start(); b.start();
a.join(); b.join();
System.out.println("count = " + c.count);
c.lock.lock();
c.lock.lock(); // reentrant: same thread, no deadlock
System.out.println("hold count = " + c.lock.getHoldCount());
c.lock.unlock();
c.lock.unlock();
System.out.println("locked = " + c.lock.isLocked());
}
}count = 200000
hold count = 2
locked = falseThe lock keeps a hold count. Each lock() by the owner increments it and each unlock() decrements it; the lock is free at zero. Calling unlock() from a thread that does not own the lock throws IllegalMonitorStateException.
Example 2: tryLock, timeouts, and interruptible waits
tryLock() returns at once with true or false. tryLock(timeout, unit) waits up to the timeout and responds to interruption. lockInterruptibly() waits without a limit but throws InterruptedException when the thread is interrupted. A thread blocked on synchronized cannot be interrupted out of the wait; these methods can.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class TryLockDemo {
public static void main(String[] args) throws Exception {
ReentrantLock lock = new ReentrantLock();
CountDownLatch held = new CountDownLatch(1);
CountDownLatch release = new CountDownLatch(1);
Thread owner = new Thread(() -> {
lock.lock();
try {
held.countDown();
release.await();
} catch (InterruptedException ignored) {
} finally {
lock.unlock();
}
});
owner.start();
held.await();
System.out.println("tryLock: " + lock.tryLock());
System.out.println("timed tryLock: " + lock.tryLock(50, TimeUnit.MILLISECONDS));
Thread waiter = new Thread(() -> {
try {
lock.lockInterruptibly();
lock.unlock();
System.out.println("waiter got lock");
} catch (InterruptedException e) {
System.out.println("waiter interrupted");
}
});
waiter.start();
waiter.interrupt();
waiter.join();
release.countDown();
owner.join();
System.out.println("after release: " + lock.tryLock());
lock.unlock();
}
}tryLock: false
timed tryLock: false
waiter interrupted
after release: trueThe waiter is interrupted while the owner still holds the lock, so it cannot succeed. If the interrupt arrives before lockInterruptibly() is called, the method throws at once because it checks the interrupt status first. A CountDownLatch (Example 6) is used here only to make the order of steps fixed.
Timed tryLock is the standard way to avoid deadlock when a thread needs two locks: if the second lock is not acquired in time, release the first and retry. This removes the "hold and wait" condition from the four deadlock conditions.
Fairness. new ReentrantLock(true) creates a fair lock: waiting threads acquire it in roughly arrival order. The default is unfair, which lets a newly arriving thread take a just-released lock before queued threads ("barging"). Unfair locks have higher throughput because the lock does not sit idle while a parked thread wakes up; fair locks reduce starvation at a throughput cost. The untimed tryLock() barges even on a fair lock.
Example 3: Condition for a bounded buffer
A Condition is a wait queue bound to a Lock, created with lock.newCondition(). await() releases the lock and waits; signal() wakes one waiter. A lock can have several conditions, so producers and consumers wait in separate queues, which wait/notify on one monitor cannot do.
import java.util.ArrayDeque;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedBuffer<T> {
private final ArrayDeque<T> items = new ArrayDeque<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
BoundedBuffer(int capacity) { this.capacity = capacity; }
void put(T item) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) notFull.await(); // loop: spurious wakeups
items.addLast(item);
notEmpty.signal();
} finally { lock.unlock(); }
}
T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) notEmpty.await();
T item = items.removeFirst();
notFull.signal();
return item;
} finally { lock.unlock(); }
}
public static void main(String[] args) throws InterruptedException {
BoundedBuffer<Integer> buf = new BoundedBuffer<>(2);
long[] sum = new long[1];
Thread producer = new Thread(() -> {
try { for (int i = 1; i <= 1000; i++) buf.put(i); } catch (InterruptedException ignored) {}
});
Thread consumer = new Thread(() -> {
try { for (int i = 0; i < 1000; i++) sum[0] += buf.take(); } catch (InterruptedException ignored) {}
});
producer.start(); consumer.start();
producer.join(); consumer.join();
System.out.println("sum = " + sum[0]);
}
}sum = 500500The while loop around await() is required for the same reason as with wait(): spurious wakeups are allowed, and another thread may change the state between the signal and the reacquisition of the lock. join() makes the consumer's write to sum[0] visible to main.
Example 4: ReentrantReadWriteLock for a read-mostly cache
A read-write lock has a shared read lock and an exclusive write lock. Any number of threads can hold the read lock when no thread holds the write lock. It pays off when reads are frequent, writes rare, and the critical section is long enough to outweigh the extra bookkeeping.
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class RwCache {
private final Map<String, String> map = new HashMap<>();
private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
String get(String key) {
rw.readLock().lock();
try { return map.get(key); } finally { rw.readLock().unlock(); }
}
void put(String key, String value) {
rw.writeLock().lock();
try { map.put(key, value); } finally { rw.writeLock().unlock(); }
}
public static void main(String[] args) throws InterruptedException {
RwCache cache = new RwCache();
cache.put("EURUSD", "1.0850");
rwDemo(cache);
System.out.println(cache.get("EURUSD"));
}
static void rwDemo(RwCache cache) throws InterruptedException {
cache.rw.readLock().lock();
Thread other = new Thread(() -> {
System.out.println("second reader allowed: " + cache.rw.readLock().tryLock());
cache.rw.readLock().unlock();
System.out.println("writer allowed: " + cache.rw.writeLock().tryLock());
});
other.start();
other.join();
cache.rw.readLock().unlock();
cache.rw.writeLock().lock();
cache.rw.readLock().lock(); // downgrade: write -> read is allowed
cache.rw.writeLock().unlock();
System.out.println("downgraded, read holds = " + cache.rw.getReadHoldCount());
cache.rw.readLock().unlock();
}
}second reader allowed: true
writer allowed: false
downgraded, read holds = 1
1.0850A thread holding the write lock may acquire the read lock and then release the write lock (downgrading). The reverse, upgrading from read to write, is not supported: a thread that holds the read lock and calls writeLock().lock() blocks forever, because it waits for all readers to leave, including itself.
Example 5: StampedLock and optimistic reads
StampedLock (Java 8) returns a long stamp from each acquire. Its optimistic read mode takes no lock at all: tryOptimisticRead() returns a stamp, the thread reads fields into locals, and validate(stamp) reports whether a write happened in between. If validation fails, the code falls back to a real read lock.
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
void move(double dx, double dy) {
long stamp = sl.writeLock();
try { x += dx; y += dy; } finally { sl.unlockWrite(stamp); }
}
double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead();
double cx = x, cy = y; // copy into locals
if (!sl.validate(stamp)) { // a writer intervened
stamp = sl.readLock();
try { cx = x; cy = y; } finally { sl.unlockRead(stamp); }
}
return Math.sqrt(cx * cx + cy * cy);
}
public static void main(String[] args) {
Point p = new Point();
p.move(3, 4);
System.out.println(p.distanceFromOrigin());
long s = p.sl.tryOptimisticRead();
p.move(1, 1);
System.out.println("valid after write: " + p.sl.validate(s));
}
}5.0
valid after write: falseOptimistic reads remove the write to shared lock state that a read lock needs, so readers on different cores do not contend on one cache line. The rules are strict:
- Read fields into locals and use only the locals after
validatesucceeds. Values read before validation may be inconsistent. - Do not follow references or call methods that can fail on inconsistent data before validating.
StampedLockis not reentrant. A thread that callswriteLock()while holding a stamp deadlocks itself.- It has no
Conditionsupport.
Example 6: AQS, Semaphore, and CountDownLatch
AbstractQueuedSynchronizer (AQS) is the base class behind ReentrantLock, ReentrantReadWriteLock, Semaphore, and CountDownLatch. It holds one volatile int state, changes it with compare-and-set, and keeps a FIFO queue of waiting threads that it parks and unparks with LockSupport. A subclass defines what state means by overriding tryAcquire/tryRelease (exclusive) or tryAcquireShared/tryReleaseShared (shared). For ReentrantLock, state is the hold count; for Semaphore, the number of free permits; for CountDownLatch, the remaining count. StampedLock and Phaser do not use AQS.
A Semaphore holds a number of permits. acquire() takes one or blocks; release() returns one. A CountDownLatch starts at N; countDown() decrements it and await() blocks until it reaches zero. A latch cannot be reset.
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Semaphore;
public class SemaphoreLatch {
public static void main(String[] args) throws InterruptedException {
Semaphore permits = new Semaphore(3); // at most 3 concurrent "connections"
CountDownLatch done = new CountDownLatch(10);
Object stats = new Object(); // guards active and maxActive
int[] active = {0}, maxActive = {0};
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
permits.acquire();
try {
synchronized (stats) {
active[0]++;
maxActive[0] = Math.max(maxActive[0], active[0]);
}
Thread.sleep(10);
synchronized (stats) { active[0]--; }
} finally {
permits.release();
}
} catch (InterruptedException ignored) {
} finally {
done.countDown();
}
}).start();
}
done.await();
synchronized (stats) {
System.out.println("max concurrent <= 3: " + (maxActive[0] <= 3));
}
System.out.println("permits left: " + permits.availablePermits());
}
}max concurrent <= 3: true
permits left: 3The program prints a property ("at most 3") instead of the exact maximum, because the exact value depends on scheduling. The counters are one-element arrays because a lambda can capture only effectively final locals; synchronized (stats) makes each update atomic. Atomic Classes and CAS shows a lock-free way to keep such counters.
A semaphore has no owner: any thread may call release(), and releasing more than was acquired raises the permit count above its starting value. That makes it a counter of capacity, not a lock.
Example 7: CyclicBarrier and Phaser for phased computation
A CyclicBarrier makes a fixed number of threads (parties) wait until all of them call await(). It then runs an optional barrier action once and resets for the next round. A Phaser does the same with a party count that can change: threads register() and arriveAndDeregister() at run time, and the phase number advances each round.
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Phaser;
public class Phases {
public static void main(String[] args) throws Exception {
long[] total = {0}; // guarded by synchronized (total)
CyclicBarrier barrier = new CyclicBarrier(4, () -> {
synchronized (total) { System.out.println("round done, total = " + total[0]); }
});
Thread[] workers = new Thread[4];
for (int w = 0; w < 4; w++) {
workers[w] = new Thread(() -> {
try {
for (int round = 0; round < 2; round++) {
synchronized (total) { total[0] += 10; }
barrier.await();
}
} catch (Exception e) { throw new RuntimeException(e); }
});
workers[w].start();
}
for (Thread t : workers) t.join();
Phaser phaser = new Phaser(1); // main is registered
for (int i = 0; i < 3; i++) {
phaser.register();
new Thread(() -> {
phaser.arriveAndAwaitAdvance(); // phase 0
phaser.arriveAndDeregister(); // leave during phase 1
}).start();
}
int phase = phaser.arriveAndAwaitAdvance();
System.out.println("phaser now in phase " + phase);
phase = phaser.arriveAndAwaitAdvance();
System.out.println("phaser now in phase " + phase
+ ", parties = " + phaser.getRegisteredParties());
phaser.arriveAndDeregister();
}
}round done, total = 40
round done, total = 80
phaser now in phase 1
phaser now in phase 2, parties = 1The barrier action runs in the last thread to arrive, before any thread is released, so total is exactly 40 and then 80. If one party is interrupted or times out, the barrier becomes broken and every other waiter gets BrokenBarrierException.
Example 8: Choosing a tool
The table maps the problem to the tool. The criteria are the shape of the coordination (exclusion, counting, waiting for events, waiting for peers) and the read/write ratio.
| Need | Tool | Reason |
|---|---|---|
| Short critical section, no extras | synchronized |
Simplest; released automatically |
Timeout, interruptible wait, or tryLock |
ReentrantLock |
synchronized has none of these |
| Several wait conditions on one lock | ReentrantLock + Condition |
One queue per condition |
| Read-mostly, long reads | ReentrantReadWriteLock |
Readers run in parallel |
| Read-mostly, very short reads, hot path | StampedLock optimistic read |
No shared write on the read path |
| Limit concurrent access to N | Semaphore |
Permits count capacity |
| Wait for N one-time events | CountDownLatch |
One-shot, cannot reset |
| N threads meet each round | CyclicBarrier |
Reusable, barrier action |
| Parties join and leave between rounds | Phaser |
Dynamic registration |
import java.util.concurrent.CountDownLatch;
public class StartGate {
public static void main(String[] args) throws InterruptedException {
CountDownLatch startGate = new CountDownLatch(1);
CountDownLatch endGate = new CountDownLatch(5);
int[] finished = {0}; // guarded by synchronized (finished)
for (int i = 0; i < 5; i++) {
new Thread(() -> {
try {
startGate.await(); // all start together
synchronized (finished) { finished[0]++; }
} catch (InterruptedException ignored) {
} finally { endGate.countDown(); }
}).start();
}
startGate.countDown();
endGate.await();
synchronized (finished) { System.out.println("finished = " + finished[0]); }
}
}finished = 5This start-gate/end-gate pattern is common in load tests: one latch releases all workers at once, another lets the caller wait until all finish. The same pattern works with a thread pool, covered in Executors and CompletableFuture.
Interview points
ReentrantLockaddstryLock, timed and interruptible acquisition, fairness, and multipleConditions; it must be unlocked infinally.- Unfair locks allow barging and give higher throughput; fair locks reduce starvation and cost throughput.
Condition.await()must sit in awhileloop, likewait().- Read-write locks allow downgrade (write to read), not upgrade; an upgrade attempt deadlocks.
StampedLockoptimistic reads take no lock; validate before using values; the lock is not reentrant and has no conditions.- AQS is a
volatile int state, CAS, and a FIFO queue of parked threads; subclasses define the meaning ofstate. - A
Semaphorehas no owner; aCountDownLatchis one-shot; aCyclicBarrieris reusable and breaks if one party fails; aPhasersupports dynamic parties.
Summary
ReentrantLockkeeps a hold count and an owner thread.- Timed
tryLockbreaks the hold-and-wait deadlock condition. Conditiongives one wait queue per predicate on a single lock.ReentrantReadWriteLockpermits many readers or one writer.StampedLock.tryOptimisticReadplusvalidatereads without locking.ReentrantLock,ReentrantReadWriteLock,Semaphore, andCountDownLatchare built on AQS.CountDownLatchwaits for events,CyclicBarrierandPhaserwait for peers.
