Java Review - Concurrent Programming_ Principle of ReentrantReadWriteLock & source code analysis

Article catalog

ReentrantLock VS ReentrantReadWriteLock

To solve the thread safety problem, you can use ReentrantLock. However, ReentrantLock is an exclusive lock. At some time, only one thread can obtain the lock. In practice, there will be scenarios of less writing and more reading. Obviously, ReentrantLock can not meet this requirement, so ReentrantReadWriteLock should be produced. ReentrantReadWriteLock adopts a read-write separation strategy, allowing multiple threads to obtain read locks at the same time.

Class diagram structure

To understand the internal structure of ReentrantReadWriteLock, let's take a look at its class diagram structure

A ReadLock and a WriteLock are maintained inside the read-write lock, which rely on Sync to achieve specific functions.

Sync inherits from AQS and also provides fair and unfair implementations.

Implementation of unfair read-write lock

The following only introduces the implementation of unfair read-write lock.

We know that only one state is maintained in AQS, while ReentrantReadWriteLock needs to maintain read and write states. How can a state represent write and read States?

ReentrantReadWriteLock cleverly uses the high 16 bits of state to represent the read state, that is, the number of times to obtain the read lock; The low 16 bits are used to indicate the number of reentrants of the thread that acquired the write lock.

        /*
         * Read vs write count extraction constants and functions.
         * Lock state is logically divided into two unsigned shorts:
         * The lower one representing the exclusive (writer) lock hold count,
         * and the upper the shared (reader) hold count.
         */

        static final int SHARED_SHIFT   = 16;
        //Shared lock (read lock) status unit value 65536
        static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
        //Maximum number of shared lock threads 65535
        static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
	    //Exclusive lock (write lock) mask, binary, 15 1
        static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

		
		// Returns the number of lock reading threads
	    /** Returns the number of shared holds represented in count  */
        static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
		
		// Returns the number of write lock threads
        /** Returns the number of exclusive holds represented in count  */
        static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

In Sync

  • The firstReader is used to record the first thread that obtains the read lock
  • firstReaderHoldCount records the number of reentrant times that the first thread that obtains the read lock obtains the read lock.
  • cachedHoldCounter is used to record the reentrant times of the last thread to obtain a read lock
        /**
         * A counter for per-thread read hold counts.
         * Maintained as a ThreadLocal; cached in cachedHoldCounter
         */
        static final class HoldCounter {
            int count = 0;
            // Use id, not reference, to avoid garbage retention
            final long tid = getThreadId(Thread.currentThread());
        }

readHolds is a ThreadLocal variable, which is used to store the number of reentrant times that other threads except the first one acquire a read lock acquire a read lock.

ThreadLocalHoldCounter inherits ThreadLocal, so the initialValue method returns a HoldCounter object.

  /**
         * ThreadLocal subclass. Easiest to explicitly define for sake
         * of deserialization mechanics.
         */
        static final class ThreadLocalHoldCounter
            extends ThreadLocal<HoldCounter> {
            public HoldCounter initialValue() {
                return new HoldCounter();
            }
        }

Acquisition and release of write lock

In ReentrantReadWriteLock, the write lock is implemented using WriteLock.

void lock()

A write lock is an exclusive lock that can be acquired by only one thread at a time.

  • If the current thread does not acquire the read lock and write lock, the current thread can acquire the write lock and return.
  • If a thread has acquired a read lock and a write lock, the thread requesting the write lock will be blocked and suspended.
  • In addition, a write lock is a reentrant lock. If the current thread has acquired the lock, acquiring it again simply increases the reentrant times by 1 and returns directly.
     public void lock() {
            sync.acquire(1);
        }
   public final void acquire(int arg) {
   	// tryAcquire method overridden by sync
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

As shown in the above code, the AQS acquire method is called inside lock(), where tryAcquire is overridden by the sync class inside ReentrantReadWriteLock. The code is as follows.

   protected final boolean tryAcquire(int acquires) {
            /*
             * Walkthrough:
             * 1. If read count nonzero or write count nonzero
             *    and owner is a different thread, fail.
             * 2. If count would saturate, fail. (This can only
             *    happen if count is already nonzero.)
             * 3. Otherwise, this thread is eligible for lock if
             *    it is either a reentrant acquire or
             *    queue policy allows it. If so, update state
             *    and set owner.
             */
            Thread current = Thread.currentThread();
            int c = getState();
            int w = exclusiveCount(c);
			// 1  c!= 0 indicates that the read lock or write lock has been acquired by another thread	
            if (c != 0) {
                // (Note: if c != 0 and w == 0 then shared count != 0)
                // 2 w = 0 indicates that a thread has obtained a write lock, w= 0 and the current thread is not the owner of the write lock, false is returned
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                 // 3 indicates that the current thread has obtained the write lock and determines the number of reentrants
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                // Reentrant acquire
                // Set reentrant times (1) 	)
                setState(c + acquires);
                return true;
            }
			// 5 the first write thread obtains the write lock 
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            setExclusiveOwnerThread(current);
            return true;
        }
  • In code (1), if the current AQS status value is not 0, it indicates that a thread has obtained a read lock or a write lock.
  • In code (2), if w==0 indicates that the lower 16 bits of the status value are 0, but the AQS status value is not 0, then the upper 16 bits are not 0, which indicates that a thread has obtained a read lock, so it directly returns false. If w!=0, it indicates that a thread has obtained the write lock, and then see whether the current thread is the holder of the lock. If not, it returns false.
  • Executing code (3) indicates that the current thread has obtained the lock before, so judge whether the reentrant times of the thread exceed the maximum value. If yes, throw an exception. Otherwise, execute code (4) to increase the reentrant times of the current thread, and then return true
  • If the AQS status value is equal to 0, it means that no thread has obtained the read lock and write lock, so execute the code (5). For the writerShouldBlock method,

The implementation of unfair lock is

  final boolean writerShouldBlock() {
            return false; // writers can always barge
        }

If the code always returns false for an unfair lock, it means that the code (5) preemptively executes CAS to try to obtain a write lock. If the acquisition is successful, set the holder of the current lock as the current thread and return true, otherwise return false.

The implementation of fair lock is

 final boolean writerShouldBlock() {
            return hasQueuedPredecessors();
        }

Here, we still use hasQueuedPredecessors to judge whether the current thread node has a precursor node. If so, the current thread will give up the permission to obtain the write lock and directly return false.

void lockInterruptibly()

Similar to the lock() method, the difference is that it will respond to interrupts. That is, when other threads call the thread's interrupt() method to interrupt the current thread, the current thread will throw an exception, InterruptedException.

   public void lockInterruptibly() throws InterruptedException {
            sync.acquireInterruptibly(1);
        }

boolean tryLock()

Try to acquire a write lock. If no other thread currently holds a write lock or a read lock, the current thread will acquire the write lock successfully, and then return true. If another thread already holds a write lock or a read lock, the method directly returns false, and the current thread will not be blocked. If the current thread already holds the write lock, simply increase the AQS status value and return directly Return true.

   public boolean tryLock( ) {
            return sync.tryWriteLock();
        }
    /**
         * Performs tryLock for write, enabling barging in both modes.
         * This is identical in effect to tryAcquire except for lack
         * of calls to writerShouldBlock.
         */
        final boolean tryWriteLock() {
            Thread current = Thread.currentThread();
            int c = getState();
            if (c != 0) {
                int w = exclusiveCount(c);
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                if (w == MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
            }
            if (!compareAndSetState(c, c + 1))
                return false;
            setExclusiveOwnerThread(current);
            return true;
        }

The above code is similar to the tryAcquire method, except that the unfair strategy is used here.

boolean tryLock(long timeout, TimeUnit unit)

The difference between tryAcquire() and tryAcquire() is that the timeout parameter is added. If the attempt to obtain the write lock fails, the current thread will be suspended for a specified time. After the timeout time expires, the current thread will be activated. If the write lock is still not obtained, false will be returned. In addition, this method will respond to the interrupt, that is, when other threads call the thread's interrupt() When the method interrupts the current thread, the current thread will throw an InterruptedException exception.

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

void unlock()

Try to release the lock. If the current thread holds the lock, calling this method will reduce the AQS status value held by the thread by 1. If the current status value after subtracting 1 is 0, the current thread will release the lock, otherwise it will only decrease by 1. If the current thread does not hold the lock and calls this method, an IllegalMonitorStateException will be thrown

   public void unlock() {
            sync.release(1);
        }
    public final boolean release(int arg) {
    	// Call ReentrantReadWriteLock#Sync overridden tryRelease
        if (tryRelease(arg)) {
        	// Activate a thread in the blocking queue
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
   protected final boolean tryRelease(int releases) {
   			// 6. Check whether it is the unLock called by the thread with write lock
            if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
            // 7. When obtaining the reentrant value, the high 16 bits are not considered, because the value of the read lock must be 0 when obtaining the write lock
            int nextc = getState() - releases;
            boolean free = exclusiveCount(nextc) == 0;
            // If the reentrant lock value is 0, the lock is released; otherwise, the status value is simply updated 
            if (free)
                setExclusiveOwnerThread(null);
            setState(nextc);
            return free;
        }
  • tryRelease first determines whether the current thread is the holder of the write lock through ishldexclusively. If not, an exception will be thrown
  • Otherwise, execute the code (7), which indicates that the current thread holds a write lock. Holding a write lock indicates that the upper 16 bits of the status value are 0, so the nextc value here is the remaining reentrant times of the current thread's write lock.
  • Code (8) determines whether the current reentrant count is 0. If free is true, it means that the reentrant count is 0. Therefore, the current thread will release the write lock and set the holder of the current lock to null. If free is false, the reentrant count is simply updated.

Acquisition and release of read lock

ReadLock is used to implement the read lock in ReentrantReadWriteLock.

void lock()

Obtain the read lock. If no other thread currently holds the write lock, the current thread can obtain the read lock. The value of the upper 16 bits of the AQS status value state will increase by 1, and then the method returns. Otherwise, if another thread holds a write lock, the current thread will be blocked.

      /**
         * Acquires the read lock.
         *
         * Acquires the read lock if the write lock is not held by
         * another thread and returns immediately.
         *
         * If the write lock is held by another thread then
         * the current thread becomes disabled for thread scheduling
         * purposes and lies dormant until the read lock has been acquired.
         */
        public void lock() {
            sync.acquireShared(1);
        }
  public final void acquireShared(int arg) {
  		// Call tryAcquireShared of syn in ReentrantReadWriteLock
        if (tryAcquireShared(arg) < 0)
        	// Call doAcquireShared of AQS
            doAcquireShared(arg);
    }
  protected final int tryAcquireShared(int unused) {
            /*
             * Walkthrough:
             * 1. If write lock held by another thread, fail.
             * 2. Otherwise, this thread is eligible for
             *    lock wrt state, so ask if it should block
             *    because of queue policy. If not, try
             *    to grant by CASing state and updating count.
             *    Note that step does not check for reentrant
             *    acquires, which is postponed to full version
             *    to avoid having to check hold count in
             *    the more typical non-reentrant case.
             * 3. If step 2 fails either because thread
             *    apparently not eligible or CAS fails or count
             *    saturated, chain to version with full retry loop.
             */
            Thread current = Thread.currentThread();
            // 1 get the current status value
            int c = getState();
            // 2. Judge whether the write lock is occupied
            if (exclusiveCount(c) != 0 &&
                getExclusiveOwnerThread() != current)
                return -1;
            // 3 get read lock count
            int r = sharedCount(c);
            // 4. When trying to obtain a lock, only one of multiple read threads will succeed. If it fails, enter fullTryAcquireShared to retry
            if (!readerShouldBlock() &&
                r < MAX_COUNT &&
                compareAndSetState(c, c + SHARED_UNIT)) {
                // 5 the first thread obtains the lock
                if (r == 0) {
                    firstReader = current;
                    firstReaderHoldCount = 1;
                // 6 if the current thread is the first thread to obtain a read lock
                } else if (firstReader == current) {
                    firstReaderHoldCount++;
                } else {
                	// 7 record the last thread to acquire the read lock or record the number of reentrant times that other threads can read the lock
                    HoldCounter rh = cachedHoldCounter;
                    if (rh == null || rh.tid != getThreadId(current))
                        cachedHoldCounter = rh = readHolds.get();
                    else if (rh.count == 0)
                        readHolds.set(rh);
                    rh.count++;
                }
                return 1;
            }
            // 8 is similar to tryacquisureshared, but spin acquisition
            return fullTryAcquireShared(current);
        }
  • First, the status value of the current AQS is obtained
  • Then code (2) checks whether other threads get the write lock, if so, directly returns to -1, and then calls the doAcquireShared method of AQS to put the current thread into the AQS blocking queue.

If the thread currently acquiring the read lock already holds the write lock, it can also acquire the read lock. However, it should be noted that when a thread obtains the write lock first and then the read lock, after processing, remember to release both the read lock and the write lock, not only the write lock.

  • Otherwise, execute the code (3) to get the number of read locks obtained. Here, it shows that no thread has obtained the write lock, but some threads may hold the read lock and then execute the code (4)

The implementation code of reader shouldblock for non fair lock is as follows

 final boolean readerShouldBlock() {
            /* As a heuristic to avoid indefinite writer starvation,
             * block if the thread that momentarily appears to be head
             * of queue, if one exists, is a waiting writer.  This is
             * only a probabilistic effect since a new reader will not
             * block if there is a waiting writer behind other enabled
             * readers that have not yet drained from the queue.
             */
            return apparentlyFirstQueuedIsExclusive();
        }
    }
  final boolean apparentlyFirstQueuedIsExclusive() {
        Node h, s;
        return (h = head) != null &&
            (s = h.next)  != null &&
            !s.isShared()         &&
            s.thread != null;
    }

The function of the above code is to determine whether the first element is trying to obtain a write lock if there is an element in the queue. If not, the current thread determines whether the thread currently obtaining a read lock has reached the maximum value. Finally, execute CAS operation to increase the high 16 bit value of AQS status value by 1.

  • Code (5) (6) records the first thread that obtains the read lock and counts the reentrant number of the thread that obtains the read lock.
  • Code (7) uses cachedHoldCounter to record the last thread that obtains the read lock and the reentrant number of the thread that obtains the read lock. readHolds records the reentrant number of the current thread that obtains the read lock.
  • If readerShouldBlock returns true, it indicates that a thread is acquiring a write lock, so execute the code (8).
  • The code of fullTryAcquireShared is similar to tryAcquireShared, except that the former is obtained by cyclic spin.
 final int fullTryAcquireShared(Thread current) {
            /*
             * This code is in part redundant with that in
             * tryAcquireShared but is simpler overall by not
             * complicating tryAcquireShared with interactions between
             * retries and lazily reading hold counts.
             */
            HoldCounter rh = null;
            for (;;) {
                int c = getState();
                if (exclusiveCount(c) != 0) {
                    if (getExclusiveOwnerThread() != current)
                        return -1;
                    // else we hold the exclusive lock; blocking here
                    // would cause deadlock.
                } else if (readerShouldBlock()) {
                    // Make sure we're not acquiring read lock reentrantly
                    if (firstReader == current) {
                        // assert firstReaderHoldCount > 0;
                    } else {
                        if (rh == null) {
                            rh = cachedHoldCounter;
                            if (rh == null || rh.tid != getThreadId(current)) {
                                rh = readHolds.get();
                                if (rh.count == 0)
                                    readHolds.remove();
                            }
                        }
                        if (rh.count == 0)
                            return -1;
                    }
                }
                if (sharedCount(c) == MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                if (compareAndSetState(c, c + SHARED_UNIT)) {
                    if (sharedCount(c) == 0) {
                        firstReader = current;
                        firstReaderHoldCount = 1;
                    } else if (firstReader == current) {
                        firstReaderHoldCount++;
                    } else {
                        if (rh == null)
                            rh = cachedHoldCounter;
                        if (rh == null || rh.tid != getThreadId(current))
                            rh = readHolds.get();
                        else if (rh.count == 0)
                            readHolds.set(rh);
                        rh.count++;
                        cachedHoldCounter = rh; // cache for release
                    }
                    return 1;
                }
            }
        }

void lockInterruptibly()

Similar to the lock() method, the difference is that this method will respond to the interrupt, that is, when other threads call the thread's interrupt() method to interrupt the current thread, the current thread will throw an InterruptedException exception.

boolean tryLock()

  • Try to obtain a read lock. If no other thread holds a write lock, the current thread will succeed in obtaining a read lock, and then return true.
  • If another thread already holds a write lock, the method directly returns false, but the current thread will not be blocked.
  • If the current thread already holds the read lock, simply increase the AQS status value by 16 bits and return true directly.

The code is similar to the code of tryLock, which will not be described here.

boolean tryLock(long timeout, TimeUnit unit)

The difference from tryLock() is that the timeout parameter is added. If the attempt to obtain the read lock fails, the current thread will be suspended for a specified time. After the timeout expires, the current thread will be activated. If the read lock has not been obtained at this time, false will be returned.

In addition, this method responds to interrupts, that is, when other threads call the thread's interrupt() method to interrupt the current thread, the current thread will throw an InterruptedException exception.

void unlock()

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

In the above code, the operation of releasing the lock is delegated to the Sync class. The code of the sync.releaseShared method is as follows:

public final boolean releaseShared(int arg) {
        if (tryReleaseShared(arg)) {
            doReleaseShared();
            return true;
        }
        return false;
    }
 protected final boolean tryReleaseShared(int unused) {
            Thread current = Thread.currentThread();
            if (firstReader == current) {
                // assert firstReaderHoldCount > 0;
                if (firstReaderHoldCount == 1)
                    firstReader = null;
                else
                    firstReaderHoldCount--;
            } else {
                HoldCounter rh = cachedHoldCounter;
                if (rh == null || rh.tid != getThreadId(current))
                    rh = readHolds.get();
                int count = rh.count;
                if (count <= 1) {
                    readHolds.remove();
                    if (count <= 0)
                        throw unmatchedUnlockException();
                }
                --rh.count;
            }
            // Cycle until its read counter is - 1, and CAS update is successful 
            for (;;) {
                int c = getState();
                int nextc = c - SHARED_UNIT;
                if (compareAndSetState(c, nextc))
                    // Releasing the read lock has no effect on readers,
                    // but it may allow waiting writers to proceed if
                    // both read and write locks are now free.
                    return nextc == 0;
            }
        }

As shown in the above code, in the infinite loop, first obtain the current AQS status value and save it to variable c. then, after variable c is subtracted by a read count unit, use CAS operation to update the AQS status value. If the update is successful, check whether the current AQS status value is 0. If it is 0, it indicates that no read thread is occupying the read lock, and tryrereleaseshared returns true.

Then, the doReleaseShared method will be called to release a thread blocked due to obtaining a write lock. If the current AQS status value is not 0, it indicates that there are other threads holding a read lock, so tryrereleaseshared returns false.

If CAS in tryrereleaseshared fails to update AQS status value, spin retry until success.

Demo

Java Review - Concurrent Programming_ Principle of exclusive lock ReentrantLock & source code analysis This paper introduces how to use ReentrantLock to implement thread safe list. However, because ReentrantLock is an exclusive lock, it has poor performance in the case of more reads and less writes. Let's use ReentrantReadWriteLock to transform it. The code is as follows

import java.util.ArrayList;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * @author Small craftsman
 * @version 1.0
 * @description: TODO
 * @date 2021/12/4 23:05
 * @mark: show me the code , change the world
 */
public class ReentrantReadWriteLockList {

    //Thread unsafe List
    private ArrayList<String> list = new ArrayList<String>();

    //Exclusive lock
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    private final Lock readLock = lock.readLock();
    private final Lock writeLock = lock.writeLock();

    //Add elements to the collection
    public void add(String str) {
        writeLock.lock();
        try {
            list.add(str);
        } finally {
            writeLock.unlock();
        }
    }

    //Delete elements in the collection
    public void remove(String str) {
        writeLock.lock();
        try {
            list.remove(str);
        } finally {
            writeLock.unlock();
        }
    }

    //Gets an element in the collection according to the index
    public String get(int index) {
        readLock.lock();
        try {
            return list.get(index);
        } finally {
            readLock.unlock();
        }
    }
}

The above code uses a read lock when calling the get method. In this way, multiple read threads are run to access the elements of the list at the same time, which will have better performance in the case of more reads and less writes.

Summary

Here we introduce the principle of ReentrantReadWriteLock. Its bottom layer is implemented using AQS. ReentrantReadWriteLock skillfully uses the high 16 bits of the AQS status value to indicate the number of read locks obtained, and the low 16 bits to indicate the reentrant times of the thread that obtains the write lock, and operates it through CAS to realize read-write separation, which is more applicable in the scenario of more reads and less writes.

Posted on Sun, 05 Dec 2021 19:37:31 -0500 by dmayo2