ReadWriteLock

Hi,我是阿昌,今天学习记录的是关于ReadWriteLock

管程信号量这两个同步原语在 Java 语言中的实现,理论上用这两个同步原语中任何一个都可以解决所有的并发问题。

那 Java SDK 并发包里为什么还有很多其他的工具类呢?

原因很简单:分场景优化性能,提升易用性。一种非常普遍的并发场景:读多写少场景

实际工作中,为了优化性能,经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。

缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。

针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock,非常容易使用,并且性能很好。

那什么是读写锁呢?

读写锁,并不是 Java 语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则:

  1. 允许多个线程同时读共享变量;
  2. 只允许一个线程写共享变量;
  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。

但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。


一、快速实现一个缓存

ReadWriteLock 快速实现一个通用的缓存工具类。

在下面的代码中,声明了一个 Cache 类,其中类型参数 K 代表缓存里 key 的类型,V 代表缓存里 value 的类型。

缓存的数据保存在 Cache 类内部的 HashMap 里面,HashMap 不是线程安全的,这里我们使用读写锁 ReadWriteLock 来保证其线程安全。

ReadWriteLock 是一个接口,它的实现类是 ReentrantReadWriteLock,通过名字你应该就能判断出来,它是支持可重入的。

下面通过 rwl 创建了一把读锁和一把写锁。

Cache 这个工具类,提供了两个方法,一个是读缓存方法 get(),另一个是写缓存方法 put()。

读缓存需要用到读锁,读锁的使用和前面介绍的 Lock 的使用是相同的,都是 try{}finally{}这个编程范式。

写缓存则需要用到写锁,写锁的使用和读锁是类似的。

这样看来,读写锁的使用还是非常简单的。


class Cache<K,V> {final Map<K, V> m =new HashMap<>();final ReadWriteLock rwl =new ReentrantReadWriteLock();// 读锁final Lock r = rwl.readLock();// 写锁final Lock w = rwl.writeLock();// 读缓存V get(K key) {r.lock();try { return m.get(key); }finally { r.unlock(); }}// 写缓存V put(K key, V value) {w.lock();try { return m.put(key, v); }finally { w.unlock(); }}
}

如果曾经使用过缓存的话,你应该知道使用缓存首先要解决缓存数据的初始化问题。

缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载的方式。

如果源头数据的数据量不大,就可以采用一次性加载的方式,这种方式最简单(可参考下图),只需在应用启动的时候把源头数据查询出来,依次调用类似上面示例代码中的 put() 方法就可以了。


如果源头数据量非常大,那么就需要按需加载了,按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作。

下面你可以结合文中示意图看看如何利用 ReadWriteLock 来实现缓存的按需加载。


二、实现缓存的按需加载

文中下面的这段代码实现了按需加载的功能,这里假设缓存的源头是数据库。

需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁,所以在代码中的⑤处,调用了 w.lock() 来获取写锁。

另外,还需要注意的是,在获取写锁之后,并没有直接去查询数据库,而是在代码⑥⑦处,重新验证了一次缓存中是否存在,再次验证如果还是不存在,才去查询数据库并更新本地缓存。

为什么要再次验证呢?


class Cache<K,V> {final Map<K, V> m =new HashMap<>();final ReadWriteLock rwl = new ReentrantReadWriteLock();final Lock r = rwl.readLock();final Lock w = rwl.writeLock();V get(K key) {V v = null;//读缓存r.lock();         ①try {v = m.get(key); ②} finally{r.unlock();     ③}//缓存中存在,返回if(v != null) {   ④return v;}  //缓存中不存在,查询数据库w.lock();         ⑤try {//再次验证//其他线程可能已经查询过数据库v = m.get(key); ⑥if(v == null){  ⑦//查询数据库v=省略代码无数m.put(key, v);}} finally{w.unlock();}return v; }
}

原因是在高并发的场景下,有可能会有多线程竞争写锁。

假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。

那么它们会同时执行到代码⑤处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。

此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。

T2 释放写锁之后,T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。

所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。


三、读写锁的升级与降级

上面按需加载的示例代码中,在①处获取读锁,在③处释放读锁,那是否可以在②处的下面增加验证缓存并更新缓存的逻辑呢?

详细的代码如下。


//读缓存
r.lock();         ①
try {v = m.get(key); ②if (v == null) {w.lock();try {//再次验证并更新缓存//省略详细代码} finally{w.unlock();}}
} finally{r.unlock();     ③
}

这样看上去好像是没有问题的,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫锁的升级。可惜 ReadWriteLock 并不支持这种升级。

在上面的代码示例中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。

锁的升级是不允许的,这个你一定要注意。不过,虽然锁的升级是不允许的,但是锁的降级却是允许的。

以下代码来源自 ReentrantReadWriteLock 的官方示例,略做了改动。

会发现在代码①处,获取读锁的时候线程还是持有写锁的,这种锁的降级是支持的


class CachedData {Object data;volatile boolean cacheValid;final ReadWriteLock rwl =new ReentrantReadWriteLock();// 读锁  final Lock r = rwl.readLock();//写锁final Lock w = rwl.writeLock();void processCachedData() {// 获取读锁r.lock();if (!cacheValid) {// 释放读锁,因为不允许读锁的升级r.unlock();// 获取写锁w.lock();try {// 再次检查状态  if (!cacheValid) {data = ...cacheValid = true;}// 释放写锁前,降级为读锁// 降级是可以的r.lock(); ①} finally {// 释放写锁w.unlock(); }}// 此处仍然持有读锁try {use(data);} finally {r.unlock();}}
}

四、总结

读写锁类似于 ReentrantLock,也支持公平模式非公平模式

读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。

但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。

今天用 ReadWriteLock 实现了一个简单的缓存,这个缓存虽然解决了缓存的初始化问题,但是没有解决缓存数据与源头数据的同步问题,这里的数据同步指的是保证缓存数据和源头数据的一致性。

解决数据同步问题的一个最简单的方案就是超时机制。

所谓超时机制指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。

而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。当然也可以在源头数据发生变化时,快速反馈给缓存,但这个就要依赖具体的场景了。

例如 MySQL 作为数据源头,可以通过近实时地解析 binlog 来识别数据是否发生了变化,如果发生了变化就将最新的数据推送给缓存。

另外,还有一些方案采取的是数据库和缓存的双写方案。

总之,具体采用哪种方案,还是要看应用的场景。


线上系统停止响应了,CPU 利用率很低,怀疑有人一不小心写出了读锁升级写锁的方案,那该如何验证自己的怀疑呢?

考虑到是线上应用,可采用以下方法

  1. 源代码分析。查找ReentrantReadWriteLock在项目中的引用,看下写锁是否在读锁释放前尝试获取
  2. 如果线上是Web应用,应用服务器比如说是Tomcat,并且开启了JMX,则可以通过JConsole等工具远程查看下线上死锁的具体情况

Day836.ReadWriteLock -Java 并发编程实战相关推荐

  1. 【极客时间】《Java并发编程实战》学习笔记

    目录: 开篇词 | 你为什么需要学习并发编程? 内容来源:开篇词 | 你为什么需要学习并发编程?-极客时间 例如,Java 里 synchronized.wait()/notify() 相关的知识很琐 ...

  2. 《Java 并发编程实战》--读书笔记

    Java 并发编程实战 注: 极客时间<Java 并发编程实战>–读书笔记 GitHub:https://github.com/ByrsH/Reading-notes/blob/maste ...

  3. Java并发编程实战笔记2:对象的组合

    设计线程安全的类 在设计现车让安全类的过程之中,需要包含以下三步: 找出构成对象状态的所有变量 找出约束状态变量的不变性条件 建立对象状态的并发访问策略 实例封闭 通过封闭机制与合适的加锁策略结合起来 ...

  4. aqs clh java_【Java并发编程实战】—– AQS(四):CLH同步队列

    在[Java并发编程实战]-–"J.U.C":CLH队列锁提过,AQS里面的CLH队列是CLH同步锁的一种变形. 其主要从双方面进行了改造:节点的结构与节点等待机制.在结构上引入了 ...

  5. java 多线程缓存_[Java教程]【JAVA并发编程实战】12、使用condition实现多线程下的有界缓存先进先出队列...

    [Java教程][JAVA并发编程实战]12.使用condition实现多线程下的有界缓存先进先出队列 0 2016-11-29 17:00:10 package cn.study.concurren ...

  6. Java并发编程实战————恢复中断

    中断是一种协作机制,一个线程不能强制其他线程停止正在执行的操作而去执行其他操作. 什么是中断状态? 线程类有一个描述自身是否被中断了的boolean类型的状态,可以通过调用 .isInterrupte ...

  7. Java并发编程实战————Executor框架与任务执行

    引言 本篇博客介绍通过"执行任务"的机制来设计应用程序时需要掌握的一些知识.所有的内容均提炼自<Java并发编程实战>中第六章的内容. 大多数并发应用程序都是围绕&qu ...

  8. Java并发编程实战————Semaphore信号量的使用浅析

    引言 本篇博客讲解<Java并发编程实战>中的同步工具类:信号量 的使用和理解. 从概念.含义入手,突出重点,配以代码实例及讲解,并以生活中的案例做类比加强记忆. 什么是信号量 Java中 ...

  9. Java并发编程实战_不愧是领军人物!这种等级的“Java并发编程宝典”谁能撰写?...

    前言 大家都知道并发编程技术就是在同一个处理器上同时的去处理多个任务,充分的利用到处理器的每个核心,最大化的发挥处理器的峰值性能,这样就可以避免我们因为性能而产生的一些问题. 大厂的核心负载肯定是非常 ...

最新文章

  1. excel去掉一行文字中的逗号合并在一起_Python使用pandas库五行代码合并excel
  2. 【PAT】A1079 Total Sales of Supply Chain
  3. python的输入和格式输出
  4. 从 SAP WebIDE 里向Github 发起 push 的错误消息 - Git result: REJECTED_NONFASTFORWARD
  5. 程序员实际情况_程序员实际上是做什么的?
  6. Android 视频播放器,VideoView播放视频
  7. Go1.18泛型使用详解(附最新gocode)
  8. GIS案例练习-----------第九天
  9. 使用谷歌云盘生成直接下载的url
  10. 单片机C语言基础知识篇
  11. 批量获取百度网盘文件目录
  12. 445端口被封之后,在公网实现smb文件共享
  13. 【天光学术】学前教育论文:幼儿园区角活动中存在的问题及有效对策(节选)
  14. 真正理解mybatis拦截器以及Interceptor和Plugin作用
  15. 最新版CATIA,让您快速创造完整高级机械项目
  16. Sata接口读取新硬盘读不出问题解决
  17. JAVA MemCache 史无前例的详细讲解 看完包精通MEMCACHE
  18. python所用到的英语单词_用python从字符串中提取英语单词
  19. Mac OS开发者软件清单
  20. Halcon学习笔记(九)——OCR实战练习 倾斜日期检测、倒着的字符检测

热门文章

  1. Tomcat安装与部署
  2. redis集群报错:(error) MOVED 11469 192.168.163.249:7002
  3. 并查集(详细解释+完整C语言代码)
  4. 6-4 学生成绩链表处理(20 分)
  5. 编程要了解的基础知识
  6. Scanner基本用法及其实例
  7. 使用 Bumblebee 控制 NVIDIA 双显卡(ubuntu13.04-X64)
  8. CSS进阶式-附加样式
  9. windows安装TexStudio
  10. Usage of Pseudocode