Explanation of AQS and ReentrantLock with ten thousand words of super strong graphics and text (recommended Collection)

|Make a habit of looking good

  • You have an idea. I have an idea. After we exchange, one person has two ideas

  • If you can NOT explain it simply, you do NOT understand it well enough

Now we are sorting out the Demo code and technical articles one by one Github practice selection , it's convenient for you to read and check. This article is also included here. I think it's good. Please Star

Write in front

I have entered the source code stage and have written more than ten articles Concurrent series Knowledge is finally coming to use. I believe that many people have forgotten some of the theoretical knowledge. Don't worry. I will bring in the corresponding theoretical knowledge points in the source code link to help you recall and combine theory with practice. In addition, this is an extra long picture and text. It's recommended to collect. If it's useful for you, please let more people see it

Why Java SDK design Lock

Once upon a time, I dreamed that if Java concurrency control is only as good as synchronized, there are only three ways to use it, simple and convenient

public class ThreeSync {

	private static final Object object = new Object();

	public synchronized void normalSyncMethod(){
		//Critical area
	}

	public static synchronized void staticSyncMethod(){
		//Critical area
	}

	public void syncBlockMethod(){
		synchronized (object){
			//Critical area
		}
	}
}

If it is true before Java 1.5, master Doug Lea has rebuilt a wheel Lock since version 1.5

***

I don't know if you remember *** , where [inalienable condition] means:

The thread has obtained the resource. Before it is used up, it cannot be deprived. It can only be released when it is used up

To break this condition, you need to have the ability to release existing resources before applying for further resources

***

Explicit Lock

***

characteristic describe API
Able to respond to interruptions If it can't be released by itself, it's also good to respond to interrupts. Java multithreading interrupt mechanism It describes the interrupt process specifically, which aims to jump out of a certain state, such as blocking, through the interrupt signal lockInterruptbly()
Non blocking access lock Try to get it. If you can't get it, it won't block. Return directly tryLock()
Support timeout Given a time limit, if it is not obtained within a period of time, it does not enter the blocking state, and it also returns directly tryLock(long time, timeUnit)

A good solution is available, but you can't have both fish and bear's paw. Lock has more features that synchronized doesn't have. Naturally, it won't go around the world with one keyword and three playing methods like synchronized, and its use is relatively complicated

Lock usage paradigm

synchronized has a standard usage, such a fine tradition we need Lock. I believe many people know a paradigm of using Lock

Lock lock = new ReentrantLock();
lock.lock();
try{
	...
}finally{
	lock.unlock();
}

Since it's a paradigm, there must be a reason. Let's take a look

Release lock in standard 1-finally

You should all understand that the purpose of releasing a lock in finally is to ensure that after the lock is acquired, it can finally be released

Standard 2 - get lock outside try {}

I don't know if you have thought about why standard 2 exists. We usually "like" to try to live in all the content, for fear of exception and can't be caught

There are two main aspects to be considered in obtaining lock outside try {}:

  1. If you don't get the lock, throw an exception. It must be a problem to release the lock eventually, because how can you release the lock before you have the lock
  2. If an exception is thrown when acquiring the lock, that is, the current thread does not acquire the lock, but when executing the finally code, if another thread happens to acquire the lock, it will be released (released without reason)

The implementation of different locks is slightly different. The existence of paradigms is to avoid all problems, so we try to follow the paradigms

How does Lock function as a Lock?

If you are familiar with synchronized, you know that after the program is compiled into CPU instructions, there will be moniterenter and moniterexit instructions in the critical area, which can be understood as the identification of entering and leaving the critical area

From the perspective of paradigm:

  • lock.lock() acquire lock, moniterenter instruction equivalent to "synchronized"

  • lock.unlock() release lock, moniterexit instruction equivalent to "synchronized"

So how does Lock do it?

Here's a brief introduction. In this way, when you come to source code analysis, you can see the design outline from afar and the implementation details from afar, which will become easier

In fact, it is very simple. For example, a volatile variable state is maintained in ReentrantLock, which is read and written through CAS (the bottom layer is still handed over to the hardware to ensure atomicity and visibility). If the CAS change is successful, the lock is acquired, and the thread enters the try code block for continuous execution. If the change is not successful, the thread will be [suspended] and will not be executed downward

But Lock is an interface. There is no state variable in it

What does it do with this state? It's obvious that we need a little design bonus. The interface defines the behavior, which needs to implement the class

The implementation classes of Lock interface basically complete thread access control by aggregating a subclass of queue synchronizer

So what is a queue synchronizer? (this should be the most powerful title party you've ever seen. It took half a century to get to the point. I was scolded in the comment area.)

Queue synchronizer AQS

Abstract queued synchronizer, or AQS for short, is our hero today

Q: why do you analyze the source code of JUC from AQS?

A: look at the picture below

I'm sure you can see the screenshot. You've heard about it. It's often asked about in interviews and used in work

  • ReentrantLock
  • ReentrantReadWriteLock
  • Semaphore (semaphore)
  • CountDownLatch
  • Fair lock
  • Unfair lock
  • ThreadPoolExecutor (for the understanding of thread pool, please refer to Why use thread pools? )

All of them are directly related to AQS, so understand the abstract implementation of AQS, and on this basis, check the implementation details of the above categories a little bit, and all of them can be done soon, so as not to be confused when you check the source code and lose the main line

As mentioned above, the synchronizer will be aggregated in the lock implementation class, and then the synchronizer will be used to implement the lock semantics. Then the problem comes:

Why use aggregation mode, how to further understand the relationship between lock and synchronizer?

Most of us use locks. After implementing locks, the core is to make them easy to use

From AQS In terms of class name and decoration, this is an abstract class, so from the perspective of design pattern, synchronizer must be designed based on template pattern. Users need to inherit synchronizer, implement user-defined synchronizer, rewrite specified methods, then group synchronizer into user-defined synchronization components, and call template methods of synchronizer, and these template methods Call back the method rewritten by the user

I don't want to make the above explanation so abstract. In fact, to understand the above sentence, we just need to know the following two questions

  1. What are the methods that a custom synchronizer can override?
  2. What are the template methods provided by the abstract synchronizer?

Rewritable method of synchronizer

The synchronizer provides only five rewritable methods, which greatly facilitates the lock users:

In principle, the methods that need to be rewritten should also be modified by abstract. Why not here? In fact, the reason is very simple. I have divided the above methods into two categories by color area:

  • exclusive
  • Shared

A custom synchronization component or lock cannot be exclusive or shared. In order to avoid forcing an irrelevant method to be rewritten, there is no abstract to modify it. However, an exception should be thrown to inform that the method cannot be used directly:

    protected boolean tryAcquire(int arg) {
        throw new UnsupportedOperationException();
    }

It's warm and considerate (if you have similar needs, you can imitate this design)

The synchronization state mentioned in the table method description is the state decorated with volatile, so when we rewrite the above methods, we also need to obtain or modify the synchronization state through the following three methods (provided by AQS) provided by the synchronizer:

The difference between exclusive and shared operation state variables is very simple

So the reentrantlock reentrantreadwritelock semaphore (semaphore) CountDownLatch classes you see are only slightly different in the implementation of the above methods. Other implementations are implemented through the template method of synchronizer. Have you relaxed your mind a lot? Let's take a look at the template method:

Template method provided by synchronizer

Above, we divide the implementation methods of synchronizer into exclusive and shared methods. In addition to providing the above two types of template methods, template methods only have more template methods for response interrupt and timeout limit for Lock. Let's take a look at them

Don't remember the functions of the above methods. At present, you only need to know about some functions. In addition, I believe you have noticed:

The above methods are all decorated with the final keyword, indicating that the subclass cannot override this method

Seeing this, you may be a little confused. Let's sum it up:

Programmers should be more realistic about the code. Let's use the code to explain the above relationship (note the comments in the code, the following code is not very rigorous, just to simply explain the code implementation in the figure above):

package top.dayarch.myjuc;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;

/**
 * Custom mutex
 *
 * @author tanrgyb
 * @date 2020/5/23 9:33 PM
 */
public class MyMutex implements Lock {

	// Static inner class - Custom synchronizer
	private static class MySync extends AbstractQueuedSynchronizer{
		@Override
		protected boolean tryAcquire(int arg) {
			// Call the method provided by AQS to ensure atomicity through CAS
			if (compareAndSetState(0, arg)){
				// We implement the mutex, so mark the thread that gets the synchronization state (update state succeeded),
				// Mainly to determine whether it can be re entered (to be explained later)
				setExclusiveOwnerThread(Thread.currentThread());
				//Get synchronization status successfully, return true
				return true;
			}
			// Failed to get synchronization status, return false
			return false;
		}

		@Override
		protected boolean tryRelease(int arg) {
			// If the lock is not owned but released, the IMSE will be thrown
			if (getState() == 0){
				throw new IllegalMonitorStateException();
			}
			// Can release, clear exclusive thread mark
			setExclusiveOwnerThread(null);
			// Set the synchronization status to 0 to release the lock
			setState(0);
			return true;
		}

		// Exclusive holding or not
		@Override
		protected boolean isHeldExclusively() {
			return getState() == 1;
		}

		// It will be used later, mainly for waiting / notification mechanism. Each condition has a corresponding condition waiting queue, which is described in the lock model
		Condition newCondition() {
			return new ConditionObject();
		}
	}

  // Aggregate custom synchronizer
	private final MySync sync = new MySync();


	@Override
	public void lock() {
		// Blocking access to lock, exclusive access to synchronizer template method, access to synchronization status
		sync.acquire(1);
	}

	@Override
	public void lockInterruptibly() throws InterruptedException {
		// Calling the synchronizer template method to obtain the synchronization status interruptively
		sync.acquireInterruptibly(1);
	}

	@Override
	public boolean tryLock() {
		// Call your own overridden method to get the synchronization status nonblocking
		return sync.tryAcquire(1);
	}

	@Override
	public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
		// Call the synchronizer template method to respond to interrupt and timeout limits
		return sync.tryAcquireNanos(1, unit.toNanos(time));
	}

	@Override
	public void unlock() {
		// Release lock
		sync.release(1);
	}

	@Override
	public Condition newCondition() {
		// Use custom conditions
		return sync.newCondition();
	}
}

If you open the IDE now, you will find that the reentrantlock reentrantreadwritelock semaphore (semaphore) CountDownLatch mentioned above is implemented according to this structure, so let's take a look at how the AQS template method implements locks

AQS implementation analysis

From the above code, you should understand lock.tryLock() non blocking access lock is to call the tryAcquire() method rewritten by the custom synchronizer, set the state state through CAS, and it will return immediately whether it succeeds or not; then lock.lock() how is this blocking lock implemented?

If there is a block, you need to queue

CLH: Craig, Landin and Hagersten queue is a one-way linked list, and the queue in AQS is the virtual two-way queue (FIFO) of CLH variant -- just understand the concept, don't remember

Each queued individual in the queue is a Node, so let's take a look at the structure of Node

Node node

A synchronization queue is maintained within AQS to manage synchronization status.

  • When the thread fails to obtain the synchronization status, it will construct a Node node with the current thread and waiting status information, and add it to the tail of the synchronization queue to block the thread
  • When the synchronization state is released, the thread of the "first node" in the synchronization queue will be woken up to obtain the synchronization state

In order to make the above steps clear, we need to take a look at the Node structure (if you can open the IDE to see it together, it is excellent)

At first glance, it seems a bit messy. Let's classify it as follows:

It's good to have an impression on the above status descriptions. With the structure description of Node, you can also imagine the connection structure of synchronous queue:

The pre knowledge is basically finished. Let's take a look at the whole process of exclusive access to synchronization state

Exclusive get synchronization status

Stories should be based on Paradigms lock.lock() start

public void lock() {
	// Blocking lock acquisition, calling synchronizer template method to obtain synchronization status
	sync.acquire(1);
}

Enter AQS template method acquire()

public final void acquire(int arg) {
  // Call the tryAcquire method of custom synchronizer rewrite
	if (!tryAcquire(arg) &&
		acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
		selfInterrupt();
}

First of all, it will also try to obtain the synchronization status non blocking. If the acquisition fails (tryAcquire returns false), it will call the addWaiter method to construct the node node( Node.EXCLUSIVE Exclusive) and secure (CAS) are added to the synchronization queue [tail]

    private Node addWaiter(Node mode) {
      	// Construct Node node, including current thread information and Node mode exclusive / share
        Node node = new Node(Thread.currentThread(), mode);
      	// New variable pred points to the node pointed to by tail
        Node pred = tail;
      	// If the tail node is not empty
        if (pred != null) {
          	// The newly added node's predecessor node points to the tail node
            node.prev = pred;

          	// Because if multiple threads fail to get synchronization status at the same time, this code will be executed
            // Therefore, CAS ensures that the current node is the latest tail node
            if (compareAndSetTail(pred, node)) {
              	// The successor node of the former tail node points to the current node
                pred.next = node;
              	// Return to the newly built node
                return node;
            }
        }
      	// The tail node is empty, indicating that the current node is the first node added to the synchronization queue
      	// Need a join operation
        enq(node);
        return node;
    }

    private Node enq(final Node node) {
      	// Ensure that the node is added correctly through the "dead cycle" and will not return until it is finally set as the tail node. The reason for using CAS here is the same as above
        for (;;) {
            Node t = tail;
          	// The first loop, if the tail node is null
            if (t == null) { // Must initialize
              	// Build a sentinel node and point the head pointer at it
                if (compareAndSetHead(new Node()))
                  	// The tail pointer also points to the sentinel node
                    tail = head;
            } else {
              	// The second cycle points the precursor node of the new node to t
                node.prev = t;
              	// Add a new node to the queue tail node
                if (compareAndSetTail(t, node)) {
                  	// The successor node of the predecessor node points to the current new node to complete the two-way queue
                    t.next = node;
                    return t;
                }
            }
        }
    }

You may be confused about the processing of enq(). Entering this method is a "dead cycle". Let's use the diagram to describe how it jumps out of the cycle

Some students may have questions. Why do they have sentinel nodes?

Sentinels, as the name suggests, are used to solve border problems between countries and do not directly participate in production activities. In the same way, sentinels mentioned in computer science are also used to solve boundary problems. If there is no boundary, the specified link may have exceptions at the boundary according to the same algorithm, such as acquireQueued() method to continue the downward analysis

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
          	// ***
            for (;;) {
              	// Get the predecessor node of the current node
                final Node p = node.predecessor();
              	// Only when the predecessor of the current node is the head node, will the lock be attempted to be acquired
              	// See this, you should understand the meaning of adding sentinel node
                if (p == head && tryAcquire(arg)) {
                  	// Get synchronization status successfully, set yourself as the header
                    setHead(node);
                  	// Leave the successor node of the sentinel node blank for GC
                    p.next = null; // help GC
                    failed = false;
                  	// Return interrupt ID
                    return interrupted;
                }
              	// The predecessor node of the current node is not the head node
              	//[or] the precursor node of the current node is the head node, but failed to obtain the synchronization status
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

It's understandable that a successful synchronization will return, but if it fails, it will fall into a "dead cycle" and waste resources? Obviously not. shouldParkAfterFailedAcquire(p, node) and parkAndCheckInterrupt() will suspend the thread that failed to get synchronization status. Let's continue to look down

    private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
      	// Get the status of the precursor node
        int ws = pred.waitStatus;
      	// If it is in the SIGNAL state, i.e. waiting for the occupied resources to be released, it will directly return true
      	// Ready to continue calling the parkAndCheckInterrupt method
        if (ws == Node.SIGNAL)
            return true;
      	// If ws is greater than 0, it indicates canceled status,
        if (ws > 0) {
            // Cycle to judge whether the precursor node of the precursor node is also in canceled state, ignore the node in this state and reconnect the queue
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else {
          	// Set the predecessor node of the current node to the SIGNAL state for subsequent wake-up operations
          	// When the program executes for the first time, it returns false, and it will go through an outer loop for the second time, finally returning from line 7 of the code
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        return false;
    }

Here you may have a question:

What is the effect of setting the precursor node to the SIGNAL state in this place?

Keep this question, we will unveil it one after another

If the waitStatus of the predecessor node is the SIGNAL state, that is, the shouldParkAfterFailedAcquire method will return true, and the program will continue to execute the parkAndCheckInterrupt method downward to suspend the current thread

    private final boolean parkAndCheckInterrupt() {
      	// Thread suspended, program will not continue to execute downward
        LockSupport.park(this);
      	// According to the park method API description, the program will continue to execute downward in the following three cases
      	// 	1. unpark 
      	// 	2. Interrupted
      	// 	3. Other illogical returns will continue to be executed downward
      	
      	// Because of the above three situations, the program returns to the interrupt state of the current thread and clears the interrupt state
      	// If interrupted, the method returns true
        return Thread.interrupted();
    }

The waked program will continue to execute the loop in acquireQueued method. If the synchronization status is obtained successfully, the result of interrupted = true will be returned

The program continues to return to the upper layer of the call stack, and finally returns to AQS template method acquire

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

You may have doubts:

The program has successfully obtained the synchronization state and returned, how can there be a self interruption?

static void selfInterrupt() {
    Thread.currentThread().interrupt();
}

If you can't understand the interruption, it's highly recommended that you look back Java multithreading interrupt mechanism

We have missed a line about obtaining synchronization status here. If you look closely at the final code block of acquirequeueued, you may have doubts immediately:

Under what circumstances will the code in if(failed) be executed?

if (failed)
  cancelAcquire(node);

The condition for this code to be executed is that failed is true. Under normal circumstances, if you jump out of the loop, the value of failed is false. If you can't jump out of the loop, it seems that you can't execute here. Therefore, only under abnormal circumstances can you execute here. That is to say, an exception will occur before you execute here

Looking at the try code block, there are only two methods that throw exceptions:

  • node.processor() method

  • tryAcquire() method rewritten by oneself

First look at the former:

Obviously, the exception thrown here is not important. Take the tryAcquire() method overridden by ReentrantLock as an example

In addition, the above analysis of the shouldParkAfterFailedAcquire method also judges the status of canceled

When will nodes in the cancelled state be generated?

The answer is in the cancelAcquire method. Let's see how cancelAcquire can set / handle CANNELLED

	private void cancelAcquire(Node node) {
        // Ignore invalid nodes
        if (node == null)
            return;
				// Clear the associated thread information
        node.thread = null;

        // Skip the precursor node that is also cancelled
        Node pred = node.prev;
        while (pred.waitStatus > 0)
            node.prev = pred = pred.prev;

        // Jump out of the above loop to find the precursor effective node and obtain the successor node of the effective node
        Node predNext = pred.next;

        // Set the status of the current node to cancel LED
        node.waitStatus = Node.CANCELLED;

        // If the current node is at the end node, just delete itself from the queue
        if (node == tail && compareAndSetTail(node, pred)) {
            compareAndSetNext(pred, predNext, null);
        } else {
            int ws;
          	// 1. If the effective predecessor node of the current node is not the head node, that is to say, the current node is not the successor node of the head node
            if (pred != head &&
                // 2. Judge whether the status of the valid precursor node of the current node is SIGNAL
                ((ws = pred.waitStatus) == Node.SIGNAL ||
                 // 3. If not, try to set the state of the precursor node to SIGNAL
                 (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
                // Judge whether the thread information of the valid predecessor node of the current node is empty
                pred.thread != null) {
              	// The above conditions are met
                Node next = node.next;
              	// Point the pointer of the successor node of the valid predecessor node of the current node to the successor node of the current node
                if (next != null && next.waitStatus <= 0)
                    compareAndSetNext(pred, predNext, next);
            } else {
              	// If the predecessor node of the current node is the head node, or other conditions above are not met, wake up the successor node of the current node
                unparkSuccessor(node);
            }
						
            node.next = node; // help GC
        }

You may be confused when seeing this note. Its core purpose is to remove CANCELLED nodes from the waiting queue and reassemble the whole queue. In summary, there are only three situations for setting CANCELLED status nodes. Let's draw a picture to analyze:

At this point, the process of obtaining the synchronization status is over. Let's simply illustrate the whole process with a flowchart

This is the end of the lock acquisition process. First pause for a few minutes to sort out your own ideas. We haven't explained the function of SIGNAL yet. What is the purpose of SIGNAL status SIGNAL? This involves the release of locks. Let's continue to understand that the overall idea is the same as the acquisition of locks, but the release process is relatively simple

Exclusive release synchronization state

The story starts with the unlock() method

	public void unlock() {
		// Release lock
		sync.release(1);
	}

Call AQS template method release to enter the method

    public final boolean release(int arg) {
      	// Call the tryRelease method written by the custom synchronizer to try to release the synchronization state
        if (tryRelease(arg)) {
          	// Release succeeded, get header node
            Node h = head;
          	// There is a header node and waitStatus is not the initial state
          	// We have analyzed the process of obtaining, during which we will update the value of waitStatus from the initial state to the SIGNAL state
            if (h != null && h.waitStatus != 0)
              	// Release thread suspend state
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

Look at the unparkSuccessor method, which is actually the successor node to wake up the header node

    private void unparkSuccessor(Node node) {      
      	// Get the waitStatus of the header node
        int ws = node.waitStatus;
        if (ws < 0)
          	// The waitStatus value of the clearing head node is set to 0
            compareAndSetWaitStatus(node, ws, 0);
      
      	// Get the successor node of the header node
        Node s = node.next;
      	// Determine whether the successor node of the current node is in the cancelled state. If so, remove it and reconnect the queue
        if (s == null || s.waitStatus > 0) {
            s = null;
          	// Look forward from the tail node to find the first node in the queue with waitStatus less than 0
            for (Node t = tail; t != null && t != node; t = t.prev)
              	// If it's exclusive, if it's less than 0, it's SIGNAL
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
          	// Release thread suspend state
            LockSupport.unpark(s.thread);
    }

Some students may have questions:

Why does this place look forward from the end of the queue for nodes that are not CANCELLED?

There are two reasons:

First, let's look back at the scenario of nodes joining the queue:

    private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

Node queuing is not an atomic operation, lines 6 and 7 of the code

node.prev = pred; 
compareAndSetTail(pred, node) 

These two places can be regarded as atomic operations of tail node queuing, if the code has not yet executed pred.next =Node; at this time, it happens that the unparkSuccessor method is executed, so there is no way to look from the front to the back. Because the subsequent pointers are not connected, you need to look from the back to the front

The second reason is that the Next pointer is disconnected first and the Prev pointer is not. Therefore, it is necessary to traverse all nodes from the back to the front

The synchronization state has been released successfully. The thread that has previously obtained the synchronization state and is suspended will be woken up to continue execution from line 3 of the following code:

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

Continue to return to the upper call stack, start from line 15 of the following code, execute the loop again, and try to get the synchronization status again

    final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            for (;;) {
                final Node p = node.predecessor();
                if (p == head && tryAcquire(arg)) {
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

So far, the exclusive lock acquisition / release process has been closed, but the other two template methods of AQS have not been introduced

  • Response interrupt
  • Timeout limit

Exclusive response interrupt get synchronization state

Story from lock.lockInterruptibly() about methods

	public void lockInterruptibly() throws InterruptedException {
		// Calling the synchronizer template method to obtain the synchronization status interruptively
		sync.acquireInterruptibly(1);
	}

With the previous understanding, it can be understood at a glance to understand the exclusive and responsive interrupt acquisition synchronization state mode:

    public final void acquireInterruptibly(int arg)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
      	// The attempt to obtain the synchronization state non blocking failed. If the synchronization state is not obtained, execute line 7
        if (!tryAcquire(arg))
            doAcquireInterruptibly(arg);
    }

Continue to see the doacquireinterruptible method:

    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;
                    return;
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                  	// After getting the interrupt signal, the value of interrupted = true is no longer returned, but the InterruptedException is thrown directly 
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

Unexpectedly, there is such similar code in the JDK. It's not too profound to respond to the interrupt acquisition lock. The interrupt throws an InterruptedException exception (line 17 of the code), which returns to the upper call stack layer by layer to catch the exception for the next operation

To strike while the iron is hot, take a look at another template method:

Exclusive timeout limit get synchronization status

It is well understood that given a time limit, if the synchronization status is obtained within the time period, true will be returned; otherwise, false will be returned. For example, if a thread sets an alarm for itself, once the alarm rings, the thread will return itself, which will not make itself blocked

Since the time-out limit is involved, the core logic must be to calculate the time interval. Because in the time-out period, there must be multiple attempts to acquire the lock, and each acquisition of the lock must take time, so the logic to calculate the time interval is as simple as the log we spend in the program printing program

nanosTimeout = deadline - System.nanoTime()

Story from lock.tryLock(time, unit) method

	public boolean tryLock(long time, TimeUnit unit) throws InterruptedException {
		// Call the synchronizer template method to respond to interrupt and timeout limits
		return sync.tryAcquireNanos(1, unit.toNanos(time));
	}

Take a look at the tryAcquireNanos method

    public final boolean tryAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
        if (Thread.interrupted())
            throw new InterruptedException();
        return tryAcquire(arg) ||
            doAcquireNanos(arg, nanosTimeout);
    }

Does it look very detailed with the acquireinterruptible method above? Continue to look at the doacquiranos method and the program. This method is also a throws InterruptedException. As we said in the interruption article, if there is a throws InterruptedException on the method tag, it means that this method can also respond to the interruption, so you can understand that the timeout limit is acquireinruptible Enhanced version of the method, double insurance with timeout and non blocking control

    private boolean doAcquireNanos(int arg, long nanosTimeout)
            throws InterruptedException {
      	// In the timeout period, in order to get the synchronization status, false is returned directly
        if (nanosTimeout <= 0L)
            return false;
      	// Calculate timeout deadline
        final long deadline = System.nanoTime() + nanosTimeout;
      	// Join synchronization queue exclusively
        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;
                }
              	// Calculate new timeout
                nanosTimeout = deadline - System.nanoTime();
              	// If timeout, return false directly
                if (nanosTimeout <= 0L)
                    return false;
                if (shouldParkAfterFailedAcquire(p, node) &&
                		// Determine whether the latest timeout is greater than the threshold value of 1000    
                    nanosTimeout > spinForTimeoutThreshold)
                  	// Suspend thread nanosTimeout for a long time, time to, automatic return
                    LockSupport.parkNanos(this, nanosTimeout);
                if (Thread.interrupted())
                    throw new InterruptedException();
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

The above method should not be difficult to understand, but students may be confused on line 27

Why is nanosTimeout compared to the spin timeout threshold of 1000?

    /**
     * The number of nanoseconds for which it is faster to spin
     * rather than to use timed park. A rough estimate suffices
     * to improve responsiveness with very short timeouts.
     */
    static final long spinForTimeoutThreshold = 1000L;

In fact, doc is very clear. To put it bluntly, the time of 1000 nanoseconds is very short. There is no need to perform suspend and wake-up operations. It is better to directly enter the next cycle of the current thread

Up to now, our custom MyMutex is only poor in Condition. I don't know if you are tired? I'm still holding on

Condition

If you read what you wrote before Waiting notification mechanism in concurrent programming You should be impressed by the following picture:

If you understand this model at that time, and then look at the implementation of Condition, it's not a problem at all. First, Condition is still an interface, and there must be an implementation class

The story starts from lock.newnewCondition Let's talk about it

	public Condition newCondition() {
		// Use custom conditions
		return sync.newCondition();
	}

The custom synchronizer encapsulates the method:

		Condition newCondition() {
			return new ConditionObject();
		}

ConditionObject is the implementation class of Condition, which is defined in AQS with only two member variables:

/** First node of condition queue. */
private transient Node firstWaiter;
/** Last node of condition queue. */
private transient Node lastWaiter;

So, we just need to take a look at the await / signal method implemented by ConditionObject to use these two member variables

        public final void await() throws InterruptedException {
            if (Thread.interrupted())
                throw new InterruptedException();
          	// Also build the Node node and join the waiting queue
            Node node = addConditionWaiter();
          	// Release synchronization status
            int savedState = fullyRelease(node);
            int interruptMode = 0;
            while (!isOnSyncQueue(node)) {
              	// Suspend current thread
                LockSupport.park(this);
                if ((interruptMode = checkInterruptWhileWaiting(node)) != 0)
                    break;
            }
            if (acquireQueued(node, savedState) && interruptMode != THROW_IE)
                interruptMode = REINTERRUPT;
            if (node.nextWaiter != null) // clean up if cancelled
                unlinkCancelledWaiters();
            if (interruptMode != 0)
                reportInterruptAfterWait(interruptMode);
        }

Note the words here. When it comes to obtaining the synchronization status, addWaiter is added to the synchronization queue, which is the entry waiting queue as shown in the figure above. Here, it is the waiting queue, so addConditionWaiter must have built its own queue:

        private Node addConditionWaiter() {
            Node t = lastWaiter;
            
            if (t != null && t.waitStatus != Node.CONDITION) {
                unlinkCancelledWaiters();
                t = lastWaiter;
            }
          	// The waitStatus of the newly built node is CONDITION. Note that it is not 0 or SIGNAL
            Node node = new Node(Thread.currentThread(), Node.CONDITION);
          	// Building a one-way synchronization queue
            if (t == null)
                firstWaiter = node;
            else
                t.nextWaiter = node;
            lastWaiter = node;
            return node;
        }

Here are friends who may have questions:

Why is it a one-way queue and does not use CAS to ensure the security of joining the queue?

Because await is used in Lock paradigm try, it indicates that the Lock has been acquired, so CAS is unnecessary. As for unidirectional, because there is no competitive Lock involved here, it is just a conditional waiting queue

In Lock, you can define multiple conditions, each of which corresponds to a condition waiting queue. Therefore, the above figure is a rich illustration of this situation:

The thread has been added to the conditional waiting queue according to the corresponding conditions. How can I try to acquire the lock again? The signal / signalAll method is already in use

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

The Signal method only wakes up the first node in the conditional wait queue by calling the doSignal method

        private void doSignal(Node first) {
            do {
                if ( (firstWaiter = first.nextWaiter) == null)
                    lastWaiter = null;
                first.nextWaiter = null;
              	// Call this method to move the thread node of the conditional wait queue to the synchronization queue
            } while (!transferForSignal(first) &&
                     (first = firstWaiter) != null);
        }

Continue with the transferForSignal method

    final boolean transferForSignal(Node node) {       
        if (!compareAndSetWaitStatus(node, Node.CONDITION, 0))
            return false;

       	// Rejoin the team
        Node p = enq(node);
        int ws = p.waitStatus;
        if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL))
          	// Wake up the thread in the synchronization queue
            LockSupport.unpark(node.thread);
        return true;
    }

So let's illustrate the whole process of wakeup

So far, it's very easy to understand signalAll, except that the loop judges whether there is nextWaiter. If there is one, just like the signal operation, move it from the conditional waiting queue to the synchronization queue

        private void doSignalAll(Node first) {
            lastWaiter = firstWaiter = null;
            do {
                Node next = first.nextWaiter;
                first.nextWaiter = null;
                transferForSignal(first);
                first = next;
            } while (first != null);
        }

I don't know if you remember. I'm here Waiting notification mechanism in concurrent programming There's also a word in

Try to use the signalAll method for no special reason

When can I use the signal method? Please check it by yourself

Here I want to say one more detail. There is a time difference between moving from the conditional waiting queue to the synchronous queue, so using the await() method is also a paradigm, which is also explained in this article

If there is a time gap, there will be fairness and unfairness. To fully understand this problem, we need to go to ReentrantLock. In addition to understanding fairness / unfairness, to check the application of ReentrantLock, we need to verify the AQS it uses. Let's continue

How ReentrantLock is applied to AQS

The typical application of exclusive is ReentrantLock. Let's see how it rewrites this method

At first glance, it seems strange how to customize three synchronizers: in fact, NonfairSync and FairSync only further divide Sync:

You should also know from the name, this is the fair lock / unfair lock you have heard

What is fair lock / unfair lock?

In life, it's fair to wait in line and come first. The fairness in the program also conforms to the absolute time of the request lock, in fact, it is FIFO, otherwise it will be regarded as unfair

Let's compare how ReentrantLock implements fair lock and unfair lock

In fact, it's no big deal. A fair lock is to determine whether there is a pioneer node in the synchronization queue. Only if there is no pioneer node can a lock be obtained. But a fair lock doesn't matter whether it can get the synchronization state. That's so simple. Here comes the problem:

Why is there a fair lock / unfair lock design?

Considering this problem, we need to recall the above implementation diagram of lock acquisition. In fact, I have revealed a little above

There are two main reasons:

Reason 1:

There is still a time difference between the recovery of the suspended thread and the acquisition of the real lock. From the perspective of human beings, the time is very small, but from the perspective of CPU, the time difference is still very obvious. Therefore, the unfair lock can make full use of the CPU time slice and minimize the CPU idle time

Reason 2:

I don't know if you remember me How many threads are suitable to create? It is repeatedly mentioned in the article that the important consideration of using multithreading is the cost of thread switching. Imagine that if unfair lock is used, when a thread requests a lock to obtain the synchronization state, then release the synchronization state, because there is no need to consider whether there are any precursor nodes, so the probability of the thread that just released the lock getting the synchronization state again at this moment becomes very large, so Reduce the cost of threads

Believe me, you will understand why ReentrantLock's default constructor uses an unfair lock synchronizer

    public ReentrantLock() {
        sync = new NonfairSync();
    }

See here, feel unfair lock perfect, no, there must be gains and losses

What's the problem with fair locks?

Fair locks ensure the fairness of queues. Unfair locks ignore this rule, which may lead to long queues and no chance to get locks. This is the legendary "hunger"

How to choose fair lock / unfair lock?

I believe that the answer is already in your mind. If you want to achieve higher throughput, it's obvious that the unfair lock is more appropriate. Because you can save a lot of thread switching time, the throughput will go up naturally. Otherwise, you can use the fair lock to give everyone a fair return

We're just one last step away. We really need to hold on

Reentrant lock

So far, we haven't analyzed the name of ReentrantLock. The name of JDK is so exquisite, and it must have its meaning

Why support lock reentry?

Imagine if it's a recursive call method decorated with synchronized, isn't it a big joke that the second entry of the program is blocked by itself, so synchronized supports lock reentry

Lock is a new wheel. Naturally, it also needs to support this function. Its implementation is also very simple. Please check the comparison diagram of fair lock and unfair lock, including a code:

// Determine whether the current thread is the same as the thread with lock occupied
else if (current == getExclusiveOwnerThread())

Look at the code carefully. You may find that one of my previous instructions is wrong. I need to explain it again

The reentry thread will always state + 1, and the release lock will state - 1 until it is equal to 0. This is also to help you distinguish quickly

summary

***

This is the end of the introduction of exclusive access lock. We are still far from AQS shared xxxShared. In combination with shared type, let's read Semaphore, ReentrantReadWriteLock, CountLatch, etc

At last, we welcome your comments. If there are any mistakes, please point out. My hands are sore and my eyes are dry. I'll prepare for the next

Soul questioning

  1. Why there are two ways to change state: setState(), compareandsetstate(), which feels safer, but is setState() safe in many places in the lock's sight?

  2. The following code is a transfer program. Is there any deadlock or other lock problems?

    class Account {
      private int balance;
      private final Lock lock
              = new ReentrantLock();
      // transfer accounts
      void transfer(Account tar, int amt){
        while (true) {
          if(this.lock.tryLock()) {
            try {
              if (tar.lock.tryLock()) {
                try {
                  this.balance -= amt;
                  tar.balance += amt;
                } finally {
                  tar.lock.unlock();
                }
              }//if
            } finally {
              this.lock.unlock();
            }
          }//if
        }//while
      }//transfer
    }
    

reference resources

  1. Java Concurrent practice
  2. The art of Java Concurrent Programming
  3. https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html

Tags: Java Programming less JDK

Posted on Tue, 02 Jun 2020 21:57:33 -0400 by MrCool