AQS Explained - How Locks Are Built
AbstractQueuedSynchronizer (AQS) is the foundation class in java.util.concurrent used to build locks and synchronizers such as ReentrantLock, ReentrantReadWriteLock, Semaphore, and CountDownLatch. It maintains a shared int state and a FIFO queue of waiting threads; subclasses define when to acquire and release by interpreting the state. This article explains the design, how ReentrantLock and Semaphore use it, and the concepts of fairness and condition variables.
Overview
Building a custom lock from scratch requires handling:
- State: Whether the lock is held, how many times (reentrancy), or how many permits remain.
- Queue: When a thread cannot acquire, it must block and wait. When the holder releases, a waiter must be woken.
- Atomicity: State changes and queue operations must be atomic; otherwise races corrupt the lock.
AQS centralizes the queuing and blocking logic. Subclasses only implement:
tryAcquire/tryRelease(exclusive mode, e.g. ReentrantLock)tryAcquireShared/tryReleaseShared(shared mode, e.g. Semaphore)
AQS handles enqueuing failed acquirers, parking threads, and unparking the appropriate successor when a release occurs.
State: AQS holds a volatile int state. Subclasses give it meaning:
- For
ReentrantLock: 0 = free, ≥1 = held, value = hold count for reentrancy. - For
Semaphore: state = number of available permits. - For
CountDownLatch: state = remaining count; 0 means the latch is open.
Queue: A CLH-style queue of nodes. Each node represents a waiting thread. When a thread fails to acquire, it creates a node, enqueues it, and parks. When the holder releases, it dequeues the head's successor and unparks it so it can retry.
Example
ReentrantLock usage
JavaReentrantLock lock = new ReentrantLock(); lock.lock(); try { // critical section doWork(); } finally { lock.unlock(); }
ReentrantLock with tryLock
Javaif (lock.tryLock(1, TimeUnit.SECONDS)) { try { doWork(); } finally { lock.unlock(); } } else { // could not acquire in time }
Semaphore usage
JavaSemaphore sem = new Semaphore(5); // 5 permits sem.acquire(); // blocks until a permit is available try { useResource(); } finally { sem.release(); }
CountDownLatch usage
JavaCountDownLatch latch = new CountDownLatch(3); // Worker threads for (int i = 0; i < 3; i++) { new Thread(() -> { doWork(); latch.countDown(); }).start(); } latch.await(); // main thread blocks until count reaches 0 System.out.println("All workers done");
How ReentrantLock Uses AQS
Acquire (lock):
tryAcquirereturns true if state is 0 and CAS to 1 succeeds (lock acquired), or if the current thread already holds the lock (increment state for reentrancy).- If
tryAcquirereturns false, AQS enqueues the thread and parks it. - When a thread is unparked, it retries
tryAcquire.
Release (unlock):
tryReleasedecrements state. When state becomes 0, the lock is free. Return true so AQS can unpark the next waiter.- AQS then dequeues the head's successor and unparks it.
Fair vs nonfair:
- Nonfair (default): A newly arriving thread can "barge" ahead of waiters if the lock is free. This can improve throughput but may cause starvation.
- Fair: The lock is granted in FIFO order. New acquirers go to the tail of the queue. Implemented by checking in
tryAcquirewhether the current thread is the head's successor (for fair mode).
How Semaphore Uses AQS
Acquire:
tryAcquireSharedreturns a negative value if state (permits) is 0; otherwise it CAS-decrements state and returns a non-negative value (success).- AQS parks threads that get a negative return; it unparks them when
tryReleaseSharedsucceeds.
Release:
tryReleaseSharedCAS-increments state (adds a permit) and returns true. AQS then wakes one or more waiters.
Core Mechanism / Behavior
- CAS: All state changes use
compareAndSetStateso multiple threads can compete without a separate mutex. Only one thread wins; others retry or block. - Queue structure: Each node has a predecessor and successor. The queue is bidirectional to support cancellation and efficient removal. Nodes also have a
waitStatus(e.g. CANCELLED, SIGNAL, CONDITION) used for parking and signalling. - Condition variables:
ConditionObjectis an AQS inner class. A thread that callscondition.await()releases the lock, adds itself to the condition queue, and parks. When another thread callscondition.signal(), a node is moved from the condition queue to the main sync queue and will eventually be unparked to reacquire the lock.
| Component | Role |
|---|---|
| state | Subclass-defined (lock count, permits, etc.) |
| tryAcquire / tryRelease | Exclusive mode (ReentrantLock) |
| tryAcquireShared / tryReleaseShared | Shared mode (Semaphore, CountDownLatch) |
| Queue | AQS: enqueue waiters, unpark on release |
| ConditionObject | Condition variables (await/signal) |
Key Rules
- AQS is a framework: you use locks and synchronizers built on it (
ReentrantLock,Semaphore, etc.), not AQS directly in application code. Understanding AQS helps you reason about blocking, fairness, and interrupt handling. ReentrantLockreentrancy: the same thread can lock multiple times; each unlock decrements the count until 0.- Fair locks avoid starvation but can reduce throughput. Nonfair is the default; use fair only when you need strict FIFO ordering.
Conditionprovides multiple wait sets per lock (e.g. "not full" and "not empty" for a bounded queue), which is more flexible than a singlewait()/notify()on the lock object.
What's Next
See Synchronized vs ReentrantLock for when to use which. See ConcurrentHashMap Internals for concurrent data structures using similar ideas (CAS). See Deadlock - How to Detect and Prevent for lock ordering.