Briefly explain that J.U.C is a concurrency toolkit provided in JDK, java.util.concurrent. It provides many utility classes commonly used in concurrent programming, such as atomic operations, lock synchronization locks, fork/join, etc.
Starting from Lock
I want to use lock as a starting point to explain AQS. After all, synchronization lock is a common means to solve thread safety problems, and it is also a way we use more in our work.
Lock API
Lock is an interface, and the method is defined as follows
void lock() // If the lock is available, the lock is obtained. If the lock is unavailable, it is blocked until the lock is released void lockInterruptibly() // It is similar to the lock() method, but the blocked thread can be interrupted and throw a java.lang.InterruptedException exception boolean tryLock() // Non blocking acquisition lock; Attempts to acquire the lock, and returns true if successful boolean tryLock(long timeout, TimeUnit timeUnit) //Lock acquisition method with timeout void unlock() // Release lock
Implementation of Lock
There are many classes that implement the Lock interface. The following are some common Lock implementations
- ReentrantLock: represents a reentrant Lock. It is the only class that implements the Lock interface. Reentry Lock means that after a thread obtains a Lock, it does not need to be blocked to obtain the Lock again. Instead, it is directly associated with a counter to increase the number of reentries
- ReentrantReadWriteLock: reentrant read-write Lock. It implements the ReadWriteLock interface. In this class, two locks are maintained, one is ReadLock and the other is WriteLock. Both of them implement the Lock interface respectively. Read write Lock is a tool suitable for solving thread safety problems in the scenario of more reading and less writing. The basic principles are: read and read are not mutually exclusive, read and write are mutually exclusive, and write and write are mutually exclusive. In other words, operations that affect data changes will be mutually exclusive.
- stampedLock: stampedLock is a new locking mechanism introduced by JDK8. It can be simply regarded as an improved version of read-write lock. Although read and write lock can be completely concurrent by separating read and write, read and write conflict. If a large number of read threads exist, it may cause hunger of write threads. stampedLock is an optimistic read strategy so that optimistic locks do not block write threads at all
The simplicity and practicality of ReentrantLock
How to use ReentrantLock in practical applications? Let's demonstrate it with a simple demo
public class Demo { private static int count=0; static Lock lock=new ReentrantLock(); public static void inc(){ lock.lock(); try { Thread.sleep(1); count++; } catch (InterruptedException e) { e.printStackTrace(); }finally{ lock.unlock(); } }
This code mainly does one thing, that is, continuously incrementing the shared variable count through a static incr() method. If multiple threads access this method without adding synchronization lock, there will be thread safety problems. Therefore, ReentrantLock is used to implement synchronous lock and release the lock in the finally statement block.
So let me raise a question. Let's think about it
When multiple threads compete for locks through locks, how do they wait and wake up when the lock fails to compete?
What is AQS
The full name of aqs is AbstractQueuedSynchronizer. It provides a FIFO queue, which can be regarded as a core component used to implement synchronization lock and other synchronization functions. The common ones are ReentrantLock, CountDownLatch, etc.
AQS is an abstract class, which is mainly used by inheritance. It does not implement any synchronization interface, but only defines the methods of obtaining and releasing synchronization status to provide custom synchronization components.
It can be said that as long as you understand AQS, most APIs in J.U.C can be easily mastered.
###Two functions of AQS
From the perspective of usage, AQS has two functions: exclusive and shared
- Exclusive lock. Only one thread can hold the lock at a time. For example, the ReentrantLock demonstrated earlier is a mutually exclusive lock implemented in an exclusive manner
- Shared locks allow multiple threads to acquire locks at the same time and access shared resources concurrently, such as ReentrantReadWriteLock
Class diagram of ReentrantLock
Still take ReentrantLock as an example to analyze the use of AQS in reentry lock. After all, simple analysis of AQS does not have much meaning. Understanding this class diagram first can facilitate us to understand the principle of AQS
Internal implementation of AQS
The implementation of AQS depends on the internal synchronization queue, that is, the two-way queue of FIFO. If the current thread fails to compete for the lock, AQS will construct the current thread and waiting status information into a Node to join the synchronization queue, and then block the thread. When the thread acquiring the lock releases the lock, it will wake up a blocked Node (thread) from the queue.
The AQS queue maintains a FIFO two-way linked list. The characteristic of this structure is that each data structure has two pointers, pointing to the direct successor Node and the direct predecessor Node respectively. Therefore, the bidirectional linked list can easily access the predecessor and successor from any Node. Each Node is actually encapsulated by a thread. When the thread fails to compete for the lock, it will be encapsulated as a Node and added to the ASQ queue
The Node class consists of the following
static final class Node { static final Node SHARED = new Node(); static final Node EXCLUSIVE = null; static final int CANCELLED = 1; static final int SIGNAL = -1; static final int CONDITION = -2; static final int PROPAGATE = -3; volatile int waitStatus; volatile Node prev; //Precursor node volatile Node next; //Successor node volatile Thread thread;//Current thread Node nextWaiter; //Successor nodes stored in the condition queue //Is it a shared lock final boolean isShared() { return nextWaiter == SHARED; } final Node predecessor() throws NullPointerException { Node p = prev; if (p == null) throw new NullPointerException(); else return p; } Node() { // Used to establish initial head or SHARED marker } //Construct the thread as a Node and add it to the waiting queue Node(Thread thread, Node mode) { // Used by addWaiter this.nextWaiter = mode; this.thread = thread; } //This method will be used in the condition queue. A separate article will be written later to analyze the condition Node(Thread thread, int waitStatus) { // Used by Condition this.waitStatus = waitStatus; this.thread = thread; } }
###Release the lock and add thread changes to the queue
####Add node
When lock competition and lock release occur, the nodes in the AQS synchronization queue will change. First, take a look at the scenario of adding nodes.
Two changes will be involved here
- The new thread is encapsulated as a Node node and added to the synchronization queue. The prev Node is set and the next Node of the front Node of the current Node is modified to point to itself
- Re point to the new tail node through CAS tail
####Release lock remove node
The head node indicates the node that has successfully obtained the lock. When the head node releases the synchronization state, it will wake up the successor node. If the successor node obtains the lock successfully, it will set itself as the head node. The change process of the node is as follows
This process also involves two changes
- Modify the head node to point to the next node to obtain the lock
- For the new node that obtains the lock, point the pointer of prev to null
A small change here is that CAS is not required for setting the head node. The reason is that setting the head node is completed by the thread that obtains the lock, and the synchronization lock can only be obtained by one thread. Therefore, CAS guarantee is not required. Just set the head node as the successor node of the original head node and disconnect the next reference of the original head node
Source code analysis of AQS
After knowing the basic architecture of AQS, let's analyze the source code of AQS, still using ReentrantLock as the model.
Sequence diagram of ReentrantLock
Call the lock() method in ReentrantLock. I use a sequence diagram to show the calling process of the source code
It can be seen from the figure that when the lock acquisition fails, the addWaiter() method will be called to encapsulate the current thread as a Node and add it to the AQS queue. Based on this idea, let's analyze the source code implementation of AQS
Analysis source code
ReentrantLock.lock()
public void lock() { sync.lock(); }
This is the entry to obtain the lock. Call the method in the sync class. What is sync?
abstract static class Sync extends AbstractQueuedSynchronizer
sync is a static internal class, which inherits the abstract class AQS. As mentioned earlier, AQS is a synchronization tool, which is mainly used to realize synchronization control. When we use this tool, we will inherit it to realize synchronization control function.
Through further analysis, it is found that Sync has two specific implementations, namely nofairsync (unfair lock) and failsync (fair lock)
- Fair lock means that all threads acquire locks in strict accordance with FIFO
- Unfair lock means that there can be preemptive lock, that is, whether there are other threads waiting on the current queue or not, the new thread has the opportunity to preempt the lock
I will explain the difference between the implementation of fair lock and unfair lock later in the article. The following analysis still takes unfair lock as the main analysis logic.
NonfairSync.lock
final void lock() { if (compareAndSetState(0, 1)) //Modify the state through cas operation, indicating the operation of competing for lock setExclusiveOwnerThread(Thread.currentThread());//Sets the thread that currently obtains the lock state else acquire(1); //Try to get the lock }
This code briefly explains
- Because this is a non fair lock, when calling the lock method, first preempt the lock through cas
- If the lock preemption is successful, save the current thread that has successfully obtained the lock
- Lock preemption failed. Call acquire to perform lock contention logic
compareAndSetState
The code implementation logic of compareAndSetState is as follows
protected final boolean compareAndSetState(int expect, int update) { // See below for intrinsics setup to support this return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }
In fact, the logic of this code is very simple, which is to compare and replace it through cas optimistic locking. The above code means that if the value of state in the current memory is equal to the expected value expect, it will be replaced with update. If the update is successful, return true; otherwise, return false
This operation is atomic and will not cause thread safety problems. It involves the operation of Unsafe class and the meaning of state attribute at the first level.
state
AQS has such an attribute definition, which represents a synchronization state for the implementation of reentry lock. It has two meanings
- When state=0, it indicates no lock state
- When state > 0, it means that a thread has obtained a lock, that is, state=1. However, because ReentrantLock allows reentry, when the same thread obtains a synchronous lock multiple times, the state will increase. For example, if it reentries 5 times, state=5. When releasing the lock, it also needs to be released 5 times until state=0. Other threads are not eligible to obtain the lock
private volatile int state;
It should be noted that the meaning of state is different for different AQS implementations.
Unsafe
The unsafe class is under the sun.misc package and does not belong to the Java standard. However, many basic Java class libraries, including some widely used high-performance development libraries, are developed based on unsafe classes, such as Netty, Hadoop, Kafka, etc; Unsafe can be considered as a back door left in Java, providing some low-level operations, such as direct memory access, thread scheduling, etc
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
This is a native method. The first parameter is the object to be changed, the second is the offset (i.e. the previously calculated headOffset value), the third parameter is the expected value, and the fourth is the updated value
The function of the whole method is to update to the new expected value var5 if the value at the current time is equal to the expected value var4, and return true if the update is successful, otherwise return false;
acquire
Acquire is a method in AQS. If the CAS operation fails, it means that the state is no longer 0. At this time, continue the acquire(1) operation. Here, let's think about what the 1 parameter in the acquire method is used for? If you don't guess right, review the concept of state
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
The main logic of this method is
- Try to obtain the exclusive lock through tryAcquire. If it succeeds, it returns true and if it fails, it returns false
- If the tryAcquire fails, the current thread will be encapsulated as a Node and added to the end of the AQS queue through the addWaiter method
- Acquirequeueueued, take Node as a parameter and try to obtain the lock by spinning.
If you read what I wrote Synchronized source code analysis We should be able to understand the meaning of spin
NonfairSync.tryAcquire
This method is used to try to obtain the lock. If it succeeds, it returns true and if it fails, it returns false
It rewrites the tryAcquire method in the AQS class, and we take a closer look at the definition of the tryAcquire method in the AQS. Instead of implementing it, we throw an exception. According to the general thinking mode, since it is a template method that is not implemented, it should be defined as abstract and implemented by subclasses? Let's think about why
protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); }
nonfairTryAcquire
The implementation code of tryAcquire(1) in NonfairSync is as follows
ffinal boolean nonfairTryAcquire(int acquires) { //Gets the thread currently executing final Thread current = Thread.currentThread(); int c = getState(); //Get the value of state if (c == 0) { //state=0 indicates that the current state is unlocked //Replace the value of state with 1 through cas operation. Why do you use cas? //The reason is that in a multithreaded environment, directly modifying state=1 will have thread safety problems. Did you guess? if (compareAndSetState(0, acquires)) { //Save the thread that currently obtains the lock setExclusiveOwnerThread(current); return true; } } //This logic is very simple. If the same thread obtains the lock, the number of reentries will be increased directly else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; //Increase reentry times if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
- Get the current thread and judge the status of the current lock
- If state=0 indicates that the current state is unlocked, update the state value through cas
- If the current thread belongs to reentry, increase the number of reentries
addWaiter
When the tryAcquire method fails to acquire the lock, it will first call addWaiter to encapsulate the current thread into a Node, and then add it to the AQS queue
private Node addWaiter(Node mode) { //mode=Node.EXCLUSIVE //Encapsulate the current thread as a Node, and the mode is an exclusive lock Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure // tail is the attribute representing the end of the synchronization queue in AQS. It was initially null, so enq(node) method was used Node pred = tail; if (pred != null) { //If the tail is not empty, it indicates that there is node data in the queue node.prev = pred; //The prev Node of the Node of the current thread points to the tail if (compareAndSetTail(pred, node)) {//Add node to AQS queue through cas pred.next = node;//cas succeeds, pointing the next pointer of the old tail to the new tail return node; } } enq(node); //tail=null, add node to synchronization queue return node; }
- Encapsulate the current thread as a Node
- Judge whether the tail node in the current linked list is empty. If not, add the node of the current thread to the AQS queue through cas operation
- If it is empty or cas fails, enq is called to add the node to the AQS queue
enq
enq is to add the current node to the queue through spin operation
private Node enq(final Node node) { //Spin, not too much explanation, not clearly concerned about the official account [architect's book of practice] for (;;) { Node t = tail; //If it is added to the queue for the first time, then tail=null if (t == null) { // Must initialize //CAS creates an empty Node as the header Node if (compareAndSetHead(new Node())) //At this time, there is only one header node in the queue, so the tail also points to it tail = head; } else { //During the second cycle, the tail is not null and enters the else area. Point the prev of the Node node of the current thread to the tail, and then use CAS to point the tail to the Node node.prev = t; if (compareAndSetTail(t, node)) { //T at this time, it points to the tail, so CAS can succeed and re point the tail to the Node. At this time, t is the value of tail before updating, that is, it points to the empty head Node, t.next=node, and then points the subsequent nodes of the head Node to the Node and returns the head Node t.next = node; return t; } } } }
If two threads T1 and T2 enter the enq method at the same time, t==null indicates that the queue is used for the first time and needs to be initialized first
If the cas of another thread fails, it will enter the next cycle and add the node to the end of the queue through cas operation
So far, an AQS queue has been constructed through the addwaiter method, and the thread has been added to the node of the queue
acquireQueued
Pass the Node added to the queue as a parameter into the acquirequeueueueued method, which will preempt the lock
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor();// Get the prev node. If it is null, NullPointException will be thrown immediately if (p == head && tryAcquire(arg)) {// If the precursor is head, it is qualified to rob the lock setHead(node); // After obtaining the lock successfully, there is no need to synchronize. The thread that obtains the lock successfully is used as a new head node //For head nodes, head.thread and head.prev are always null, but head.next is not null p.next = null; // help GC failed = false; //Lock acquisition succeeded return interrupted; } //If the lock acquisition fails, determine whether to suspend the thread according to the waitStatus of the node if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())// If the preceding is true, the execution is suspended, and the interrupt flag is detected when waking up next time interrupted = true; } } finally { if (failed) // If an exception is thrown, the lock acquisition is canceled and the sync queue operation is performed cancelAcquire(node); } }
- Gets the prev node of the current node
- If the prev node is a head node, it is qualified to compete for the lock and call tryAcquire to seize the lock
- After the lock preemption is successful, set the node that obtains the lock as head, and remove the original initialized head node
- If the lock acquisition fails, the waitStatus determines whether the thread needs to be suspended
- Finally, cancel the lock acquisition by cancelAcquire
The previous logic is well understood. Let's mainly look at the function of shouldParkAfterFailedAcquire and parkAndCheckInterrupt
shouldParkAfterFailedAcquire
It can be seen from the above analysis that only the second node of the queue can have the opportunity to compete for locks. If the lock is successfully obtained, this node will be promoted to the head node. For the third and subsequent nodes, if (p == head) condition does not hold, first perform shouldParkAfterFailedAcquire(p, node) operation
The shouldParkAfterFailedAcquire method is to determine whether a thread competing for a lock should be blocked. It first determines whether the state of the front node of a node is Node.SIGNAL. If yes, it indicates that the node has set the State - if the lock is released, it should be notified, so it can block safely and return true.
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; //Status of predecessor node if (ws == Node.SIGNAL)//If it is in the SIGNAL state, it means that the current thread needs to be awakened by unpark return true; If the status of the previous node is greater than 0, it is CANCELLED When the node is in the state, it will start to cycle step by step from the front node to find one that has not been deleted“ CANCELLED"The node is set as the front node of the current node. Return false. Execute in next cycle shouldParkAfterFailedAcquire When, return true. This operation is actually put in the queue CANCELLED Remove all nodes. if (ws > 0) {// If the predecessor node is in "Cancel" status, set "current predecessor node" of "current node" to "predecessor node of" original predecessor node ". do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { // If the predecessor node is in "0" or "shared lock" state, set the predecessor node to SIGNAL state. /* * waitStatus must be 0 or PROPAGATE. Indicate that we * need a signal, but don't park yet. Caller will need to * retry to make sure it cannot acquire before parking. */ compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }
parkAndCheckInterrupt
If shouldParkAfterFailedAcquire returns true, it will execute the parkAndCheckInterrupt() method, which suspends the current thread to the waiting state through LockSupport.park(this). It needs to wait for an interrupt and unpark method to wake it up. The Lock operation is realized through the wait of such a FIFO mechanism.
private final boolean parkAndCheckInterrupt() { LockSupport.park(this); return Thread.interrupted(); }
LockSupport
LockSupport class is a class introduced in Java 6 and provides basic thread synchronization primitives. LockSupport actually calls functions in Unsafe class. In Unsafe, there are only two functions:
public native void unpark(Thread jthread); public native void park(boolean isAbsolute, long time);
The unpark function provides "permission" for the thread, and the thread calls the park function and waits for "permission". This is a bit like a semaphore, but the "permission" cannot be superimposed, and the "permission" is one-time.
Permission is equivalent to the switch of 0 / 1. By default, it is 0. Calling unpark once will add 1 to become 1. Calling park once will consume permission and become 0 again. If you call park again, it will block because the permission is already 0. Until the permission becomes 1. Calling unpark at this time will set the permission to 1. Each thread has a related permission. There is only one permission at most, and repeated calls to unpark will not accumulate
Lock release
ReentrantLock.unlock
After analyzing the lock adding process, analyze the lock releasing process and call the release method, which does two things: 1. Release the lock; 2. Wake up the park thread
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }
tryRelease
This action can be regarded as an operation to set the lock state, and subtract the passed parameter value from the state (the parameter is 1). If the result state is 0, set the Owner of the exclusive lock to null, so that other threads have the opportunity to execute.
In an exclusive lock, the state will increase by 1 when the lock is added (of course, you can modify this value yourself) and decrease by 1 when the lock is unlocked. After the same lock can be re entered, it may be superimposed into the values of 2, 3 and 4. Only when the number of unlock() corresponds to the number of lock() will the Owner thread be set to null, and only in this case will it return true.
protected final boolean tryRelease(int releases) { int c = getState() - releases; // Here is to reduce the number of locks by 1 if (Thread.currentThread() != getExclusiveOwnerThread())// If the released thread is not the same as the thread obtaining the lock, an illegal monitor state exception is thrown throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { // Due to reentry, not every time the lock c is released is equal to 0, // The current thread will not be released until the last time the lock is released free = true; setExclusiveOwnerThread(null); } setState(c); return free; }
unparkSuccessor
In the method unparksuccess (node), it means to release the lock. It passes in the head node (the head node is the node that occupies the lock). After the current thread is released, it needs to wake up the thread of the next node
private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) {//Judge whether the successor node is empty or in cancelled status, s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) //Then traverse forward from the tail of the queue to find the first node with waitStatus less than 0. As for why traverse forward from the tail, only the change of next is set during the processing of doacquireinterruptible.cancelacquire method, and the change of prev is not set. At the end, there is such a line of code: node.next = node. If the unparksuccess method is executed at this time , and if you traverse backward, it becomes an endless loop, so only prev is stable at this time s = t; } //The first internal action is to obtain the next node of the head node. If the obtained node is not empty, directly release the corresponding suspended thread through the "LockSupport.unpark()" method. In this way, a node will wake up and continue to enter the loop, and further try the tryAcquire() method to obtain the lock if (s != null) LockSupport.unpark(s.thread); //Release license }
summary
Through this article, the implementation process of AQS queue is analyzed clearly, mainly the implementation of exclusive lock based on unfair lock. When obtaining the synchronization lock, the synchronizer maintains a synchronization queue, and the threads that fail to obtain the status will be added to the queue and spin in the queue; The condition to move out of the queue (or stop spinning) is that the precursor node is the head node and successfully obtains the synchronization status. When releasing the synchronization state, the synchronizer calls the tryRelease(int arg) method to release the synchronization state, and then wakes up the successor nodes of the head node.
- Pay attention to the official account of Mic architecture, and regularly update high-quality original articles.