Deeply and Simply Explain the Exclusive Lock Mode of AQS

Keywords: less REST Java Programming

Every Java engineer should know more or less about AQS, and I have studied it over and over again for a long time, forgetting to look at it again and again. Every time I have a different experience. This time, taking advantage of blogging, I intend to re-take out the source code of the system and summarize it into an article for future review.

AbstractQueued Synchronizer (hereinafter referred to as AQS) is the basis of java.util.concurrent package. It provides a complete synchronization programming framework. Developers can freely use many synchronization modes such as monopoly, sharing, conditional queue and so on by implementing only a few simple methods. The basic class libraries we often use, such as ReentrantLock, CountDownLatch and so on, are implemented based on AQS, which is enough to illustrate the strength of this framework. In view of this, we developers should understand the principle of its implementation, so as to be able to use in the process of handy.

Generally speaking, the code of AQS is very difficult to understand. In this paper, the principle of exclusive lock is analyzed.

Overview of the implementation process

First of all, we start with the overall process, understand the execution logic of AQS exclusive lock, and then step by step in-depth analysis of the source code.

The process of acquiring locks:

  1. When the thread calls acquire() to apply for lock resources, if successful, it enters the critical zone.
  2. When the acquisition lock fails, it enters a FIFO waiting queue and is hung up for wake-up.
  3. When the waiting thread in the queue is awakened, try again to get the lock resource. If it succeeds, it enters the critical area, otherwise it will continue to hang up waiting.

Release lock process:

  1. When a thread calls release() to release the lock resource, if no other thread is waiting for the lock resource, the release is complete.
  2. If there are other threads waiting for lock resources in the queue that need to be waked up, the first waiting node in the queue (FIFO) is waked up.

2. Source code in-depth analysis

Based on the general process of acquiring and releasing exclusive locks mentioned above, let's look at the source code implementation logic.
First, let's look at the acqui () method for acquiring locks.

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

Although the code is short, it contains a lot of logic. Look at it step by step:

  1. The first is to call the developer's own tryAcquire() method to try to acquire lock resources. If successful, the entire acquire() method is executed, that is, the current thread acquires lock resources and can enter the critical zone.
  2. If the acquisition lock fails, it begins to enter the following logic, starting with the addWaiter(Node.EXCLUSIVE) method. Look at the source implementation of this method:
    //Note: The return value of this entry method is the newly created node
    private Node addWaiter(Node mode) {
        //Node type (Node.EXCLUSIVE) creates new nodes based on the current thread
        //Because this is the exclusive mode, the node type is Node.EXCLUSIVE
        Node node = new Node(Thread.currentThread(), mode);
        Node pred = tail;
        //In order to improve performance, we first perform a fast queue entry operation, that is, we directly try to add new nodes to the end of the queue.
        if (pred != null) {
            node.prev = pred;
            //According to the logic of CAS, even concurrent operations can only have one thread succeed and return, and the rest will perform the subsequent queuing operations. That is, enq() method
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

    //Complete Entry Operation
    private Node enq(final Node node) {
        for (;;) {
            Node t = tail;
            //If the queue hasn't been initialized, initialize, creating an empty header node
            if (t == null) { 
                //Similarly, CAS, only one thread can initialize the header node successfully, and the rest must repeat the loop body.
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else {
                //The newly created node points to the end of the queue, and there is no doubt that there will be multiple newly created nodes pointing to the end of the queue in the case of concurrency.
                node.prev = t;
                //Based on the CAS of this step, no matter how many new nodes in the previous step point to the tail node, only one can really join the team successfully in this step, and the rest must re-execute the loop body.
                if (compareAndSetTail(t, node)) {
                    t.next = node;
                    //The only exit operation of the loop is successful entry (or infinite retries)
                    return t;
                }
            }
        }
    }

There are two points to be explained in the above entry operation:
First, the trigger condition of initialization queue is that the thread currently occupies the lock resource, so the empty header node created above can be regarded as the node currently occupying the lock resource (although it does not set any attributes).
2. Note that the whole code is in a dead cycle, knowing that the team is successful. If they fail, they try again and again.

  1. After the above operation, the thread that we applied for acquiring the lock has successfully joined the waiting queue. Through the exclusive lock acquisition process mentioned at the beginning of the article, what the node needs to do now is to hang the current thread and wait for wake-up. How can this logic be realized? Look at the source code:
Through the above analysis, this method is included in the reference. node Is the node that just entered the queue that contains the current thread information
final boolean acquireQueued(final Node node, int arg) {
        //Lock resource acquisition failure tag bit
        boolean failed = true;
        try {
            //Waiting for interrupt marker bits for threads
            boolean interrupted = false;
            //The timing of execution of this loop body includes two places: the new node queuing and the queue waiting for the node to be awakened.
            for (;;) {
                //Get the front-end node of the current node
                final Node p = node.predecessor();
                //If the front-end node is the head node, try to get the lock resource
                if (p == head && tryAcquire(arg)) {
                    //After the current node acquires the lock resource, it is set to the header node, so I will continue to understand what I said above.
                    //The header node represents the node currently occupying the lock resource
                    setHead(node);
                    p.next = null; //Help GC
                    //Represents a successful acquisition of lock resources, so failed is set to false
                    failed = false;
                    //Returns an interrupt flag indicating whether the current node is awakened normally or by interruption
                    return interrupted;
                }
                //If the lock is not successful, the suspension logic is entered
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            //Finally, the acquisition lock failure handling logic is analyzed.
            if (failed)
                cancelAcquire(node);
        }
    }

Suspension logic is a very important logic. Let's analyze it separately. First of all, we should pay attention to the fact that so far, we have only created a node based on the current thread and the node type and joined the queue. Other attributes are default values.

//Let's start by explaining the parameter that node is the node of the current thread and pred is its pre-node.
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        //Get the waitStatus of the front-end node
        int ws = pred.waitStatus;
        if (ws == Node.SIGNAL)
            //If the waitStatus of the front-end node is Node.SIGNAL, it returns true and then executes the parkAndCheckInterrupt() method to suspend it.
            return true;
        if (ws > 0) {
            //Several values of waitStatus indicate that the leading node is cancelled.
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            //Here we start with the front-end node of the current node, looking forward to the nearest node that has not been cancelled.
            //Note, since the header node is created by new Node(), its waitStatus is 0, there will be no null pointer problem here, that is to say, the loop above the header node will exit at most.
            pred.next = node;
        } else {
            //According to the value limit of waitStatus, where the value of waitStatus can only be 0 or PROPAGATE, we set the waitStatus of the front node as Node.SIGNAL and re-enter the method for judgment.
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

The logic of the above method is more complex. It is used to determine whether the current node can be suspended, that is, whether the wake-up conditions have been met, that is, if suspended, it must be awakened by other threads. If the method returns false, that is, the suspension condition is not complete, it will re-execute the loop body of acquireQueued method and make a re-judgement. If it returns true, it means that everything is ready and can be suspended, and it will enter the parkAndCheckInterrupt() method to see the source code:

private final boolean parkAndCheckInterrupt() {
        LockSupport.park(this);
        //After wake-up, return the interrupt flag, that is, false if wake-up is normal, true if wake-up is due to interruption.
        return Thread.interrupted();
    }

Look at the source code in the acquireQueued method. If the interrupt wakes up, set the interrupt flag to true. Whether it's normal to wake up or wake up from interruption, you try to get the lock resource. If successful, the interrupt flag is returned, otherwise the wait is suspended.
Note: The Thread.interrupted() method clears the interrupt flag when it returns the interrupt flag. That is to say, when the interrupt wakes up and the lock succeeds, the entire acquireQueued method returns true to indicate that the interrupt wakes up, but if the interrupt wakes up and the lock is not acquired, it continues to hang, because the interrupt has been cleared, and if it is normal next time. Wake up, and the acquireQueued method returns false, indicating no interruption.

finally, we return to the final step of the acquireQueued method, the final module. Here are some aftermath work done after the failure of lock resource acquisition. Looking at the above code, what can actually enter here is the tryAcquire() method throwing an exception. That is to say, if the AQS framework throws an exception for the lock acquisition operation implemented by developers themselves, it also makes proper handling. Let's look at the source code together:

//The incoming method parameter is the node currently failing to obtain lock resources
private void cancelAcquire(Node node) {
        // Ignore directly if the node does not exist
        if (node == null)
            return;
        
        node.thread = null;

        // Skip all cancelled pre-nodes, similar to the jump logic above
        Node pred = node.prev;
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;
        //This is the successor node of the front node. Because of the operation of the hopping node above, it may not be the current node here. Think carefully. C
        Node predNext = pred.next;

        //Set the current node waitStatus to cancel so that other nodes will skip the node when processing
        node.waitStatus = Node.CANCELLED;
        //If the current tail node is deleted directly, i.e. out of the queue
        //Note: There is no need to care about CAS failures here, because even if concurrency fails, the node has been successfully deleted.
        if (node == tail && compareAndSetTail(node, pred)) {
            compareAndSetNext(pred, predNext, null);
        } else {
            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)
                    //The logic of judgment here is very ambiguous, specifically if the front node of the current node is not the head node and the node behind it waits for it to wake up (waitStatus is less than 0).
                    //In addition, if the successor node of the current node is not cancelled, the former node is connected with the latter node, which is equivalent to deleting the current node.
                    compareAndSetNext(pred, predNext, next);
            } else {
                //Enter here, either the front node of the current node is the head node, or the waitStatus of the front node is PROPAGATE, which wakes up the successor node of the current node directly.
                unparkSuccessor(node);
            }

            node.next = node; // help GC
        }
    }

Above is the core source code of the exclusive mode acquisition lock, which is really very difficult to understand, very round, these methods need to be repeated many times, in order to slowly understand.

Next, look at the process of releasing the lock:

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

The tryRelease() method is a user-defined release lock logic. If it succeeds, judge if there are any nodes in the waiting queue that need to be waked up (waitStatus 0 means no nodes need to be waked up). Watch the wake-up operation together:

private void unparkSuccessor(Node node) {
        int ws = node.waitStatus;
        if (ws < 0)
            //Setting the mark to 0 indicates that the wake-up operation has started and improves performance in concurrent environments
            compareAndSetWaitStatus(node, ws, 0);

        Node s = node.next;
        //If the successor node of the current node is null, or has been cancelled
        if (s == null || s.waitStatus > 0) {
            s = null;
            //Notice that the loop does not break, that is, it looks backwards and forwards until it finds the nearest node waiting to wake up from the current node.
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        //Perform wake-up operation
        if (s != null)
            LockSupport.unpark(s.thread);
    }

In contrast, the release of locks is much simpler and less code.

Three, summary

Above is the acquisition and release process of AQS exclusive locks. The general idea is simple: try to acquire locks, and join a queue to hang if it fails. When the lock is released, it wakes up if there are waiting threads in the queue. But if you look at the source code step by step, you will find that there are many details, many places are difficult to understand, I have learned something over and over again for a long time, but I dare not say that I have studied AQS, or even dare not say that the above research results are correct, just write an article to summarize, exchange experience with peers.
In addition to exclusive locks, a series of AQS articles will be produced later, including shared locks, the implementation principle of conditional queues and so on.

Posted by nfr on Wed, 22 May 2019 18:57:48 -0700