AQS Series I: Source Code Analysis Unfair ReentrantLock

Posted by Nhoj on Mon, 02 Sep 2019 05:22:59 +0200

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!

Topics: Java JDK Attribute