Readwritelock readwritelock source code analysis

What is a read-write lock

In the last article, we talked about reentrant locks ReentrantLcok However, it is also an exclusive lock (also known as an exclusive lock), that is, only one thread is allowed to hold it at the same time, but in most scenarios, there are more reads and less writes, and there is no data competition problem in reading, so there is no thread safety problem. Therefore, if you use it at this time ReentrantLcok , efficiency is bound to be low, so there is a readwritelock ReentrantReadWriteLock.

internal structure

The read-write lock is also implemented based on AQS. It internally maintains a pair of locks, a read lock and a write lock. By separating read lock and write lock, concurrency is significantly improved compared with general exclusive lock. At the same time, it also has the properties of fair lock and unfair lock. Therefore, the read-write lock is mainly analyzed based on unfair lock.

/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;

Class diagram is as follows:

Simple use

Read / write locks are generally divided into the following three situations:

  • Read concurrency, that is, multiple read threads can be accessed and shared at the same time.
  • Read and write are mutually exclusive, that is, when the write thread accesses, all read threads and other write threads are blocked.
  • Write and write are mutually exclusive. When the write thread accesses, other write threads are blocked.

Use the following code to demonstrate these three situations:

1. Thread 1 gets the write lock. Because the write lock is exclusive, other threads cannot get the lock.

2. Thread 2 goes to get the read lock.

3. Thread 3 goes to get the read lock.

4. Thread 4 goes to get the write lock.

5. Thread 5 goes to get the read lock.

Q: if t5 is read lock, will it be executed together with t2 and t3?

@Slf4j(topic = "ayue")
public class RWTest {

    public static void main(String[] args) throws InterruptedException {
        ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
        //Read lock
        Lock rl = rwl.readLock();
        //Write lock
        Lock wl = rwl.writeLock();

        /**
         * t1 It is a write lock. Let it sleep for 5s. At this time, other threads cannot get the lock
         */
        new Thread(() -> {
            wl.lock();
            try {
                log.info("t1 Get write lock");
                TimeUnit.SECONDS.sleep(5);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                wl.unlock();
                log.info("t1 Release write lock");
            }
        }, "t1").start();

        //Sleep for 1s and let it execute first by pressing t1
        TimeUnit.SECONDS.sleep(1);

        /**
         * At this time, t2 cannot get the lock within 5s
         */
        new Thread(() -> {
            rl.lock();
            try {
                log.info("t2 Acquire read lock");
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                rl.unlock();
                log.info("t2 Release read lock");
            }
        }, "t2").start();

        /**
         * At this time, t3 cannot get the lock within 5s
         *
         * But after t1 is released, t2 and t3 can get the lock at the same time
         */
        new Thread(() -> {
            rl.lock();
            try {
                log.info("t3 Acquire read lock");
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                rl.unlock();
                log.info("t3 Release read lock");
            }
        }, "t3").start();

        /**
         * t4 Obtain the read lock, sleep for 10 seconds, and ask if t5 you can obtain the read lock after release
         */
        new Thread(() -> {
            wl.lock();
            try {
                log.info("t4 Get write lock");
                TimeUnit.SECONDS.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                wl.unlock();
                log.info("t4 Release write lock");
            }
        }, "t4").start();

        /**
         * t5 Is the read lock executed together with T2 and T3?
         * */
        new Thread(() -> {
            rl.lock();
            try {
                log.info("t5 Acquire read lock");
            } finally {
                rl.unlock();
                log.info("t5 Release read lock");
            }
        }, "t5").start();
    }
}

Output:

11:43:36.390 [t1] INFO  ayue - t1 Get write lock
11:43:41.398 [t1] INFO  ayue - t1 Release write lock
11:43:41.398 [t2] INFO  ayue - t2 Acquire read lock
11:43:41.398 [t3] INFO  ayue - t3 Acquire read lock
11:43:42.411 [t3] INFO  ayue - t3 Release read lock
11:43:42.411 [t2] INFO  ayue - t2 Release read lock
11:43:42.411 [t4] INFO  ayue - t4 Get write lock
11:43:52.423 [t4] INFO  ayue - t4 Release write lock
11:43:52.423 [t5] INFO  ayue - t5 Acquire read lock
11:43:52.423 [t5] INFO  ayue - t5 Release read lock

According to the results, we can know:

If a t1 write lock gets the lock first, followed by some other locks (possibly read locks or write locks), when t1 releases the lock, wake up the waiting thread according to the FIFO principle. If the first awakened thread is a write lock (if t2 is a write lock), it will not wake up t3 and subsequent locks. It will wake up t3 only after t2 execution is completed. Assuming that the awakened t2 is a read lock, t2 will judge whether his next t3 is a read lock. If so, t3 will be awakened. After t3 is awakened, t4 will judge whether t4 is a read lock. If t4 is also a read lock, t5 will be awakened, and so on. However, if t4 is awakened as a write lock, t5 will not be awakened, even if the following t5 is a read lock.

Therefore, the above situation will occur, t2 and t3 will be executed together, and t5 will not be executed together.

Lock degradation

Lock degradation refers to the process of obtaining a write lock, obtaining a read lock, and then releasing a write lock. Lock degradation is to ensure data visibility. Lock degradation is one of the important features of ReentrantReadWriteLock.

public class RWDowngrade {

    public static void main(String[] args) {
        ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
        //Read lock
        Lock rl = rwl.readLock();
        //Write lock
        Lock wl = rwl.writeLock();

        /**
         * Lock degradation
         */
        new Thread(() -> {
            wl.lock();
            try {
                System.out.println("Get write lock first");
                rl.lock();
                System.out.println("Acquire read lock after");
            } finally {
                rl.unlock();
                wl.unlock();
            }
        }, "t").start();
    }
}

Output:

Get write lock first
 Acquire read lock after

Note: ReentrantReadWriteLock does not upgrade locks. (the reason will be known in the source code)

As follows, if the read lock is obtained first and the write lock is obtained later, the deadlock will occur:

new Thread(() -> {
    rl.lock();
    try {
        System.out.println("Acquire read lock first");
        wl.lock();
        System.out.println("Acquire write lock after");
    } finally {
        wl.unlock();
        rl.unlock();
    }
}, "t").start();

Output, directly stuck:

Source code analysis

ReentrantReadWriteLock and ReentrantLock are actually the same. The Lock core is Sync, and the read Lock and write Lock are implemented based on Sync. From this analysis, ReentrantReadWriteLock is actually a Lock, but two different implementation methods are designed according to different scenarios. Its read-write Lock is divided into two internal classes: ReadLock and WriteLock, both of which implement the Lock interface.

The read-write lock is also implemented based on AQS, but we know that the synchronization status is determined according to the state and int integer variables in AQS, and the read-write lock is two locks. How can we represent two locks through an attribute?

How to maintain multiple states on an integer? We know that the int type is 32 bits, so we need to cut the variable by bit. The read-write lock cuts the variable into two parts. The high 16 bits represent read and the low 16 bits represent write.

How to quickly determine the status of read and write locks after segmentation? Through bit operation (those who forget bit operation can see my previous article: Bit operation in Java ), if the current synchronization state is c, the write state is equal to c & 0x0000ffff (all the high 16 bits are erased), and the read state is equal to c > > > 16 (unsigned complement 0 moves 16 bits right).

The code is as follows:

static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

/** Return to read status  */
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }

/** Return to write status  */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }

According to the division of States, a corollary can be drawn: when c is not equal to 0, when the write state is equal to 0, the read state is greater than 0, that is, the read lock has been obtained. Such comments can also be seen in the source code.

(Note: if c != 0 and w == 0 then shared count != 0)

Acquisition and release of write lock

A write lock is an exclusive lock that supports reentry. If the current thread has acquired a write lock, the write state is increased. If the read lock has been acquired when the current thread acquires the write lock (the read state is not 0) or the thread is not a thread that has acquired the write lock, the current thread enters the waiting state.

Acquisition of write lock

The acquisition of write lock mainly depends on the tryAcquire() method. The source code is as follows:

protected final boolean tryAcquire(int acquires) {
    //Current thread
    Thread current = Thread.currentThread();
	//Gets the status of the lock
    int c = getState();
    //Write lock status
    int w = exclusiveCount(c);
    if (c != 0) {//If locked
        /**
         * 1,w == 0,It is used to judge what the current lock is. If w is 0, it means that the lock has only read lock but not write lock
         * In other words, the current thread is to obtain the read lock, but this method was originally used to obtain the write lock, so the current thread
         * It is equivalent to lock upgrade, so obtaining the lock failed.
         * 2,If you enter 𞓜, then w= 0, indicating that the write lock has been applied. Judge whether the current thread is reentrant, if not failed.
         */
        if (w == 0 || current != getExclusiveOwnerThread())
            return false;
        /**
         * If the number of write locks obtained exceeds the maximum value of 65535, a direct exception will occur. Generally, it will not be exceeded.
         */
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // Reentrant acquire
        setState(c + acquires);
        return true;
    }
    /**
     * writerShouldBlock(),Simply put, it is to judge whether it is necessary to queue.
     * If it is a non fair lock, it directly returns false, that is, it directly grabs the lock regardless of whether there is a thread queuing or not.
     * If it is a fair lock, its bottom layer calls the hasQueuedPredecessors() method, which will judge whether there are still queued threads in the queue. If there are, it will 	 *  Queue up, if not, try locking.
     */
    if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
        return false;
    setExclusiveOwnerThread(current);
    return true;
}

By analyzing the source code:

If there is a read lock, the write lock cannot be obtained. The reason is that the read-write lock should ensure that the operation of the write lock is visible to the read lock. If the read lock is allowed to obtain the write lock when it has been obtained, other running read threads cannot perceive the operation of the current write thread. Therefore, the write lock can only be obtained by the current thread after other read threads release the read lock. Once the write lock is obtained, subsequent access of other read and write threads will be blocked.

Release of write lock

Release and of write lock ReentrantLock The release process of is basically similar to that in the previous article, which can be viewed by yourself. It is not repeated here.

Acquisition and release of read lock

Read lock is a shared lock that supports re-entry. It can be acquired by multiple threads at the same time. When no other write thread accesses (or the write status is 0), the read lock will always be acquired successfully. It should be noted that if the current thread has acquired the read lock, the read state will be increased. If the current thread obtains the read lock and the write lock has been obtained by other threads, it enters the waiting state.

Acquisition of read lock

The acquisition of write lock mainly depends on the tryAcquireShared() method. The source code is as follows:

//Try to obtain the sharing status. If the sharing status is greater than or equal to 0, it means that the lock is obtained successfully. Otherwise, join the synchronization queue.
protected final int tryAcquireShared(int unused) {
    Thread current = Thread.currentThread();
    int c = getState();
	/*
  	 * 1,exclusiveCount(c) != 0,Determine whether it is write locked.
  	 * 2,If the write lock is on, enter & &, why continue to judge when the write lock is on? This is the degradation of the lock.
  	 * If you re-enter, you must be degraded. If you do not re-enter, you will fail, because reading and writing need to be mutually exclusive.
     */
    if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
        return -1;
    /**
     * If the above code does not return to execution, there are two cases
     * 1,No one wrote on the lock 
     * 2,Reentrant demotion
     */
    int r = sharedCount(c);
    /**
     * readerShouldBlock(): Determine whether the lock needs to wait
     * r < MAX_COUNT: Determine whether the number of locks exceeds the maximum value of 65535
     * compareAndSetState(c, c + SHARED_UNIT):  Set sharing status (read lock status)
     */
    if (!readerShouldBlock() && r < MAX_COUNT && compareAndSetState(c, c + SHARED_UNIT)) {
        // r==0, indicating that no thread is currently acquiring a read lock
        if (r == 0) {
            // Set the current thread as the first thread to acquire the read lock
            firstReader = current;
            // The count is set to 1
            firstReaderHoldCount = 1;
        } else if (firstReader == current) {
            // Indicates a reentry lock on which + 1 is counted
            firstReaderHoldCount++;
        } else {
            /**
             * HoldCounter It is mainly a class to record the number of locks obtained by threads
             * cachedHoldCounter What is cached is the HoldCounter object of the last thread to acquire 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;
    }
    //Try again in spin mode
    return fullTryAcquireShared(current);
}

In the tryAcquireShared(int unused) method, if other threads have obtained the write lock, the current thread fails to obtain the read lock and enters the waiting state. If the current thread obtains the write lock or the write lock is not obtained, the current thread (thread safety, guaranteed by CAS) increases the read state and successfully obtains the read lock.

Release of read lock

protected final boolean tryReleaseShared(int unused) {
    Thread current = Thread.currentThread();
    //Judge that the current thread is the first thread to obtain the read lock
    if (firstReader == current) {
        // assert firstReaderHoldCount > 0;
        // Judge that the number of times to obtain the lock is 1. If it is 1, it indicates that there is no reentry, and directly release firstReader = null; Otherwise, the number of locks held by the thread will be - 1
        if (firstReaderHoldCount == 1)
            firstReader = null;
        else
            firstReaderHoldCount--;
    } else {
        // If the current thread is not the first thread to acquire a read lock.
        // Gets the HoldCounter in the cache
        HoldCounter rh = cachedHoldCounter;
        // If the HoldCounter in the cache does not belong to the current thread, get the HoldCounter of the current thread.
        if (rh == null || rh.tid != getThreadId(current))
            rh = readHolds.get();
        int count = rh.count;
        if (count <= 1) {
            // If the number of locks held by the thread is less than 1, delete HoldCounter directly
            readHolds.remove();
            if (count <= 0)
                throw unmatchedUnlockException();
        }
        // If the number of locks held is greater than 1, perform the - 1 operation
        --rh.count;
    }
    // Spin release synchronization state
    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;
    }
}

Lock release is relatively simple. First, check whether the current thread is the first thread to obtain the read lock. If yes and no reentry occurs, set the read lock variable obtained for the first time to null. If reentry occurs, the read lock counter - 1 will be obtained for the first time. Second, check whether the counter in the cache is empty or whether it is the current thread, If it is empty or not, the counter of the current thread is obtained. If the number of counters is less than 1, the counter is deleted from ThreadLocl and the counter value is - 1. If it is less than or equal to 0, an exception occurs. Finally, modify the synchronization state.

High performance read / write lock StampedLock

The performance of ReentrantReadWriteLock is already very good, but a series of CAS operations are still required to lock at the bottom. Therefore, JUC also provides another read-write lock, StampedLock.

If StampedLock is a read lock, there is no CAS operation. Therefore, its performance is better than ReentrantReadWriteLock. It is also called optimistic read lock, that is, when reading and obtaining the lock, it does not lock and directly returns a value. Then, when executing the critical area, it verifies whether the value has been modified (write operation locking). If it has not been modified, it directly executes the code in the critical area, If it is modified, it needs to be upgraded to read-write lock ReadLock.

Basic grammar

//The acquisition stamp does not have a lock 
long stamp = lock.tryOptimisticRead(); 
//Verification stamp 
if(lock.validate(stamp)){ 
    //If yes, execute the code of the critical area 
    //return 
}
//If it does not return, it indicates that it has been modified and needs to be upgraded to readLock 
lock.readLock();

Code example

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.StampedLock;

@Slf4j(topic = "ayue")
public class DataContainer {
    int i;
    long stampw = 0l;

    public void setI(int i) {
        this.i = i;
    }

    private final StampedLock lock = new StampedLock(); //First add StampedLock

    @SneakyThrows
    public int read() {
        //Try an optimistic reading
        long stamp = lock.tryOptimisticRead();
        log.debug("StampedLock Read the stamp from the lock{}", stamp);
        //Stamp verification after 1s
        TimeUnit.SECONDS.sleep(1);
        //Check stamp
        if (lock.validate(stamp)) {
            log.debug("StampedLock Verification complete stamp{}, data.i: {}", stamp, i);
            return i;
        }
        //The verification must have failed
        log.debug("Validation failed and was changed by the write thread{}", stampw);
        try {
            //The upgrade of the lock will also change the stamp
            stamp = lock.readLock();
            log.debug("Locking after upgrade succeeded {}", stamp);

            TimeUnit.SECONDS.sleep(1);
            log.debug("Upgrade read lock completed{}, data.i: {}", stamp, i);
            return i;
        } finally {
            log.debug("Upgrade lock unlock {}", stamp);
            lock.unlockRead(stamp);
        }
    }

    @SneakyThrows
    public void write(int i) {
        //cas locking
        stampw = lock.writeLock();
        log.debug("Write lock succeeded {}", stampw);
        try {
            TimeUnit.SECONDS.sleep(5);
            this.i = i;
        } finally {
            log.debug("Write lock unlock {},data.i: {}", stampw, i);
            lock.unlockWrite(stampw);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        //Instantiate data container
        DataContainer dataContainer = new DataContainer();
        //An initial value is assigned to the construction method without writing
        dataContainer.setI(1);
        //read
        new Thread(() -> {
            dataContainer.read();
        }, "t1").start();

        new Thread(() -> {
            dataContainer.read();
        }, "t2").start();

//        new Thread(() -> {
//            dataContainer.write(9);
//        }, "t3").start();
    }
}

Both are read lock outputs (t1, t2):

11:58:48.839 [t1] DEBUG ayue - StampedLock Read the stamp 256 from the lock
11:58:48.839 [t2] DEBUG ayue - StampedLock Read the stamp 256 from the lock
11:58:49.854 [t2] DEBUG ayue - StampedLock Verification complete stamp256, data.i: 1
11:58:49.854 [t1] DEBUG ayue - StampedLock Verification complete stamp256, data.i: 1

If modified (t1, t3):

12:07:36.234 [t1] DEBUG ayue - StampedLock Read the stamp 256 from the lock
12:07:37.238 [t3] DEBUG ayue - Write lock successful 384
12:07:41.213 [t1] DEBUG ayue - Verification failure was changed 384 by the write thread
12:07:42.240 [t3] DEBUG ayue - Write lock unlock 384,data.i: 9
12:07:42.240 [t1] DEBUG ayue - Locking after upgrade succeeded 513
12:07:43.252 [t1] DEBUG ayue - Upgrade read lock completed 513, data.i: 9
12:07:43.252 [t1] DEBUG ayue - Upgrade lock unlock 513

Can StampedLock replace ReentrantReadWriteLock

Although the performance of StampedLock is higher than that of ReentrantReadWriteLock, it also needs to be used by scene. StampedLock has the following characteristics:

  1. Reentry is not supported
  2. Conditional queues are not supported
  3. There are some concurrency problems

summary

When a thread holds a read lock, the thread cannot obtain a write lock (to ensure that the write operation remains visible to all subsequent read operations).

When a thread holds a write lock, the thread can continue to acquire the read lock (if it is found that the write lock is occupied when acquiring the read lock, the acquisition will fail only if the write lock is not occupied by the current thread).

Think about it carefully. This design is reasonable: when a thread acquires a read lock, other threads may also hold a read lock, so the thread that acquires a read lock cannot be "upgraded" to a write lock; For the thread that obtains the write lock, it must monopolize the read-write lock, so it can continue to obtain the read lock. When it obtains the write lock and read lock at the same time, it can also release the write lock and continue to hold the read lock. In this way, a write lock will be "degraded". In order to read, if a thread wants to hold the write lock and read lock at the same time, it must obtain the write lock first and then obtain the read lock.

Write locks can be "downgraded" to read locks, and read locks cannot be "upgraded" to write locks.

Tags: Java Concurrent Programming

Posted on Mon, 11 Oct 2021 22:48:02 -0400 by ypkumar