多线程系列(十一) -浅析并发读写锁StampedLock

打印 上一主题 下一主题

主题 836|帖子 836|积分 2508

一、摘要

在上一篇文章中,我们讲到了使用ReadWriteLock可以办理多线程同时读,但只有一个线程能写的题目。
如果继续深入的分析ReadWriteLock,从锁的角度分析,会发现它有一个潜伏的题目:如果有线程正在读数据,写线程准备修改数据的时间,需要等待读线程释放锁后才能获取写锁,简单的说就是,读的过程中不允许写,这其实是一种悲观的读锁。
为了进一步的提升步伐并发执行服从,Java 8 引入了一个新的读写锁:StampedLock。
与ReadWriteLock相比,StampedLock最大的改进点在于:在原先读写锁的根本上,新增了一种叫乐观读的模式。该模式并不会加锁,因此不会阻塞线程,步伐会有更高的执行服从。
什么是乐观锁和悲观锁呢?

  • 乐观锁:就是乐观的估计读的过程中大概率不会有写入,因此被称为乐观锁
  • 悲观锁:指的是读的过程中拒绝有写入,也就是写入必须等待
显然乐观锁的并发执行服从会更高,但一旦有数据的写入导致读取的数据不一致,需要能检测出来,再读一遍就行。
下面我们一起来了解一下StampedLock的用法!
二、StampedLock

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

  • readLock():表示读锁,多个线程读不会阻塞,效果与ReadWriteLock的读锁模式类似
  • writeLock():表示写锁,同一时刻有且只有一个写线程能获取锁资源,效果与ReadWriteLock的写锁模式类似
  • tryOptimisticRead():表示乐观读,并没有加锁,它用于非常短的读操作,允许多个线程同时读
此中readLock()和writeLock()方法,与ReadWriteLock的效果完全一致,在此就不重复演示了。
下面我们来看一个tryOptimisticRead()方法的简单使用示例。
2.1、tryOptimisticRead 方法
  1. public class CounterDemo {
  2.     private final StampedLock lock = new StampedLock();
  3.     private int count;
  4.     public void write() {
  5.         // 1.获取写锁
  6.         long stamp = lock.writeLock();
  7.         try {
  8.             count++;
  9.             // 方便演示,休眠一下
  10.             sleep(200);
  11.             println("获得了写锁,count:" + count);
  12.         } finally {
  13.             // 2.释放写锁
  14.             lock.unlockWrite(stamp);
  15.         }
  16.     }
  17.     public int read() {
  18.         // 1.尝试通过乐观读模式读取数据,非阻塞
  19.         long stamp = lock.tryOptimisticRead();
  20.         // 2.假设x = 0,但是x可能被写线程修改为1
  21.         int x = count;
  22.         // 方便演示,休眠一下
  23.         int millis = new Random().nextInt(500);
  24.         sleep(millis);
  25.         println("通过乐观读模式读取数据,value:" + x + ", 耗时:" + millis);
  26.         // 3.检查乐观读后是否有其他写锁发生
  27.         if(!lock.validate(stamp)){
  28.             // 4.如果有,采用悲观读锁,并重新读取数据到当前线程局部变量
  29.             stamp = lock.readLock();
  30.             try {
  31.                 x = count;
  32.                 println("乐观读后检查到数据发生变化,获得了读锁,value:" + x);
  33.             } finally{
  34.                 // 5.释放悲观读锁
  35.                 lock.unlockRead(stamp);
  36.             }
  37.         }
  38.         // 6.返回读取的数据
  39.         return x;
  40.     }
  41.     private void sleep(long millis){
  42.         try {
  43.             Thread.sleep(millis);
  44.         } catch (InterruptedException e) {
  45.             e.printStackTrace();
  46.         }
  47.     }
  48.     private void println(String message){
  49.         String time = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS").format(new Date());
  50.         System.out.println(time + " 线程:" + Thread.currentThread().getName() + " " + message);
  51.     }
  52. }
复制代码
  1. public class MyThreadTest {
  2.     public static void main(String[] args) throws InterruptedException {
  3.         CounterDemo counter = new CounterDemo();
  4.         Runnable readRunnable = new Runnable() {
  5.             @Override
  6.             public void run() {
  7.                 counter.read();
  8.             }
  9.         };
  10.         Runnable writeRunnable = new Runnable() {
  11.             @Override
  12.             public void run() {
  13.                 counter.write();
  14.             }
  15.         };
  16.         // 启动3个读线程
  17.         for (int i = 0; i < 3; i++) {
  18.             new Thread(readRunnable).start();
  19.         }
  20.         // 停顿一下
  21.         Thread.sleep(300);
  22.         // 启动3个写线程
  23.         for (int i = 0; i < 3; i++) {
  24.             new Thread(writeRunnable).start();
  25.         }
  26.     }
  27. }
复制代码
看一下运行效果:
  1. 2023-10-25 13:47:16:952 线程:Thread-0 通过乐观读模式读取数据,value:0, 耗时:19
  2. 2023-10-25 13:47:17:050 线程:Thread-2 通过乐观读模式读取数据,value:0, 耗时:172
  3. 2023-10-25 13:47:17:247 线程:Thread-1 通过乐观读模式读取数据,value:0, 耗时:369
  4. 2023-10-25 13:47:17:382 线程:Thread-3 获得了写锁,count:1
  5. 2023-10-25 13:47:17:586 线程:Thread-4 获得了写锁,count:2
  6. 2023-10-25 13:47:17:788 线程:Thread-5 获得了写锁,count:3
  7. 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的值是否满足条件。
  1. select id,... ,version
  2. from order_store
  3. where id = 1000
复制代码
  1. update order_store
  2. set version = version + 1,...
  3. where id = 1000 and version = 1
复制代码
数据库的乐观锁,就是查询的时间将version查出来,更新的时间使用version字段验证是否一致,如果相等,说明数据没有被修改,读取的数据安全;如果不相等,说明数据已经被修改过,读取的数据不安全,需要重新读取。
这里的version就类似于StampedLock的stamp值。
2.2、tryConvertToWriteLock 方法

其次,StampedLock还提供了将悲观读锁升级为写锁的功能,对应的核心方法是tryConvertToWriteLock()。
它重要使用在if-then-update的场景,即:步伐先采用读模式,如果读的数据满足条件,就返回;如果读的数据不满足条件,再尝试写。
简单示比方下:
  1. public int readAndWrite(Integer newCount) {
  2.     // 1.获取读锁,也可以使用乐观读
  3.     long stamp = lock.readLock();
  4.     int currentValue = count;
  5.     try {
  6.         // 2.检查是否读取数据
  7.         while (Objects.isNull(currentValue)) {
  8.             // 3.如果没有,尝试升级写锁
  9.             long wl = lock.tryConvertToWriteLock(stamp);
  10.             // 4.不为 0 升级写锁成功
  11.             if (wl != 0L) {
  12.                 // 重新赋值
  13.                 stamp = wl;
  14.                 count = newCount;
  15.                 currentValue = count;
  16.                 break;
  17.             } else {
  18.                 // 5.升级失败,释放之前加的读锁并上写锁,通过循环再试
  19.                 lock.unlockRead(stamp);
  20.                 stamp = lock.writeLock();
  21.             }
  22.         }
  23.     } finally {
  24.         // 6.释放最后加的锁
  25.         lock.unlock(stamp);
  26.     }
  27.     // 7.返回读取的数据
  28.     return currentValue;
  29. }
复制代码
三、小结

总结下来,与ReadWriteLock相比,StampedLock进一步把读锁细分为乐观读和悲观读,能进一步提升了并发执行服从。
好处是非常明显的,系统性能得到提升,但是代价也不小,重要有以下几点:

  • 1.代码逻辑更加复杂,如果编程不妥很容易出 bug
  • 2.StampedLock是不可重入锁,不能在一个线程中反复获取同一个锁,如果编程不妥,很容易出现死锁
  • 3.如果线程阻塞在StampedLock的readLock()大概writeLock()方法上时,此时试图通过interrupt()方法中断线程,会导致 CPU 飙升。因此,使用 StampedLock一定不要调用中断操作,如果需要支持中断功能,推荐使用可中断的读锁readLockInterruptibly()大概写锁writeLockInterruptibly()方法。
最后,在现实的使用过程中,乐观读编程模子,推荐可以按照以下固定模板编写。
  1. public int read() {
  2.     // 1.尝试通过乐观读模式读取数据,非阻塞
  3.     long stamp = lock.tryOptimisticRead();
  4.     // 2.假设x = 0,但是x可能被写线程修改为1
  5.     int x = count;
  6.     // 3.检查乐观读后是否有其他写锁发生
  7.     if(!lock.validate(stamp)){
  8.         // 4.如果有,采用悲观读锁,并重新读取数据到当前线程局部变量
  9.         stamp = lock.readLock();
  10.         try {
  11.             x = count;
  12.         } finally{
  13.             // 5.释放悲观读锁
  14.             lock.unlockRead(stamp);
  15.         }
  16.     }
  17.     // 6.返回读取的数据
  18.     return x;
  19. }
复制代码
四、参考

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企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

魏晓东

金牌会员
这个人很懒什么都没写!

标签云

快速回复 返回顶部 返回列表