Java multithreading -- ReentrantLock -- use / example / principle

Keywords: Java Back-end Multithreading ReentrantLock

Original website: Java multithreading -- ReentrantLock -- use / example / principle_ CSDN blog

brief introduction

explain

         This article introduces ReentrantLock (reentrant exclusive lock) in Java's JUC. Including: usage and principle.

summary

        ReentrantLock is mainly implemented by CAS+AQS queue. It supports fair locks and unfair locks, and their implementation is similar.

        Because CAS is used, it has the advantages and disadvantages of CAS: high performance and high CPU consumption.

        ReentrantLock (reentrant exclusive lock): when state is initialized to 0, it indicates that it is not locked. When thread A locks (), it will call tryAcquire() exclusive lock and set state+1. Then other threads will fail when they think about tryAcquire again. Other threads will not have the opportunity to obtain the lock until thread A unlock s() to state=0. Before A releases the lock, it can also acquire the lock repeatedly (state accumulation), which is the concept of reentry. Note: you must release the lock as many times as you obtain the lock to ensure that the state can return to the zero state.  

Example

private Lock lock = new ReentrantLock();
 
public void test(){
    lock.lock();
    try{
        doSomeThing();
    }catch (Exception e){
        // ignored
    }finally {
        lock.unlock();
    }
}

Fairness and unfairness

The default implementation of ReentrantLock is a non fair lock, but it can also be set to a fair lock.

  • Unfair lock
    • If another thread comes in to try to get it at the same time, it may let this thread get it first;
  • Fair lock
    • If another thread comes in at the same time to try to obtain the lock, when it finds that it is not at the head of the queue, it will queue to the end of the queue and the thread at the head of the queue will obtain the lock.

ReentrantLock provides two constructors:

public ReentrantLock() {
    sync = new NonfairSync();
}
 
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

lock() method of NonfairSync

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

        First, use a CAS operation to determine whether the state is 0 (indicating that the current lock is not occupied). If it is 0, set it to 1, and set the current thread as the exclusive thread of the lock, indicating that the lock is obtained successfully. When multiple threads try to occupy the same lock at the same time, CAS operation can only ensure the success of one thread, and the rest can only be queued.

        "Unfairness" is reflected here. If the thread occupying the lock just releases the lock and the state is set to 0, and the thread queuing for the lock has not woken up, the new thread directly preempts the lock, then it will "jump in the queue".

lock() method of FairSync

final void lock() {
    acquire(1);
}

        Call the acquire(1) method directly.

Principle of unfair lock

scene

        Brief description: thread A obtains the lock, threads B and C fail, and threads B and C execute acquire(1);

        This is explained by the example of non fair lock (NonfairSync). It is assumed that there are the following scenarios: three threads compete for the lock. Assuming that the CAS operation of thread A succeeds and returns happily after obtaining the lock, threads B and C fail to set state and go to else.

lock() method of NonfairSync

final void lock() {
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

  acquire() method

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

(1) Attempt to acquire lock

Description: try to acquire the lock. If the attempt to acquire the lock is successful, the method returns directly.

The process of non fair lock tryAcquire is as follows:

  1. Check the state field;
  2. If it is 0, it means that the lock is not occupied, then try to occupy it;
    If it is not 0, check whether the current lock is occupied by itself. If so, state+1 (number of times to re-enter the lock).
  3. If the above two points fail, obtaining the lock fails and returns false.
tryAcquire(arg)
final boolean nonfairTryAcquire(int acquires) {
    //Get current thread
    final Thread current = Thread.currentThread();
    //Get state variable value
    int c = getState();
    if (c == 0) { //No threads occupy locks
        if (compareAndSetState(0, acquires)) {
            //Lock occupation succeeded. Set the exclusive thread as the current thread
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) { //The lock is already occupied by the current thread
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // Update the state value to the new number of reentries
        setState(nextc);
        return true;
    }
    //Failed to acquire lock
    return false;
}

(2) Join the team

        As mentioned above, thread A has occupied the lock, so B and C fail to execute tryAcquire and enter the waiting queue (acquired queue (addwaiter (node. Exclusive), Arg)). If thread A holds the lock, then B and C will be suspended.

Let's take a look at the process of joining the team. First look at addWaiter(Node.EXCLUSIVE)   

/**
 * Associate the new node with the current thread and queue it
 * @param mode Exclusive / shared
 * @return New node
 */
private Node addWaiter(Node mode) {
    //Initialize the node and set the associated thread and mode (exclusive or shared)
    Node node = new Node(Thread.currentThread(), mode);
    // Get tail node reference
    Node pred = tail;
    // The tail node is not empty, indicating that the queue has been initialized
    if (pred != null) {
        node.prev = pred;
        // Set the new node as the tail node
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    // If the tail node is empty, it means that the queue has not been initialized. You need to initialize the head node and join the new node
    enq(node);
    return node;
}

        B. C thread tries to enter the queue at the same time. Since the queue has not been initialized and tail==null, at least one thread will go to enq(node). Let's assume that we go to enq(node) at the same time.

/**
 * Initialize the queue and queue up new nodes
 */
private Node enq(final Node node) {
    //Start spin
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            // If the tail is empty, a new head node will be created, and the tail points to the head
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            // tail is not empty. Join the new node in the queue
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

        The classical spin + CAS combination is embodied here to realize non blocking atomic operation. Since the implementation of compareAndSetHead uses the CAS operation provided by the unsafe class, only one thread will create the head node successfully. Suppose that thread B succeeds, and then B and C start the second cycle. At this time, the tail is not empty, and both threads go to else. Assuming that the compareAndSetTail of the B thread succeeds, then B can return, and C needs a third round of loop due to the queue failure. Finally, all threads can be successfully queued.

        When B and C enter the waiting queue, the AQS queue is as follows:

(3) Hang

        B and C execute acquirequeueueueued (final node, int ARG) successively. This method allows queued threads to attempt to acquire locks, and if they fail, they will be suspended.

/**
 * A queued thread attempted to acquire a lock
 */
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true; //Marks whether the lock was successfully acquired
    try {
        boolean interrupted = false; //Mark whether the thread has been interrupted
        for (;;) {
            final Node p = node.predecessor(); //Get precursor node
            //If the precursor is head, that is, the node has become the second, then it is qualified to try to obtain the lock
            if (p == head && tryAcquire(arg)) {
                setHead(node); // Successfully obtained. Set the current node as the head node
                p.next = null; // The original head node goes out of the queue and is recycled by GC at a certain time point
                failed = false; //Get success
                return interrupted; //Whether the return has been interrupted
            }
            // Judge whether the acquisition can be suspended after failure. If yes, suspend
            if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                // If the thread is interrupted, set interrupted to true
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

        The comments in code have clearly explained the implementation process of acquirequeueueueed. A ssuming that B and C hold the lock all the time while competing for the lock, their tryAcquire operation will fail, so they will go to the second if statement.  

        Take another look at the shouldParkAfterFailedAcquire and parkAndCheckInterrupt processes

/**
 * Judge whether the current thread needs to suspend after it fails to acquire the lock
 */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //Status of the precursor node
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        // The status of the precursor node is signal and returns true
        return true;
    // The status of the precursor node is CANCELLED
    if (ws > 0) {
        // Look for the first node whose status is not CANCELLED from the end of the queue
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // Set the status of the precursor node to SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}
  
/**
 * Suspend the current thread, return the thread interrupt status and reset
 */
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

        The premise that a thread can hang after joining the queue is that the state of its precursor node is SIGNAL, which means: "Hi, brother in front, if you get the lock and leave the queue, remember to wake me up!". Therefore, shouldParkAfterFailedAcquire will first determine whether the current node's predecessor meets the requirements. If it meets, it will return to true, then call parkAndCheckInterrupt to hang itself. If not, check whether the precursor node is > 0 (cancelled). If so, traverse forward until the first precursor that meets the requirements (the state is not greater than 0) is found. If not, set the state of the precursor node to SIGNAL.

        In the whole process, if the status of the precursor node is not SIGNAL, you can't hang at ease. You need to find a safe starting point. At the same time, you can try again to see if there is a chance to try the competitive lock.

        The final queue may look like the following figure

summary

Use a flowchart to summarize the lock acquisition process of unfair locks.  

Principle of unfair lock

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

          If you understand the process of locking, unlocking seems much easier. The process is basically to try to release the lock first. If the release is successful, check whether the status of the head node is SIGNAL. If so, wake up the thread associated with the next node of the head node. If the release fails, return false to indicate that the unlocking fails. Here we also found that each time only the thread associated with the next node of the head node is called.

    Finally, let's take a look at the implementation process of tryRelease

/**
 * Release the lock occupied by the current thread
 * @param releases
 * @return Release successful
 */
protected final boolean tryRelease(int releases) {
    // Calculate the state value after release
    int c = getState() - releases;
    // If the lock is not occupied by the current thread, an exception is thrown
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        // The number of lock re-entry times is 0, indicating that the release is successful
        free = true;
        // Empty exclusive thread
        setExclusiveOwnerThread(null);
    }
    // Update state value
    setState(c);
    return free;
}

        The input parameter here is 1. The process of tryRelease is: if the thread currently releasing the lock does not hold the lock, an exception will be thrown. If the lock is held, calculate whether the released state value is 0. If it is 0, it indicates that the lock has been successfully released, and the exclusive thread is cleared. Finally, update the state value and return free.      

Fair lock principle

The difference between a fair lock and a non fair lock is that when a fair lock obtains a lock, it does not check the state first, but directly executes aqcuire(1);

Timeout mechanism

        The tryLock(long timeout, TimeUnit unit) of ReetrantLock provides the function of obtaining locks over time. Its semantics is to return true if a lock is obtained within a specified time, and false if it is not obtained. This mechanism prevents threads from waiting indefinitely for lock release. So how is the timeout function implemented? Let's take unfair lock as an example to find out.

public boolean tryLock(long timeout, TimeUnit unit)
        throws InterruptedException {
    return sync.tryAcquireNanos(1, unit.toNanos(timeout));
}

Or called the methods in the inner class. Let's move on  

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

        The semantics here is: if the thread is interrupted, throw InterruptedException directly. If it is not interrupted, try to acquire the lock first. If the acquisition is successful, it will return directly. If the acquisition fails, enter doAcquireNanos. We've seen tryAcquire. Here we'll focus on what doacquire nanos does.  

/**
 * Compete for locks in a limited time
 * @return Is it successful
 */
private boolean doAcquireNanos(int arg, long nanosTimeout)
        throws InterruptedException {
    // Start time
    long lastTime = System.nanoTime();
    // Thread queue
    final Node node = addWaiter(Node.EXCLUSIVE);
    boolean failed = true;
    try {
        // Spin again!
        for (;;) {
            // Get precursor node
            final Node p = node.predecessor();
            // If the precursor is the head node and the lock is occupied successfully, the current node will become the head node
            if (p == head && tryAcquire(arg)) {
                setHead(node);
                p.next = null; // help GC
                failed = false;
                return true;
            }
            // If it has timed out, false is returned
            if (nanosTimeout <= 0)
                return false;
            // The timeout has not expired and needs to be suspended
            if (shouldParkAfterFailedAcquire(p, node) &&
                    nanosTimeout > spinForTimeoutThreshold)
                // Blocks the current thread until the timeout expires
                LockSupport.parkNanos(this, nanosTimeout);
            long now = System.nanoTime();
            // Update nanosTimeout
            nanosTimeout -= now - lastTime;
            lastTime = now;
            if (Thread.interrupted())
                //Corresponding interrupt
                throw new InterruptedException();
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

        The process of doAcquireNanos is briefly described as follows: the thread enters the waiting queue first, then starts to spin, tries to obtain the lock, and returns when it succeeds. If it fails, find a safe point in the queue and hang itself until the timeout expires. Why do we need a cycle here? Because the precursor state of the current thread node may not be SIGNAL, the thread will not be suspended in the current round of loop, and then update the timeout to start a new round of attempts  

Polling and interrupt

The reason why ReentrantLock is retained is that ReentrantLock has two more functions than synchronized: pollable and interruptible.

1. Pollable

        The examples in the original book look complex, but the meaning is very simple. A transfer operation is either completed within the specified time, or tell the caller that the operation is not completed within the specified time. This example requires the pollable feature of ReentrantLock, that is, repeatedly trying to obtain a lock within the specified time. If the lock is successful, the transfer operation can be completed. If the lock is not obtained within the specified time, the transfer fails. If you use synchronized, you can't do it.

public boolean transferMoney(Account fromAcct,  
                             Account toAcct,  
                             DollarAmount amount,  
                             long timeout,  
                             TimeUnit unit)  
        throws InsufficientFundsException, InterruptedException {  
    long fixedDelay = getFixedDelayComponentNanos(timeout, unit);  
    long randMod = getRandomDelayModulusNanos(timeout, unit);  
    long stopTime = System.nanoTime() + unit.toNanos(timeout);  
  
    while (true) {  
        if (fromAcct.lock.tryLock()) {  
            try {  
                if (toAcct.lock.tryLock()) {  
                    try {  
                        if (fromAcct.getBalance().compareTo(amount) < 0)  
                            throw new InsufficientFundsException();  
                        else {  
                            fromAcct.debit(amount);  
                            toAcct.credit(amount);  
                            return true;  
                        }  
                    } finally {  
                        toAcct.lock.unlock();  
                    }  
                 }  
             } finally {  
                 fromAcct.lock.unlock();  
             }  
         }  
         if (System.nanoTime() < stopTime)  
             return false;  
         NANOSECONDS.sleep(fixedDelay + rnd.nextLong() % randMod);  
     }  
}  

  2. Interruptible

        In synchronized code, the code entering the critical area cannot be interrupted, which is very inflexible. If we use a thread pool to distribute tasks, if a code occupies a lock for a long time, it will certainly affect other tasks in the thread pool. Therefore, adding an interrupt mechanism improves the control of tasks.

public boolean sendOnSharedLine(String message)  
        throws InterruptedException {  
    lock.lockInterruptibly();  
    try {  
        return cancellableSendOnSharedLine(message);  
    } finally {  
        lock.unlock();  
    }  
}  
  
private boolean cancellableSendOnSharedLine(String message)  
    throws InterruptedException { ... }

        Fairness: ReentrantLock uses unfair locks by default, and synchronized locks also use unfair locks.
        If you don't require pollable and interruptible locks, use the synchronized built-in lock.

Other web sites

ReentrantLock principle_ Java_ Long road, long water - CSDN blog
Use ReentrantLock with caution

Posted by mailtome on Mon, 08 Nov 2021 14:26:51 -0800