In depth analysis of AQS implementation principle

Posted by wilburforce on Sun, 26 Sep 2021 07:30:11 +0200

Briefly explain that J.U.C is a concurrency toolkit provided in JDK, java.util.concurrent. It provides many utility classes commonly used in concurrent programming, such as atomic operations, lock synchronization locks, fork/join, etc.

Starting from Lock

I want to use lock as a starting point to explain AQS. After all, synchronization lock is a common means to solve thread safety problems, and it is also a way we use more in our work.

Lock API

Lock is an interface, and the method is defined as follows

void lock() // If the lock is available, the lock is obtained. If the lock is unavailable, it is blocked until the lock is released
void lockInterruptibly() // It is similar to the lock() method, but the blocked thread can be interrupted and throw a java.lang.InterruptedException exception
boolean tryLock() // Non blocking acquisition lock; Attempts to acquire the lock, and returns true if successful
boolean tryLock(long timeout, TimeUnit timeUnit) //Lock acquisition method with timeout
void unlock() // Release lock

Implementation of Lock

There are many classes that implement the Lock interface. The following are some common Lock implementations

  • ReentrantLock: represents a reentrant Lock. It is the only class that implements the Lock interface. Reentry Lock means that after a thread obtains a Lock, it does not need to be blocked to obtain the Lock again. Instead, it is directly associated with a counter to increase the number of reentries
  • ReentrantReadWriteLock: reentrant read-write Lock. It implements the ReadWriteLock interface. In this class, two locks are maintained, one is ReadLock and the other is WriteLock. Both of them implement the Lock interface respectively. Read write Lock is a tool suitable for solving thread safety problems in the scenario of more reading and less writing. The basic principles are: read and read are not mutually exclusive, read and write are mutually exclusive, and write and write are mutually exclusive. In other words, operations that affect data changes will be mutually exclusive.
  • stampedLock: stampedLock is a new locking mechanism introduced by JDK8. It can be simply regarded as an improved version of read-write lock. Although read and write lock can be completely concurrent by separating read and write, read and write conflict. If a large number of read threads exist, it may cause hunger of write threads. stampedLock is an optimistic read strategy so that optimistic locks do not block write threads at all

The simplicity and practicality of ReentrantLock

How to use ReentrantLock in practical applications? Let's demonstrate it with a simple demo

public class Demo {
    private static int count=0;
    static Lock lock=new ReentrantLock();
    public static void inc(){
        lock.lock();
        try {
            Thread.sleep(1);
            count++;
        } catch (InterruptedException e) {
            e.printStackTrace();
        }finally{
            lock.unlock();
        }
    }

This code mainly does one thing, that is, continuously incrementing the shared variable count through a static incr() method. If multiple threads access this method without adding synchronization lock, there will be thread safety problems. Therefore, ReentrantLock is used to implement synchronous lock and release the lock in the finally statement block.
So let me raise a question. Let's think about it

When multiple threads compete for locks through locks, how do they wait and wake up when the lock fails to compete?

What is AQS

The full name of aqs is AbstractQueuedSynchronizer. It provides a FIFO queue, which can be regarded as a core component used to implement synchronization lock and other synchronization functions. The common ones are ReentrantLock, CountDownLatch, etc.
AQS is an abstract class, which is mainly used by inheritance. It does not implement any synchronization interface, but only defines the methods of obtaining and releasing synchronization status to provide custom synchronization components.
It can be said that as long as you understand AQS, most APIs in J.U.C can be easily mastered.
###Two functions of AQS
From the perspective of usage, AQS has two functions: exclusive and shared

  • Exclusive lock. Only one thread can hold the lock at a time. For example, the ReentrantLock demonstrated earlier is a mutually exclusive lock implemented in an exclusive manner
  • Shared locks allow multiple threads to acquire locks at the same time and access shared resources concurrently, such as ReentrantReadWriteLock

Class diagram of ReentrantLock

Still take ReentrantLock as an example to analyze the use of AQS in reentry lock. After all, simple analysis of AQS does not have much meaning. Understanding this class diagram first can facilitate us to understand the principle of AQS

Internal implementation of AQS

The implementation of AQS depends on the internal synchronization queue, that is, the two-way queue of FIFO. If the current thread fails to compete for the lock, AQS will construct the current thread and waiting status information into a Node to join the synchronization queue, and then block the thread. When the thread acquiring the lock releases the lock, it will wake up a blocked Node (thread) from the queue.

The AQS queue maintains a FIFO two-way linked list. The characteristic of this structure is that each data structure has two pointers, pointing to the direct successor Node and the direct predecessor Node respectively. Therefore, the bidirectional linked list can easily access the predecessor and successor from any Node. Each Node is actually encapsulated by a thread. When the thread fails to compete for the lock, it will be encapsulated as a Node and added to the ASQ queue

The Node class consists of the following

static final class Node {
        static final Node SHARED = new Node();
        static final Node EXCLUSIVE = null;
        static final int CANCELLED =  1;
        static final int SIGNAL    = -1;
        static final int CONDITION = -2;
        static final int PROPAGATE = -3;
        volatile int waitStatus;
        volatile Node prev; //Precursor node
        volatile Node next; //Successor node
        volatile Thread thread;//Current thread
        Node nextWaiter; //Successor nodes stored in the condition queue
        //Is it a shared lock
        final boolean isShared() { 
            return nextWaiter == SHARED;
        }

        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }

        Node() {    // Used to establish initial head or SHARED marker
        }
        //Construct the thread as a Node and add it to the waiting queue
        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }
        //This method will be used in the condition queue. A separate article will be written later to analyze the condition
        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

###Release the lock and add thread changes to the queue
####Add node
When lock competition and lock release occur, the nodes in the AQS synchronization queue will change. First, take a look at the scenario of adding nodes.

Two changes will be involved here

  • The new thread is encapsulated as a Node node and added to the synchronization queue. The prev Node is set and the next Node of the front Node of the current Node is modified to point to itself
  • Re point to the new tail node through CAS tail

####Release lock remove node
The head node indicates the node that has successfully obtained the lock. When the head node releases the synchronization state, it will wake up the successor node. If the successor node obtains the lock successfully, it will set itself as the head node. The change process of the node is as follows

This process also involves two changes

  • Modify the head node to point to the next node to obtain the lock
  • For the new node that obtains the lock, point the pointer of prev to null

A small change here is that CAS is not required for setting the head node. The reason is that setting the head node is completed by the thread that obtains the lock, and the synchronization lock can only be obtained by one thread. Therefore, CAS guarantee is not required. Just set the head node as the successor node of the original head node and disconnect the next reference of the original head node

Source code analysis of AQS

After knowing the basic architecture of AQS, let's analyze the source code of AQS, still using ReentrantLock as the model.

Sequence diagram of ReentrantLock

Call the lock() method in ReentrantLock. I use a sequence diagram to show the calling process of the source code

It can be seen from the figure that when the lock acquisition fails, the addWaiter() method will be called to encapsulate the current thread as a Node and add it to the AQS queue. Based on this idea, let's analyze the source code implementation of AQS

Analysis source code

ReentrantLock.lock()

public void lock() {
    sync.lock();
}

This is the entry to obtain the lock. Call the method in the sync class. What is sync?

abstract static class Sync extends AbstractQueuedSynchronizer

sync is a static internal class, which inherits the abstract class AQS. As mentioned earlier, AQS is a synchronization tool, which is mainly used to realize synchronization control. When we use this tool, we will inherit it to realize synchronization control function.
Through further analysis, it is found that Sync has two specific implementations, namely nofairsync (unfair lock) and failsync (fair lock)

  • Fair lock means that all threads acquire locks in strict accordance with FIFO
  • Unfair lock means that there can be preemptive lock, that is, whether there are other threads waiting on the current queue or not, the new thread has the opportunity to preempt the lock

I will explain the difference between the implementation of fair lock and unfair lock later in the article. The following analysis still takes unfair lock as the main analysis logic.

NonfairSync.lock

final void lock() {
    if (compareAndSetState(0, 1)) //Modify the state through cas operation, indicating the operation of competing for lock
      setExclusiveOwnerThread(Thread.currentThread());//Sets the thread that currently obtains the lock state
    else
      acquire(1); //Try to get the lock
}

This code briefly explains

  • Because this is a non fair lock, when calling the lock method, first preempt the lock through cas
  • If the lock preemption is successful, save the current thread that has successfully obtained the lock
  • Lock preemption failed. Call acquire to perform lock contention logic

compareAndSetState

The code implementation logic of compareAndSetState is as follows

protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

In fact, the logic of this code is very simple, which is to compare and replace it through cas optimistic locking. The above code means that if the value of state in the current memory is equal to the expected value expect, it will be replaced with update. If the update is successful, return true; otherwise, return false
This operation is atomic and will not cause thread safety problems. It involves the operation of Unsafe class and the meaning of state attribute at the first level.
state
AQS has such an attribute definition, which represents a synchronization state for the implementation of reentry lock. It has two meanings

  • When state=0, it indicates no lock state
  • When state > 0, it means that a thread has obtained a lock, that is, state=1. However, because ReentrantLock allows reentry, when the same thread obtains a synchronous lock multiple times, the state will increase. For example, if it reentries 5 times, state=5. When releasing the lock, it also needs to be released 5 times until state=0. Other threads are not eligible to obtain the lock
private volatile int state;

It should be noted that the meaning of state is different for different AQS implementations.
Unsafe
The unsafe class is under the sun.misc package and does not belong to the Java standard. However, many basic Java class libraries, including some widely used high-performance development libraries, are developed based on unsafe classes, such as Netty, Hadoop, Kafka, etc; Unsafe can be considered as a back door left in Java, providing some low-level operations, such as direct memory access, thread scheduling, etc

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

This is a native method. The first parameter is the object to be changed, the second is the offset (i.e. the previously calculated headOffset value), the third parameter is the expected value, and the fourth is the updated value
The function of the whole method is to update to the new expected value var5 if the value at the current time is equal to the expected value var4, and return true if the update is successful, otherwise return false;

acquire

Acquire is a method in AQS. If the CAS operation fails, it means that the state is no longer 0. At this time, continue the acquire(1) operation. Here, let's think about what the 1 parameter in the acquire method is used for? If you don't guess right, review the concept of state

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

The main logic of this method is

  • Try to obtain the exclusive lock through tryAcquire. If it succeeds, it returns true and if it fails, it returns false
  • If the tryAcquire fails, the current thread will be encapsulated as a Node and added to the end of the AQS queue through the addWaiter method
  • Acquirequeueueued, take Node as a parameter and try to obtain the lock by spinning.

If you read what I wrote Synchronized source code analysis We should be able to understand the meaning of spin

NonfairSync.tryAcquire

This method is used to try to obtain the lock. If it succeeds, it returns true and if it fails, it returns false
It rewrites the tryAcquire method in the AQS class, and we take a closer look at the definition of the tryAcquire method in the AQS. Instead of implementing it, we throw an exception. According to the general thinking mode, since it is a template method that is not implemented, it should be defined as abstract and implemented by subclasses? Let's think about why

protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

nonfairTryAcquire

The implementation code of tryAcquire(1) in NonfairSync is as follows

ffinal boolean nonfairTryAcquire(int acquires) {
    //Gets the thread currently executing
    final Thread current = Thread.currentThread();
    int c = getState(); //Get the value of state
    if (c == 0) { //state=0 indicates that the current state is unlocked
        //Replace the value of state with 1 through cas operation. Why do you use cas?
        //The reason is that in a multithreaded environment, directly modifying state=1 will have thread safety problems. Did you guess?
        if (compareAndSetState(0, acquires)) {
             //Save the thread that currently obtains the lock
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //This logic is very simple. If the same thread obtains the lock, the number of reentries will be increased directly
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires; //Increase reentry times
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

  • Get the current thread and judge the status of the current lock
  • If state=0 indicates that the current state is unlocked, update the state value through cas
  • If the current thread belongs to reentry, increase the number of reentries

addWaiter

When the tryAcquire method fails to acquire the lock, it will first call addWaiter to encapsulate the current thread into a Node, and then add it to the AQS queue

private Node addWaiter(Node mode) { //mode=Node.EXCLUSIVE
        //Encapsulate the current thread as a Node, and the mode is an exclusive lock
        Node node = new Node(Thread.currentThread(), mode); 
        // Try the fast path of enq; backup to full enq on failure
        // tail is the attribute representing the end of the synchronization queue in AQS. It was initially null, so enq(node) method was used
        Node pred = tail;
        if (pred != null) { //If the tail is not empty, it indicates that there is node data in the queue
            node.prev = pred;  //The prev Node of the Node of the current thread points to the tail
            if (compareAndSetTail(pred, node)) {//Add node to AQS queue through cas
                pred.next = node;//cas succeeds, pointing the next pointer of the old tail to the new tail
                return node;
            }
        }
        enq(node); //tail=null, add node to synchronization queue
        return node;
    }
  • Encapsulate the current thread as a Node
  • Judge whether the tail node in the current linked list is empty. If not, add the node of the current thread to the AQS queue through cas operation
  • If it is empty or cas fails, enq is called to add the node to the AQS queue

enq

enq is to add the current node to the queue through spin operation

private Node enq(final Node node) {
        //Spin, not too much explanation, not clearly concerned about the official account [architect's book of practice]
        for (;;) {
            Node t = tail; //If it is added to the queue for the first time, then tail=null
            if (t == null) { // Must initialize
                //CAS creates an empty Node as the header Node
                if (compareAndSetHead(new Node()))
                   //At this time, there is only one header node in the queue, so the tail also points to it
                    tail = head;
            } else {
//During the second cycle, the tail is not null and enters the else area. Point the prev of the Node node of the current thread to the tail, and then use CAS to point the tail to the Node
                node.prev = t;
                if (compareAndSetTail(t, node)) {
//T at this time, it points to the tail, so CAS can succeed and re point the tail to the Node. At this time, t is the value of tail before updating, that is, it points to the empty head Node, t.next=node, and then points the subsequent nodes of the head Node to the Node and returns the head Node
                    t.next = node;
                    return t;
                }
            }
        }
    }

If two threads T1 and T2 enter the enq method at the same time, t==null indicates that the queue is used for the first time and needs to be initialized first
If the cas of another thread fails, it will enter the next cycle and add the node to the end of the queue through cas operation

So far, an AQS queue has been constructed through the addwaiter method, and the thread has been added to the node of the queue

acquireQueued

Pass the Node added to the queue as a parameter into the acquirequeueueueued method, which will preempt the lock

final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();// Get the prev node. If it is null, NullPointException will be thrown immediately
            if (p == head && tryAcquire(arg)) {// If the precursor is head, it is qualified to rob the lock
                setHead(node); // After obtaining the lock successfully, there is no need to synchronize. The thread that obtains the lock successfully is used as a new head node
//For head nodes, head.thread and head.prev are always null, but head.next is not null
                p.next = null; // help GC
                failed = false; //Lock acquisition succeeded
                return interrupted;
            }
//If the lock acquisition fails, determine whether to suspend the thread according to the waitStatus of the node
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())// If the preceding is true, the execution is suspended, and the interrupt flag is detected when waking up next time
                interrupted = true;
        }
    } finally {
        if (failed) // If an exception is thrown, the lock acquisition is canceled and the sync queue operation is performed
            cancelAcquire(node);
    }
}

  • Gets the prev node of the current node
  • If the prev node is a head node, it is qualified to compete for the lock and call tryAcquire to seize the lock
  • After the lock preemption is successful, set the node that obtains the lock as head, and remove the original initialized head node
  • If the lock acquisition fails, the waitStatus determines whether the thread needs to be suspended
  • Finally, cancel the lock acquisition by cancelAcquire

The previous logic is well understood. Let's mainly look at the function of shouldParkAfterFailedAcquire and parkAndCheckInterrupt

shouldParkAfterFailedAcquire

It can be seen from the above analysis that only the second node of the queue can have the opportunity to compete for locks. If the lock is successfully obtained, this node will be promoted to the head node. For the third and subsequent nodes, if (p == head) condition does not hold, first perform shouldParkAfterFailedAcquire(p, node) operation
The shouldParkAfterFailedAcquire method is to determine whether a thread competing for a lock should be blocked. It first determines whether the state of the front node of a node is Node.SIGNAL. If yes, it indicates that the node has set the State - if the lock is released, it should be notified, so it can block safely and return true.

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus; //Status of predecessor node
    if (ws == Node.SIGNAL)//If it is in the SIGNAL state, it means that the current thread needs to be awakened by unpark
               return true;
If the status of the previous node is greater than 0, it is CANCELLED When the node is in the state, it will start to cycle step by step from the front node to find one that has not been deleted“ CANCELLED"The node is set as the front node of the current node. Return false. Execute in next cycle shouldParkAfterFailedAcquire When, return true. This operation is actually put in the queue CANCELLED Remove all nodes.
    if (ws > 0) {// If the predecessor node is in "Cancel" status, set "current predecessor node" of "current node" to "predecessor node of" original predecessor node ".
       
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else { // If the predecessor node is in "0" or "shared lock" state, set the predecessor node to SIGNAL state.
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

parkAndCheckInterrupt

If shouldParkAfterFailedAcquire returns true, it will execute the parkAndCheckInterrupt() method, which suspends the current thread to the waiting state through LockSupport.park(this). It needs to wait for an interrupt and unpark method to wake it up. The Lock operation is realized through the wait of such a FIFO mechanism.

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

LockSupport
LockSupport class is a class introduced in Java 6 and provides basic thread synchronization primitives. LockSupport actually calls functions in Unsafe class. In Unsafe, there are only two functions:

public native void unpark(Thread jthread);  
public native void park(boolean isAbsolute, long time);  

The unpark function provides "permission" for the thread, and the thread calls the park function and waits for "permission". This is a bit like a semaphore, but the "permission" cannot be superimposed, and the "permission" is one-time.
Permission is equivalent to the switch of 0 / 1. By default, it is 0. Calling unpark once will add 1 to become 1. Calling park once will consume permission and become 0 again. If you call park again, it will block because the permission is already 0. Until the permission becomes 1. Calling unpark at this time will set the permission to 1. Each thread has a related permission. There is only one permission at most, and repeated calls to unpark will not accumulate

Lock release

ReentrantLock.unlock

After analyzing the lock adding process, analyze the lock releasing process and call the release method, which does two things: 1. Release the lock; 2. Wake up the park thread

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

tryRelease

This action can be regarded as an operation to set the lock state, and subtract the passed parameter value from the state (the parameter is 1). If the result state is 0, set the Owner of the exclusive lock to null, so that other threads have the opportunity to execute.
In an exclusive lock, the state will increase by 1 when the lock is added (of course, you can modify this value yourself) and decrease by 1 when the lock is unlocked. After the same lock can be re entered, it may be superimposed into the values of 2, 3 and 4. Only when the number of unlock() corresponds to the number of lock() will the Owner thread be set to null, and only in this case will it return true.

protected final boolean tryRelease(int releases) {
    int c = getState() - releases; // Here is to reduce the number of locks by 1
    if (Thread.currentThread() != getExclusiveOwnerThread())// If the released thread is not the same as the thread obtaining the lock, an illegal monitor state exception is thrown
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) { 
// Due to reentry, not every time the lock c is released is equal to 0,
    // The current thread will not be released until the last time the lock is released
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

unparkSuccessor

In the method unparksuccess (node), it means to release the lock. It passes in the head node (the head node is the node that occupies the lock). After the current thread is released, it needs to wake up the thread of the next node

private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {//Judge whether the successor node is empty or in cancelled status,
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0) //Then traverse forward from the tail of the queue to find the first node with waitStatus less than 0. As for why traverse forward from the tail, only the change of next is set during the processing of doacquireinterruptible.cancelacquire method, and the change of prev is not set. At the end, there is such a line of code: node.next = node. If the unparksuccess method is executed at this time , and if you traverse backward, it becomes an endless loop, so only prev is stable at this time
                s = t;
    }
//The first internal action is to obtain the next node of the head node. If the obtained node is not empty, directly release the corresponding suspended thread through the "LockSupport.unpark()" method. In this way, a node will wake up and continue to enter the loop, and further try the tryAcquire() method to obtain the lock
    if (s != null)
        LockSupport.unpark(s.thread); //Release license
}

summary

Through this article, the implementation process of AQS queue is analyzed clearly, mainly the implementation of exclusive lock based on unfair lock. When obtaining the synchronization lock, the synchronizer maintains a synchronization queue, and the threads that fail to obtain the status will be added to the queue and spin in the queue; The condition to move out of the queue (or stop spinning) is that the precursor node is the head node and successfully obtains the synchronization status. When releasing the synchronization state, the synchronizer calls the tryRelease(int arg) method to release the synchronization state, and then wakes up the successor nodes of the head node.

  • Pay attention to the official account of Mic architecture, and regularly update high-quality original articles.

Topics: Java Concurrent Programming