## 23 按需上锁—ReadWriteLock详解
> 合理安排时间,就等于节约时间。
> ——培根
前文我们分析了 java.util.concurrent.locks 包下的 Lock 接口和它的实现 ReentrantLock。ReentrantLock 是一种显式锁,提供更为高级的功能,但需要显式的上锁和解锁。ReentrantLock 也是互斥锁,有如下三种互斥情况:读/写、写/写、读/读。ReentrantLock 的加锁策略是保守的,任意操作都需要先加锁才可以.但其实在某些情况下,我们只需要控制“读/写”和“写/写”这两种互斥情况。一般情况下,绝大多数程序中读操作比例更大。其实读操作之间并不需要互斥,因为读的需求是读到最新的数据,并且在读的同时不要有其它线程修改数据即可。那我们需要的锁是“读/写”互斥,而“读/读”并不互斥。这样的好处是,读操作可以并发进行,减少了互斥的情况,能够提升程序的性能。打个比方,我们去旅游,在观景台大家可以一块欣赏风景,这就是 “读/读” 并不互斥,大家可以在观景台一块看。但是假如在观景台有个望远镜,需要使用望远镜才能看清。那么就变成了 “读/读” 互斥,人多的话显然会造成排队现象。
![图片描述](https://img.mukewang.com/5dc37ef8000162e908460514.jpg)
显然 ReentrantLock 和 synchronized 是做不到这一点的,本节我们会介绍一种新的锁—读写锁 ReadWriteLock。
## 1、ReadWriteLock 简介
ReadWriteLock 为我们提供了读写之间不同互斥策略的锁。因此,在某些情况下,他能够带来更好的性能。一般来说,假如你的程序有频繁的读操作,那么ReadWriteLock可能会为你带来性能的提升。但是由于读写控制的策略不一样,带来了锁内部的复杂度。所以如果你程序的读操作并没有达到一定数量,反而使用读写锁会比互斥锁性能更差。
因此 ReadWriteLock 是一种提升性能的手段,但不一定奏效。我们的程序可以尝试使用它来调节性能,如果发现没有效果或者更差,也可以很方便的换回互斥锁。
ReadWriteLock 顾名思义读写锁,也就是说同一个锁对读和写的上锁方式是不一样的写锁的互斥性更高。这里我们来看看锁降级和升级的概念。
**锁降级**
如果线程持有写锁,如果可以在不释放写锁的情况下,获取读锁,这就是锁降级。ReadWriteLock 是支持锁降级的。
**锁升级**
如果线程持有读锁,那么他是否可以不释放读锁,直接获取写锁。这意味着从一个低级别的锁升级到高级别的锁。其实就是变相的插队,无视其它在排队等待写锁的线程。ReadWriteLock 并不支持锁升级。
以上两种概念我们可以通过写段代码来体验下。
代码一:
~~~java
public class Client {
public static void main(String[] args) throws InterruptedException {
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
writeLock.lock();
System.out.println("got the write lock");
readLock.lock();
System.out.println("got the read lock");
}
}
~~~
输出:
~~~java
got the write lock
got the read lock
~~~
代码二:
~~~java
public class Client {
public static void main(String[] args) throws InterruptedException {
ReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
readLock.lock();
System.out.println("got the read lock");
writeLock.lock();
System.out.println("got the write lock");
}
}
~~~
输出:
~~~java
got the read lock
~~~
第一段代码中,我们可以在获取写锁后,再次成功获得读锁。而代码 2 中,我们在获取读锁后试图去获取写锁。这样会使得程序阻塞在 readLock.lock()。由于 writeLock 没有机会 unlock,就形成了死锁。
## 2、ReadWriteLock使用
ReadWriteLock 使用起来其实很简单,和 Lock 基本一致。我们使用它主要是为了对性能进行优化。我们通过下面的例子,一是熟悉它的使用,二来也可以测试下它对性能的优化效果。
首先我们看使用 Lock 的情况:
~~~java
public class LockExample {
String myName;
ReentrantLock lock = new ReentrantLock();
public void printMyName() {
lock.lock();
try {
System.out.println(Thread.currentThread().getName() + "My name is " + myName);
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
public void setMyName() {
lock.lock();
try {
myName=Thread.currentThread().getName();
System.out.println(Thread.currentThread().getName() + "set my name to " + myName);
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
Long startTime = new Date().getTime();
LockExample example = new LockExample();
new Thread(()->{
while (true){
example.setMyName();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(()->{
while (true){
example.setMyName();
try {
Thread.sleep(new Random().nextInt(10));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
IntStream.range(0,100).forEach(num->{
new Thread(()->{
example.printMyName();
System.out.println("NO. "+num+" reader finished. Time passed: "+(new Date().getTime()-startTime));
}).start();
});
}
}
~~~
以上代码我们启动了两个线程不断写入,每次间隔 10ms, 以让其它线程能够获取到锁。另外有 100 个读线程 ,每次读取完成,睡眠 10ms,目的是延迟读锁的释放。由于使用了排他锁,所以读取操作间是互斥的,每次读取都要等 10ms 释放锁后,其它线程才能读取。那么 100 次读取就至少花费了 100\*10 = 1000ms。再加上其它消耗,所以最终全部读取线程完成工作的时候,过去了 1182ms。输出如下:
~~~
NO. 96 reader finished. Time passed: 1148
Thread-99My name is Thread-0
NO. 97 reader finished. Time passed: 1160
Thread-100My name is Thread-0
NO. 98 reader finished. Time passed: 1172
Thread-101My name is Thread-0
NO. 99 reader finished. Time passed: 1182
~~~
接下来我们改造下程序,改为 ReadWriteLock,代码如下:
~~~java
public class ReadWriteLockExample {
String myName;
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
public void printMyName() {
lock.readLock().lock();
try {
System.out.println(Thread.currentThread().getName() + "My name is " + myName);
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.readLock().unlock();
}
}
public void setMyName() {
lock.writeLock().lock();
try {
myName=Thread.currentThread().getName();
System.out.println(Thread.currentThread().getName() + "set my name to " + myName);
} finally {
lock.writeLock().unlock();
}
}
public static void main(String[] args) {
Long startTime = new Date().getTime();
ReadWriteLockExample example = new ReadWriteLockExample();
new Thread(()->{
while (true){
example.setMyName();
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
new Thread(()->{
while (true){
example.setMyName();
try {
Thread.sleep(new Random().nextInt(10));
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
IntStream.range(0,100).forEach(num->{
new Thread(()->{
example.printMyName();
System.out.println("NO. "+num+" reader finished. Time passed: "+(new Date().getTime()-startTime));
}).start();
});
}
}
~~~
可以看到只是把锁换成了 ReentrantReadWriteLock,然后 printMyName 中使用读锁,setMyName 中使用写锁。运行后输出如下:
~~~
NO. 92 reader finished. Time passed: 97
NO. 96 reader finished. Time passed: 97
Thread-0set my name to Thread-0
NO. 98 reader finished. Time passed: 97
Thread-1set my name to Thread-1
NO. 99 reader finished. Time passed: 97
~~~
最后一个 reader 完成工作,只用了 97ms,对比起使用互斥锁的 1182ms,速度提升了 10倍以上!原因就是读操作之间不会互斥,可以并发读取。从而性能大幅度得到提升。
## 3、ReadWriteLock 使用场景
从上面例子可以看出,如果读操作远远多于写操作,使用 ReadWriteLock 可以大幅提升性能。但如果是一个写入密集型的程序,那么 ReadWriteLock 并不会带来显著性能的提升,因为即使使用 ReadWriteLock,“写/写”及“读/写”依旧是互斥的。并且由于要分开控制读写两种锁,还需要额外的开销。
如果你的并发程序存在性能问题,可以把 ReadWriteLock 作为性能调优的手段,进行尝试。究竟读和写的线程达到什么比例时,使用 ReadWriteLock 性能更好,其实并没有定论。完全和你的程序场景有关系,所以使用 ReadWriteLock 做性能调优时,一定要基于实际的测试数据,而不是一股脑的全部使用 ReadWriteLock。
## 4、ReadWriteLock 实现
关于 ReadWriteLock 的实现,我们先看下面的类图:
![图片描述](https://img.mukewang.com/5dc37eb40001705d08130325.jpg)
可以看到 ReentrantReadWriteLock 中持有 readerLock 和 writerLock 两把锁,而这两把锁也是 Lock 接口的实现。ReadLock 间由于是非互斥的,所以ReadLock对lock方法的实现如下:
~~~java
public void lock() {
sync.acquireShared(1);
}
~~~
而WriteLock对lock方法的实现则是如下:
~~~java
public void lock() {
sync.acquire(1);
}
~~~
可以看到 ReadLock 的 lock 方法中调用的是 acquireShared,也就是共享方式获取锁。两者都是通过 sync 来实现,两种锁的 sync 对象都是来自 ReentrantReadWriteLock 的构造函数:
~~~java
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
~~~
而 Sync 之前我们已经讲解过,它继承自 AbstractQueuedSynchronizer,也就是 AQS。通过 AQS 提供的模版实现同步原语。而最终的实现方式则是在 AbstractQueuedSynchronizer 的子类,也就是 FairSync 和 NonfairSync 中。AQS 的原理之前已经讲过。结合 AQS 的原理,再加上之前我们对 ReentrantLock 源代码的分析,再来分析 ReentrantReadWriteLock 的源代码,并不困难,大家可以自行继续分析。
## 5、总结
本节我们又学习了一种比较实用的锁。ReentrantReadWriteLock 允许并发的读,如果你的程序以读取为主,那么使用 ReentrantReadWriteLock 会显著提升你的性能。但如果场景不符合,不但不会提升性能,还会因为锁的复杂度,反而降级性能。
- 前言
- 第1章 Java并发简介
- 01 开篇词:多线程为什么是你必需要掌握的知识
- 02 绝对不仅仅是为了面试—我们为什么需要学习多线程
- 03 多线程开发如此简单—Java中如何编写多线程程序
- 04 人多力量未必大—并发可能会遇到的问题
- 第2章 Java中如何编写多线程
- 05 看若兄弟,实如父子—Thread和Runnable详解
- 06 线程什么时候开始真正执行?—线程的状态详解
- 07 深入Thread类—线程API精讲
- 08 集体协作,什么最重要?沟通!—线程的等待和通知
- 09 使用多线程实现分工、解耦、缓冲—生产者、消费者实战
- 第3章 并发的问题和原因详解
- 10 有福同享,有难同当—原子性
- 11 眼见不实—可见性
- 12 什么?还有这种操作!—有序性
- 13 问题的根源—Java内存模型简介
- 14 僵持不下—死锁详解
- 第4章 如何解决并发问题
- 15 原子性轻量级实现—深入理解Atomic与CAS
- 16 让你眼见为实—volatile详解
- 17 资源有限,请排队等候—Synchronized使用、原理及缺陷
- 18 线程作用域内共享变量—深入解析ThreadLocal
- 第5章 线程池
- 19 自己动手丰衣足食—简单线程池实现
- 20 其实不用造轮子—Executor框架详解
- 第6章 主要并发工具类
- 21 更高级的锁—深入解析Lock
- 22 到底哪把锁更适合你?—synchronized与ReentrantLock对比
- 23 按需上锁—ReadWriteLock详解
- 24 经典并发容器,多线程面试必备—深入解析ConcurrentHashMap上
- 25 经典并发容器,多线程面试必备—深入解析ConcurrentHashMap下
- 26不让我进门,我就在门口一直等!—BlockingQueue和ArrayBlockingQueue
- 27 倒数计时开始,三、二、一—CountDownLatch详解
- 28 人齐了,一起行动—CyclicBarrier详解
- 29 一手交钱,一手交货—Exchanger详解
- 30 限量供应,不好意思您来晚了—Semaphore详解
- 第7章 高级并发工具类及并发设计模式
- 31 凭票取餐—Future模式详解
- 32 请按到场顺序发言—Completion Service详解
- 33 分阶段执行你的任务-学习使用Phaser运行多阶段任务
- 34 谁都不能偷懒-通过 CompletableFuture 组装你的异步计算单元
- 35 拆分你的任务—学习使用Fork/Join框架
- 36 为多线程们安排一位经理—Master/Slave模式详解
- 第8章 总结
- 37 结束语