Four knowledge points take you easily to master the thread hunger weapon StampedLock

table of Contents

Preface

characteristic

Three access data modes:

Support read-write lock mutual conversion

1. Explain the performance improvement brought by optimistic reading in detail

Usage example

Usage scenarios and precautions

2. Principle analysis

Spin optimization

Three, waiting queue

Acquire write lock

Acquire a read lock

Key steps to obtain a read lock

Four, acquireRead

Release lock

Release read lock

Release the write lock

to sum up


Preface

The introduction of StampedLock in JDK 1.8 can be understood as an enhancement of ReentrantReadWriteLock in some aspects. A new mode called Optimistic Reading is added to the original read-write lock. This mode does not add locks, so threads will not be blocked, and there will be higher throughput and higher performance.

  • With ReentrantReadWriteLock, why introduce StampedLock?
  • What is optimistic reading?
  • How does StampedLock solve the problem of thread "starvation" when it is difficult for the writer thread to acquire the lock in the concurrent scenario of more reading and less writing?
  • What kind of scene is used?
  • Implementation principle analysis, is it realized through AQS or something else?

characteristic

It was originally designed as an internal tool class to develop other thread-safe components to improve system performance, and the programming model is also more complicated than ReentrantReadWriteLock, so inexplicable problems such as deadlock or thread safety can easily occur if it is not used well.

Three access data modes :

  • Writing (exclusive write lock): The writeLock method will block the thread waiting for exclusive access. It can be analogous to the write lock mode of ReentrantReadWriteLock. At the same time, there is one and only one writing thread to acquire the lock resource;
  • Reading (pessimistic read lock): The readLock method allows multiple threads to acquire a pessimistic read lock at the same time. The pessimistic read lock and the exclusive write lock are mutually exclusive, and are shared with optimistic reads.
  • Optimistic Reading (optimistic reading): Need to pay attention here, it is optimistic reading, and there is no lock. That is, there will be no CAS mechanism and no blocking threads. The tryOptimisticRead will return a non-zero postmark (Stamp) only when it is not currently in the Writing mode. If the write mode thread does not acquire the lock after acquiring the optimistic read, the method validate returns true, allowing multiple threads to acquire the optimistic read and read lock . At the same time, a writer thread is allowed to acquire a write lock .

Support read-write lock mutual conversion

ReentrantReadWriteLock can be downgraded to a read lock after a thread acquires a write lock, but not vice versa.

StampedLock provides the function of mutual conversion between read lock and write lock, making this class support more application scenarios.

Precautions

  1. StampedLock is a non-reentrant lock. If the current thread has acquired the write lock, it will deadlock if it is acquired again;
  2. Does not support the Conditon condition to wait for the thread;
  3. After the write lock and pessimistic read lock of StampedLock are successfully locked, a stamp will be returned; then when unlocking, this stamp needs to be passed in.

1. Explain the performance improvement brought by optimistic reading in detail

So why is the performance of StampedLock better than ReentrantReadWriteLock?

The key lies in the optimistic read provided by StampedLock. We know that ReentrantReadWriteLock supports multiple threads to acquire read locks at the same time, but when multiple threads read at the same time, all write threads are blocked. The optimistic read of StampedLock  allows one write thread to acquire the write lock, so it will not cause all write threads to block, that is, when the read is more and less, the write thread has the opportunity to acquire the write lock, reducing the problem of thread starvation, and greatly improving throughput .

You may have questions here. If multiple optimistic reads and a pre-thread are allowed to enter critical resource operations at the same time, what should be done if the read data may be wrong?

Yes, optimistic reading cannot guarantee that the data read is the latest, so when reading the data to a local variable, you need to check whether it has been modified by the writing thread through lock.validate(stamp). If it is modified, you need to be pessimistic Read the lock, and then re-read the data to the local variable.

At the same time, because optimistic reading is not a lock, there is no context switching caused by thread wakeup and blocking, and the performance is better. In fact, it is similar to the "optimistic lock" of the database, and its realization idea is very simple. Let's take an example of a database.

A numeric version number field version is added to the product_doc table of the production order. Each time the product_doc table is updated, the version field is incremented by 1.

select id,... ,version
from product_doc
where id = 123

The update is performed only when the version matches the update.

update product_doc
set version = version + 1,...
where id = 123 and version = 5

The optimistic lock of the database is to find out the version when querying, and use the version field to verify when updating. If they are equal, the data has not been modified and the data read is safe.

The version here is similar to the Stamp of StampedLock.

Usage example

Imitate to write one that saves the user id and user name data in the shared variable idMap, and provides the put method to add data, the get method to obtain the data, and putIfNotExist to obtain the data from the map first, if not, simulate the query data from the database and put it in the map in.

public class CacheStampedLock {
    /**
     * 共享变量数据
     */
    private final Map<Integer, String> idMap = new HashMap<>();
    private final StampedLock lock = new StampedLock();

    /**
     * 添加数据,独占模式
     */
    public void put(Integer key, String value) {
        long stamp = lock.writeLock();
        try {
            idMap.put(key, value);
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    /**
     * 读取数据,只读方法
     */
    public String get(Integer key) {
        // 1. 尝试通过乐观读模式读取数据,非阻塞
        long stamp = lock.tryOptimisticRead();
        // 2. 读取数据到当前线程栈
        String currentValue = idMap.get(key);
        // 3. 校验是否被其他线程修改过,true 表示未修改,否则需要加悲观读锁
        if (!lock.validate(stamp)) {
            // 4. 上悲观读锁,并重新读取数据到当前线程局部变量
            stamp = lock.readLock();
            try {
                currentValue = idMap.get(key);
            } finally {
                lock.unlockRead(stamp);
            }
        }
        // 5. 若校验通过,则直接返回数据
        return currentValue;
    }

    /**
     * 如果数据不存在则从数据库读取添加到 map 中,锁升级运用
     * @param key
     * @param value 可以理解成从数据库读取的数据,假设不会为 null
     * @return
     */
    public String putIfNotExist(Integer key, String value) {
        // 获取读锁,也可以直接调用 get 方法使用乐观读
        long stamp = lock.readLock();
        String currentValue = idMap.get(key);
        // 缓存为空则尝试上写锁从数据库读取数据并写入缓存
        try {
            while (Objects.isNull(currentValue)) {
                // 尝试升级写锁
                long wl = lock.tryConvertToWriteLock(stamp);
                // 不为 0 升级写锁成功
                if (wl != 0L) {
                    // 模拟从数据库读取数据, 写入缓存中
                    stamp = wl;
                    currentValue = value;
                    idMap.put(key, currentValue);
                    break;
                } else {
                    // 升级失败,释放之前加的读锁并上写锁,通过循环再试
                    lock.unlockRead(stamp);
                    stamp = lock.writeLock();
                }
            }
        } finally {
            // 释放最后加的锁
            lock.unlock(stamp);
        }
        return currentValue;
    }
}

In the above example, what needs attention is the get() and putIfNotExist() methods. The first one uses optimistic reading to make reading and writing can be executed concurrently, and the second one is programming that uses a read lock to convert to a write lock. The model first queries the cache, and when it does not exist, reads data from the database and adds it to the cache.

When using optimistic reading, you must write in accordance with a fixed template, otherwise it is easy to cause bugs. Let's summarize the template of the optimistic reading programming model:

public void optimisticRead() {
    // 1. 非阻塞乐观读模式获取版本信息
    long stamp = lock.tryOptimisticRead();
    // 2. 拷贝共享数据到线程本地栈中
    copyVaraibale2ThreadMemory();
    // 3. 校验乐观读模式读取的数据是否被修改过
    if (!lock.validate(stamp)) {
        // 3.1 校验未通过,上读锁
        stamp = lock.readLock();
        try {
            // 3.2 拷贝共享变量数据到局部变量
            copyVaraibale2ThreadMemory();
        } finally {
            // 释放读锁
            lock.unlockRead(stamp);
        }
    }
    // 3.3 校验通过,使用线程本地栈的数据进行逻辑操作
    useThreadMemoryVarables();
}

Usage scenarios and precautions

For the high-concurrency scenarios with more reads and less writes, the performance of StampedLock is very good. The optimistic read mode solves the problem of "starvation" of the write thread. We can use StampedLock instead of ReentrantReadWriteLock, but it should be noted that the function of  StampedLock is only When using a subset of ReadWriteLock , there are still a few things to pay attention to.

  1. StampedLock is a non-reentrant lock, you must pay attention to it during use;
  2. Both pessimistic read and write locks do not support the condition variable Conditon, you need to pay attention when you need this feature;
  3. If the thread is blocked on the readLock() or writeLock() of StampedLock, calling the interrupt() method of the blocked thread at this time will cause the CPU to surge. Therefore, you must not call interrupt operations when using StampedLock. If you need to support interrupt functions, you must use interruptible pessimistic read lock readLockInterruptibly() and write lock writeLockInterruptibly() . This rule must be clearly remembered.

2. Principle analysis

We found that it is not like other locks by defining internal classes to inherit AbstractQueuedSynchronizer abstract class and then subclasses implementing template methods to achieve synchronization logic. But the realization idea is still similar, the CLH queue is still used to manage threads, and the state of the lock is identified by the synchronization state value state. A lot of variables are defined inside. The purpose of these variables is the same as ReentrantReadWriteLock. The state is divided into bits, and the state variables are manipulated to distinguish the synchronization state through bit operations.

For example, if the eighth bit of the write lock is 1, it means the write lock, and the read lock uses bits 0-7, so in general, the number of threads that acquire the read lock is 1-126. After the amount exceeds, the readerOverflow int variable will be used to save the excess Threads.

Spin optimization

Certain optimizations are also made to multi-core CPUs. NCPU obtains the number of cores. When the number of cores exceeds 1, the retry of the thread acquiring lock and the retry of enqueuing money all spin operations. It is mainly judged by some internally defined variables, as shown in the figure.

Three, waiting queue

The nodes of the queue are defined by WNode, as shown in the figure above. The nodes waiting in the queue are simpler than AQS. There are only three states:

  • 0: initial state;
  • -1: waiting;
  • cancel;

There is also a field cowait, which points to a stack to save the reader thread. The structure is shown in the figure

At the same time, two variables are defined to point to the head node and the tail node respectively.

/** Head of CLH queue */
private transient volatile WNode whead;
/** Tail (last) of CLH queue */
private transient volatile WNode wtail;

Another point to note is cowait, which saves all read node data and uses the header interpolation method.

When the read and write threads compete to form a waiting queue, the data is shown in the following figure:

Acquire write lock

public long writeLock() {
    long s, next;  // bypass acquireWrite in fully unlocked case only
    return ((((s = state) & ABITS) == 0L &&
             U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) ?
            next : acquireWrite(false, 0L));
}

Acquire the write lock. If the acquisition fails, the node will be built and put into the queue, and the thread will be blocked at the same time. This method does not respond to interruption when it needs to be noted. If interruption is required, writeLockInterruptibly() must be called. Otherwise, it will cause high CPU usage.

(s = state) & ABITS indicates that the read lock and write lock are not used, then directly execute U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) CAS operation sets the eighth bit to 1, indicating that the write lock is occupied success. If CAS fails, acquireWrite(false, 0L) is called to join the waiting queue and the thread is blocked.

In addition, the acquireWrite(false, 0L) method is very complicated, using a lot of spin operations, such as spinning into a queue.

Acquire a read lock

public long readLock() {
    long s = state, next;  // bypass acquireRead on common uncontended case
    return ((whead == wtail && (s & ABITS) < RFULL &&
             U.compareAndSwapLong(this, STATE, s, next = s + RUNIT)) ?
            next : acquireRead(false, 0L));
}

Key steps to obtain a read lock

(whead == wtail && (s & ABITS) <RFULL If the queue is empty and the number of read lock threads does not exceed the limit, modify the state flag by U.compareAndSwapLong(this, STATE, s, next = s + RUNIT))CAS Acquired the read lock successfully. Otherwise, call acquireRead(false, 0L) to try to acquire the read lock using spin, and enter the waiting queue if it fails to acquire it.

Four, acquireRead

When the A thread acquires the write lock and the B thread acquires the read lock, call the acquireRead method, it will join the blocking queue and block the B thread. The method is still very complicated inside, and the general process is as follows:

  1. If the write lock is not occupied, it will immediately try to acquire the read lock, and the CAS will return directly if the status is changed to the flag successfully.
  2. If the write lock is occupied, the current thread is packaged as a WNode read node and inserted into the waiting queue. If it is a writing thread node, put it directly into the end of the queue, otherwise put it into the stack pointed to by the WNode cowait of the reading thread . The stack structure is to insert data in the header insertion method, and finally wake up the read node, starting from the top of the stack.

Release lock

Whether it is unlockRead to release the read lock or unlockWrite to release the write lock, the overall process is basically through the CAS operation. After the state is successfully modified, the release method is called to wake up the subsequent node threads of the head node of the waiting queue.

  1. I want to set the waiting state of the head node to 0 to indicate that the subsequent node is about to wake up.
  2. After waking up the subsequent node to acquire the lock through CAS, if it is a reading node, all reading nodes of the stack pointed to by the cowait lock will be awakened.

Release read lock

unlockRead(long stamp) If the stamp passed in is consistent with the stamp held by the lock, the non-exclusive lock is released. The internal main method is to modify the state successfully through spin + CAS. Before modifying the state, it is judged whether it exceeds the limit of the number of read threads. If it is less than the limit, modify the state synchronization state through CAS, and then call the release method to wake up the successor node of whead.

Release the write lock

unlockWrite(long stamp) If the stamp passed in is consistent with the stamp held by the lock, the write lock is released, whead is not empty, and the current node status status! = 0 then call the release method to wake up the subsequent node threads of the head node.

to sum up

StampedLock cannot completely replace ReentrantReadWriteLock. Because of the optimistic read mode in the scenario of more reads and less writes, a write thread is allowed to acquire a write lock, which solves the problem of write thread starvation and greatly improves throughput. When using optimistic reading, you need to pay attention to writing according to the programming model template, otherwise it is easy to cause deadlock or unexpected thread safety issues. It is not a reentrant lock and does not support the condition variable Conditon. And when the thread is blocked on readLock() or writeLock(), calling the interrupt() method of the blocked thread at this time will cause the CPU to surge. If you need to interrupt the thread scenario, you must pay attention to calling the pessimistic read lock readLockInterruptibly() and write lock writeLockInterruptibly() . In addition, the rules for waking up threads are similar to AQS. The head node is awakened first. The difference is that when the node awakened by StampedLock is a read node, all read nodes of the stack pointed to by the cowait lock of this read node will be awakened, but the order of waking up and inserting in contrast.

This concludes the article!

The last benefit from the editor

The following is a compilation of interview materials with real questions from a big factory and a collection of Alibaba’s interviews. Those who need to receive it can order me to receive it for free . The world of programming is always open to all those who love programming. This is freedom and equality. , The shared world, I always believe in this way.  

Part of the profile picture:

If you like the editor’s sharing, you can like and follow it. The editor continues to share the latest articles and benefits

Guess you like

Origin blog.csdn.net/QLCZ0809/article/details/111461247