乐趣区

关于java:高性能解决线程饥饿利器-StampedLock

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

如需转载请分割我(微信 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,...,version
from product_doc
where id = 123

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

update product_doc
set 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]

退出移动版