ReentrantLock Internals

1 synchronized和lock

1.1 Limitations of synchronized

    Synchronized is a built-in keyword in java, which provides an exclusive locking method. The acquisition and release of synchronized locks are implemented by the JVM, and users do not need to explicitly release the locks, which is very convenient. However, synchronized also has certain limitations, such as:

  1. When a thread tries to acquire a lock, it will block forever if the lock cannot be acquired.
  2. If the thread acquiring the lock goes to sleep or blocks, unless the current thread is abnormal, other threads trying to acquire the lock must wait.

    Released after JDK1.5, the concurrent package implemented by Doug Lea was added. The Lock class is provided in the package to provide more extended locking functions. Lock makes up for the limitations of synchronized and provides a more fine-grained locking function.

1.2 Introduction to Lock

Lock api is as follows

 

void lock();
void lockInterruptibly() throws InterruptedException;
boolean tryLock();
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
void unlock();
Condition newCondition();

 

The most commonly used are the lock and unlock operations. Because when using lock, you need to manually release the lock, so you need to use try..catch to wrap the business code, and release the lock in finally. Typical use is as follows

 

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

 

2 AQS

    AbstractQueuedSynchronizer, referred to as AQS, is a framework for building locks and synchronization containers. In fact, many classes in the concurrent package are built based on AQS, such as ReentrantLock, Semaphore, CountDownLatch, ReentrantReadWriteLock, FutureTask, etc. AQS solves a lot of design details when implementing synchronous containers.

    AQS uses a FIFO queue to represent threads queued for locks. The head node of the queue is called a "sentry node" or "dumb node", which is not associated with any thread. Other nodes are associated with waiting threads, and each node maintains a waiting state waitStatus. As shown

     There is also a field state in AQS that represents the state. For example, ReentrantLocky uses it to represent the number of times the thread reentrants the lock, Semaphore uses it to represent the number of remaining licenses, and FutureTask uses it to represent the state of the task. Updates to the value of the state variable use the CAS operation to ensure the atomicity of the update operation.

    AbstractQueuedSynchronizer inherits AbstractOwnableSynchronizer. This class has only one variable: exclusiveOwnerThread, which represents the thread currently occupying the lock, and provides the corresponding get and set methods.

    Understanding AQS can help us better understand the synchronization container in the JCU package.

3 The principle of lock() and unlock() implementation

3.1 Basic knowledge

    ReentrantLock is one of the default implementations of Lock. So how are lock() and unlock() implemented? First we need to clarify a few concepts

  • Reentrant lock. A reentrant lock means that the same thread can acquire the same lock multiple times. Both ReentrantLock and synchronized are reentrant locks.
  • Interruptible lock. Interruptible locks refer to whether a thread can respond to interrupts in the process of trying to acquire a lock. Synchronized is an uninterruptible lock, while ReentrantLock provides an interrupt function.
  • Fair locks and unfair locks. A fair lock means that when multiple threads try to acquire the same lock at the same time, the order in which the locks are acquired is in the order that the threads are reached, while an unfair lock allows threads to "jump in the queue". Synchronized is an unfair lock, and the default implementation of ReentrantLock is an unfair lock, but it can also be set to a fair lock.
  • CAS operation (CompareAndSwap). The CAS operation is simply a compare and exchange. A CAS operation consists of three operands - the memory location (V), the expected old value (A), and the new value (B). If the value of the memory location matches the expected original value, the processor automatically updates the location value to the new value. Otherwise, the processor does nothing. In either case, it returns the value at that location before the CAS instruction. CAS effectively says "I think location V should contain the value A; if it does, put B at this location; otherwise, don't change the location, just tell me what the location is now." Java Concurrent Packages (java.util.concurrent) uses a lot of CAS operations, and the sun.misc.Unsafe class method is called for CAS operations where concurrency is involved.

3.2 Internal structure

    ReentrantLock provides two constructors, namely

 

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

 

    The default constructor is initialized as a NonfairSync object, that is, a non-fair lock, and the constructor with parameters can specify the use of fair locks and unfair locks. As you can see from the source code of lock() and unlock, they just call the lock() and release(1) methods of the sync object, respectively.

    Sync is an inner class of ReentrantLock, and its structure is as follows

 You can see that Sync extends AbstractQueuedSynchronizer.

3.3 NonfairSync

    Starting from the source code, we analyze the process of acquiring and releasing locks for unfair locks. 

3.3.1 lock() 

    The source code of lock() is as follows

 

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 successfully acquired. When multiple threads try to occupy the same lock at the same time, the CAS operation can only guarantee the success of one thread operation, and the rest can only be queued obediently.

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

    If there are currently three threads competing for the lock, assuming that the CAS operation of thread A is successful, and it returns happily after getting the lock, then threads B and C fail to set the state and go to the else. We look down at acquire.

acquire(arg)

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

The code is very simple, but the logic behind it is very complex, which shows the programming skills of God Doug Lea.

 1. The first step. Attempt to acquire the lock. If the attempt to acquire the lock succeeds, the method returns directly.

tryAcquire (arg)

 

final boolean nonfairTryAcquire(int acquires) {
    // get the current thread
    final Thread current = Thread.currentThread();
    //Get the value of the state variable
    int c = getState();
    if (c == 0) { //No thread occupies the lock
        if (compareAndSetState(0, acquires)) {
            //Occupy the lock successfully, set the exclusive thread as the current thread
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) { //The current thread already occupies the lock
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        // Update the state value to the new reentry times
        setState(nextc);
        return true;
    }
    // Failed to acquire lock
    return false;
}

 

    The process of unfair lock tryAcquire is: check the state field, if it is 0, it means 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 it is occupied by itself, update the state field, indicating The number of reentrant locks. If neither of the above two points succeeds, the acquisition of the lock fails and false is returned.

2. The  second step, join the team. Since thread A has already occupied the lock as mentioned above, B and C fail to execute tryAcquire and enter the waiting queue. If thread A is holding on to the lock, then B and C will be suspended.

Let's take a look at the process of joining the team.

Forecast addWaiter (Node.EXCLUSIVE)

 

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

 

Threads B and C try 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). We assume that we have walked to enq(node) at the same time.

 

/**
 * Initialize the queue and enqueue new nodes
 */
private Node enq(final Node node) {
    // start spinning
    for (;;) {
        Node t = tail;
        if (t == null) { // Must initialize
            // If tail is empty, create a new head node, and tail points to head
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            node.prev = t;
            // tail is not empty, enqueue the new node
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}

 

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

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

3.  The third step, suspend. B and C execute acquireQueued(final Node node, int arg) successively. This method lets an already enqueued thread try to acquire the lock, and if it fails, it will be suspended.

 

/**
 * An already enqueued thread tries to acquire the lock
 */
final boolean acquireQueued(final Node node, int arg) {
    boolean failed = true; //Mark whether the lock was successfully acquired
    try {
        boolean interrupted = false; //Mark if the thread has been interrupted
        for (;;) {
            final Node p = node.predecessor(); //Get the predecessor node
            //If the predecessor is the head, that is, the node has become the second child, then it is eligible to try to acquire the lock
            if (p == head && tryAcquire(arg)) {
                setHead(node); // Get success, set the current node as the head node
                p.next = null; // The original head node is dequeued and reclaimed by GC at a certain point in time
                failed = false; // get success
                return interrupted; //return is interrupted
            }
            // Determine whether it can be suspended after the acquisition fails, and if so, 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 the code have clearly explained the execution flow of acquireQueued. Assuming that B and C are in the process of competing for locks, A has been holding the lock, then their tryAcquire operations will fail, so they will go to the second if statement. Let's take a look at what shouldParkAfterFailedAcquire and parkAndCheckInterrupt do.

 

/**
 * Determine 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 predecessor node
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        // The status of the predecessor node is signal, return true
        return true;
    // The precursor node status is CANCELLED
    if (ws > 0) {
        // Find 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 state of the predecessor node to SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}
  
/**
 * Suspend the current thread, return to the thread interrupt status and reset
 */
private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}

 

    The premise that a thread can be suspended after being enqueued is that the state of its predecessor node is SIGNAL, and its meaning is "Hi, brother in front, if you acquire the lock and dequeue, remember to wake me up!". Therefore, shouldParkAfterFailedAcquire will first determine whether the status of the current node's predecessor meets the requirements, and if so, return true, and then call parkAndCheckInterrupt to suspend itself. If it does not meet the requirements, check whether the predecessor node is > 0 (CANCELLED). If so, traverse forward until the first predecessor that meets the requirements is found. If not, set the state of the predecessor node to SIGNAL.

     In the whole process, if the status of the precursor node is not SIGNAL, then you can't suspend it at ease. You need to find a comfortable suspension point, and you can try again to see if there is a chance to try the competition lock.

    The final queue may look like the image below

  Both threads B and C have been enqueued and both are suspended. When thread A releases the lock, it will wake up thread B to acquire the lock.

3.3.2 unlock()

unlock is much simpler than lock. The source code is as follows

 

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;
}

 

Unlocking seems a lot easier if you understand the process of locking. The process is roughly 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 unlock failed. Here we also found that each time only the thread associated with the next node of the head node is awakened.

   Finally, let's look at the execution process of tryRelease

 

/**
 * Release the lock occupied by the current thread
 * @param releases
 * @return whether the release is successful
 */
protected final boolean tryRelease(int releases) {
    // Calculate the state value after release
    int c = getState() - releases;
    // If the lock is not held by the current thread, then throw an exception
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        // The number of times the lock is reentrant is 0, indicating that the release is successful
        free = true;
        // clear the 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 that currently releases the lock does not hold the lock, an exception will be thrown. If the lock is held, calculate whether the state value after the release is 0. If it is 0, it means that the lock has been successfully released, and the exclusive thread is emptied, and finally the state value is updated, and free is returned. 

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=325510499&siteId=291194637