java Concurrent Programming: the implementation of Lock

Posted by SieRobin on Wed, 19 Jan 2022 15:29:55 +0100

This chapter mainly analyzes the implementation details of reentrant lock ReentrantLock

First, show the class diagram involved in the implementation of ReentrantLock

1.AQS introduction

AQS is the AbstractQueuedSynchronizer class in the figure. This class is the basis of the concurrency tool class. AQS defines a set of synchronizer framework for multi-threaded access to shared resources. Many synchronization class implementations depend on it, such as ReentrantLock/Semaphore/CountDownLatch.

(1) The member volatile int state in AQS represents shared resources. For example, when ReentrantLock locks, it will change the state from 0 to 1 in CAS mode, which means that the current ReentrantLock object has been held by a thread. In addition, AbstractOwnableSynchronizer class will record the held thread reference of the current resource,

AQS provides corresponding methods to obtain and modify state and resource holding threads:

protected final int getState() {
        return state;
    }

protected final void setState(int newState) {
        state = newState;
    }

protected final boolean compareAndSetState(int expect, int update) {
        // unsafe yes jdk Atomic modification tool class provided(CAS)
        return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
    }

protected final void setExclusiveOwnerThread(Thread thread) {
        exclusiveOwnerThread = thread;
    }

 protected final Thread getExclusiveOwnerThread() {
        return exclusiveOwnerThread;
    }

(2) AQS completes the queuing of threads through a built-in FIFO two-way queue (internally, the head and tail elements of the queue are recorded through the nodes, and the Node type of the element is Node type)

/**
     * Head of the wait queue, lazily initialized.  Except for
     * initialization, it is modified only via method setHead.  Note:
     * If head exists, its waitStatus is guaranteed not to be
     * CANCELLED.
     */
    private transient volatile Node head;

    /**
     * Tail of the wait queue, lazily initialized.  Modified only via
     * method enq to add new wait node.
     */
    private transient volatile Node tail;

(3) The thread in the Node node stores the thread reference currently queued. SHARED in the Node node indicates that the marked thread is blocked and added to the queue because of the failure to obtain SHARED resources; EXCLUSIVE in Node indicates that the thread is blocked and added to the queue because it failed to obtain EXCLUSIVE resources. waitStatus indicates the waiting status of the current thread:

① CANCELLED=1: indicates that the thread needs to cancel waiting from the waiting queue because of interruption or waiting timeout;

② SIGNAL=-1: the slave node representing the current node is blocked due to the failure of competing for shared resources. When releasing the lock, the thread represented by the subsequent node should be awakened.

③ CONDITION=-2: indicates that the node is in the waiting queue (this refers to waiting on the condition of a lock. The principle of condition will be described below). When the thread holding the lock calls the signal() method of condition, the node will transfer from the waiting queue of the condition to the synchronization queue of the lock to compete for the lock. (Note: the synchronization queue here is the FIFO queue maintained by AQS, and the waiting queue is the queue associated with each condition.)

④ PROPAGTE=-3: it means that the next sharing status acquisition will be passed to the subsequent nodes to obtain the sharing synchronization status.

2. Analyze the locking and unlocking process of reentrantlock (unfair mode)

(1) Lock

Suppose there are existing threads t1,t2,t3,

① First, t1 thread calls the locking method of ReentrantLock:

ReentrantLock:
public void lock() {
        sync.lock();
    }

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

It can be seen that the lock() method of ReentrantLock essentially calls the lock() method of its static internal class NonfairSync, and NonfairSync is a subclass of AQS. Continue to analyze the lock() method of NonfairSync:

 

final void lock() {
(1)use CAS Method modification state,If you succeed, you will get the lock. If you fail, the lock has been occupied
            if (compareAndSetState(0, 1))
(2)Get the lock and change the occupied thread to the current thread
                setExclusiveOwnerThread(Thread.currentThread());
(3)Failed to acquire lock
            else
                acquire(1);
        }

Because t1 is the first thread to acquire the lock, CAS method will succeed in acquiring the lock successfully. The current state is modified to 1 and the occupied thread reference is set to t1

AQS status is shown in the figure below:

                                                                                                                       

 

 

② Suppose that t2 thread calls the locking method of ReentrantLock at this time:

At this time, the CAS method fails to set state because the t1 thread has occupied the lock. At this time, the code enters the acquire(1) method, which is the template method defined by AQS:


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

In the acquire method, first call the tryAcquire method, which is implemented by the AQS subclass. This method is implemented in NofairSync:

protected final boolean tryAcquire(int acquires) {
            return nonfairTryAcquire(acquires);
        }

In NofairSync, the nonfairTryAcquire method is called, which is defined in Sync:

final boolean nonfairTryAcquire(int acquires) {
            1.Get current thread
            final Thread current = Thread.currentThread();
            2.Gets the status of the current lock
            int c = getState();
            3.c Is 0, which means that no thread is currently occupying the lock
            if (c == 0) {
                3-1. use CAS Method settings state,Represents that the lock is occupied by an existing thread
                if (compareAndSetState(0, acquires)) {
                    3-2.Set thread occupied application
                    setExclusiveOwnerThread(current);
                    3-3.  Lock successfully, return true
                    return true;
                }
            }
            4. Determine whether the thread occupying the lock is the current thread (reentrant lock)
            else if (current == getExclusiveOwnerThread()) {
                4-1.calculation state New value for
                int nextc = c + acquires;
                4-2.exception handling
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                4-3.Set new value
                setState(nextc);
                4-4.Lock successfully, return true
                return true;
            }
            5.Locking failed, return false
            return false;
        }                                                                                    

Because thread t1 already holds the lock, thread t2 will judge as false at 3, and 4 is the lock verification of reentrant lock. If thread t1 locks again, the state value will be updated to 2, but t2= t1, so the final locking fails and returns false

After false is returned, addWaiter(Node.EXCLUSIVE) method will be called. This method is to build an exclusive mode Node containing t2 thread reference in the FIFO queue in AQS:

private Node addWaiter(Node mode) {
        1.The current thread and the blocking reason(Because SHARED Pattern acquisition state Failure or EXCLUSIVE Acquisition failed)Constructed as Node node
        Node node = new Node(Thread.currentThread(), mode);
       2.Quickly insert the current node to the end of the queue
        Node pred = tail;
        if (pred != null) {
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
       3.Join the team
        enq(node);
       4.Returns the current node
        return node;
    }

The FIFO in AQS adopts lazy loading mode. Before the node joins the queue, the tail and head are always null. Therefore, pred here will be null and enq method will be called to join the queue:

private Node enq(final Node node) {
        1.Spin until the node joins the team successfully
        for (;;) {
            2.Get tail node
            Node t = tail;
            3. Queue is null,Initialize queue
            if (t == null) { // Must initialize
                3-1.  Set sentinel node
                if (compareAndSetHead(new Node()))
                    tail = head;
            } else { 4.Queue initialized
                4-1.Set the predecessor node of the current node to tail
                node.prev = t;
                4-2.CAS Set the current node as the new tail node
                if (compareAndSetTail(t, node)) {
                    4-3. Set the subsequent nodes of the original tail node as the current node
                    t.next = node;
                    4-4. Return to original tail node
                    return t;
                }
            }
        }
    }

Enter the enq method, because the queue has not been initialized, enter the 3-1 branch, set the sentinel node, and then enter the second cycle, set the current node as a new tail node and return to the original tail node.

Review the acquire method again:

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

At this time, the t2 thread has been put into the blocking queue. Next, the acquirequeueueueued method will be called. At this time, the whole queue is as follows:

The acquirequeueueueued method is as follows, and its input parameter is the newly added t2 thread node:

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            1.Spin, only when the lock is obtained successfully or interrupted can the loop exit
            for (;;) {
                2.Get the precursor node of the current node
                final Node p = node.predecessor();
                3.If the predecessor node of the current node is the head node, an attempt is made to acquire the lock
                if (p == head && tryAcquire(arg)) {
                    3-1.If the lock is obtained successfully, set the current node as the new head node
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                4.If the precursor node is not the head node or the lock acquisition fails, confirm whether to park()own
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt()) //park()own
                    interrupted = true;
            }
        } finally {
            //If the current future is cancelled due to interruption or timeout,
            if (failed) 
                cancelAcquire(node);
        }
    }

According to the acquirequeueueued method, the precursor node of t2 node is indeed the head node, but because t1 thread holds the lock, all tryAcquire methods must fail. At this time, shouldParkAfterFailedAcquire method should be called:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
        1.Get precursor node status
        int ws = pred.waitStatus;
        2.If the precursor node status is SIGNAL,Then return true,Represents that the thread can be blocked
        if (ws == Node.SIGNAL)
            return true;
        3.All nodes in cancel status before deleting the node from the queue
        if (ws > 0) {
            do {
                node.prev = pred = pred.prev;
            } while (pred.waitStatus > 0);
            pred.next = node;
        } else { 4.Set the precursor node status to SIGNAL
            compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
        }
        5.return false,Indicates that the thread cannot be blocked
        return false;
    }

Analyze shouldParkAfterFailedAcquire method. According to the queue diagram drawn above, if the waitStatus of pred node is 0, CAS method will be called to set waitStatus to SIGNAL, i.e. - 1, and return false. The new queue is shown in the figure:

 

This is the second cycle of the acquirequeueueueueueueued method. Since the t1 thread still holds the lock, it will still enter the shouldParkAfterFailedAcquire method. However, at this time, the waitStatus of the pred Node is already SIGNAL, so it will directly return true. Here we can see that when the waitState of the Node in AQS is SIGNAL,

It means that the subsequent drive node is blocked due to the failure to compete for mutually exclusive resources.

After shouldParkAfterFailedAcquire returns true, the parkAndCheckInterrupt method will be executed:

private final boolean parkAndCheckInterrupt() {
        1.jdk The provided blocking thread method can only be called unpark Method or interrupted to wake up
        LockSupport.park(this);
        2.Return thread interrupt status
        return Thread.interrupted();
    }

In the parkAndCheckInterrupt method, we can see that after the t2 thread fails to compete for mutually exclusive resources, it first enters the AQS waiting queue and sets the state of the precursor node. At this time, it will call the park method to block itself until it is awakened.

So when will it be awakened? Regardless of interruption, the thread holding the lock can only wake up the thread in the waiting queue after releasing the lock.

③ Suppose that t3 thread calls the locking method of ReentrantLock at this time:

The locking process of thread t3 is the same as that of thread t2. It will not be introduced in detail here, but only the state diagram of the waiting queue in the final AQS

(2) Unlock

Suppose that at this time, the t1 thread calls the release lock method of ReentrantLock.

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

The unlock method directly calls the release method of Sync, a subclass of AQS. The release method is defined in the AQS class.

public final boolean release(int arg) {
     
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

The release method first calls the tryRelease method, which is defined in the Sync class.

protected final boolean tryRelease(int releases) {
1. calculation state Final value
int c = getState() - releases;
2. Verify whether the current thread is a locked thread
if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false;
3.state 0 indicates that the lock has been released and the waiting thread can be awakened
if (c == 0) { free = true; setExclusiveOwnerThread(null); }
4.modify state setState(c);
return free; }

The tryRelease method is mainly used to modify the state variable. If the state is 0 after modification, it returns true; otherwise, it returns false.

Because the t1 thread is locked only once, the state value is 1. After calling the tryrease method, the state is 0 and returns true. At this time, the unparksuccess method is called, which is defined in AQS.

private void unparkSuccessor(Node node) {
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signalling.  It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node.  But if cancelled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        Node s = node.next;
        if (s == null || s.waitStatus > 0) {
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);
    }

The unparksuccess method is mainly used to wake up the successor node of the head node. If the successor node has been cancelled, start from the end of the queue to find the wake-up node closest to the head of the queue.

Call locksupport After the unpark method wakes up the specified thread, the whole process of releasing the lock is over.

Summary of main processes for releasing locks:

1. Modify AQS state

2. Wake up the successor node of the head node in the waiting queue. If the successor node is cancelled, wake up the thread corresponding to the node closest to the head of the queue that meets the requirements from the back to the front.

Here, the t2 thread is awakened. Review the process when t2 is locked. t2 is blocked in the parkAndCheckInterrupt method. After being awakened, continue to execute the spin method.

Because the precursor node of t2 thread is the head node, t2 thread calls the tryAcquire method. Because t1 thread has released the lock, t2 thread can lock successfully, and then set itself as the head node. At this time, the whole locking and unlocking process is completely completed.

 

final boolean acquireQueued(final Node node, int arg) {
        boolean failed = true;
        try {
            boolean interrupted = false;
            1.Spin, only when the lock is obtained successfully or interrupted can the loop exit
            for (;;) {
                2.Get the precursor node of the current node
                final Node p = node.predecessor();
                3.If the predecessor node of the current node is the head node, an attempt is made to acquire the lock
                if (p == head && tryAcquire(arg)) {
                    3-1.If the lock is obtained successfully, set the current node as the new head node
                    setHead(node);
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                4.If the precursor node is not the head node or the lock acquisition fails, confirm whether to park()own
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt()) //park()own
                    interrupted = true;
            }
        } finally {
            //If the current future is cancelled due to interruption or timeout,
            if (failed) 
                cancelAcquire(node);
        }
    }