作者:码哥字节 公众号:码哥字节

如需转载请分割我(微信ID):MageByte1024

概览

在JDK 1.8 引入 StampedLock,能够了解为对 ReentrantReadWriteLock 在某些方面的加强,在原先读写锁的根底上新增了一种叫乐观读(Optimistic Reading)的模式。该模式并不会加锁,所以不会阻塞线程,会有更高的吞吐量和更高的性能。

它的设计初衷是作为一个外部工具类,用于开发其余线程平安的组件,晋升零碎性能,并且编程模型也比ReentrantReadWriteLock 简单,所以用不好就很容易呈现死锁或者线程平安等莫名其妙的问题。

跟着“码哥字节”带着问题一起学习StampedLock给咱们带来了什么…

  • 有了ReentrantReadWriteLock,为何还要引入StampedLock
  • 什么是乐观读?
  • 在读多写少的并发场景下,StampedLock如何解决写线程难以获取锁的线程“饥饿”问题?
  • 什么样的场景应用?
  • 实现原理剖析,是通过 AQS 实现还是其余的?

个性

三种拜访数据模式

  • Writing(独占写锁):writeLock 办法会使线程阻塞期待独占拜访,可类比ReentrantReadWriteLock 的写锁模式,同一时刻有且只有一个写线程获取锁资源;
  • Reading(乐观读锁):readLock办法,容许多个线程同时获取乐观读锁,乐观读锁与独占写锁互斥,与乐观读共享。
  • Optimistic Reading(乐观读):这里须要留神了,是乐观读,并没有加锁。也就是不会有 CAS 机制并且没有阻塞线程。仅当以后未处于 Writing 模式 tryOptimisticRead 才会返回非 0 的邮戳(Stamp),如果在获取乐观读之后没有呈现写模式线程获取锁,则在办法validate返回 true ,容许多个线程获取乐观读以及读锁。同时容许一个写线程获取写锁

反对读写锁互相转换

ReentrantReadWriteLock 当线程获取写锁后能够降级成读锁,然而反过来则不行。

StampedLock提供了读锁和写锁互相转换的性能,使得该类反对更多的利用场景。

注意事项

  1. StampedLock是不可重入锁,如果以后线程曾经获取了写锁,再次反复获取的话就会死锁;
  2. 都不反对 Conditon 条件将线程期待;
  3. StampedLock 里的写锁和乐观读锁加锁胜利之后,都会返回一个 stamp;而后解锁的时候,须要传入这个 stamp。

详解乐观读带来的性能晋升

那为何 StampedLock 性能比 ReentrantReadWriteLock 好?

关键在于StampedLock 提供的乐观读,咱们晓得ReentrantReadWriteLock 反对多个线程同时获取读锁,然而当多个线程同时读的时候,所有的写线程都是阻塞的。

StampedLock 的乐观读容许一个写线程获取写锁,所以不会导致所有写线程阻塞,也就是当读多写少的时候,写线程有机会获取写锁,缩小了线程饥饿的问题,吞吐量大大提高。

这里可能你就会有疑难,居然同时容许多个乐观读和一个先线程同时进入临界资源操作,那读取的数据可能是错的怎么办?

是的,乐观读不能保障读取到的数据是最新的,所以将数据读取到局部变量的时候须要通过 lock.validate(stamp) 椒盐虾是否被写线程批改过,若是批改过则须要上乐观读锁,再从新读取数据到局部变量。

同时因为乐观读并不是锁,所以没有线程唤醒与阻塞导致的上下文切换,性能更好。

其实跟数据库的“乐观锁”有殊途同归之妙,它的实现思维很简略。咱们举个数据库的例子。

在生产订单的表 product_doc 里减少了一个数值型版本号字段 version,每次更新 product_doc 这个表的时候,都将 version 字段加 1。

select id,... ,versionfrom product_docwhere id = 123

在更新的时候匹配 version 才执行更新。

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

数据库的乐观锁就是查问的时候将 version 查出来,更新的时候利用 version 字段验证,若是相等阐明数据没有被批改,读取的数据是平安的。

这里的 version 就相似于 StampedLock 的 Stamp。

应用示例

模拟写一个将用户id与用户名数据保留在 共享变量 idMap 中,并且提供 put 办法增加数据、get 办法获取数据、以及 putIfNotExist 先从 map 中获取数据,若没有则模仿从数据库查问数据并放到 map 中。

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

下面的应用例子中,须要引起留神的是 get()putIfNotExist() 办法,第一个应用了乐观读,使得读写能够并发执行,第二个则是应用了读锁转换成写锁的编程模型,先查问缓存,当不存在的时候从数据库读取数据并增加到缓存中。

在应用乐观读的时候肯定要依照固定模板编写,否则很容易出 bug,咱们总结下乐观读编程模型的模板:

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

应用场景和注意事项

对于读多写少的高并发场景 StampedLock 的性能很好,通过乐观读模式很好的解决了写线程“饥饿”的问题,咱们能够应用StampedLock 来代替ReentrantReadWriteLock ,然而须要留神的是 StampedLock 的性能仅仅是 ReadWriteLock 的子集,在应用的时候,还是有几个中央须要留神一下。

  1. StampedLock 是不可重入锁,应用过程中肯定要留神;
  2. 乐观读、写锁都不反对条件变量 Conditon ,当须要这个个性的时候须要留神;
  3. 如果线程阻塞在 StampedLock 的 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 办法,会导致 CPU 飙升。所以,应用 StampedLock 肯定不要调用中断操作,如果须要反对中断性能,肯定应用可中断的乐观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()。这个规定肯定要记分明。

原理剖析

咱们发现它并不像其余锁一样通过定义外部类继承 AbstractQueuedSynchronizer抽象类而后子类实现模板办法实现同步逻辑。然而实现思路还是有相似,仍然应用了 CLH 队列来治理线程,通过同步状态值 state 来标识锁的状态。

其外部定义了很多变量,这些变量的目标还是跟 ReentrantReadWriteLock 一样,将状态为按位切分,通过位运算对 state 变量操作用来辨别同步状态。

比方写锁应用的是第八位为 1 则示意写锁,读锁应用 0-7 位,所以个别状况下获取读锁的线程数量为 1-126,超过当前,会应用 readerOverflow int 变量保留超出的线程数。

自旋优化

对多核 CPU 也进行肯定优化,NCPU 获取核数,当核数目超过 1 的时候,线程获取锁的重试、入队钱的重试都有自旋操作。次要就是通过外部定义的一些变量来判断,如图所示。

期待队列

队列的节点通过 WNode 定义,如上图所示。期待队列的节点相比 AQS 更简略,只有三种状态别离是:

  • 0:初始状态;
  • -1:期待中;
  • 勾销;

另外还有一个字段 cowait ,通过该字段指向一个栈,保留读线程。构造如图所示

同时定义了两个变量别离指向头结点与尾节点。

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

另外有一个须要留神点就是 cowait, 保留所有的读节点数据,应用的是头插法。

当读写线程竞争造成期待队列的数据如下图所示:

获取写锁

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

获取写锁,如果获取失败则构建节点放入队列,同时阻塞线程,须要留神的时候该办法不响应中断,如需中断须要调用 writeLockInterruptibly()。否则会造成高 CPU 占用的问题。

(s = state) & ABITS 标识读锁和写锁未被应用,那么久间接执行 U.compareAndSwapLong(this, STATE, s, next = s + WBIT)) CAS 操作将第八位设置 1,标识写锁占用胜利。CAS失败的话则调用 acquireWrite(false, 0L)退出期待队列,同时将线程阻塞。

另外acquireWrite(false, 0L) 办法很简单,使用大量自旋操作,比方自旋入队列。

获取读锁

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

获取读锁关键步骤

(whead == wtail && (s & ABITS) < RFULL如果队列为空并且读锁线程数未超过限度,则通过 U.compareAndSwapLong(this, STATE, s, next = s + RUNIT))CAS 形式批改 state 标识获取读锁胜利。

否则调用 acquireRead(false, 0L) 尝试应用自旋获取读锁,获取不到则进入期待队列。

acquireRead

当 A 线程获取了写锁,B 线程去获取读锁的时候,调用 acquireRead 办法,则会退出阻塞队列,并阻塞 B 线程。办法外部仍然很简单,大抵流程梳理后如下:

  1. 如果写锁未被占用,则立刻尝试获取读锁,通过CAS批改状态为标记胜利则间接返回。
  2. 如果写锁被占用,则将以后线程包装成 WNode 读节点,并插入期待队列。如果是写线程节点则间接放入队尾,否则放入队尾专门寄存读线程的 WNode cowait 指向的栈。栈构造是头插法的形式插入数据,最终唤醒读节点,从栈顶开始。

开释锁

无论是 unlockRead 开释读锁还是 unlockWrite开释写锁,总体流程根本都是通过 CAS 操作,批改 state 胜利后调用 release 办法唤醒期待队列的头结点的后继节点线程。

  1. 想将头结点期待状态设置为 0 ,标识行将唤醒后继节点。
  2. 唤醒后继节点通过CAS形式获取锁,如果是读节点则会唤醒 cowait 锁指向的栈所有读节点。

开释读锁

unlockRead(long stamp) 如果传入的 stamp 与锁持有的 stamp 统一,则开释非排它锁,外部次要是通过自旋 + CAS 批改 state 胜利,在批改 state 之前做了判断是否超过读线程数限度,若是小于限度才通过CAS 批改 state 同步状态,接着调用 release 办法唤醒 whead 的后继节点。

开释写锁

unlockWrite(long stamp) 如果传入的 stamp 与锁持有的 stamp 统一,则开释写锁,whead 不为空,且以后节点状态 status != 0 则调用 release 办法唤醒头结点的后继节点线程。

总结

StampedLock 并不能齐全代替ReentrantReadWriteLock ,在读多写少的场景下因为乐观读的模式,容许一个写线程获取写锁,解决了写线程饥饿问题,大大提高吞吐量。

在应用乐观读的时候须要留神依照编程模型模板形式去编写,否则很容易造成死锁或者意想不到的线程平安问题。

它不是可重入锁,且不反对条件变量 Conditon。并且线程阻塞在 readLock() 或者 writeLock() 上时,此时调用该阻塞线程的 interrupt() 办法,会导致 CPU 飙升。如果须要中断线程的场景,肯定要留神调用乐观读锁 readLockInterruptibly() 和写锁 writeLockInterruptibly()

另外唤醒线程的规定和 AQS 相似,先唤醒头结点,不同的是 StampedLock 唤醒的节点是读节点的时候,会唤醒此读节点的 cowait 锁指向的栈的所有读节点,然而唤醒与插入的程序相同。

举荐浏览

以下几篇文章浏览量与读者反馈都很好,举荐大家浏览:

  • RESTful 最佳实际
  • Tomcat 架构原理解析到架构设计借鉴
  • Tomcat 高并发之道原理拆解与性能调优
  • 数据库系统设计概述

参考内容

[Java多线程进阶(十一)—— J.U.C之locks框架:StampedLock]