一、摘要
在上一篇文章中,我们讲到了使用ReadWriteLock可以办理多线程同时读,但只有一个线程能写的题目。
如果继续深入的分析ReadWriteLock,从锁的角度分析,会发现它有一个潜伏的题目:如果有线程正在读数据,写线程准备修改数据的时间,需要等待读线程释放锁后才能获取写锁,简单的说就是,读的过程中不允许写,这其实是一种悲观的读锁。
为了进一步的提升步伐并发执行服从,Java 8 引入了一个新的读写锁:StampedLock。
与ReadWriteLock相比,StampedLock最大的改进点在于:在原先读写锁的根本上,新增了一种叫乐观读的模式。该模式并不会加锁,因此不会阻塞线程,步伐会有更高的执行服从。
什么是乐观锁和悲观锁呢?
- 乐观锁:就是乐观的估计读的过程中大概率不会有写入,因此被称为乐观锁
- 悲观锁:指的是读的过程中拒绝有写入,也就是写入必须等待
显然乐观锁的并发执行服从会更高,但一旦有数据的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
下面我们一起来了解一下StampedLock的用法!
二、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-0和Thread-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就类似于StampedLock的stamp值。
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;
- }
复制代码 三、小结
总结下来,与ReadWriteLock相比,StampedLock进一步把读锁细分为乐观读和悲观读,能进一步提升了并发执行服从。
好处是非常明显的,系统性能得到提升,但是代价也不小,重要有以下几点:
- 1.代码逻辑更加复杂,如果编程不妥很容易出 bug
- 2.StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁,如果编程不妥,很容易出现死锁
- 3.如果线程阻塞在StampedLock的readLock()大概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;
- }
复制代码 四、参考
1、https://www.liaoxuefeng.com/wiki/1252599548343744/1309138673991714
2、https://zhuanlan.zhihu.com/p/257868603
五、写到最后
近来无意间得到一份阿里大佬写的技术笔记,内容涵盖 Spring、Spring Boot/Cloud、Dubbo、JVM、集合、多线程、JPA、MyBatis、MySQL 等技术知识。需要的小同伴可以点击如下链接获取,资源地点:技术资料笔记。
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。 |