Fundamentals 36 min read

Why Java Replaced synchronized with Lock and How AQS Powers Modern Concurrency

This article explains why Java introduced the Lock API after Java 1.5, compares it with synchronized, details the explicit lock features, walks through the AQS (AbstractQueuedSynchronizer) implementation, and shows how ReentrantLock, fairness, reentrancy, and conditions are built on top of AQS.

Programmer DD
Programmer DD
Programmer DD
Why Java Replaced synchronized with Lock and How AQS Powers Modern Concurrency

Java SDK Why Design Lock

Imagine a world where Java concurrency only had synchronized; the code would be simple but limited to three usage patterns.

public class ThreeSync {
    private static final Object object = new Object();
    public synchronized void normalSyncMethod() {
        // critical section
    }
    public static synchronized void staticSyncMethod() {
        // critical section
    }
    public void syncBlockMethod() {
        synchronized (object) {
            // critical section
        }
    }
}

Before Java 1.5 this was the case, but Doug Lea introduced a new wheel—Lock—to solve problems that synchronized could not handle.

We often say “don’t reinvent the wheel,” yet a new wheel is needed when the old one cannot solve certain scenarios.

Coffman identified four conditions that can cause deadlock; the non‑preemptive condition means a thread that has acquired a resource cannot be forced to release it until it voluntarily does so.

Thread has obtained a resource and cannot be preempted before it releases the resource.

To break this condition a lock must be able to release resources when it cannot acquire further ones, something synchronized cannot do because a thread that fails to acquire the monitor simply blocks.

Explicit Lock

The new wheel must provide non‑blocking capabilities. The following table lists three key features and their APIs:

These features make Lock more flexible but also a bit more complex to use.

Lock Usage Paradigm

Just like synchronized has a standard pattern, Lock follows a similar paradigm:

Lock lock = new ReentrantLock();
lock.lock();
try {
    // critical section
} finally {
    lock.unlock();
}

Two best practices are emphasized:

Standard 1—Release the lock in finally

Ensures the lock is always released after acquisition.

Standard 2—Acquire the lock outside the try block

Prevents situations where an exception occurs before the lock is held, which would make finally attempt to release a lock that was never acquired.

try {
    // code that may throw
} finally {
    // release lock
}
Different lock implementations share the same template methods; follow the paradigm to avoid problems.

How Does Lock Actually Work?

If you are familiar with synchronized, you know the JVM emits monitorenter and monitorexit bytecode instructions to mark entry and exit of a critical section.

From the paradigm perspective: lock.lock() is equivalent to monitorenter. lock.unlock() is equivalent to monitorexit.

The implementation relies on a volatile state field and CAS operations inside the synchronizer.

AQS Implementation Analysis

When lock.tryLock() fails, it calls the custom tryAcquire() method, which attempts to set state via CAS. If it fails, the thread is placed into a FIFO queue (the synchronizer queue) and blocked.

public final void acquire(int arg) {
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

The queue is built from Node objects. Each node holds the thread reference, wait status, and links to predecessor and successor nodes.

When a thread cannot acquire the lock, it creates a node and appends it to the tail of the queue using CAS to ensure thread‑safety.

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

If the tail is null, a sentinel node is created to avoid edge‑case problems.

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) {
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

The waiting thread eventually parks via LockSupport.park() and is unparked when the lock is released.

private static final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

When the lock is released, the head node’s successor is unparked:

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

Interruptible Lock Acquisition

The method lockInterruptibly() checks the interrupt status first and then attempts a non‑blocking acquire; if it fails, it enters a loop that parks the thread but throws InterruptedException when interrupted.

public final void acquireInterruptibly(int arg) throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    if (!tryAcquire(arg))
        doAcquireInterruptibly(arg);
}

Timed Lock Acquisition

tryLock(long time, TimeUnit unit)

computes a deadline, repeatedly attempts acquisition, and parks with a timeout. If the timeout expires, it returns false.

public final boolean tryAcquireNanos(int arg, long nanosTimeout) throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    return tryAcquire(arg) || doAcquireNanos(arg, nanosTimeout);
}

Condition

A Condition is essentially a separate wait queue associated with a lock. Each condition maintains a singly‑linked list of waiting nodes.

private transient Node firstWaiter;
private transient Node lastWaiter;

When a thread calls await(), it creates a node with waitStatus = CONDITION, adds it to the condition queue, releases the lock, and then parks.

public final void await() throws InterruptedException {
    if (Thread.interrupted())
        throw new InterruptedException();
    Node node = addConditionWaiter();
    int savedState = fullyRelease(node);
    int interruptMode = 0;
    while (!isOnSyncQueue(node)) {
        LockSupport.park(this);
        if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
            break;
    }
    if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
        interruptMode = REINTERRUPT;
    if (node.nextWaiter != null)
        unlinkCancelledWaiters();
    if (interruptMode != 0)
        reportInterruptAfterWait(interruptMode);
}

Signalling moves a node from the condition queue to the synchronizer queue:

public final void signal() {
    if (!isHeldExclusively())
        throw new IllegalMonitorStateException();
    Node first = firstWaiter;
    if (first != null)
        doSignal(first);
}

If the node cannot be transferred (e.g., cancelled), the method continues with the next waiter.

ReentrantLock and AQS

ReentrantLock is built on top of AQS. It has two internal synchronizer subclasses: NonfairSync and FairSync. The default constructor uses NonfairSync for higher throughput.

public ReentrantLock() {
    sync = new NonfairSync();
}

Fair locks check whether there are queued predecessors before acquiring the lock, while non‑fair locks try to acquire immediately.

// Fair lock acquisition
if (!hasQueuedPredecessors() && tryAcquire(arg))
    return true;
// Non‑fair lock acquisition
if (tryAcquire(arg))
    return true;

ReentrantLock is re‑entrant: the owning thread can acquire the lock multiple times, incrementing the internal state each time, and must release it the same number of times.

if (current == getExclusiveOwnerThread()) {
    int c = getState() + acquires;
    if (c < 0) // overflow
        throw new Error("Maximum lock count exceeded");
    setState(c);
    return true;
}

Conclusion

This long article explained why Java needed a new lock abstraction, how to use Lock correctly, the inner workings of AQS, and how common classes like ReentrantLock are built on top of it. It also covered exclusive acquisition, release, interruptibility, timeout, conditions, fairness, and re‑entrancy.

Soul Questions

Why does AQS provide both setState() and compareAndSetState()? The former is used when the current thread already owns the lock and no race exists, making it safe; compareAndSetState() is used when multiple threads may contend for the state.

Does the following transfer code risk deadlock?

class Account {
    private int balance;
    private final Lock lock = new ReentrantLock();
    void transfer(Account tar, int amt) {
        while (true) {
            if (this.lock.tryLock()) {
                try {
                    if (tar.lock.tryLock()) {
                        try {
                            this.balance -= amt;
                            tar.balance += amt;
                        } finally {
                            tar.lock.unlock();
                        }
                    }
                } finally {
                    this.lock.unlock();
                }
            }
        }
    }
}

The loop repeatedly attempts to acquire both locks with tryLock(). If both locks cannot be obtained simultaneously, the thread releases any acquired lock and retries, so it does not block and therefore avoids deadlock, though it may cause livelock under high contention.

References

Java Concurrency in Practice

Java Concurrency: The Art of Multiprocessor Programming

https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaconcurrencyLockAQSReentrantLockthread synchronization
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.