History of adding/unlocking
Earlier (before jdk 1.5), when synchronization was used in concurrent environments, you didn't have many choices, mostly using synchronized keywords. Whether it's a synchronization method or a synchronization block, in short, if you encounter this keyword, the thread that has not acquired the lock will wait until the thread that has acquired the lock releases the lock.
After the launch of ReenntrantLock in jdk 1.5, this tool was once very popular. At that time, people preferred Lock to synchronized, mainly because it was flexible to use. (I still use synchronized scenes or Lock more often) Until later, more and more articles, from the performance, fairness, implementation principles and other aspects of comparison, we have a more intuitive understanding of them.
The purpose of this paper is to analyze the main implementation logic of ReenntrantLock and to explore the structure of AQS. If you are not lazy, I hope you can make a series of AQS and really understand the classic implementation of Doug Lea.
ReenntrantLock uses
Before we study the principle of tools, we should use them first.
tryLock()
public class ReentrantLockTest { Lock lock = new ReentrantLock(); //Create locks public void doSomething(){ //### 1 - Attempt to acquire locks, success if(lock.tryLock()){ System.out.println(String.format("%s Thread, get the lock",Thread.currentThread().getName())); try { //Analog logic execution TimeUnit.MILLISECONDS.sleep(1100L); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(String.format("%s Thread, Business Execution Completed",Thread.currentThread().getName())); lock.unlock(); //### 1.1 - Logic Execution, Lock Release } //### 2 - Attempt to acquire locks, fail else { System.out.println(String.format("%s Thread, acquisition lock failed",Thread.currentThread().getName())); } } public static void main(String[] args) throws InterruptedException { ReentrantLockTest test = new ReentrantLockTest(); int total = 3; while (total>0){ Thread t = new Thread(()->{ test.doSomething(); },"T-"+total); t.start(); total--; TimeUnit.MILLISECONDS.sleep(1000L); } } }
The tryLock() method attempts to retrieve the lock, and if not, returns false directly (without blocking); if the lock is retrieved, return true.
In the example above, the execution results are as follows:
T-3 thread, get the lock T-2 thread, acquisition lock failed T-3 Thread, Business Execution Completed T-1 thread, get the lock T-1 Thread, Business Execution Completed
lock()
Modify the locking method in the following example:
Lock lock = new ReentrantLock(); public void doSomething2(){ lock.lock(); System.out.println(String.format("%s Thread, get the lock",Thread.currentThread().getName())); try { TimeUnit.MILLISECONDS.sleep(1000L); //Analog business logic } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(String.format("%s Thread, Business Execution Completed",Thread.currentThread().getName())); lock.unlock(); } public static void main(String[] args) throws InterruptedException { ReentrantLockTest test = new ReentrantLockTest(); int total = 3; while (total>0){ Thread t = new Thread(()->{ test.doSomething2(); },"T-"+total); t.start(); total--; } }
Unlike tryLock(), lock() attempts to acquire a lock, and if not, it waits.
The execution result will change to:
T-3 thread, get the lock T-3 Thread, Business Execution Completed T-2 thread, get the lock T-2 Thread, Business Execution Completed T-1 thread, get the lock T-1 Thread, Business Execution Completed
ReenntrantLock Analysis
That's how ReenntrantLock is used, and it's coded. The following figure gives a partial structure of the ReenntrantLock class:
ReenntrantLock implements unfair locks by default (this article only analyses unfair implementations).
final Sync sync; public ReentrantLock() { sync = new NonfairSync(); //The member variable sync, which is assigned to the object of NonfairSync }
tryLock() implementation
Firstly, we study the simpler tryLock():
## ReentrantLock class public boolean tryLock() { return sync.nonfairTryAcquire(1); } ↓↓↓↓↓ ↓↓↓↓↓ ## ReentrantLock.Sync class final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); // 1 - Get the state state value in the AQS class if (c == 0) { // 2 - If the state is 0 (default), change the state atom to 1 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); // 2.1 - Atomic modification is successful, marking exclusive Owner Thread in AOS as the current thread return true; } } // 3 - At this point, the state is not 1. The current thread == exclusive Owner Thread in AOS changes the state to 1 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
The core logic of the tryLock() method is atomic modification of the state value in AQS, volatile+CAS (jdk9 VarHandle implementation).
Specifically:
In the process of implementation, the atomic CAS mode is adopted only when the state value is changed from 0 to 1 for the first time.
After that, it only judges whether the current thread is consistent with the exclusive owner Thread in AOS, and if it is consistent with state +; if it is inconsistent, it returns false directly.
unLock() implementation
The unLock() implementation is equally simple
## ReentrantLock class public void unlock() { sync.release(1); } ↓↓↓↓↓ ↓↓↓↓↓ ## ReentrantLock.Sync class public final boolean release(int arg) { ... tryRelease(arg) //Trying to release ... } ↓↓↓↓↓ ↓↓↓↓↓ ## ReentrantLock.Sync class protected final boolean tryRelease(int releases) { int c = getState() - releases; // state-- // 1-Verification Threads if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { //2 - If state==0, assign the result to true and empty the owner thread free = true; setExclusiveOwnerThread(null); } setState(c); //state assignment return free; }
If the operating thread is the owner thread (the first tryLock() will record owner):
tryLock() calls each time, state ++; unLock() calls each time, state - (when state=0, empty the owner thread).
Tip: Note 1, if the current thread is not the owner thread, the exception will be thrown directly!
lock() implementation
For tryLock(), it doesn't use the essence of AQS at all. Since it's called Abstract Queued Synchronizer, Abstract queue synchronizer, what's the focus of queue and synchronization? Don't worry, the lock() method will use this.
public void lock() { sync.acquire(1); } public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); //interrupt }
For the default unfair lock implementation, acquire(int arg) can be completely replaced by the following:
public final void acquire(int arg) { ##### tryAcquire(arg) changed to tryLock(arg) if (!tryLock(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); //Thread interrupt }
After this substitution, the logic is well understood: acquiQueued (addWaiter (Node. EXCLUSIVE), arg) is invoked in the case of a failure to obtain a lock with tryLock().
And acquireQueued (addWaiter (Node. EXCLUSIVE, arg)) is obviously divided into two methods, addWaiter and acquireQueued.
- First look at the addWaiter(Node.EXCLUSIVE) section:
private Node addWaiter(Node mode) { Node node = new Node(mode); //Create node s and bind threads at the same time for (;;) { Node oldTail = tail; if (oldTail != null) { //Loop 2 - Associates node nodes with queues initialized in the first loop node.setPrevRelaxed(oldTail); if (compareAndSetTail(oldTail, node)) { oldTail.next = node; return node; } } else { initializeSyncQueue(); //Loop 1 - Initialize synchronization queue } } }
Some key attributes of the AQS.Node class (the use of each attribute is indicated in text) need to be addressed here:
## Represents the status of Node nodes, including CANCELLED (to be cancelled), SIGNAL (to be awakened), CONDITION, or default zero states. volatile int waitStatus; static final int CANCELLED = 1; static final int SIGNAL = -1; static final int CONDITION = -2; volatile Node prev; //prev points to the front node volatile Node next; //next points to the back node ## Node-bound threads volatile Thread thread;
From the following figure, you can see more clearly the execution process of the addWaiter method (where thread T-3 is executing):
Conclusion 1:
` AddiWaiter `creates a queue and returns the tail node, which is `Node2'in the graph.`
- Look again at the acquireQueued(final Node node, int arg) method:
final boolean acquireQueued(final Node node, int arg) { boolean interrupted = false; try { for (;;) { final Node p = node.predecessor(); //Get the pre node, Node1 if (p == head && tryAcquire(arg)) { //### Note 1 - Attempt to retrieve the lock again setHead(node); //Get the lock, remove Node1, Node2 becomes the new head node p.next = null; // help GC return interrupted; } if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); } } ... } private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; if (ws == Node.SIGNAL) //Two loops, waitStatus==Node.SIGNAL, renturn true return true; if (ws > 0) { do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { pred.compareAndSetWaitStatus(ws, Node.SIGNAL); //For the first loop, change the waitStatus of pre node Node1 to SIGNAL } return false; }
Here, according to the figure above, explain in detail:
The reference of acquireQueued method mentioned earlier is the new tail node of addWaiter method, that is, input node= Node2, so node.predecessor() is naturally Node1 - p= Node1.
Note 1 position, first determine whether p is a header node:
-
If P is the head node (p is the head node in the figure above), try Acquire (arg) again to get the lock. There are also two situations:
- Thread T-3 has executed and released the lock, so the current thread T-2 can get the lock; then remove the current header Node1 and set Node2 as the header node.
- Thread T-3 is not finished, then the current thread T-2 cannot get the lock, and then the shouldParkAfterFailed Acquire (Node pred, Node node) method is executed.
- p is not a header node, and the shouldParkAfterFailed Acquire (Node pred, Node node) method is also executed
Because the shouldParkAfterFailed Acquire (Node pred, Node node) method is in the loop, it may execute twice:
- For the first loop, change the waitStatus of the pre node Node 1 to SIGNAL (note that because of the loop, it will be executed again to comment 1, and it will try to get the lock again - the last thread T-3 is not finished, and this time it will probably end);
- Fortunately, when entering the second cycle, the waitStatus of pre node Node1 is SIGNAL and returns true directly. The following parkAndCheckInterrupt() method blocks the current thread T-2.
The queue of thread T-2 without acquiring locks is given.
List the complete queue diagram in which thread T-1 also participates. You can see that the nodes before the tail node, the bound threads are all in the blocked state (park), while the waitStatus is in the awakened state (waitStatus = SIGNAL = -1):
Summarize the above as conclusion 2:
` The acquireQueued `method, if the current thread is the first thread to acquire a lock failure (in the example, Thread T-3 is executing, Thread T-2 is the first thread to acquire a lock failure), will try to acquire the lock twice again; The acquisition lock fails or the current thread is not the first thread to acquire the lock fails (T-1 is not the first thread to acquire the lock fails in the example), modifying the status of the pre-node to be awakened, and blocking the associated thread.
For ease of understanding, draw the logic diagram of the entire acquireQueued (addWaiter (Node. EXCLUSIVE, arg):
Blocking is not the end point, but look again at what unlock() did.
See also unlock()
## ReentrantLock class public void unlock() { sync.release(1); } public final boolean release(int arg) { if (tryRelease(arg)) { //Trying to release, the previous analysis has been done. Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); // ### Focus on unparkSuccessor(h) method, with `header node'as the reference` return true; } return false; } ## Class AQS private void unparkSuccessor(Node node) { // Gets the waitStatus of the Node node, and if < 0 (e.g., with wake-up SIGNA = 1), the atom is reduced to 0. int ws = node.waitStatus; if (ws < 0) node.compareAndSetWaitStatus(ws, 0); // Get the next node of the header node, if it is empty (CANCELLED may produce empty), traverse the end of the list, and take the first waitStatus < 0 node. Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node p = tail; p != node && p != null; p = p.prev) if (p.waitStatus <= 0) s = p; } if (s != null) LockSupport.unpark(s.thread); // awaken }
Without considering the CANCELLED case, the thread corresponding to the second node will be awakened. What is the route of the second node? As previously analyzed, the first thread that fails to acquire the lock binds to the second node (Node2 in the example, the corresponding thread is naturally T-2, as shown below):
What will thread T-2 do when it wakes up?
final boolean acquireQueued(final Node node, int arg) { boolean interrupted = false; try { for (;;) { //loop final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; // help GC return interrupted; } if (shouldParkAfterFailedAcquire(p, node)) interrupted |= parkAndCheckInterrupt(); //### Thread T-2 was originally blocked here } } catch (Throwable t) { cancelAcquire(node); if (interrupted) selfInterrupt(); throw t; } }
Obviously, if thread T-2 is awakened, it will go into the following logic again because of the loop:
final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); //head Change p.next = null; return interrupted; } private void setHead(Node node) { head = node; node.thread = null; node.prev = null; }
tryAcquire(arg) tries to acquire the lock again. It is obvious that thread T-3 has been executed (or unlock will not be executed), then thread T-2 will probably acquire the lock.——
Then, head changes ownership and the queue changes as follows:
Add/unlock queue changes
Finally, the queue changes in the process of adding/unlocking are given to help understand.
- Locking process
- Unlocking process
Epilogue
Finally, the implementation of ReentrantLock's main methods is analyzed. (A little fragmented)
The next article in this series will continue to explore fair lock implementations for ReentrantLock. Please look forward to it!