难度
初级
学习时间
30分钟
适合人群
零基础
开发语言
Java
开发环境
- JDK v11
- IntelliJIDEA v2018.3
友情提示
- 本教学属于系列教学,内容具有连贯性,本章使用到的内容之前教学中都有详细讲解。
- 本章内容针对零基础或基础较差的同学比较友好,可能对于有基础的同学来说很简单,希望大家可以根据自己的实际情况选择继续看完或等待看下一篇文章。谢谢大家的谅解!
1.温故知新
前面在《“全栈2019”Java多线程第三十八章:从零手写一个线程安全缓冲区》一章中介绍了Lock与Condition实战项目:从零手写一个线程安全缓冲区。
在《“全栈2019”Java多线程第三十九章:显式锁实现生产者消费者模型》一章中介绍了用显式锁Lock与Condition对象来实现生产者与消费者模型。
现在我们来讲解读写锁ReadWriteLock。
2.为什么需要读写锁?
我想通过围棋赛来说一下读写锁的应用场景。
在古代,如果有精彩的围棋赛,那么肯定会有很多棋迷前去观看。围棋赛中,除了有正在对决的棋手:
还有抄写当前棋局的人,负责抄写当前棋局并传递给外面摆棋的人展示给观众:
外面的观众:
请问大家:你认为准备几个人抄写棋局合适?
答案肯定是无论准备多少人抄写棋局都合适,但有一个要求,他们必须在某一时刻只能有一人抄写棋局。
为什么某一时刻只能有一人抄写棋局?
如果是好几个人都在抄写,同时交给外面摆棋的人,摆出来的棋局肯定是错乱的。大家都知道围棋是你下一手,对方下一手,每一手都有先后顺序,同时几个人交给他显然不合适。
若将人看作是线程,那么抄写棋局的人是线程在执行写入操作;那么观看棋局的人是读取操作。
抄写棋局的人需要同步,外面观棋的人不需要同步。
为什么外面观棋的人不需要同步?
如果外面观棋的人需要同步,也就是说他们需要一个个排好队,等前面的人看完了后面的人才能看。显然不合适,等看完一盘棋天都黑了,浪费时间。再者,抄写棋局的人需要同步是因为要保证每一步棋传递不能错乱,观棋的人又不影响棋局,何须同步?
同理,线程也是这样,执行写入操作的线程需要同步,执行读取操作的线程不需要同步。
针对上述情况,Java为我们提供了读写锁。
2.读写锁ReadWriteLock接口
在Java中,读写锁的规范是用ReadWriteLock接口来定义的:
可以看到,ReadWriteLock维护了一对关联的锁,一个用于读取操作,另一个用于写入操作。分别用两个方法来获取它们:
- readLock()
- writeLock()
其中,readLock()方法返回用于读取操作的锁;writeLock()方法返回用于写入操作的锁。
写锁在某一时刻最多只能被一个线程拥有;
读锁在某一时刻可以被多个线程拥有。
这readLock()方法和writeLock()方法都返回Lock类型的对象,而显式锁Lock在《“全栈2019”Java多线程第二十七章:Lock获取lock/释放unlock锁》一章中介绍过了,这里就不再赘述,不清楚的小伙伴请前去查阅。下面,我们来看看这两个方法返回的Lock怎么用。
3.ReadWriteLock接口的实现类ReentrantReadWriteLock
ReentrantReadWriteLock叫作“可重入读写锁”,它实现了ReadWriteLock接口。
ReentrantReadWriteLock有两个构造方法:
- ReentrantReadWriteLock()
- ReentrantReadWriteLock(boolean fair)
其中,ReentrantReadWriteLock()是使用非公平策略创建新的ReentrantReadWriteLock;ReentrantReadWriteLock(boolean fair)是使用指定的公平策略创建新的ReentrantReadWriteLock。
公平与非公平策略我们之前在《“全栈2019”Java多线程第二十八章:公平锁与非公平锁详解》一章中介绍过,这里就不再赘述了,不清楚的小伙伴请前去查阅。
下面我们就来看看如何使用读写锁,在用之前我们先来看看没有使用同步锁的例子,然后再来看看使用同步锁改写例子,最后用读写锁来改写例子,通过不同的例子演示来对比它们有何不同。
4.非同步例子
我们就拿下围棋作为例子,围棋的英文是“Go”,为了避免和Java关键字“go”有所误会,将其即将要新建的类名改为“Chess”,Chess是棋的意思:
接着,我们需要用一个计数器来记录当前棋下到第几手了:
然后,需要记录棋局信息,声明一个String字符串类型的变量即可:
接着,我们需要一个抄写棋局的方法和一个获取棋局的方法,分别是put()方法和take()方法:
然后,在下棋的方法里面记录当前棋局信息:
接着,在获取棋局方法里面获取到当前棋局信息:
如此一来,Chess类就写好了。
接着,我们来创建Chess类实例:
然后,分别创建抄写棋局信息的任务和获取棋局信息的任务:
在抄写棋局信息任务中调用棋局的put()方法:
在获取棋局信息任务中调用棋局的take()方法,并输出棋局信息:
然后,使用for循环创建多个抄写棋局信息的线程,这里暂时创建200个:
使用for循环创建多个观棋的线程,这里暂时创建1000个:
例子书写完毕。
运行程序,执行结果:
从运行结果来看,程序有问题。
程序有什么问题?
大家请看下图:
抄写棋局的线程步数记录有前有后,显然有问题,理应每次只有一个抄写棋局的人传递给观棋者看,棋局步数排列有序,而不是无序。
综上所述,我们需要同步和等待唤醒机制。
5.同步例子
下面,我们将棋局类中的两个方法都设置为同步,而且还要相互唤醒。
首先,创建显式锁Lock和获取Condition对象:
然后,将put()方法里的内容同步:
同理,take()方法里的内容也需要同步:
例子暂时先改写到这,先运行看看效果。
运行程序,执行结果:
从运行结果来看,程序比之前要好很多,但还有不足。棋局的步骤还有错乱的情况:
接下来,我们加入等待唤醒机制看看。
此时,我们需要在棋局类中定义一个表示已看过此手棋的标识变量:
当此手棋未被看过时,使抄写棋局的线程等待:
然后,将watched的值为false:
接着,在抄写完棋局信息之后唤醒获取棋局信息的线程:
await()方法会抛出异常,我们将其throws即可:
然后,我们在take()方法中也需要判断,当此手棋已被看过时,使获取棋局的线程等待:
接着,将watched的值为true:
同时,在获取棋局信息之前唤醒抄写棋局信息的线程:
await()方法会抛出异常,我们将其throws即可:
如此一来,我们在调用put()方法和take()方法的地方都需要处理抛出的异常:
例子修改完毕。
运行程序,执行结果:
从运行结果来看,貌似符合预期。
我们发现还是有错乱的结果:
这个是输出导致的,抄写棋局信息的线程并没有错乱,这个可以通过在put()方法中让抄写棋局信息的线程睡1秒钟来验证:
再来运行程序,看看执行结果:
从运行结果来看,符合预期。
程序没什么问题,但是逻辑上有问题,大家想想看:抄写棋局的人有很多,但每次最多只能允许一个人抄写,这是为了保证棋局信息的顺序和正确性。同样的,观看棋局的人也很多,但他们在本例中却被同步起来,每次只能一个一个排队观棋,这显然不合理,理应观棋的人大家同时一起观看。
简而言之,就是抄写棋局信息的线程应该同步,观看棋局的线程不应该同步。
要想解决这个问题,用读写锁再合适不过了,这抄写棋局就是写入操作,获取棋局信息就是读取操作。所以,抄写棋局的方法应该用写锁,获取棋局信息的方法应该用读锁。
下面,我们就用读写锁来改写此例子。
6.读写锁例子
首先,我们把1个显式锁Lock对象和2个Condition对象移除:
然后,创建出读写锁:
接着,根据读写锁对象获取读锁和写锁:
然后,我们先来修改抄写棋局信息的put()方法,此方法是写入操作,所以里面内容需要使用写锁来同步:
同时,将跟Condition相关的代码也都移除掉:
不需要Condition对象之后,意味着标识watched也不需要了:
也不需要将线程睡1秒钟,同时也移除throws代码:
该移除的代码都移除之后的put()方法:
然后,我们再来修改获取棋局信息的take()方法,此方法是读取操作,所以里面内容需要使用读锁来同步:
接着,将跟Condition相关的代码和watched相关代码移除:
因为没有await()方法了,也就无需throws InterruptedException异常了,所以将此代码移除掉:
该移除的代码移除之后,take()方法内容如下:
因为put()方法和take()方法没有再抛出异常了,所以调用put()方法和take()方法时没有要处理的异常,修改以下代码:
修改之后:
接下来,在创建抄写棋局信息线程的之间隔1秒钟创建一个:
然后,在无限创建观看棋局信息的线程:
这么做的目的就是为了让大家看见,写锁在某一时刻最多只能被一个线程拥有;而读锁在某一时刻可以被多个线程拥有。
运行程序,执行结果:
从运行结果来看,符合预期。
可以清楚的看见,每一手棋都被无数的观众看过。这可以说明的是:写锁在某一时刻最多只能被一个线程拥有;而读锁在某一时刻可以被多个线程拥有。
另外,我们还可以得知的是,读锁每次读到的内容都是写锁释放锁时就更新了的。这是读写锁的其中一个特性:
所有ReadWriteLock实现(如ReentrantReadWriteLock实现类)都必须保证写锁操作的内存同步效果对相关的readLock也有效。也就是说,成功获取读锁的线程将看到在先前释放写锁时所做的所有更新。
上述信息中说的内存同步效果说的是对共享数据的操作,在本例中,共享数据就是Chess类中的message变量。
还有,读写锁适合用在多读少写的场景。像本例,就非常适合,棋手在下每一步棋时都需要很久,这符合少写的条件,而观棋者则有很多,他们都在同时观棋,这符合多读条件。
读写锁还有其他内容没有介绍,由于篇幅原因,留到后续章节中再介绍。
最后,希望大家可以把这个例子照着写一遍,然后再自己默写一遍,方便以后碰到类似的面试题可以轻松应对。
祝大家编码愉快!
GitHub
本章程序GitHub地址:https://github.com/gorhaf/Java2019/tree/master/Thread/ReadWriteLock
总结
- 写锁在某一时刻最多只能被一个线程拥有;而读锁在某一时刻可以被多个线程拥有。
- 所有ReadWriteLock实现(如ReentrantReadWriteLock实现类)都必须保证写锁操作的内存同步效果对相关的readLock也有效。也就是说,成功获取读锁的线程将看到在先前释放写锁时所做的所有更新。
- 读写锁适合用在多读少写的场景。
至此,Java中读写锁ReadWriteLock相关内容讲解先告一段落,更多内容请持续关注。
答疑
如果大家有问题或想了解更多前沿技术,请在下方留言或评论,我会为大家解答。
上一章
“全栈2019”Java多线程第三十九章:显式锁实现生产者消费者模型
下一章
“全栈2019”Java多线程第四十一章:读锁与写锁之间相互嵌套例子
学习小组
加入同步学习小组,共同交流与进步。
- 方式一:关注头条号Gorhaf,私信“Java学习小组”。
- 方式二:关注公众号Gorhaf,回复“Java学习小组”。
全栈工程师学习计划
关注我们,加入“全栈工程师学习计划”。
版权声明
原创不易,未经允许不得转载!