AQS principle and ReentrantLock source code analysis

The full name of AQS is AbstractQueuedSynchronizer, that is, abstract queue synchronizer. This class is under the java.util.concurrent.locks package.

AQS is a framework used to build locks and synchronizers. Using AQS can easily and efficiently construct a large number of synchronizers widely used, such as ReentrantLock and Semaphore, and other synchronizers such as ReentrantReadWriteLock, SynchronousQueue, FutureTask, etc. are based on AQS.

AQS principle

The core idea / workflow of AQS is: if the requested shared resource is idle, set the thread requesting the resource as a valid working thread, and set the shared resource as locked. If the requested shared resources are occupied, a set of mechanisms for thread blocking and waiting and lock allocation when waking up are required. This mechanism AQS is implemented with CLH queue lock, that is, the thread that cannot obtain the lock temporarily is added to the queue.

CLH(Craig,Landin and Hagersten) queue is a virtual two-way queue (virtual two-way queue means that there is no queue instance, but only the association relationship between nodes). AQS encapsulates each thread requesting shared resources into a Node of a CLH lock queue to realize lock allocation.

This queue is implemented through the CLH queue. As can be seen from the figure, the queue is a two-way queue composed of Node nodes. Each Node node maintains a prev reference and next reference, which point to the predecessor Node and successor Node of its own Node respectively. At the same time, AQS also maintains two pointers Head and Tail, which point to the Head and Tail of the queue respectively.

AQS uses an int member variable to represent the synchronization status, and completes the queuing of the resource acquisition thread through the built-in FIFO queue.

AQS uses CAS to perform atomic operations on the synchronization state to modify its value.

private volatile int state; //Share variables and use volatile decoration to ensure thread visibility

State information is operated through getState, setState and compareAndSetState of protected type

//Returns the current value of the synchronization status
protected final int getState() {
    return state;
}
//Sets the value of the synchronization status
protected final void setState(int newState) {
    state = newState;
}
//Atomically (CAS operation) set the synchronization status value to the given value update if the value of the current synchronization status is equal to expect
protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

Resource sharing mode

AQS defines two resource sharing methods

  • Exclusive mode: resources are exclusive and can only be obtained by one thread at a time. Such as ReentrantLock.
    • It can also be divided into fair lock and unfair lock:
      • Fair lock: according to the queuing order of threads in the queue, the first to arrive gets the lock first
      • Unfair lock: when a thread wants to obtain a lock, it directly grabs the lock regardless of the queue order. Whoever grabs the lock is who
  • Share mode: it can be obtained by multiple threads at the same time. The specific number of resources can be specified through parameters. Such as CountDownLatch, Semaphore, CyclicBarrier, ReadWriteLock.

Relatively speaking, unfair lock will have better performance because its throughput is relatively large. Of course, unfair locks make the time to acquire locks more uncertain, which may lead to long-term starvation of threads in the blocking queue.


AQS underlying template based approach pattern

The design of AQS is based on the template method mode. When customizing the synchronizer, you need to rewrite the following template methods provided by AQS:

  • Ishldexclusively(): whether the thread is monopolizing resources. You only need to implement condition.

  • tryAcquire(int): exclusive mode. When trying to obtain resources, it returns true if successful, and false if failed.

  • tryRelease(int): exclusive mode. When attempting to release resources, it returns true if successful, and false if failed.

  • Tryacquiresered (int): sharing mode, trying to obtain resources. A negative number indicates failure, 0 indicates success but no available resources remain, and a positive number indicates success and resources remain.

  • Tryrereleaseshared (int): sharing mode. Try to release the resource. If wake-up is allowed after release, the subsequent waiting nodes will return true; otherwise, return false.

Although these methods are protected methods, they are not specifically implemented in AQS, but directly throw exceptions (the purpose of not using abstract methods here is to avoid forcing subclasses to implement all abstract methods once and reduce useless work. In this way, subclasses only need to implement the abstract methods they care about. For example, Semaphore only needs to implement tryAcquire method instead of other unnecessary template methods.) . other methods in AQS class are final, so they cannot be used by other classes. Only these methods can be used by other classes.


AQS source code analysis

Get resources

Let's take a look at the resource acquisition method provided by AQS:

public final void acquire(int arg) {
    //Attempt to obtain a license, arg is the number of licenses. For reentry locks, 1 is requested at a time.
    if (!tryAcquire(arg) &&
    // If tryAcquire fails, use addWaiter() to add the current thread to the synchronization waiting queue first
    // Then continue trying to get the lock
    acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}

Next, take a look at the tryAcquire() function. This function is used to try to obtain a license (resource). For AbstractQueuedSynchronizer, this is an abstract function that is not implemented and throws an exception directly by default. The specific implementation is in subclasses. There are respective implementations in the implementation of reentry lock, read-write lock, semaphore, etc.

If tryAcquire() succeeds, acquire() returns success directly. If resource acquisition fails, add this thread to the synchronization waiting queue through the addWaiter(Node.EXCLUSIVE) method. The parameter passed in represents that the Node to be inserted is exclusive.

private Node addWaiter(Node mode) {
    // Generate the Node corresponding to the thread
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        // Use CAS to try to insert the node into the tail of the waiting queue 
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // If the waiting queue is empty or the above CAS fails, insert the CAS again
    enq(node);
    return node;
}

Because multiple threads compete for resources at the same time in AQS, multiple threads will be inserted into the node at the same time. Here, the thread safety of the operation is guaranteed by CAS spin.

After adding Node nodes to the tail of the waiting queue, the nodes in the waiting queue obtain resources from the original nodes one by one. The specific implementation is in the method acquirequeueueueueueueueueued:

// Get threads that are already in the queue in an exclusive and uninterrupted manner.
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        // Spin acquisition lock
        for (;;) {
            // Get precursor node
            final Node p = node.predecessor();
            // Check whether the precursor node of the current node is head
            // That is, only the second node in the waiting queue can obtain resources, because the first node is already running and the request for lock has been successful
            if (p == head && tryAcquire(arg)) {
                // If the lock request is successful, you will set yourself as the head node
                setHead(node);
                p.next = null; // help GC
                failed = false; // Mark request succeeded
                return interrupted;
            }
            // Failed to acquire the lock. Judge whether to block until it is unpark
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true; //Blocking interrupt
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

Therefore, after the node enters the waiting queue, it calls park to make it enter the blocking state. Only the thread of the head node is active.

Release resources

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // Wake up a waiting thread from the queue (skip directly when encountering the CANCEL node)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    // Get the successor node head.next of the head node
    Node s = node.next;
    // If the successor node is empty or the status is greater than 0 (the node is cancelled)
    if (s == null || s.waitStatus > 0) {
        s = null;
        // All useful nodes in the waiting queue move forward
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    // If the successor node is not empty, unpark wakes up
    if (s != null)
        LockSupport.unpark(s.thread);
}

Take ReentrantLock as an example

ReentrantLock uses a non fair lock by default. Considering better performance, it uses a boolean to decide whether to use a fair lock (pass in true and use a fair lock).

/** Synchronizer providing all implementation mechanics */
private final Sync sync;
public ReentrantLock() {
    // Default unfair lock
    sync = new NonfairSync();
}
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

After calling lock, the unfair lock will first call CAS to grab the lock. If the lock is not occupied at this time, the lock will be directly obtained and returned.

static final class NonfairSync extends Sync {
    private static final long serialVersionUID = 7316153563782823691L;
    final void lock() {
        // CAS lock grab
        if (compareAndSetState(0, 1))
            setExclusiveOwnerThread(Thread.currentThread());
        else
            acquire(1);
    }

The fair lock will directly non preempt the lock resources

static final class FairSync extends Sync {
    private static final long serialVersionUID = -3000897897090466540L;
    final void lock() {
        acquire(1);
    }

After CAS fails for a non fair lock, the AQS template method acquire method will be called just like for a fair lock.

The entry to get resources is the acquire(int arg) method. arg is the number of resources to obtain, which is always 1 in exclusive mode. Let's first look at the logic of this method:

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

Then, the template method will call the method tryAcquire rewritten by the user (user-defined synchronizer) to try to obtain resources. If it is found that the lock is released at this time (state == 0), the unfair lock will directly grab the lock, but the fair lock will judge whether there are threads in the waiting queue in the waiting state. If so, it will not grab the lock and be ranked in the back in order.

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // If (! Hasqueuedpredecessors() & & compareandsetstate (0, acquires)) {fair lock implementation
        // The fair lock will judge whether there are threads waiting in front of itself, instead of the fair lock, continue to grab the lock directly
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) { //If the current thread gets the lock, it can re-enter
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

Get resource flowchart

State is initialized to 0, indicating that it is not locked. When A thread locks (), it will call tryAcquire() to monopolize the lock and set state+1. After that, other threads will fail when tryAcquire() again. Other threads will not have A chance to acquire the lock until A thread unlocks() to state=0 (i.e. releases the lock). Of course, thread A can acquire the lock repeatedly before releasing the lock (state will accumulate), which is the concept of reentry. However, it should be noted that the number of times to obtain must be released, so as to ensure that the state can return to the zero state.


reference material

11 AQS · simple Java multithreading (redspider.group)

Tags: Java Multithreading Concurrent Programming

Posted on Sun, 28 Nov 2021 08:03:28 -0500 by edawg