前言

前面介绍过ReentrantLock,它实现的是一种标准的互斥锁:每次最多只有一个线程能持有ReentrantLock。这是一种强硬的加锁规则,在某些场景下会限制并发性导致不必要的抑制性能。互斥是一种保守的加锁策略,虽然可以避免“写/写”冲突和“写/读”冲突,但是同样也避免了“读/读”冲突。

在读多写少的情况下,如果能够放宽加锁需求,允许多个执行读操作的线程同时访问数据结构,那么将提升程序的性能。只要每个线程都能确保读到最新的数据,并且在读取数据时不会有其他的线程修改数据,那么就不会发生问题。在这种情况下,就可以使用读写锁:一个资源可以被多个读操作访问,或者被一个写操作访问,但两者不能同时进行。

Java中读写锁的实现是ReadWriteLock。下面我们先介绍什么是读写锁,然后利用读写锁快速实现一个缓存,最后我们再来介绍读写锁的升级与降级。

什么是读写锁

读写锁是一种性能优化措施,在读多写少场景下,能实现更高的并发性。读写锁的实现需要遵循以下三项基本原则:

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

读写锁与互斥锁的一个重要区别就是:读写锁允许多个线程同时读共享变量,而互斥锁是不允许的。读写锁的写操作时互斥的。

下面是ReadWriteLock接口:

public interface ReadWriteLock{
Lock readLock();
Lock writeLock();
}

其中,暴露了两个Lock对象,一个用于读操作,一个用于写操作。要读取由ReadWriteLock保护的数据,必须首先获得读取锁,当需要修改由ReadWriteLock保护的数据时,必须首先获得写入锁。尽管这两个锁看上去是彼此独立的,但读取锁和写入锁只是读写锁对象的不同视图。

与Lock一样,ReadWriteLock可以采用多种不同的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有些不同。读取锁与写入锁之间的交互方式也可以采用多种方式实现。

ReadWriteLock中有一些可选实现包括:

  • 释放优先:当一个写入操作释放写入锁时,并且队列中同时存在读线程和写线程,那么应该优先选择读线程,写线程,还是最先发出请求的线程?
  • 读线程插队:如果锁是由读线程持有,但有写线程正在等待,那么新到达的读线程能否立即获得访问权,还是应该在写线程后面等待?如果允许读线程插队到写线程之前,那么将提高并发性,但却可能造成写线程发生饥饿问题。
  • 重入性:读取锁和写入锁是否是可重入的?
  • 降级:如果一个线程持有写入锁,那么它能否在不释放该锁的情况下获得读取锁?这可能会使得写入锁被“降级”为读取锁,同时不允许其他写线程修改被保护的资源。
  • 升级:读取锁能否优先于其他正在等待的读线程和写线程而升级为一个写入锁?在大多数的读-写锁实现中并不支持升级,因为如果没有显式的升级操作,那么很容易造成死锁。(如果两个读线程试图同时升级为读写锁,那么二者都不会释放读取锁。)

ReentrantReadWriteLock

ReentrantReadWriteLock是ReadWriteLock的一个实现,它为读取锁和写入锁都提供了可重入的加锁语义。与ReentrantLock相似,ReentrantReadWriteLock在构造时也可以选择是一个非公平的锁(默认)还是一个公平的锁。

在公平的锁中,等待时间最长的线程将优先获得锁。如果这个线程是由读线程持有,而另一个线程请求写入锁,那么其他读线程都不能获得读取锁,直到写线程使用完并且释放了写入锁。

在非公平的锁中,线程获得访问许可的顺序是不确定的。写线程降级为读线程是可以的,但从读线程升级为写线程则是不可以的(容易导致死锁)。

实现一个快速缓存

下面使用ReentrantReadWriteLock来实现一个通用的缓存工具类。

实现一个Cache<K,V>类,类型参数K代表缓存中key类型,V代表缓存里的value类型。我们将缓存数据存储在Cache类中的HashMap中,但是HashMap不是线程安全的,所以我们使用读写锁来保证其线程安全

Cache工具类提供了两个方法,读缓存方法get()和写缓存方法put()。读缓存需要用到读取锁,读取锁的使用方法同Lock使用方式一致,都需要使用try{}finally{}编程范式。写缓存需要用到写入锁,写入锁和读取锁使用类似。

代码参考如下:(代码来自参考[1])

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()方式就可以了。可参考下图(图来自参考[1])

如果源头数据量非常大,那么就需要按需加载,按需加载也叫做懒加载。指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进行缓存的操作。可参考下图(图来自参考[1])

实现缓存的按需加载

下面代码实现了按需加载的功能(代码来自参考[1])。

这里假设缓存的源头时数据库。如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存是需要获取写入锁。

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是不支持这种升级,在代码中,读取锁还没有释放,又尝试获取写入锁,将导致相关线程被阻塞(读取锁和写入锁只是读写锁对象的不同视图),永远没有机会被唤醒。

虽然锁的升级不被允许,但是锁的降级却是被允许的。(下例代码来自参考[1])

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();
}
}
}

小结

读写锁的读取锁和写入锁都实现了java.util.concurrent.locks.Lock接口,所以除了支持lock()方法外,tryLock()lockInterruptibly()等方法也都是支持的。但是需要注意,只有写入锁支持条件变量,读取是不支持条件变量的,读取锁调用newCondition()会泡池UnsupporteOperationException异常。

我们实现的简单缓存是没有解决缓存数据与源头数据同步的,即保持与源头数据的一致性。解决这个问题的一个简单方案是超时机制:当缓存的数据超过时效后,这条数据在缓存中就失效了;访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。也可以在源头数据发生变化时,快速反馈给缓存。

虽说读写锁在读多写少场景下性能优于互斥锁(独占锁),但是在其他情况下,性能可能要略差于互斥锁,因为读写锁的复杂性更高。所以,我们要根据场景来具体考虑使用哪一种同步方案。

参考:

[1]极客时间专栏王宝令《Java并发编程实战》

[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

【Java并发工具类】ReadWriteLock的更多相关文章

  1. Java并发工具类 - CountDownLatch

    Java并发工具类 - CountDownLatch 1.简介 CountDownLatch是Java1.5之后引入的Java并发工具类,放在java.util.concurrent包下面 http: ...

  2. 基于AQS实现的Java并发工具类

    本文主要介绍一下基于AQS实现的Java并发工具类的作用,然后简单谈一下该工具类的实现原理.其实都是AQS的相关知识,只不过在AQS上包装了一下而已.本文也是基于您在有AQS的相关知识基础上,进行讲解 ...

  3. 25.大白话说java并发工具类-CountDownLatch,CyclicBarrier,Semaphore,Exchanger

    1. 倒计时器CountDownLatch 在多线程协作完成业务功能时,有时候需要等待其他多个线程完成任务之后,主线程才能继续往下执行业务功能,在这种的业务场景下,通常可以使用Thread类的join ...

  4. Java并发工具类CountDownLatch源码中的例子

    Java并发工具类CountDownLatch源码中的例子 实例一 原文描述 /** * <p><b>Sample usage:</b> Here is a pai ...

  5. java 并发工具类CountDownLatch & CyclicBarrier

    一起在java1.5被引入的并发工具类还有CountDownLatch.CyclicBarrier.Semaphore.ConcurrentHashMap和BlockingQueue,它们都存在于ja ...

  6. JAVA并发工具类---------------(CountDownLatch和CyclicBarrier)

    CountDownLatch是什么 CountDownLatch,英文翻译为倒计时锁存器,是一个同步辅助类,在完成一组正在其他线程中执行的操作之前,它允许一个或多个线程一直等待. 闭锁可以延迟线程的进 ...

  7. 【Java并发工具类】Semaphore

    前言 1965年,荷兰计算机科学家Dijkstra提出的信号量机制成为一种高效的进程同步机制.这之后的15年,信号量一直都是并发编程领域的终结者.1980年,管程被提出,成为继信号量之后的在并发编程领 ...

  8. 【Java并发工具类】Java并发容器

    前言 Java并发包有很大一部分都是关于并发容器的.Java在5.0版本之前线程安全的容器称之为同步容器.同步容器实现线程安全的方式:是将每个公有方法都使用synchronized修饰,保证每次只有一 ...

  9. Java并发工具类(四):线程间交换数据的Exchanger

    简介 Exchanger(交换者)是一个用于线程间协作的工具类.Exchanger用于进行线程间的数据交换.它提供一个同步点,在这个同步点两个线程可以交换彼此的数据.这两个线程通过exchange方法 ...

随机推荐

  1. vue兄弟组件传值——事件总线

    1.创建一个js文件,例如msg.js,放到合适位置,例如components中,或者其他位置也行.然后在兄弟两个组件中分别引入msg.js文件 msg.js: import Vue from 'vu ...

  2. JS原型,原型链,类,继承,class,extends,由浅到深

    一.构造函数和原型 1.构造函数.静态成员和实例成员 在ES6之前,通常用一种称为构造函数的特殊函数来定义对象及其特征,然后用构造函数来创建对象.像其他面向对象的语言一样,将抽象后的属性和方法封装到对 ...

  3. AFN请求问题

    在使用AFNetworking 2.0  的时候本来一切很顺畅,但是中途遇到几个比较坑的地方 在发送请求后,NSURLSessionDataTask一直报错 Error Domain=com.alam ...

  4. Scala 学习(4)之「类——基本概念2」

    目录 内部类 extends override和super override field isInstanceOf和asInstanceOf getClass和classOf 内部类 import s ...

  5. [Other]THUWC2020 游记

    Dec. 20th 一下飞机,\(\text{FJ}\) 选手感觉 \(\text{BJ}\) 好冷 下午去了鸟巢,晚上回 \(\text{GLHT}\) 酒店吃泡面 写了洛谷上的线段树分治模板题之后 ...

  6. [bzoj3244] [洛谷P1232] [Noi2013] 树的计数

    Description 我们知道一棵有根树可以进行深度优先遍历(DFS)以及广度优先遍历(BFS)来生成这棵树的DFS序以及BFS序.两棵不同的树的DFS序有可能相同,并且它们的BFS序也有可能相同, ...

  7. ContractPattern 面向面向契约模式

  8. 对接口运用扩展方法 Applying Extension Methods to an Interface 精通ASP-NET-MVC-5-弗瑞曼 Listing 4-15

  9. allegro设置内存分配器的一个坑

    看过<游戏引擎架构>后我开始对内存的分配问题关注,一直想用内存分配器来管理游戏的内存.前两天发现了有许多第三方内存分配器可以用.最后挑中了nedmalloc,这个库也是ogre所使用的,测 ...

  10. beta版本发布说明与总结

    1.发布说明: 软件介绍: deta版本的发布最终是一个可安装使用的窗体程序,已经由Alpha版本的应用解决方案完成到一个程序: deta版本解决了Alpha版本遗留的软件技术方面错误问题,以及针对有 ...