第6章 读写锁
2.3 读写锁
2.3.1 什么是读写锁
读多写少场景的需求
什么是读多写少的场景 ?
比如核酸检测结果查询、学生成绩信息查询场景下读操作要远远多于写操作。
在读多写少的场景 下使用排他锁保护共享资源,当前只能有一个线程拥有锁去操作共享资源,即使是纯粹的读操作也需要先获取锁再去读取共享资源的信息,读操作是不会影响线程安全的,很显然排他锁不适用在读多写少的场景。
读多写少的场景希望遵循如下特性:
1、读读不互斥
一个线程在读另一个线程可以同时去读。

2、读写互斥
一个线程在读另一个线程不能同时去写,因为可以读到不准确的数据。

读多写少场景的解决方案
前边学习的synchronized、Reentrantlock都属于排他锁、互斥锁,它适合用在读多写少的场景吗?
根据需求要实现的是读读共享,读写互斥,互斥锁实现读写互斥没有问题,但无法实现读读共享。
读写锁可实现读多写少场景下的需求:
针对一个共享资源可设置两个锁,一个读锁,一个写锁。读写锁具有互斥锁的功能同时也具有读读共享的功能。多线程可以同时获取两个读锁读取共享资源的数据,但是读锁和写锁不允许同时获取,这里读写锁又具有了互斥锁的功能。

ReadWriteLock入门
ReadWriteLock是读写锁的接口,适用于读多写少的场景,ReentrantReadWriteLock是ReadWriteLock接口的实现类,此类维护了两个锁,一个是Read读锁一个是Wirte写锁。
根据前边的需求分析,读写锁需要实现读读共享,读写互斥。
下边进行测试:
package com.yjoffer.javase.thread.rwlock;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* @author 预见猿份(www.yjoffer.com)
* @version 1.0
**/
public class ReadWriteLockTest {
static ReadWriteLock rwLock = new ReentrantReadWriteLock();
static Lock r = rwLock.readLock();
static Lock w = rwLock.writeLock();
//测试多线程读读共享
public static void test_readread() {
ExecutorService executorService = Executors.newCachedThreadPool();
//启动读线程
executorService.execute(()->{
r.lock();
try {
logger.debug("开始读");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
r.unlock();
}
logger.debug("读结束");
});
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//启动读线程
executorService.execute(()->{
r.lock();
try {
logger.debug("开始读");
} catch (Exception e) {
e.printStackTrace();
}finally {
r.unlock();
}
logger.debug("读结束");
});
executorService.shutdown();
}
public static void main(String[] args) {
test_readread();
}
}运行,输出:
15:10:56.428 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-1] - 开始读
15:10:57.428 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-2] - 开始读
15:10:57.428 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-2] - 读结束
15:10:59.496 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-1] - 读结束通过输出可知:线程1获取读锁后休眠3秒,1秒后线程2启动并且在线程1还没有释放读锁时线程2获取了读锁并输出日志。
下边代码测试读写互斥。
//测试读写互斥
public static void test_readwrite(){
ExecutorService executorService = Executors.newCachedThreadPool();
//启动读线程
executorService.execute(()->{
r.lock();
try {
logger.debug("开始读");
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
r.unlock();
}
logger.debug("读结束");
});
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//启动写线程
executorService.execute(()->{
w.lock();
try {
logger.debug("开始写");
} catch (Exception e) {
e.printStackTrace();
}finally {
w.unlock();
}
logger.debug("写结束");
});
executorService.shutdown();
}运行,输出:
15:12:04.711 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-1] - 开始读
15:12:07.736 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-1] - 读结束
15:12:07.736 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-2] - 开始写
15:12:07.737 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[pool-1-thread-2] - 写结束通过输出可知:线程1启动后获取读锁并休眠3秒钟,1秒后启动线程2,但由于读写互斥,线程2等线程释放读锁后方可获取写锁。
多线程下读写锁的规则
要使用读写锁需要搞清楚读写锁规则,如下:
1、读读共享
当一个线程获取读锁且没有释放时,另一个线程也可以获取读锁,多个线程可以同时拥有读锁进行读操作。
2、读写互斥
当一个线程获取读锁且没有释放时,另一个线程不可以获取写锁,多线程读写操作不能同时进行,防止读到不准确的数据。
3、写写互斥
当一个线程获取写锁且没有释放时,另一个线程不可以获取写锁,多线程不可以同时拥有写锁进行写操作,防止并发写。
4、写读互斥
当一个线程获取写锁且没有释放时,另一个线程不可以获取读锁,多线程读写操作不能同时进行,防止读到不准确的数据。
请参考ReadWriteLock入门中的测试方法自行测试读写锁规则 。
单线程下的读写锁规则
上边的测试是基于多线程环境测试,试想: 一个写线程将数据写成功后要获取读锁去读数据可以吗?下边测试在一个线程内读写是否互斥,写读是否互斥,读读是否共享。
下边代码测试单线程下读读共享:
//测试单线程下读读是不共享
public static void test_onlyone_readread() {
System.out.println("===========读读不互斥==========");
r.lock();
logger.debug("读数据");
r.lock();
logger.debug("读数据");
r.unlock();
r.unlock();
}运行,输出:
===========读读不互斥==========
15:13:59.371 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[main] - 读数据
15:13:59.395 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[main] - 读数据从输出可以看出单线程内读锁不释放时可以继续获取读锁。
下边代码测试单线程下读写是否互斥:
//测试单线程下读写是否互斥
public static void test_onlyone_readwrite() {
System.out.println("===========读写互斥==========");
r.lock();
logger.debug("读数据");
w.lock();
logger.debug("写数据");
w.unlock();
r.unlock();
}运行,输出:
===========读写互斥==========
15:14:54.587 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[main] - 读数据从输出可以看出单线程下读锁没有释放不可以获取写锁,这表示读写互斥,这是因为如果读写不互斥那么当另一个线程也执行读写操作时会存在写写不互斥的情况,两个写操作并发执行会造成线程不安全。
下边代码测试单线程下写读是否互斥:
//测试单线程下写读是否互斥
public static void test_onlyone_writeread() {
System.out.println("===========写读不互斥==========");
w.lock();
logger.debug("写数据");
r.lock();
logger.debug("读数据");
r.unlock();
w.unlock();
}运行输出:
===========写读不互斥==========
15:15:42.724 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[main] - 写数据
15:15:42.745 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[main] - 读数据从输出可以看出单线程下写锁不释放可以继续获取读锁,说明写读不互斥,这是因为多线程之间写读或写写是互斥的,单线程内在获取写锁后其它线程则无法操作共享资源,单线程内写操作后再去读则不会读到不准备的数据。
下边代码测试单线程下写写是否互斥:
//测试单线程下写写是否互斥
public static void test_onlyone_writewrite() {
System.out.println("===========写写不互斥==========");
w.lock();
logger.debug("写数据");
w.lock();
logger.debug("写数据");
w.unlock();
w.unlock();
}运行输出:
===========写写不互斥==========
15:16:40.707 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[main] - 写数据
15:16:40.726 FINE com.yjoffer.javase.thread.rwlock.ReadWriteLockTest[main] - 写数据从输出结果可以看出单线程内写锁不释放可以继续获取写锁,写写不互斥,原因同单线程内写读不互斥。
通过单线程测试发现,写读不互斥,写写不互斥,其中写读不互斥叫锁的降级,写锁降级为读锁是允许的,而读写互斥说明读锁升级为写锁是不允许的。
2.3.2 读写锁案例
读写锁案例
上边对读写锁多线程及单线程互斥规则进行测试,下边通过一个案例进行综合运用读写锁。
案例需求:
数据缓存是项目中的常见场景,数据缓存的目的是提高查询速度,原始数据在数据库中存储,如果高并发查询从数据库查询其速度无法保证,数据缓存即将数据存入内存,查询数据从内存中查询提高查询效率。

执行流程如下:
1)每个线程都先从缓存读取数据
采取读写锁,读读不互斥,读取数据速度快。
2)如果缓存中没有则从数据库中查询,同时将数据写入缓存
采取读写锁,当读不到数据时将读锁释放获取写锁,将数据从数据库查出来写入缓存,然后再释放写锁。
代码如下:
package com.yjoffer.javase.thread.rwlock;
import java.util.HashMap;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
/**
* @author 预见猿份(www.yjoffer.com)
* @version 1.0
**/
public class ObjectCache {
//定义map存储缓存数据
private Map<String, Object> cache = new HashMap<String, Object>();
//创建读写锁
private ReadWriteLock rwLock = new ReentrantReadWriteLock();
public Object getObject(String key) {
Lock r = rwLock.readLock();
Lock w = rwLock.writeLock();
//获取读锁
r.lock();
try {
//从缓存取出数据
Object value = cache.get(key);
if(value == null){
//从数据库查询数据写入缓存
r.unlock();//释放读锁
//获取写锁
w.lock();
try {
//为了防止有其它线程已经将数据写入缓存这里再次判断
value = cache.get(key);
if (value == null) {
//从数据库查询数据
value = "www.yjoffer.com";
logger.debug("开始写入"+value);
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.debug("写入结束");
cache.put(key,value);
}else{
logger.debug("已经有线程写入"+value);
}
}finally {
w.unlock();
}
//为了匹配最外层的释放读锁,这里再打开读锁
r.lock();
}
return value;
}finally {
r.unlock();
}
}
public static void main(String[] args) {
ExecutorService threadPool = Executors.newCachedThreadPool();
ObjectCache objectCache = new ObjectCache();
for (int i = 0; i < 100; i++) {
threadPool.execute(()->{
//获取key为01的数据
Object obj = objectCache.getObject("01");
System.out.println(obj);
});
}
}
}输出:
开始写入www.yjoffer.com
写入结束
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
obj=www.yjoffer.com
...启动100个线程异步从缓存读数据,从输出可以看出只有一个线程向缓存写入数据,其它线程均是从缓存读数据。
2.3.3 StampedLock
StampedLock的优势
ReadWriteLock读写锁可以实现多个线程同时读,适合读多写少的场景。但是ReadWriteLock存在"饥饿写"的缺点,什么是饥饿写?就是多个线程在同时读,写线程必须等到读线程释放读锁后才能获取写锁。
JDK8引入的StampedLock,它可以解决饥饿写的问题,它允许读的同时可以获取写锁,相比ReadWriteLock在读的同时不允许写,StampedLock是一种乐观锁,ReadWriteLock是一种悲观锁。
可能有人会问,如果在读的时候允许写操作那么读出的数据就可能不是最新的数据,这不就有问题了吗?没错,乐观锁就是乐观的估计在读的同时不会有写操作,因为它是在读多写少的场景。不过也不排除存在读数据的同时执行了写操作从而读到旧数据,这个问题稍后讲如何解决,这里需要明白的是乐观读与悲观读的区别。
StampedLock除了具备ReadWriteLock的悲观锁的特性外还有乐观锁的特点,这是它对ReadWriteLock的优化,因为乐观锁要比悲观锁在性能上更优。
StampedLock悲观锁测试
下边的代码测试了StampedLock实现悲观锁的方法,先启动一个读取数据的线程,读线程获取悲观锁,在主线程中需等到读锁释放方可获取写锁。
StampedLock stampedLock = new StampedLock();
//获取读锁(悲观锁),与写锁互斥
long stamp = stampedLock.readLock();获取悲观锁得到一个long类型的数值,它表示锁的标记,在释放锁时需要用此标记。
//释放锁
stampedLock.unlockRead(stamp);测试代码如下:
package com.yjoffer.javase.thread.basic;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.StampedLock;
/**
* 测试StampedLock的悲观锁,乐观锁
*
* @author 预见猿份(www.yjoffer.com)
* @version 1.0
**/
public class StampedLockTest {
//优化锁
static StampedLock stampedLock = new StampedLock();
//共享资源
static String url = "www.yjoffer.com";
//悲观读
private static void read(){
//线程池
ExecutorService service = Executors.newCachedThreadPool();
service.execute(() -> {
//获取读锁(悲观锁),与写锁互斥
long stamp = stampedLock.readLock();
logger.debug("获取读锁");
try {
String pbteach = url;
try {
//休眠5秒
TimeUnit.MILLISECONDS.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger.debug("pbteach=" + pbteach);
} finally {
//释放读锁
logger.debug("释放读锁");
stampedLock.unlockRead(stamp);
}
});
}
//悲观写
private static void write(){
//线程池
ExecutorService service = Executors.newCachedThreadPool();
long start = System.currentTimeMillis();
service.execute(() -> {
//获取写锁,阻塞,等待读锁释放
long stamp = stampedLock.writeLock();
logger.debug("获取写锁");
try {
url = "http://"+url;
logger.debug("数据写入完成pbteach="+url);
} finally {
//释放写锁
stampedLock.unlockWrite(stamp);
}
});
}
//悲观读锁测试
private static void testReadLock() {
//悲观读
read();
try {
//休眠1秒,等待读线程启动
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//悲观写
write();
}
public static void main(String[] args) {
testReadLock();
}
}注意: 悲观锁用完要在finally中释放锁。
运行程序,输出:
15:24:56.526 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-1-thread-1] - 获取读锁
15:25:01.549 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-1-thread-1] - pbteach=www.yjoffer.com
15:25:01.550 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-1-thread-1] - 释放读锁
15:25:01.552 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-2-thread-1] - 获取写锁
15:25:01.553 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-2-thread-1] - 数据写入完成pbteach=http://www.yjoffer.com从输出可以看出写线程在等到读线程释放锁后才向url写入数据,所以读线程读到的数据是写入之前的数据。
StampedLock乐观锁测试
下面用StampedLock乐观读锁修改上边的代码,不用等待释放乐观读锁即可获取写锁,方法如下:
StampedLock stampedLock = new StampedLock();
//获取乐观读锁,由于是乐观锁并非互斥锁所以不用释放锁
long stamp = stampedLock.tryOptimisticRead();下边的程序的实现方式和测试悲观锁一致,先启动读锁,再启动写锁,由于读锁是乐观锁,所以写锁不用等待释放读锁即可获取写锁,从而向共享资源url写入数据。
下边是测试代码:
//乐观读
private static void optimisticRead(){
//线程池
ExecutorService service = Executors.newCachedThreadPool();
service.execute(() -> {
//获取乐观读锁
long stamp = stampedLock.tryOptimisticRead();
logger.debug("获取读锁");
String pbteach = url;
try {
//休眠5秒
TimeUnit.MILLISECONDS.sleep(5000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//如果乐观读锁期间有写锁获取
if (!stampedLock.validate(stamp)) {
//获取悲观读锁再次获取数据
stamp = stampedLock.readLock();
try {
pbteach = url;
} finally {
stampedLock.unlockRead(stamp); //释放读锁
}
}
logger.debug("pbteach=" + url);
});
}
//乐观读锁测试
private static void testOptimisticRead() {
//乐观读
optimisticRead();
try {
//休眠1秒,等待读线程启动
TimeUnit.MILLISECONDS.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
//悲观写
write();
}
public static void main(String[] args) throws Exception {
testOptimisticRead();
}执行程序输出:
15:27:46.134 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-1-thread-1] - 获取读锁
15:27:47.134 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-2-thread-1] - 获取写锁
15:27:47.134 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-2-thread-1] - 数据写入完成pbteach=http://www.yjoffer.com
15:27:51.157 FINE com.yjoffer.javase.thread.basic.StampedLockTest[pool-1-thread-1] - pbteach=http://www.yjoffer.com从输出可以看出,写锁无需等待读锁释放即向共享资源写入数据,1秒后启动了写线程即向共享资源写入了数据。
读线程是如何保证读到最新的数据呢?下边的代码实现了此功能。
//如果乐观读锁期间有写锁获取
if (!stampedLock.validate(stamp)) {
//获取悲观读锁再次获取数据
stamp = stampedLock.readLock();
try {
pbteach = url;
} finally {
stampedLock.unlockRead(stamp); //释放读锁
}
}stampedLock.validate(stamp)这句代码校验了获取读锁在读数据期间是否有数据写入,如果有则返回false,没有则为true。返回false说明有另外的线程获取了写锁,所以这里要用悲观锁重新获取一下数据,从而获取到了最新数据。
所以,用乐观读锁读取数据时要注意校验数据是否存在修改数据的可能,如果存在修改的可能则用悲观锁重新获取数据。
鉴于此,建议在乐观读锁读取数据时使用下边的模板代码:
//获取乐观读锁
long stamp = stampedLock.tryOptimisticRead();
//读取数据...
//如果乐观读锁期间有写锁获取
if (!stampedLock.validate(stamp)) {
//获取悲观读锁再次获取数据
stamp = stampedLock.readLock();
try {
//读取数据...
} finally {
stampedLock.unlockRead(stamp); //释放读锁
}
}虽然乐观锁在应用时添加了校验,但在读多写少的场景下此校验一般不会通过,也不会使用悲观读锁,所以乐观读锁的性能要高于悲观读锁。
StampedLock可重入性测试
StampedLock是可重入锁吗?下边的代码进行测试
//StampedLock的可重入性测试
public static void testReentrantLock(){
StampedLock stampedLock = new StampedLock();
long stamp = stampedLock.writeLock();
logger.debug("获取写锁");
long stamp2 = stampedLock.writeLock();//StampedLock为非可重入锁
logger.debug("获取写锁");
try {
url = "http://"+url;
logger.debug("数据写入完成");
} finally {
//释放写锁
stampedLock.unlockWrite(stamp);
}
}运行程序:
15:41:24.208 FINE com.yjoffer.javase.thread.basic.StampedLockTest[main] - 获取写锁由于StampedLock的非可重入性,测试上边的代码第6行stampedLock.writeLock();无法再次获取锁。
非重入锁很容易引起死锁这点需要注意。
乐观读与悲观读性能测试
根据上边的测试可知乐观读锁比悲观读锁性能高,下边测试乐观读与悲观读的性能差别,分别启动1000个线程测试乐观读和悲观读,共进行5轮测试。
代码如下:
//多线程并发读(悲观)
public static void testReadLock_concurrency() throws InterruptedException {
ExecutorService service = Executors.newCachedThreadPool();
List<Thread> list = new ArrayList<>();
for(int i = 0;i < 1000;i++){
Thread thread = new Thread(() -> {
//获取读锁(悲观)
long stamp = stampedLock.readLock();
try {
//读数据...
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放读锁
stampedLock.unlockRead(stamp);
}
});
list.add(thread);
}
long begin = System.currentTimeMillis();
list.forEach(f->f.start());
list.forEach(f->{
try {
f.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.currentTimeMillis();
System.out.println("悲观锁并发性能测试:"+(end-begin));
}
//多线程并发读(乐观)
public static void testOptimisticRead_concurrency() throws InterruptedException {
ExecutorService service = Executors.newCachedThreadPool();
List<Thread> list = new ArrayList<>();
for(int i = 0;i < 1000;i++){
Thread thread = new Thread(() -> {
//获取乐观读锁
long stamp = stampedLock.tryOptimisticRead();
//校验是否有数据写入
if (!stampedLock.validate(stamp)) {
stamp = stampedLock.readLock();
try {
//读数据...
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
//释放读锁
stampedLock.unlockRead(stamp);
}
}
});
list.add(thread);
}
long begin = System.currentTimeMillis();
list.forEach(f->f.start());
list.forEach(f->{
try {
f.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
long end = System.currentTimeMillis();
System.out.println("乐观锁并发性能测试:"+(end-begin));
}
public static void main(String[] args) throws Exception {
for (int i = 0; i < 5; i++) {
System.out.println("*************************");
testReadLock_concurrency();
testOptimisticRead_concurrency();
}
}运行程序输出如下:
*************************
悲观锁并发性能测试:185
乐观锁并发性能测试:79
*************************
悲观锁并发性能测试:158
乐观锁并发性能测试:82
*************************
悲观锁并发性能测试:160
乐观锁并发性能测试:82
*************************
悲观锁并发性能测试:152
乐观锁并发性能测试:80
*************************
悲观锁并发性能测试:155
乐观锁并发性能测试:82从输出上可以看出悲观读的耗费时间是乐观读的2倍以上。
