“In-depth exploration of ReentrantLock lock in Java JUC: Implementing multi-thread synchronization and concurrency control”

Introduction

1. Starting from Java5, Java provides a more powerful thread synchronization mechanism – synchronization is achieved by explicitly defining a synchronization lock object. Under this mechanism, the synchronization lock is acted by a Lock object.

2. Lock provides a wider range of locking operations than synchronized methods and synchronized code blocks. Lock allows for a more flexible structure, can have widely different attributes, and supports multiple related Condition objects.

3. Lock is a tool that controls multiple threads’ access to shared resources. Generally, locks provide exclusive access to shared resources. Only one thread can lock the Lock object at a time. The thread should obtain the Lock object before starting to access shared resources.

4. Some locks may allow concurrent access to shared resources, such as ReadWriteLock (read-write lock). Lock and ReadWriteLock are the two root interfaces provided by Java5, and the ReentrantLock (reentrant lock) implementation class is provided for Lock and ReadWriteLock. Provides ReentrantReadWriteLock implementation class.

5. Java8 adds a new StampedLock class, which can replace the traditional ReentrantReadWriteLock in most scenarios. ReentrantReadWriteLock provides three lock modes for read and write operations: Writing, ReadingOptimistic, and Reading.

ReentrantLock

What is ReentrantLock

ReentrantLock is a lock implementation in Java that provides similar functionality to the traditional synchronized keyword, but with more flexibility and control.

ReentrantLock feature

Reentrancy: Like synchronized, ReentrantLock is reentrant, which means that a thread can acquire the same lock multiple times without deadlock.

Lock fairness: ReentrantLock supports fair locks and unfair locks. In fair lock mode, locks are allocated in the order requested by threads. In unfair lock mode, the lock is allocated to the waiting thread as soon as it becomes available.

Condition object: ReentrantLock provides a Condition object, which allows threads to wait for and notify other threads under specific conditions. This is useful for collaboration between threads.

Interrupt response: ReentrantLock supports interrupt response, which means that the thread can respond to the interrupt signal while waiting for the lock.

Timeout Lock: ReentrantLock allows you to try to acquire a lock and set a timeout. If the lock cannot be acquired within the timeout period, the thread can perform other operations.

Comparison between lock and synchronized

Reentrancy:
ReentrantLock is reentrant, allowing the same thread to acquire the same lock multiple times without causing deadlock.
synchronized is also reentrant, and the same thread can obtain the same lock multiple times.
Flexibility:
ReentrantLock provides more flexibility and control, allowing you to choose fairness and unfairness, set timeouts, use read-write locks, and other advanced features.
synchronized is relatively simple, provides fewer functions, and does not support advanced functions such as timeouts and read-write locks.
Conditional wait:
ReentrantLock provides a Condition object that allows a thread to wait under specific conditions and then reacquire the lock when the conditions are met.
synchronized lacks this direct conditional waiting mechanism, but can use the wait() and notify() methods to achieve similar functionality.
Fairness:
ReentrantLock allows you to choose the fairness of the lock, assigning the lock in a fair or unfair manner. In fair mode, locks are allocated to waiting threads in waiting order.
synchronized uses unfair locks and does not guarantee allocation in waiting order.
Performance:
synchronized may be more efficient than ReentrantLock in some cases because it is a mechanism built into the JVM.
ReentrantLock provides better performance in high contention situations, but it is generally more expensive to create and maintain.
Exception handling:
ReentrantLock has a flexible exception handling mechanism that can capture and handle exceptions in lock operations.
Synchronized’s exception handling is relatively simple. Once an exception occurs, the lock will be automatically released.
Interruptability:
ReentrantLock allows threads to respond to interrupts and can interrupt threads while waiting for the lock.
synchronized does not support thread interruption.
Lock bindability:
ReentrantLock allows locks to be bound to multiple conditions.
synchronized does not provide a similar mechanism for binding conditions.

Use cases

In this example, we create a ReentrantLock instance and use it to protect the doWork method in the SharedResource object. Two threads (“Thread 1” and “Thread 2”) share the SharedResource object and call the doWork method respectively. lock.lock() acquires the lock and lock.unlock() releases the lock, ensuring that only one thread can enter the synchronization block of the doWork method at the same time. This ensures safety and synchronized execution between threads.

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockExample {<!-- -->
    public static void main(String[] args) {<!-- -->
        //Create a ReentrantLock instance
        Lock lock = new ReentrantLock();

        //Create a shared resource
        SharedResource resource = new SharedResource(lock);

        //Create multiple threads and start them
        Thread thread1 = new Thread(new Worker(resource), "Thread 1");
        Thread thread2 = new Thread(new Worker(resource), "Thread 2");

        thread1.start();
        thread2.start();
    }
}

class SharedResource {<!-- -->
    private Lock lock;

    public SharedResource(Lock lock) {<!-- -->
        this.lock = lock;
    }

    public void doWork() {<!-- -->
        lock.lock(); // Get lock
        try {<!-- -->
            // Synchronized code block
            for (int i = 1; i <= 5; i + + ) {<!-- -->
                System.out.println(Thread.currentThread().getName() + " is working: " + i);
            }
        } finally {<!-- -->
            lock.unlock(); // Release the lock
        }
    }
}

class Worker implements Runnable {<!-- -->
    private SharedResource resource;

    public Worker(SharedResource resource) {<!-- -->
        this.resource = resource;
    }

    @Override
    public void run() {<!-- -->
        resource.doWork();
    }
}

AQS Review

AQS is the abbreviation of AbstractQueuedSynchronizer. This is an abstract class that implements two queues internally, namely synchronization queue and conditional queue. The synchronization queue is a two-way linked list, which stores threads in a waiting state, waiting in line to wake up to acquire the lock, while the condition queue is a one-way linked list, which also stores threads in a waiting state, but these threads wake up The result is that it is added to the end of the synchronization queue. What AQS does is to manage the waiting state-wakeup work between the threads in the two queues.
In the synchronization queue, there are also 2 modes, namely exclusive mode and shared mode. The difference between these two modes is whether AQS delivers wake-up when waking up the thread node. These two modes correspond to exclusive locks and shared locks respectively. .
AQS is an abstract class, so it cannot be instantiated directly. When we need to implement a custom lock, we can inherit AQS and then rewrite the way to acquire the lock, release the lock and manage the state, and ReentrantLock is rewritten. AQS’s tryAcquire and tryRelease methods implement lock and unlock.
For details, please refer to https://juejin.cn/post/7006895386103119908

ReentrantLock implementation principle

Please add image description

ReentrantLock structure


ReentrantLock implements the Lock interface and has three internal classes: Sync, NonfairSync, and FairSync. The Sync internal class inherits from AQS and takes over the functions of AQS. NonfairSync represents unfair lock and FairSync represents fair lock. They all inherit the Sync class and use Sync to repeat the function. From the written methods tryAcquire and tryRelease, we can know that ReentrantLock implements the exclusive mode of AQS, which is an exclusive lock. This lock is a pessimistic lock.

Implementation principle of unfair lock

Get lock

Please add an image description
ReentrantLock has two constructors. The parameterless constructor creates an unfair lock by default. Passing false to fair also creates an unfair lock.
The default is unfair lock, so the subclass NonfairSync implements the abstract method of the parent class to execute lock
1. First use case to try to update the value of state
If the update is successful, it means that the lock can be preempted, the state is updated to 1 and the thread information is set to end execution.
If the update fails, that is, the state is not equal to 0 at this time, it means that the lock is occupied by other threads at this time, and the acquire method is executed.
2.nonfairTryAcquire will first get the value of state to determine whether state is equal to 0. If it is equal to 0 at this time, it means that a thread has released the lock and changed the state back to 0.
If the state is equal to 0 at this time, try to use cas again to change the value of the state from 0 to 1. If the change is successful, it means that the lock has been preempted and the thread information is set (this reflects the characteristics of the unfair lock and does not care about the blocking queue) whether there are waiting threads) and then end
If state is not equal to 0 at this time or cas fails to update the state value, it means that a thread occupies the lock. At this time, it will be judged whether the current thread is the thread that obtained the lock. If it is the thread that obtained the lock, it means that it is reentrant, and the state will be + 1 then execution ends
3. If the current thread is not the thread that acquired the lock, a Node node will be constructed and then put from the tail into the blocking queue to park.

 public ReentrantLock() {<!-- -->
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {<!-- -->
        sync = fair ? new FairSync() : new NonfairSync();
    }
 final void lock() {<!-- -->
           //cas atomic operation modifies the value of state. If the modification is successful, change 0 to 1 and record the current thread id.
            if (compareAndSetState(0, 1))
                setExclusiveOwnerThread(Thread.currentThread());
            else
                //Preempt lock logic
                acquire(1);
        }
 public final void acquire(int arg) {<!-- -->
         //Try to acquire an exclusive lock
        if (!tryAcquire(arg) & amp; & amp;
            //If it fails, if it is in the aqs queue
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }
 final boolean nonfairTryAcquire(int acquires) {<!-- -->
            //Get the current thread
            final Thread current = Thread.currentThread();,
             //Get the State value
            int c = getState();
            //If it is 0, it means you can obtain the lock
            if (c == 0) {<!-- -->
               //cas atomic operation modifies the value of state. If the modification is successful, change 0 to 1 and record the current thread id.
                if (compareAndSetState(0, acquires)) {<!-- -->
                    setExclusiveOwnerThread(current);
                    return true;
                }
            }
            //If the current thread is the thread that obtained the lock
            else if (current == getExclusiveOwnerThread()) {<!-- -->
                //Increase the number of reentries
                int nextc = c + acquires;
                if (nextc < 0) // overflow
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
 private Node addWaiter(Node mode) {<!-- -->
        //Build a node
        Node node = new Node(Thread.currentThread(), mode);
        // Try the fast path of enq; backup to full enq on failure
        // tail = tail node, the default is null
        Node pred = tail;
        if (pred != null) {<!-- -->
            //If the tail node is not equal to empty, treat the current node as the tail node, then point the prev pointer to the previous node, and change the newly incoming node to the tail node.
            node.prev = pred;
            if (compareAndSetTail(pred, node)) {<!-- -->
                 //Put the next pointer of the previous node to the node that just came in
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }
 private Node enq(final Node node) {<!-- -->
        for (;;) {<!-- -->
            Node t = tail;
            if (t == null) {<!-- --> // Must initialize
                 //If the tail node == null, use cas to build a node
                if (compareAndSetHead(new Node()))
                    //Assign the head node to the tail node
                    tail = head;
            } else {<!-- -->
                //If the tail node is not equal to empty, treat the current node as the tail node, then point the prev pointer to the previous node, and change the newly incoming node to the tail node.
                node.prev = t;
                if (compareAndSetTail(t, node)) {<!-- -->
                    //Put the next pointer of the previous node to the node that just came in
                    t.next = node;
                    return t;
                }
            }
        }
    }
 final boolean acquireQueued(final Node node, int arg) {<!-- -->
        boolean failed = true;
        try {<!-- -->
            boolean interrupted = false;
            for (;;) {<!-- -->
                //Get the previous node of the current node
                final Node p = node.predecessor();
                //If the previous node of the current node is the head node, it means that it is qualified to compete for the lock.
                if (p == head & amp; & amp; tryAcquire(arg)) {<!-- -->
                    //Set the current node as the head node
                    setHead(node);
                    
                    p.next = null; // help GC
                    failed = false;
                    return interrupted;
                }
                if (shouldParkAfterFailedAcquire(p, node) & amp; & amp;
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {<!-- -->
            if (failed)
                cancelAcquire(node);
        }
    }
//When obtaining (resource) fails, check and update the node status
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {<!-- -->
    // Get the status of the predecessor node
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL) // The status is SIGNAL, which is -1
        //Can perform park operation
        return true;
    if (ws > 0) {<!-- --> // Indicates that the status is CANCELLED, which is 1
        do {<!-- -->
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0); // Find the node with the latest status before the pred node that is not CANCELLED
        // Assign the next field of the pred node
        pred.next = node;
    } else {<!-- --> // If it is PROPAGATE -3 or 0, it means no state. (When it is CONDITION -2, it means this node is in the condition queue)
        // Compare and set the status of the predecessor node to SIGNAL
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    //Cannot perform park operation
    return false;
}
 private final boolean parkAndCheckInterrupt() {<!-- -->
        LockSupport.park(this);
        return Thread.interrupted();
    }

Release lock

Please add a picture description
1. Determine whether the current thread is the owner of the lock. If so, proceed to step 2. If not, throw an exception.
2. Determine whether the value of state after the lock is released is 0. If so, it means whether the lock has been reentrant. Then set the owner of the lock to null and return true, and then perform step 3. If not, it means that the lock has occurred. Re-enter step 4.
3. Now that the lock has been released, that is, state=0, wake up the successor node in the synchronization queue to acquire the lock.
4. The lock has not been released yet, that is, state!=0, and the synchronization queue is not awakened.

 public void unlock() {<!-- -->
        sync.release(1);
    }
 public final boolean release(int arg) {<!-- -->
        //Release lock successfully
        if (tryRelease(arg)) {<!-- -->
            Node h = head;
            //If the head node is not empty and the status is not 0
            if (h != null & amp; & amp; h.waitStatus != 0)
                //wake
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
 protected final boolean tryRelease(int releases) {<!-- -->
            //state -1
            int c = getState() - releases;
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            if (c == 0) {<!-- -->
                 //If c =0, it means that the current lock-free state is clear the thread iq
                free = true;
                setExclusiveOwnerThread(null);
            }
            //reset state
            setState(c);
            return free;
        }
private void unparkSuccessor(Node node) {<!-- -->
        /*
         * If status is negative (i.e., possibly needing signal) try
         * to clear in anticipation of signaling. It is OK if this
         * fails or if status is changed by waiting thread.
         */
        int ws = node.waitStatus;
        if (ws < 0)
            //Set the status of the head node to 0
            compareAndSetWaitStatus(node, ws, 0);

        /*
         * Thread to unpark is held in successor, which is normally
         * just the next node. But if canceled or apparently null,
         * traverse backwards from tail to find the actual
         * non-cancelled successor.
         */
        //Get the next node of the head node
        Node s = node.next;
        //If the next node is null or status>0, it means CANCELLED status
        //Start scanning after listening to the tail node and find the node closest to the head with waitStatus<=0
        if (s == null || s.waitStatus > 0) {<!-- -->
            s = null;
            for (Node t = tail; t != null & amp; & amp; t != node; t = t.prev)
                if (t.waitStatus <= 0)
                    s = t;
        }
        //If the next node is not equal to empty, directly wake up this thread
        if (s != null)
            LockSupport.unpark(s.thread);
    }

Fair lock implementation principle

Please add image description
1. Get the value of the state. If state=0, it means that the lock is not occupied by other threads (but it does not mean that there are no threads waiting in the synchronization queue). Go to step 2. If state!=0, it means that the lock is being occupied by other threads, go to step 3.
2. Determine whether a thread (node) exists in the synchronization queue. If it does not exist, directly set the owner of the lock to the current thread, update the state, and then return true.
3. Determine whether the owner of the lock is the current thread. If so, update the value of the state and return true. If not, return false, that is, the thread will be added to the synchronization queue.

final void lock() {<!-- -->
    acquire(1);
}

public final void acquire(int arg) {<!-- -->
    //There are threads in the synchronization queue and the owner of the lock is not the current thread, then add the thread to the end of the synchronization queue.
    //Fairness is ensured, that is, the thread that comes first gets the lock first, and the thread that comes later cannot get it first.
    if (!tryAcquire(arg) & amp; & amp;
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

protected final boolean tryAcquire(int acquires) {<!-- -->
    final Thread current = Thread.currentThread();
    int c = getState();
    //Determine whether the state is equal to 0. If it is equal to 0, it means that the lock is not occupied. If it is not equal to 0, it means that the lock is occupied.
    if (c == 0) {<!-- -->
        //Call the hasQueuedPredecessors method to determine whether there are threads waiting in the synchronization queue. If there are no threads in the synchronization queue,
        //The thread is waiting. The current thread becomes the owner of the lock. If there is a thread waiting in the synchronization queue, execution continues.
        //This mechanism is a fair lock mechanism, that is, the thread that comes first is allowed to acquire the lock first, and subsequent threads cannot acquire it first.
        if (!hasQueuedPredecessors() & amp; & amp;
            compareAndSetState(0, acquires)) {<!-- -->
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //Determine whether the current thread is the owner of the lock. If so, update the state directly and return true.
    else if (current == getExclusiveOwnerThread()) {<!-- -->
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    //If there is a thread in the synchronization queue and the owner of the lock is not the current thread, return false.
    return false;
}