AQS deep analysis of Java concurrency

Keywords: Java

preface

The first part analyzes the implementation details and some difficult points of AQS sharing / exclusive lock. This part continues to analyze the remaining knowledge of AQS. Through this article, you will learn:

1. Exclusive / non interruptible lock
2. Interruptible / non interruptible shared lock
3. Lock that can / cannot wait for a limited time
4. Implementation of waiting / notification
5. Similarities and differences between synchronization queue and waiting queue
6. The difference between Condition.await/Condition.signal and Object.wait/Object.notify

1. Exclusive / non interruptible lock

Definition of interruptible lock

A little analogy:

  • Let's start with a scene: Xiao Ming is an Internet addict. He plays games in the Internet cafe all day. His mother calls him to go home for dinner (gives him an interruption order). Xiao Ming verbally promised his mother, but he plays games again as soon as he hangs up. Mother calls repeatedly, but she can't interrupt Xiao Ming's online process. We say that Xiao Ming's online process can't be interrupted.
  • Another scene: Although Xiao Ming is an Internet addict, he listens to his mother very much. His mother calls him to go home for dinner (sends him an interruption order). Xiao Ming orally agrees and shuts down, which shows that Xiao Ming's online process can be interrupted.

From the perspective of code, let's look at a section of pseudo code for locking / unlocking:

    private void testLock() {
        myLock.lock();
        doSometing1();
        doSometing2();
        myLock.unlock;
    }

    private void doSometing1() {//... }
    private void doSometing2() {//... }

Lock is an exclusive lock. Threads A and B compete for the lock at the same time. Assuming that A successfully obtains the lock, it can execute doSometing1() and doSometing2() methods.
Thread B calls myLock.lock() because it can't get the lock. However, another thread C is waiting for thread B's data. Because thread B can't get the lock all the time, it can't produce the data required by C. Therefore, C wants to interrupt B. after B is awakened, it has two options:

1. Do not change the original intention, continue to try to obtain the lock, and continue to block when it is not obtained. No matter how C interrupts, it is indifferent.
2. Detect whether the interrupt has occurred. If so, throw an exception directly (I quit and don't try to obtain the lock).

If the design of myLock meets option 1, it is called that the lock cannot be interrupted. If it meets option 2, it is called that the lock can be interrupted.
It is shown as follows:

Non interruptible exclusive lock

Call process of exclusive lock analyzed previously:

acquire(xx)–>acquireQueued(xx)

In acquirequeueueueueued (XX):

As shown in the above figure, only interrupts are detected in places 1 and 2, and then the interrupt value is returned to the caller of the previous layer.
In acquire(xx) on the upper layer:

If you find that an interrupt has occurred, you just fill in the interrupt flag again.

It can be seen that the thread calls acquire(xx) to obtain the lock. If an interrupt occurs, the thread still tries to obtain the lock on its own. External interrupt calls simply cannot terminate its process of obtaining the lock.
At this time, the lock is an exclusive non interruptible lock.

Interruptible exclusive lock

To interrupt the process of obtaining locks, you need to detect the interrupt flag bit, throw an exception or exit the process. See how AQS is implemented:

#AbstractQueuedSynchronizer.java
    public final void acquireInterruptibly(int arg)
            throws InterruptedException {
        //If an interrupt occurs, an interrupt exception is thrown
        if (Thread.interrupted())
            throw new InterruptedException();
        if (!tryAcquire(arg))
            doAcquireInterruptibly(arg);
    }

    private void doAcquireInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    //Because the interrupt has been detected and thrown below, there is no need to return the interrupt status here
                    return;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    //When you wake up, you find that you have been interrupted, so you throw an interrupt exception directly
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

It can be seen that exceptions are thrown in two places:

1. Check whether the interrupt has occurred before preparing to acquire the lock. If so, throw an exception.
2. After being awakened, check whether the interrupt has occurred. If so, throw an exception and interrupt the process of obtaining the lock.

2. Interruptible / non interruptible shared lock

Non interruptible shared lock

The calling process of shared lock analyzed previously:

acquireShared(xx)–>doAcquireShared(xx)

In doAcquireShared(xx):

Unlike the non interruptible exclusive lock, doAcquireShared(xx) directly fills the interrupt flag bit after detecting the interrupt and does not need to be passed to the outer layer.
Of course, like non interruptible exclusive locks, they do not handle interrupts.

At this time, the lock is a non interruptible shared lock.

Interruptible shared lock

#AbstractQueuedSynchronizer.java
    public final void acquireSharedInterruptibly(int arg)
            throws InterruptedException {
        if (Thread.interrupted())
            //If an interrupt occurs, an interrupt exception is thrown
            throw new InterruptedException();
        if (tryAcquireShared(arg) < 0)
            doAcquireSharedInterruptibly(arg);
    }

    private void doAcquireSharedInterruptibly(int arg)
        throws InterruptedException {
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head) {
                    int r = tryAcquireShared(arg);
                    if (r >= 0) {
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        failed = false;
                        //Because the interrupt has been detected and thrown below, there is no need to supplement the interrupt here
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    //When you wake up, you find that you have been interrupted, so you throw an interrupt exception directly
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

As you can see, it is consistent with the logic of interruptible exclusive lock processing interrupt.

3. Lock with / without time limit

Exclusive lock with time limited wait

It can be seen from the above that although the lock can respond to an interrupt, there are other scenarios that are not covered:

The thread does not want to wait all the time to acquire the lock, but wants to wait for a certain time. If the lock is not acquired, it will give up acquiring the lock.

Let's take a look at the time limited waiting lock mechanism provided in AQS:

#AbstractQueuedSynchronizer.java
    public final boolean tryAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        //Pass in the nanosTimeout parameter. The unit is nanosecond. It means that if the time is exhausted or the lock is not obtained, the lock acquisition process will exit
        if (Thread.interrupted())
            throw new InterruptedException();
        return tryAcquire(arg) ||
            doAcquireNanos(arg, nanosTimeout);
    }

    private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        //Time required > 0
        if (nanosTimeout <= 0L)
            return false;
        //Calculation deadline
        final long deadline = System.nanoTime() + nanosTimeout;
        final Node node = addWaiter(Node.EXCLUSIVE);
        boolean failed = true;
        try {
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return true;
                }
                //Check to see if time is running out
                nanosTimeout = deadline - System.nanoTime();
                if (nanosTimeout <= 0L)
                    //Run out, then exit directly
                    return false;
                if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                    //Wake up after a certain period of sleep
                    LockSupport.parkNanos(this, nanosTimeout);
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

It can be seen that after a thread is suspended, there are two ways to wake it up:

1. External call interrupt method.
2. The hang time limit has arrived.

When the thread is awakened, check the interrupt flag bit. If an interrupt occurs, throw an exception directly. Otherwise, try to obtain the lock again. If obtaining the lock fails, judge whether it has timed out. If so, exit.

Shared lock that can wait for a limited time

Similar to an exclusive lock that can wait for a limited time, it will not be described in detail.

4. Implementation of waiting / notification

Basic data structure

AbstractQueuedSynchronizer has a subclass: ConditionObject
It is the basis of waiting / notification.
Let's take a look at its structure:

#ConditionObject
//Point to the head of the team
private transient Node firstWaiter;
//Point to the end of the team
private transient Node lastWaiter;

firstWaiter and lastWaiter jointly maintain the waiting queue in combination with the nextWaiter pointer in the Node:

If the data structure is available, you need the method of operating the data structure:

It can be seen that there are two methods: awaitXX(xx)/signalXX():

await(): wait indefinitely.
await(long time, TimeUnit unit): wait for a limited time. You can specify the time unit.
awaitNanos(long nanosTimeout): wait for a limited time. The time unit is nanoseconds.
Awaituninterruptible(): non interruptible time limited wait.
awaitUntil(Date deadline): wait for a limited time. Specify the timeout time as a point in the future.
signal(): notify the nodes in the waiting queue.
signalAll(): notifies all nodes in the waiting queue.

Thread A calls awaitXX(xx) to block and wait for the condition to be met, and thread B calls signalXX() to notify thread A that the condition is met. Obviously, this is A thread synchronization process.
Take await()/signal()/signalAll() methods to analyze the waiting / notification mechanism.

ConditionObject.await() implementation

        public final void await() throws InterruptedException {
            //An interrupt occurs and an exception is thrown
            if (Thread.interrupted())
                throw new InterruptedException();
            //Join the waiting queue
            Node node = addConditionWaiter();//--------->(1)
            //Release lock all
            int savedState = fullyRelease(node);//--------->(2)
            int interruptMode = 0;
            //If not in the synchronization queue
            while (!isOnSyncQueue(node)) {//--------->(3)
                //Suspend thread
                LockSupport.park(this);
                //After the thread is awakened, check whether an interrupt has occurred ----------- > (4)
                //There are two reasons for being awakened: 1. An interrupt occurs, and 2. Another thread calls signal
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            //Get synchronization status
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                //If successful, the marking thread needs to reset the interrupt flag bit
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                //Remove the cancelled node from the waiting queue --------- > (5)
                unlinkCancelledWaiters();
            //Decide how to handle interrupts
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);//--------->(6)
        }

Throughout the await() method, three main tasks have been done:

1. Encapsulate the thread to the Node node and join the waiting queue.
2. The pending thread waits for the condition to be met.
3. After the thread wakes up, it scrambles for the lock.

Six key methods are marked in the note. Let's take a look at them respectively:
(1)

        private Node addConditionWaiter() {
            //Find the tail node
            Node t = lastWaiter;
            if (t != null && t.waitStatus != Node.CONDITION) {
                //If it is found that the waiting state is not a CONDITION, the waiting queue is traversed to remove the cancelled node
                unlinkCancelledWaiters();
                t = lastWaiter;
            }
            //Construction node
            Node node = new Node(Thread.currentThread(), Node.CONDITION);
            if (t == null)
                //If the queue is empty, the header pointer points to the current node
                firstWaiter = node;
            else
                //After hanging the current node to the tail node
                t.nextWaiter = node;
            //The tail pointer points to the tail node
            lastWaiter = node;
            return node;
        }

Function of this method: encapsulate the thread as a node and add it to the waiting queue.
(2)
Since you have to wait, release the lock so that other threads can obtain the lock and do the corresponding operation.

    final int fullyRelease(Node node) {
        boolean failed = true;
        try {
            //Gets the current synchronization status
            int savedState = getState();
            //Release synchronization status all
            if (release(savedState)) {
                //Release successful
                failed = false;
                return savedState;
            } else {
                //Failure to release indicates that the current lock is not exclusive, and an exception is thrown
                throw new IllegalMonitorStateException();
            }
        } finally {
            if (failed)
                node.waitStatus = Node.CANCELLED;
        }
    }

From this method, we can see that when await() is called, the AQS must be guaranteed to be an exclusive lock.
(3)

    final boolean isOnSyncQueue(Node node) {
        //If the current node status is CONDITION or the node precursor node is null, it means that the node is not in the synchronization queue
        if (node.waitStatus == Node.CONDITION || node.prev == null)
            return false;
        //If the node state is not CONDITION and the node precursor node exists and the successor node exists, it is considered to be in the synchronization queue
        if (node.next != null) 
            return true;
        //The successor node does not exist. It may be that when the node is added to the tail of the synchronization queue, CAS failed to modify the tail. Therefore, traverse the synchronization queue here and directly compare whether the nodes are equal
        return findNodeFromTail(node);
    }

You need to ensure that it is not in the synchronization queue to operate.
(4)

   private int checkInterruptWhileWaiting(Node node) {
            //If an interrupt occurs, transfer after canceledwait will be called to judge. Otherwise, 0 will be returned directly
            return Thread.interrupted() ?
                (transferAfterCancelledWait(node) ? THROW_IE : REINTERRUPT) :
                0;
        }

    final boolean transferAfterCancelledWait(Node node) {
        //This indicates that the thread has been interrupted. The next step is to judge whether the signal action has occurred
        //Attempt to modify node state
        if (compareAndSetWaitStatus(node, Node.CONDITION, 0)) {
            //If successful, it will be added to the synchronization queue
            //CAS success indicates that signal() has not occurred at this time
            enq(node);
            return true;
        }
        //Explain that CAS failed because the node state has been changed in signal().
        //Therefore, you only need to wait for signal() to add the node to the synchronization queue.
        while (!isOnSyncQueue(node))
            Thread.yield();
        return false;
    }

Transferaftercanceledwait (XX) returns true, indicating that an interrupt exception needs to be thrown, and returns false, indicating that you only need to fill in the interrupt flag bit.
interruptMode!=0, indicating that an interrupt has occurred and directly exit the cycle.

(5)
Although the node has been added to the synchronization queue, it may not be removed from the waiting queue (without calling signal), so it needs to be checked here.

(6)

        private void reportInterruptAfterWait(int interruptMode)
            throws InterruptedException {
            //An exception is thrown directly. An interrupt occurs, but there is no signal
            if (interruptMode == THROW_IE)
                throw new InterruptedException();
            else if (interruptMode == REINTERRUPT)
                //Fill in the interrupt flag bit
                selfInterrupt();
        }

It can be seen that even if an interrupt occurs, await(xx) will not process the interrupt until it obtains the lock, which ensures that the thread must obtain the lock when returning from the await(xx) call.

ConditionObject.signal() implementation

        public final void signal() {
            if (!isHeldExclusively())
                //If it is not an exclusive lock, an exception is thrown
                throw new IllegalMonitorStateException();
            //First node found
            Node first = firstWaiter;
            if (first != null)
                doSignal(first);
        }

        private void doSignal(Node first) {
            do {
                if ( (firstWaiter = first.nextWaiter) == null)
                    //If there is no subsequent node, the tail pointer is null
                    lastWaiter = null;
                //Remove the header node from the wait queue
                first.nextWaiter = null;
            } while (!transferForSignal(first) &&//Move node to synchronization queue
                     //The header pointer points to the next node
                     (first = firstWaiter) != null);
        }

        final boolean transferForSignal(Node node) {
            //modify state
            if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
                return false;
            //Join synchronization queue
            Node p = enq(node);
            int ws = p.waitStatus;
            //p is the precursor node of the node. If the precursor node is cancelled or the state modification fails, it will directly wake up the thread associated with the current node
            if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
                LockSupport.unpark(node.thread);
            return true;
    }

It can be seen that when calling signal(), you need to ensure that the AQS is an exclusive lock and that the current thread has obtained the exclusive lock.
Throughout the whole signal() method, there are three main points:

1. Remove the node from the waiting queue.
2. Join the node to the synchronization queue.
3. If there is only one node in the synchronization queue or the modification of the state of the precursor node fails, wake up the current node.

ConditionObject.signalAll() implementation

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

        private void doSignalAll(Node first) {
            lastWaiter = firstWaiter = null;
            do {
                Node next = first.nextWaiter;
                //Remove from waiting queue
                first.nextWaiter = null;
                //Join to synchronization queue
                transferForSignal(first);
                first = next;
            } while (first != null);
        }

It can be seen that signal() only moves the head node of the waiting queue to the synchronization queue, while signalAll() moves all nodes in the waiting queue to the synchronization queue.

5. Similarities and differences between synchronization queue and waiting queue

1. The synchronization queue is implemented by a two-way linked list (FIFO). The precursor and successor nodes are linked through the Node.prev/Node.next pointer.
2. The waiting queue is implemented by a one-way linked list (FIFO), and the subsequent nodes are linked through the Node. nextWaiter pointer.
3. The nodes in both queues are of Node type.
4. If the lock acquisition fails, it will be added to the tail of the synchronization queue for waiting. If the lock acquisition succeeds, it will be removed from the synchronization queue.
5. Calling await() will be added to the tail of the waiting queue, and calling signal() will be removed from the head of the waiting queue and added to the tail of the synchronization queue.

6. The difference between Condition.await/Condition.signal and Object.wait/Object.notify

Let's start with a code:
Wait / notify application of Object.java:

    Object object = new Object();
    private void testObjectWait() {
        synchronized (object) {
            try {
                object.wait();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    private void testObjectNotify() {
        synchronized (object) {
            object.notify();
        }
    }

Let's look at the Condition waiting / notification application:

    Lock lock = new ReentrantLock();
    Condition condition = lock.newCondition();
    Condition condition2 = lock.newCondition();
    private void testConditionAwait() {
        lock.lock();
        try {
            condition.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

    private void testConditionSignal() {
        lock.lock();
        try {
            condition.signal();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            lock.unlock();
        }
    }

Similarities and differences between the two:
Same point

1. The corresponding method can only be called after obtaining the lock. Obtaining the lock is to ensure the correctness of the condition variable under the condition of thread concurrency.
2. Wait / notify methods appear in pairs.
3. Waiting methods can respond to interrupts.
4. All wait methods support timeout return.

difference

Condition waiting / notification depends on AQS, that is, it needs to be used with Lock and implemented in JDK.
Object wait / notification depends on the synchronized keyword and is implemented in the JVM.

In addition, the Condition wait / notification mechanism is more flexible than the Object wait / notification mechanism, as follows:

1. Condition wait can respond to an interrupt or not.
2. Condition wait can set the timeout to a future point in time.
2. The same lock can generate multiple conditions.

The next article will focus on the implementation and application of subclass wrappers derived from AQS, such as ReentrantLock, ReentrantReadWriteLock, Semaphore and CountDownLatch.

This paper is based on jdk1.8.

If you like it, please praise and pay attention. Your encouragement is my driving force

During continuous update, work with me step by step to learn more about Android/Java

Posted by Shane10101 on Sat, 18 Sep 2021 12:31:58 -0700