01、背景介绍

在上一篇文章中,我们讲到了使用 ReadWriteLock 可以解决多线程同时读,但只有一个线程能写的问题。

如果继续深入的分析 ReadWriteLock,从锁的角度分析,会发现它有一个潜在的问题:如果有线程正在读数据,写线程准备修改数据的时候,需要等待读线程释放锁后才能获取写锁,简单的说就是,读的过程中不允许写,这其实是一种悲观的读锁。

为了进一步的提升程序并发执行效率,Java 8 引入了一个新的读写锁:StampedLock

ReadWriteLock 相比,StampedLock 最大的改进点在于:在原先读写锁的基础上,新增了一种叫乐观读的模式。该模式并不会加锁,因此不会阻塞线程,程序会有更高的执行效率。

什么是乐观锁和悲观锁呢?

  • 乐观锁:就是乐观的估计读的过程中大概率不会有写入,因此被称为乐观锁
  • 悲观锁:指的是读的过程中拒绝有写入,也就是写入必须等待
    显然乐观锁的并发执行效率会更高,但一旦有数据的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。

下面我们一起来了解一下 StampedLock 的用法!

02、StampedLock 用法介绍

StampedLock 的使用方式比较简单,只需要实例化一个 StampedLock 对象,然后调用对应的读写方法即可,它有三个核心方法如下!

  • readLock():表示读锁,多个线程读不会阻塞,效果与 ReadWriteLock 的读锁模式类似
  • writeLock():表示写锁,同一时刻有且只有一个写线程能获取锁资源,效果与 ReadWriteLock 的写锁模式类似
  • tryOptimisticRead():表示乐观读,并没有加锁,它用于非常短的读操作,允许多个线程同时读
    其中 readLock()writeLock() 方法,与 ReadWriteLock 的效果完全一致,在此就不重复演示了。

下面我们来看一个 tryOptimisticRead() 方法的简单使用示例。

2.1、tryOptimisticRead 方法

public class CounterDemo {  
    private final StampedLock lock = new StampedLock();  
    private int count;  
    public void write() {  
        // 1.获取写锁  
        long stamp = lock.writeLock();  
        try {  
            count++;  
            // 方便演示,休眠一下  
            sleep(200);  
            println("获得了写锁,count:" + count);  
        } finally {  
            // 2.释放写锁  
            lock.unlockWrite(stamp);  
        }  
    }  
    public int read() {  
        // 1.尝试通过乐观读模式读取数据,非阻塞  
        long stamp = lock.tryOptimisticRead();  
        // 2.假设x = 0,但是x可能被写线程修改为1  
        int x = count;  
        // 方便演示,休眠一下  
        int millis = new Random().nextInt(500);  
        sleep(millis);  
        println("通过乐观读模式读取数据,value:" + x + ", 耗时:" + millis);  
        // 3.检查乐观读后是否有其他写锁发生  
        if(!lock.validate(stamp)){  
            // 4.如果有,采用悲观读锁,并重新读取数据到当前线程局部变量  
            stamp = lock.readLock();  
            try {  
                x = count;  
                println("乐观读后检查到数据发生变化,获得了读锁,value:" + x);  
            } finally{  
                // 5.释放悲观读锁  
                lock.unlockRead(stamp);  
            }  
        }  
        // 6.返回读取的数据  
        return x;  
    }  
    private void sleep(long millis){  
        try {  
            Thread.sleep(millis);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
    }  
    private void println(String message){  
        String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());  
        System.out.println(time + " 线程:" + Thread.currentThread().getName() + " " + message);  
    }  
}
public class MyThreadTest {  
    public static void main(String[] args) throws InterruptedException {  
        CounterDemo counter = new CounterDemo();  
        Runnable readRunnable = new Runnable() {  
            @Override  
            public void run() {  
                counter.read();  
            }  
        };  
        Runnable writeRunnable = new Runnable() {  
            @Override  
            public void run() {  
                counter.write();  
            }  
        };  
        // 启动3个读线程  
        for (int i = 0; i < 3; i++) {  
            new Thread(readRunnable).start();  
        }  
        // 停顿一下  
        Thread.sleep(300);  
        // 启动3个写线程  
        for (int i = 0; i < 3; i++) {  
            new Thread(writeRunnable).start();  
        }  
    }  
}  

看一下运行结果:

2023-10-25 13:47:16:952 线程:Thread-0 通过乐观读模式读取数据,value:0, 耗时:19  
2023-10-25 13:47:17:050 线程:Thread-2 通过乐观读模式读取数据,value:0, 耗时:172  
2023-10-25 13:47:17:247 线程:Thread-1 通过乐观读模式读取数据,value:0, 耗时:369  
2023-10-25 13:47:17:382 线程:Thread-3 获得了写锁,count:1  
2023-10-25 13:47:17:586 线程:Thread-4 获得了写锁,count:2  
2023-10-25 13:47:17:788 线程:Thread-5 获得了写锁,count:3  
2023-10-25 13:47:17:788 线程:Thread-1 乐观读后检查到数据发生变化,获得了读锁,value:3  

从日志上可以分析得出,读线程 Thread-0Thread-2 在启动写线程之前就已经执行完,因此没有进入竞争读锁阶段;而读线程 Thread-1 因为在启动写线程之后才执行完,这个时候检查到数据发生变化,因此进入读锁阶段,保证读取的数据是最新的。

ReadWriteLock 相比,StampedLock 写入数据的加锁过程基本类似,不同的是读取数据。

读取数据大致的过程如下:

  • 1.尝试通过 tryOptimisticRead() 方法乐观读模式读取数据,并返回版本号
  • 2.数据读取完成后,再通过 lock.validate() 去验证版本号,如果在读取过程中没有写入,版本号不会变,验证成功,直接返回结果
  • 3.如果在读取过程中有写入,版本号会发生变化,验证将失败。在失败的时候,再通过悲观读锁再次读取数据,把读取的最新结果返回
    对于读多写少的场景,由于写入的概率不高,程序在绝大部分情况下可以通过乐观读获取数据,极少数情况下使用悲观读锁获取数据,并发执行效率得到了大大的提升。

乐观锁实际用途也非常广泛,比如数据库的字段值修改,我们举个简单的例子。

在订单库存表上 order_store,我们通常会增加了一个数值型版本号字段 version,每次更新 order_store 这个表库存数据的时候,都将 version 字段加 1,同时检查 version 的值是否满足条件。

select id,… ,version  
from order_store  
where id = 1000  
update order_store  
set version = version + 1,…  
where id = 1000 and version = 1  

数据库的乐观锁,就是查询的时候将 version 查出来,更新的时候利用 version 字段验证是否一致,如果相等,说明数据没有被修改,读取的数据安全;如果不相等,说明数据已经被修改过,读取的数据不安全,需要重新读取。

这里的 version 就类似于 StampedLockstamp 值。

2.2、tryConvertToWriteLock 方法

其次,StampedLock 还提供了将悲观读锁升级为写锁的功能,对应的核心方法是 tryConvertToWriteLock()

它主要使用在 if-then-update 的场景,即:程序先采用读模式,如果读的数据满足条件,就返回;如果读的数据不满足条件,再尝试写。

简单示例如下:

public int readAndWrite(Integer newCount) {  
    // 1.获取读锁,也可以使用乐观读  
    long stamp = lock.readLock();  
    int currentValue = count;  
    try {  
        // 2.检查是否读取数据  
        while (Objects.isNull(currentValue)) {  
            // 3.如果没有,尝试升级写锁  
            long wl = lock.tryConvertToWriteLock(stamp);  
            // 4.不为 0 升级写锁成功  
            if (wl != 0L) {  
                // 重新赋值  
                stamp = wl;  
                count = newCount;  
                currentValue = count;  
                break;  
            } else {  
                // 5.升级失败,释放之前加的读锁并上写锁,通过循环再试  
                lock.unlockRead(stamp);  
                stamp = lock.writeLock();  
            }  
        }  
    } finally {  
        // 6.释放最后加的锁  
        lock.unlock(stamp);  
    }  
    // 7.返回读取的数据  
    return currentValue;  
}  

03、小结

总结下来,与 ReadWriteLock 相比,StampedLock 进一步把读锁细分为乐观读和悲观读,能进一步提升了并发执行效率。

好处是非常明显的,系统性能得到提升,但是代价也不小,主要有以下几点:

  • 1.代码逻辑更加复杂,如果编程不当很容易出 bug
    1. StampedLock 是不可重入锁,不能在一个线程中反复获取同一个锁,如果编程不当,很容易出现死锁
  • 3.如果线程阻塞在 StampedLockreadLock() 或者 writeLock() 方法上时,此时试图通过 interrupt() 方法中断线程,会导致 CPU 飙升。因此,使用 StampedLock 一定不要调用中断操作,如果需要支持中断功能,推荐使用可中断的读锁 readLockInterruptibly() 或者写锁 writeLockInterruptibly() 方法。
    最后,在实际的使用过程中,乐观读编程模型,推荐可以按照以下固定模板编写。

public int read() {  
    // 1.尝试通过乐观读模式读取数据,非阻塞  
    long stamp = lock.tryOptimisticRead();  
    // 2.假设 x = 0,但是 x 可能被写线程修改为 1  
    int x = count;  
    // 3.检查乐观读后是否有其他写锁发生  
    if(!lock.validate(stamp)){  
        // 4.如果有,采用悲观读锁,并重新读取数据到当前线程局部变量  
        stamp = lock.readLock();  
        try {  
            x = count;  
        } finally{  
            // 5.释放悲观读锁  
            lock.unlockRead(stamp);  
        }  
    }  
    // 6.返回读取的数据  
    return x;  
}  

04、参考

1、 https://www.liaoxuefeng.com

2、 https://zhuanlan.zhihu.com/p/257868603