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

Java
ReentrantLock lock = new ReentrantLock();

lock.lock();
try {
    // critical section
    doWork();
} finally {
    lock.unlock();
}

ReentrantLock with tryLock

Java
if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        doWork();
    } finally {
        lock.unlock();
    }
} else {
    // could not acquire in time
}

Semaphore usage

Java
Semaphore sem = new Semaphore(5);  // 5 permits

sem.acquire();   // blocks until a permit is available
try {
    useResource();
} finally {
    sem.release();
}

CountDownLatch usage

Java
CountDownLatch 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):

  • tryAcquire returns 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 tryAcquire returns false, AQS enqueues the thread and parks it.
  • When a thread is unparked, it retries tryAcquire.

Release (unlock):

  • tryRelease decrements 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 tryAcquire whether the current thread is the head's successor (for fair mode).

How Semaphore Uses AQS

Acquire:

  • tryAcquireShared returns 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 tryReleaseShared succeeds.

Release:

  • tryReleaseShared CAS-increments state (adds a permit) and returns true. AQS then wakes one or more waiters.

Core Mechanism / Behavior

  • CAS: All state changes use compareAndSetState so 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: ConditionObject is an AQS inner class. A thread that calls condition.await() releases the lock, adds itself to the condition queue, and parks. When another thread calls condition.signal(), a node is moved from the condition queue to the main sync queue and will eventually be unparked to reacquire the lock.
ComponentRole
stateSubclass-defined (lock count, permits, etc.)
tryAcquire / tryReleaseExclusive mode (ReentrantLock)
tryAcquireShared / tryReleaseSharedShared mode (Semaphore, CountDownLatch)
QueueAQS: enqueue waiters, unpark on release
ConditionObjectCondition 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.
  • ReentrantLock reentrancy: 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.
  • Condition provides multiple wait sets per lock (e.g. "not full" and "not empty" for a bounded queue), which is more flexible than a single wait()/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.