JUC learning - AbstractQueuedSynchronizer source code interpretation (AQS)

Posted by Plug-in on Fri, 03 Dec 2021 13:04:56 +0100

1, Understanding of AbstractQueuedSynchronizer

AbstractQueuedSynchronizer Abstract queue synchronizer is called AQS for short. It is the basic component to implement the synchronizer. juc the following Lock implementation and some concurrency tool classes are implemented through AQS.

AQS will form all request threads into a CLH queue. When a thread completes execution (lock.unlock()), it will activate its successor nodes, but the executing threads are not in the queue, and all the threads waiting for execution are blocked (park()). [for the description of CLH queue, refer to: https://blog.csdn.net/firebolt100/article/details/82662102]

  • AQS is a built-in FIFO two-way queue to complete the queuing of threads (internally, the head and tail elements of the queue are recorded through the nodes, and the Node type of the element is Node type).
/*The queue head node (lazy load) of the waiting queue, which is reflected in the case of competition failure, will be created when the thread joining the synchronization queue executes the enq method
 Create a Head node). The node can only be modified by setHead method. Moreover, the waitStatus of the node cannot be CANCELLED*/
private transient volatile Node head;
/**The tail node of the waiting queue is also lazily loaded. (enq method). It can be modified only when a new blocking node is added*/
private transient volatile Node tail;

  • AQS internally maintains a volatile int state (representing shared resources) and a FIFO thread waiting queue (which will be entered when multi-threaded contention resources are blocked). Subclasses must define protected methods to change the state variable, which define how the state is obtained or released. [volatile does not guarantee the atomicity of the operation, but ensures the visibility of the current variable state.]
//AQS core: synchronization status
private volatile int state;

protected final int getState() {
    return state;
}

protected final void setState(int newState) {
    state = newState;
}

protected final boolean compareAndSetState(int expect, int update) {
    // See below for intrinsics setup to support this
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
  • AQS defines two resource sharing methods: Exclusive (Exclusive, only one thread can execute, such as ReentrantLock) and Share (shared, multiple threads can execute at the same time, such as Semaphore/CountDownLatch).
    Different user-defined synchronizers compete for shared resources in different ways. When implementing the user-defined synchronizer, you only need to implement the acquisition and release methods of shared resource state. As for the maintenance of specific thread waiting queue (e.g. failed to obtain resources, entering the queue / waking up out of the queue, etc.), AQS has been implemented at the top level. The user-defined synchronizer mainly implements the following methods:
1,isHeldExclusively(): Whether the thread is monopolizing resources. Only condition To achieve it.
2,tryAcquire(int): Exclusive mode. An attempt to obtain a resource is returned if successful true,Return if failed false. 
3,tryRelease(int): Exclusive mode. An attempt to free a resource is returned if successful true,Return if failed false. 
4,tryAcquireShared(int): Sharing mode. An attempt to get resources. A negative number indicates failure; 0 indicates success, but no resources are left; a positive number indicates success, and there are resources left.
5,tryReleaseShared(int): Share mode. Try to release the resource. If the resource is allowed to wake up after release, wait for the node to return true,Otherwise return false. 

The above methods will be introduced later.

Taking ReentrantLock as an example, state is initialized to 0, indicating that it is not locked. When thread A locks (), it will call tryAcquire() to monopolize the lock and set state+1. After that, other threads will fail when tryAcquire() again until thread A unlocks() to state=0 (that is, release the lock) Until now, other threads have the opportunity to acquire the lock. Of course, thread A can acquire the lock repeatedly before releasing the lock (the state will accumulate), which is the concept of reentry. However, it should be noted that the number of times it is acquired must be released, so as to ensure that the state can return to zero.

Take CountDownLatch as an example. The task is divided into n sub threads to execute, and the state is initialized to n (note that n should be consistent with the number of threads). The N sub threads are executed in parallel. After each sub thread is executed, countDown() will be performed once, and the state will be reduced by 1. After all sub threads are executed (i.e. state=0), the main calling thread will be unpark() and then the main calling thread will start from await() Function returns to continue subsequent actions.

Generally speaking, custom synchronizers are either exclusive or shared. They only need to implement one of tryacquire tryrelease and tryacquishared tryrereleaseshared. However, AQS also supports custom synchronizers to realize both exclusive and shared modes, such as ReentrantReadWriteLock.

  • Node node class
    static final class Node {
        /** Marker to indicate a node is waiting in shared mode */
        // Indicates that a node is waiting in shared mode
        static final Node SHARED = new Node();
        /** Marker to indicate a node is waiting in exclusive mode */
        // Indicates that a node is waiting in exclusive mode
        static final Node EXCLUSIVE = null;

        /** waitStatus value to indicate thread has cancelled */
        static final int CANCELLED =  1;
        /** waitStatus value to indicate successor's thread needs unparking */
        static final int SIGNAL    = -1;
        /** waitStatus value to indicate thread is waiting on condition */
        static final int CONDITION = -2;
        /**
         * waitStatus value to indicate the next acquireShared should
         * unconditionally propagate
         */
        static final int PROPAGATE = -3;

        volatile int waitStatus;

        /**
         * Link to predecessor node that current node/thread relies on
         * for checking waitStatus. Assigned during enqueuing, and nulled
         * out (for sake of GC) only upon dequeuing.  Also, upon
         * cancellation of a predecessor, we short-circuit while
         * finding a non-cancelled one, which will always exist
         * because the head node is never cancelled: A node becomes
         * head only as a result of successful acquire. A
         * cancelled thread never succeeds in acquiring, and a thread only
         * cancels itself, not any other node.
         */
        volatile Node prev;

        /**
         * Link to the successor node that the current node/thread
         * unparks upon release. Assigned during enqueuing, adjusted
         * when bypassing cancelled predecessors, and nulled out (for
         * sake of GC) when dequeued.  The enq operation does not
         * assign next field of a predecessor until after attachment,
         * so seeing a null next field does not necessarily mean that
         * node is at end of queue. However, if a next field appears
         * to be null, we can scan prev's from the tail to
         * double-check.  The next field of cancelled nodes is set to
         * point to the node itself instead of null, to make life
         * easier for isOnSyncQueue.
         */
        volatile Node next;

        /**
         * The thread that enqueued this node.  Initialized on
         * construction and nulled out after use.
         */
        // The thread that queues this node. Initialized at construction time and zeroed after use.
        volatile Thread thread;

        /**
         * Link to next node waiting on condition, or the special
         * value SHARED.  Because condition queues are accessed only
         * when holding in exclusive mode, we just need a simple
         * linked queue to hold nodes while they are waiting on
         * conditions. They are then transferred to the queue to
         * re-acquire. And because conditions can only be exclusive,
         * we save a field by using special value to indicate shared
         * mode.
         */
        Node nextWaiter;

        /**
         * Returns true if node is waiting in shared mode.
         */
        // Returns true if the node is waiting in shared mode.
        final boolean isShared() {
            return nextWaiter == SHARED;
        }

        /**
         * Returns previous node, or throws NullPointerException if null.
         * Use when predecessor cannot be null.  The null check could
         * be elided, but is present to help the VM.
         *
         * @return the predecessor of this node
         */
        // Returns the predecessor node of the node
        final Node predecessor() throws NullPointerException {
            Node p = prev;
            if (p == null)
                throw new NullPointerException();
            else
                return p;
        }
		
        // Used to create an initial header or shared tag
        Node() {    // Used to establish initial head or SHARED marker
        }

        Node(Thread thread, Node mode) {     // Used by addWaiter
            this.nextWaiter = mode;
            this.thread = thread;
        }

        Node(Thread thread, int waitStatus) { // Used by Condition
            this.waitStatus = waitStatus;
            this.thread = thread;
        }
    }

2, Interpretation of AQS source code

1. Node status waitStatus

/** waitStatus value to indicate thread has cancelled */
// Indicates that the current node has cancelled scheduling. When timeout or interrupt (in response to interrupt), it will trigger to change to this state, and the node in this state will not change again.
static final int CANCELLED =  1;

/** waitStatus value to indicate successor's thread needs unparking */
// Indicates that the successor node is waiting for the current node to wake up. When the successor node joins the queue, the status of the predecessor node will be updated to SIGNAL.
static final int SIGNAL    = -1;

/** waitStatus value to indicate thread is waiting on condition */
// Indicates that the node is waiting on the condition. When other threads call the signal() method of the condition, the node in the condition state will be transferred from the waiting queue to the synchronization queue, waiting to obtain the synchronization lock.
static final int CONDITION = -2;

/**
  * waitStatus value to indicate the next acquireShared should
  * unconditionally propagate
  */
// In the sharing mode, the predecessor node will wake up not only its successor nodes, but also the successor nodes.
static final int PROPAGATE = -3;

// waitStatus indicates the waiting status of the current thread. The default status when a new node is queued is 0.
volatile int waitStatus;

Note: a negative value indicates that the node is in a valid waiting state, while a positive value indicates that the node has been cancelled. Therefore, in many places in the source code, use > 0 and < 0 to judge whether the node state is normal. [the above states need to be remembered, which are applied in the later source code]

2. acquire(int) method

This method is the top-level entry for a thread to obtain shared resources in exclusive mode. If a resource is obtained, the thread returns directly. Otherwise, it enters the waiting queue until the resource is obtained, and the impact of interruption is ignored in the whole process. This is also the semantics of lock(), which is not limited to lock(). After the resource is obtained, the thread can execute its critical area (shared resources) The following is the source code of acquire():

/**
  * Acquires in exclusive mode, ignoring interrupts.  Implemented
  * by invoking at least once {@link #tryAcquire},
  * returning on success.  Otherwise the thread is queued, possibly
  * repeatedly blocking and unblocking, invoking {@link
  * #tryAcquire} until success.  This method can be used
  * to implement method {@link Lock#lock}.
  *
  * @param arg the acquire argument.  This value is conveyed to
  *        {@link #tryAcquire} but is otherwise uninterpreted and
  *        can represent anything you like.
  */
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // Interrupt current thread
        selfInterrupt();
}

Through comments, we know that the acquire method is a mutually exclusive mode and ignores interrupts. The method executes the tryAcquire(int) method at least once. If the tryAcquire(int) method returns true, acquire returns directly. Otherwise, the current thread needs to enter the queue for queuing. The function flow is as follows:

  1. tryAcquire() attempts to obtain resources directly, and returns directly if successful;
  2. addWaiter() adds the thread to the tail of the waiting queue and marks it as exclusive mode;
  3. Acquirequeueueueued() enables the thread to obtain resources in the waiting queue and return only after obtaining resources. If it is interrupted during the whole waiting process, it returns true, otherwise it returns false.
  4. If a thread is interrupted while waiting, it does not respond. Self interrupt () is performed only after obtaining the resource to make up the interrupt

2.1 tryAcquire(int) method

tryAcquire attempts to obtain resources exclusively. If the acquisition is successful, it will directly return true; otherwise, it will directly return false. This method can be used to implement the tryLock() method in Lock. The default implementation of this method is to throw unsupported operationexception. The specific implementation is implemented by a user-defined synchronization class that extends AQS. AQS is only responsible for defining a common method framework here.

   /**
     * Attempts to acquire in exclusive mode. This method should query
     * if the state of the object permits it to be acquired in the
     * exclusive mode, and if so to acquire it.
     *
     * <p>This method is always invoked by the thread performing
     * acquire.  If this method reports failure, the acquire method
     * may queue the thread, if it is not already queued, until it is
     * signalled by a release from some other thread. This can be used
     * to implement method {@link Lock#tryLock()}.
     *
     * <p>The default
     * implementation throws {@link UnsupportedOperationException}.
     *
     * @param arg the acquire argument. This value is always the one
     *        passed to an acquire method, or is the value saved on entry
     *        to a condition wait.  The value is otherwise uninterpreted
     *        and can represent anything you like.
     * @return {@code true} if successful. Upon success, this object has
     *         been acquired.
     * @throws IllegalMonitorStateException if acquiring would place this
     *         synchronizer in an illegal state. This exception must be
     *         thrown in a consistent fashion for synchronization to work
     *         correctly.
     * @throws UnsupportedOperationException if exclusive mode is not supported
     */
    protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

Note: the reason why it is not defined as abstract here is that only tryacquire tryrelease is implemented in the exclusive mode, while only tryacquiresered tryrereleaseshared is implemented in the shared mode. If the first mock exam is defined as abstract, then each mode must implement the interface under another mode. In a word, it is to reduce unnecessary operations.

2.2 addWaiter(Node) method

This method is used to add the current thread to the tail of the waiting queue according to different modes (Node.EXCLUSIVE mutually exclusive mode and Node.SHARED shared shared mode), and return the node where the current thread is located. If the queue is not empty, add the current thread node to the end of the waiting queue by CAS through the compareAndSetTail method. Otherwise, a waiting queue is initialized by the enq(node) method and the current node is returned. The source code is as follows:

   /**
     * Creates and enqueues node for current thread and given mode.
     *
     * @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
     * @return the new node
     */
    private Node addWaiter(Node mode) {
        // Construct nodes in a given pattern. There are two mode s: EXCLUSIVE and SHARED
        Node node = new Node(Thread.currentThread(), mode);
        
        // Try the fast path of enq; backup to full enq on failure
        // Try a quick way to put it directly at the end of the team.
        Node pred = tail;
        if (pred != null) {
            node.prev = pred; // The precursor of the current node is the tail node of the queue
            if (compareAndSetTail(pred, node)) {  // The tail node of the update queue is the current node
                pred.next = node;
                return node;
            }
        }
        
        // If the previous failure indicates that the queue is empty, a waiting queue is initialized through enq.
        enq(node);
        return node;
    }
2.2.1 enq(Node) method

enq(node) is used to insert the current node into the waiting queue. If the queue is empty, the current queue will be initialized. The whole process is carried out in the way of CAS spin until it successfully joins the tail of the team. The source code is as follows:

   /**
     * Inserts node into queue, initializing if necessary. See picture above.
     * @param node the node to insert
     * @return node's predecessor
     */
    private Node enq(final Node node) {
        // CAS "spin" until you successfully join the tail of the team
        for (;;) {
            Node t = tail; 
            // If the queue is empty, create an empty flag node as the head node and point the tail to it.
            if (t == null) { // Must initialize
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else { // Normal process, put at the end of the team
                node.prev = t;
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    return t;
                }
            }
        }
    }

2.3 acquirequeueueueued (node, int) method

Acquirequeueueueued() is used for threads in the queue to acquire synchronization status in an exclusive and non interruptible manner until they get the lock and then return. The implementation of this method is divided into two parts: if the current node has become the head node, try to acquire the lock successfully, and then return; Otherwise, check whether the current node should be Park, then park the thread and check whether the current thread can be interrupted. The source code is as follows:

    /**
     * Acquires in exclusive uninterruptible mode for thread already in
     * queue. Used by condition wait methods as well as acquire.
     *
     * @param node the node
     * @param arg the acquire argument
     * @return {@code true} if interrupted while waiting
     */
    final boolean acquireQueued(final Node node, int arg) {
        // Mark whether the resource was successfully obtained
        boolean failed = true;
        try {
            // Mark whether the waiting process has been interrupted
            boolean interrupted = false;
            // spin
            for (;;) {
                // Get the precursor
                final Node p = node.predecessor();
                // If the precursor is head, try to get the resource
                if (p == head && tryAcquire(arg)) {
                    // After getting the resource, set the current node as the head node. Therefore, the benchmark node referred to in head is the node or null that currently obtains the resource.
                    setHead(node);
                    // node.prev in setHead has been set to null, and head.next is set to null here to facilitate GC to recycle the previous head node. This means that the nodes that have finished taking resources before are out of the team!
                    p.next = null; // help GC
                    // Successfully obtained resources
                    failed = false;
                    // Whether the return waiting process has been interrupted
                    return interrupted;
                }
                // If the request for resources fails, it enters the waiting state through park() until it is unpark(). If you are interrupted when you can interrupt, you will wake up from Park () and find that you can't get resources, so you continue to enter Park () and wait.
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    // If the waiting process is interrupted even once, mark interrupted as true
                    interrupted = true;
            }
        } finally {
             // If the resource is not successfully obtained during the waiting process (such as timeout, or it is interrupted when it can be interrupted), cancel the waiting of the node in the queue.
            if (failed)
                cancelAcquire(node);
        }
    }
2.3.1 shouldParkAfterFailedAcquire(Node, Node) method

shouldParkAfterFailedAcquire method judges the status of the previous node of the current node and makes different operations on the current node.

   /**
     * Checks and updates status for a node that failed to acquire.
     * Returns true if thread should block. This is the main signal
     * control in all acquire loops.  Requires that pred == node.prev.
     *
     * @param pred node's predecessor holding status
     * @param node the node
     * @return {@code true} if thread should block
     */
    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        // Get the status of the precursor
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            /*
             * This node has already set status asking a release
             * to signal it, so it can safely park.
             */
            // When the status of the precursor is SIGNAL, the subsequent node is notified to cancel the blocking
            return true;
        if (ws > 0) {
            /*
             * Predecessor was cancelled. Skip over predecessors and
             * indicate retry.
             */
            // If the precursor node is cancelled, keep looking until you find the latest normal waiting state and line up behind it
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
            /*
             * 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.
             */
            // If the status of the precursor is normal waiting, set the status of the precursor to SIGNAL and tell it to notify itself after obtaining resources.
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

	// CAS modifies the waiting state of the node
	private static final boolean compareAndSetWaitStatus(Node node,
                                                         int expect,
                                                         int update) {
        return unsafe.compareAndSwapInt(node, waitStatusOffset,
                                        expect, update);
    }

In the whole process, if the status of the precursor node is not SIGNAL, you can't rest at ease. You need to find a rest point at ease. At the same time, you can try again to see if it's your turn to take the number.

2.3.2 parkAndCheckInterrupt() method

This method allows the thread to rest and really enter the waiting state. park() will put the current thread into the waiting state. In this state, there are two ways to wake up the thread: 1) unpark(); 2) Interrupted (). Note that Thread.interrupted() will clear the interrupt flag bit of the current thread.

   /**
     * Convenience method to park and then check if interrupted
     *
     * @return {@code true} if interrupted
     */
    private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        return Thread.interrupted();
    }
2.3.3 cancelAcquire(Node) method

Remove the current node from the waiting queue.

    /**
     * Cancels an ongoing attempt to acquire.
     *
     * @param node the node
     */
    private void cancelAcquire(Node node) {
        // Ignore if node doesn't exist
        if (node == null)  // The node does not exist and ends directly
            return;

        node.thread = null;  // Clear the thread of the current node

        // Skip cancelled predecessors
        // Skip precursor nodes that have been canceled
        Node pred = node.prev;  // Gets the precursor node of the current node
        while (pred.waitStatus > 0)  
            node.prev = pred = pred.prev;  // Connect the current node to the tail of the node in the normal state

        // predNext is the apparent node to unsplice. CASes below will
        // fail if not, in which case, we lost race vs another cancel
        // or signal, so no further action is necessary.
        // Get the next node of the node in the normal waiting state
        Node predNext = pred.next;

        // Can use unconditional write instead of CAS here.
        // After this atomic step, other Nodes can skip past us.
        // Before, we are free of interference from other threads.
        // Set the waiting status of the current node to cancelled
        node.waitStatus = Node.CANCELLED;

        // If we are the tail, remove ourselves.
        // If the current node is the tail node, remove the current node and set the predecessor node as the tail node
        if (node == tail && compareAndSetTail(node, pred)) {
            // Clear the successor node of the predecessor node
            compareAndSetNext(pred, predNext, null);
        } else {
            // If successor needs signal, try to set pred's next-link
            // so it will get one. Otherwise wake it up to propagate.
            int ws;
            if (pred != head &&
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                pred.thread != null) {
                Node next = node.next;
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
                unparkSuccessor(node);
            }

            node.next = node; // help GC
        }
    }
2.3.4 specific implementation process of acquirequeueueueueued()
  1. After the node enters the end of the team, check the status and find a safe rest point;
  2. Call park() to enter the waiting state and wait for unpark() or interrupt() to wake up;
  3. After being awakened, check whether resources can be obtained. If it can be obtained, the head points to the current node and returns whether the whole process from joining the queue to obtaining resources has been interrupted; If not, continue with process 1.

2.4 execution flow of acquire (int) method

  1. Call tryAcquire() of the custom synchronizer to try to obtain resources directly. If it is successful, it will return directly;
  2. If it fails, addWaiter() adds the thread to the tail of the waiting queue and marks it as exclusive mode;
  3. Acquirequeueueueued () makes the thread rest in the waiting queue. When it has a chance (it's its turn, it will be unpark()) it will try to get resources. Return after obtaining the resource. If it is interrupted during the whole waiting process, it returns true; otherwise, it returns false.
  4. If a thread is interrupted while waiting, it does not respond. Self interrupt () is performed only after the resource is obtained, and the interrupt is supplemented.

Reference article:

  1. https://www.cnblogs.com/waterystone/p/4920797.html
  2. https://www.cnblogs.com/fsmly/p/11274572.html
  3. http://gee.cs.oswego.edu/dl/papers/aqs.pdf -Doug Lea's paper

Topics: Java Back-end Multithreading JUC