skip to content

locks and AQS

ReentrantLock, ReadWriteLock, StampedLock, and the queue under all of them.

chapter
chapter 09.07
reading time
9 min read

java.util.concurrent.locks gives you locks as objects. ReentrantLock does what synchronized does, plus try-with-timeout, interruptible waiting, fairness and multiple **Condition**s. ReadWriteLock lets many readers in at once. StampedLock lets readers skip locking entirely and check afterwards. nearly all of them are built on one class: AbstractQueuedSynchronizer (AQS), an int plus a queue of parked threads.

the picture

synchronized is the door with a built-in lock: simple, automatic, can't forget to unlock it, can't do anything clever. ReentrantLock is the same door with a keypad. you have to remember to lock it on your way out (there's no block to do it for you), but you get options: "try the door and walk away if it's locked", "wait at most 100 ms", "stop waiting if someone calls me", "first come, first served".

the rule that comes with the keypad: unlock in finally, every time.

Counter.java
private final ReentrantLock lock = new ReentrantLock();
private int count;
 
void increment() {
    lock.lock();
    try {
        count++;
    } finally {
        lock.unlock();   // runs even if the body throws
    }
}

forget the finally, throw once, and that lock is held forever by a thread that's moved on with its life. every other thread that touches it parks for good.

what you get over synchronized

synchronizedReentrantLock
mutual exclusion + visibilityyesyes (same memory semantics)
reentrantyesyes (getHoldCount() tells you)
unlockautomatic at the end of the blockmanual, in finally
try without blockingnotryLock()
give up after a timeoutnotryLock(100, MILLISECONDS)
stop waiting when interruptednolockInterruptibly()
fair orderingnonew ReentrantLock(true)
several wait queuesno (one wait set)newCondition(), as many as you like
shows up in thread dumps asBLOCKED, waiting to lock <0x...>WAITING (parking), parking to wait for <0x...>

two of those, for real. the lock is held by main the whole time:

Main.java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
 
void main() throws InterruptedException {
    ReentrantLock lock = new ReentrantLock();
    lock.lock();                                         // main holds it and never lets go
    Thread impatient = Thread.ofPlatform().start(() -> {
        try {
            boolean got = lock.tryLock(100, TimeUnit.MILLISECONDS);
            IO.println("got the lock? " + got);          // false, after 100 ms
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    });
    impatient.join();
 
    Thread stuck = Thread.ofPlatform().start(() -> {
        try {
            lock.lockInterruptibly();                    // waits... until interrupted
        } catch (InterruptedException e) {
            IO.println("gave up waiting: interrupted");
        }
    });
    Thread.sleep(100);
    stuck.interrupt();
    stuck.join();
    lock.unlock();
}
output, from jdk 25
got the lock? false
gave up waiting: interrupted

this one needs a real JVM (java.util.concurrent synchronizers), so it doesn't run in the browser. copy it into java Main.java to try it.

a thread stuck entering a synchronized block ignores interrupts completely. that difference is what lets you build deadlock escape hatches and cancellable work with explicit locks.

the queue underneath: AQS

ReentrantLock, ReentrantReadWriteLock, Semaphore, CountDownLatch and even the worker threads inside ThreadPoolExecutor all extend one class, AbstractQueuedSynchronizer. it holds:

  • state, a single volatile int. what it means is up to the subclass: hold count for a lock, permits for a semaphore, the remaining count for a latch.
  • a wait queue, a linked list of nodes for threads that couldn't get in, based on the CLH lock (Craig, Landin and Hagersten). enqueueing is one CAS on tail.
  • park / unpark (LockSupport) to put queued threads to sleep and wake them, with no synchronized anywhere.

acquiring is "CAS the state, and if that fails, join the queue and park". releasing is "update the state, and unpark the first one in line". here's a ReentrantLock, opened up:

new ReentrantLock(
)
state0
ownernull
wait queue (CLH variant)
headdummy
← head is also the tail
T1
T2
T3
T4

state = 0 means unlocked. state > 0 is the hold count. the queue starts empty (just a dummy head).

lock with T1, then lock with T2 and T3: they link into the queue and park. unlock T1 and T2 is woken, but before it runs, have T4 call lock(): in a non-fair lock T4 barges straight past the queue. switch to new ReentrantLock(true) and try the same thing.

that barge is not a bug. it's why the default lock is non-fair: the thread that's already running can take a free lock immediately, while waking a parked thread takes microseconds. letting the runner in keeps throughput up. a fair lock hands the lock over in queue order every time, which means a context switch on almost every handoff. it's much slower under contention, and it still doesn't guarantee fair scheduling: the OS decides who actually runs.

many readers, one writer

lots of shared state is read constantly and written rarely (config, caches, routing tables). a plain lock makes readers wait for each other for no reason. a ReadWriteLock has two locks that cooperate: any number of threads can hold the read lock together, but the write lock is exclusive, against readers and other writers.

holding the lockfree
nobody
queue (first on the left)
empty

readers share the lock. the writer needs it alone.

take read locks with R1 and R2: they share. now ask for the write lock: W queues until every reader leaves. then try R3: even though readers are inside, it queues behind W, otherwise a stream of readers could starve the writer forever.
Config.java
private final ReadWriteLock rw = new ReentrantReadWriteLock();
private Map<String, String> settings = new HashMap<>();
 
String get(String key) {
    rw.readLock().lock();
    try { return settings.get(key); }
    finally { rw.readLock().unlock(); }
}
 
void put(String key, String value) {
    rw.writeLock().lock();
    try { settings.put(key, value); }
    finally { rw.writeLock().unlock(); }
}

the read lock isn't free (every acquire still CASes a shared count, which bounces a cache line between cores), so it only pays off when reads are long or very frequent compared to writes. for a map, a ConcurrentHashMap usually beats both. and for "read often, replace rarely", a volatile reference to an immutable map beats everything.

StampedLock: read first, check later

StampedLock (java 8) adds an optimistic read: don't lock at all, copy the fields you need, then ask "did any writer get in while I was reading?". if not, you're done, having written nothing to shared memory. if yes, retry with a real read lock.

reader
writer
sharedversion
x
y
·
25634

a StampedLock is, underneath, mostly a version number. the point is at (3, 4).

step 1/10
the stamp is a version number. the reader copies x, a writer sneaks in, the reader copies y, and ends up holding a point that never existed. validate catches it, and the reader falls back to a real read lock. the stamps are real: 256, then 512 after one write, on jdk 25.
Main.java
import java.util.concurrent.locks.StampedLock;
 
class Point {
    private final StampedLock lock = new StampedLock();
    private double x, y;
 
    void move(double dx, double dy) {
        long stamp = lock.writeLock();
        try {
            x += dx;
            y += dy;
        } finally {
            lock.unlockWrite(stamp);
        }
    }
 
    double distanceFromOrigin() {
        long stamp = lock.tryOptimisticRead();   // no lock taken, just a version number
        double cx = x, cy = y;                   // copy the fields
        if (!lock.validate(stamp)) {             // did a writer sneak in?
            stamp = lock.readLock();             // yes: fall back to a real read lock
            try {
                cx = x;
                cy = y;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return Math.sqrt(cx * cx + cy * cy);
    }
}
 
void main() {
    Point p = new Point();
    p.move(3, 4);
    IO.println(p.distanceFromOrigin());
}
output, from jdk 25
5.0

this one needs a real JVM (java.util.concurrent synchronizers), so it doesn't run in the browser. copy it into java Main.java to try it.

conditions: wait sets with names

a monitor has exactly one wait set, which is why the bounded buffer needed notifyAll. a Lock can create as many Condition objects as you need, each its own queue: await() is wait(), signal() is notify(), signalAll() is notifyAll().

the same four threads, waiting. on the left, one mixed wait set. on the right, producers and consumers wait on separate conditions, so a signal() can't wake the wrong kind. this is exactly how ArrayBlockingQueue is built.
BoundedBuffer.java
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
 
void put(T item) throws InterruptedException {
    lock.lock();
    try {
        while (items.size() == capacity) notFull.await();
        items.addLast(item);
        notEmpty.signal();          // wake one consumer, never a producer
    } finally {
        lock.unlock();
    }
}

the same rules as wait() apply: you must hold the lock, and await() belongs in a while loop.

which one

  • start with synchronized. it's simpler, it can't leak a lock, and it's not slower anymore.
  • reach for ReentrantLock when you need tryLock, a timeout, interruptibility, fairness or several conditions.
  • ReadWriteLock when reads are long and dominate. measure first.
  • StampedLock for tiny, hot, read-mostly data (coordinates, counters, small value pairs), and only if you're careful.
  • before any of them: can the state be immutable, confined to one thread, or inside a concurrent collection?
one level deeper:virtual threads and locks+

before java 24, a virtual thread blocking inside synchronized pinned its carrier thread, so "replace synchronized with ReentrantLock" was standard advice for virtual-thread code. since java 24 (JEP 491), synchronized no longer pins, so that advice is mostly obsolete. the details are in virtual threads.

keylines
  • ReentrantLock = synchronized plus tryLock, timeouts, interruptibility, fairness and conditions.
  • always lock() before try, and unlock() in finally.
  • AQS is one volatile int plus a queue of parked threads. most of java.util.concurrent sits on it.
  • non-fair lets a running thread barge in. that's a feature: it's faster.
  • read-write locks share between readers. a writer waiting in line blocks new readers.
  • StampedLock's optimistic read takes no lock at all, then checks the version.