| 好看请赞,养成习惯

  • 你有一个思想,我有一个思想,我们交换后,一个人就有两个思想

  • If you can NOT explain it simply, you do NOT understand it well enough

现陆续将Demo代码和技术文章整理在一起 Github实践精选 ,方便大家阅读查看,本文同样收录在此,觉得不错,还请Star

前言

有了以上两篇文章的铺垫,来理解本文要介绍的既有独占式,又有共享式获取同步状态的 ReadWriteLock,就非常轻松了

ReadWriteLock

ReadWriteLock 直译过来为【读写锁】。现实中,读多写少的业务场景是非常普遍的,比如应用缓存

一个线程将数据写入缓存,其他线程可以直接读取缓存中的数据,提高数据查询效率

之前提到的互斥锁都是排他锁,也就是说同一时刻只允许一个线程进行访问,当面对可共享读的业务场景,互斥锁显然是比较低效的一种处理方式。为了提高效率,读写锁模型就诞生了

效率提升是一方面,但并发编程更重要的是在保证准确性的前提下提高效率

一个写线程改变了缓存中的值,其他读线程一定是可以 “感知” 到的,否则可能导致查询到的值不准确

所以关于读写锁模型就了下面这 3 条规定:

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

ReadWriteLock 是一个接口,其内部只有两个方法:

public interface ReadWriteLock {
// 返回用于读的锁
Lock readLock(); // 返回用于写的锁
Lock writeLock();
}

所以要了解整个读/写锁的整个应用过程,需要从它的实现类 ReentrantReadWriteLock 说起

ReentrantReadWriteLock 类结构

直接对比ReentrantReadWriteLock 与 ReentrantLock的类结构

他们又很相似吧,根据类名称以及类结构,按照咱们前序文章的分析,你也就能看出 ReentrantReadWriteLock 的基本特性:

其中黄颜色标记的的 锁降级 是看不出来的, 这里先有个印象,下面会单独说明

另外,不知道你是否还记得,Java AQS队列同步器以及ReentrantLock的应用 说过,Lock 和 AQS 同步器是一种组合形式的存在,既然这里是读/写两种锁,他们的组合模式也就分成了两种:

  1. 读锁与自定义同步器的聚合
  2. 写锁与自定义同步器的聚合
    public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}

这里只是提醒大家,模式没有变,不要被读/写两种锁迷惑

基本示例

说了这么多,如果你忘了前序知识,整体理解感觉应该是有断档的,所以先来看个示例(模拟使用缓存)让大家对 ReentrantReadWriteLock 有个直观的使用印象

public class ReentrantReadWriteLockCache {

	// 定义一个非线程安全的 HashMap 用于缓存对象
static Map<String, Object> map = new HashMap<String, Object>();
// 创建读写锁对象
static ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 构建读锁
static Lock rl = readWriteLock.readLock();
// 构建写锁
static Lock wl = readWriteLock.writeLock(); public static final Object get(String key) {
rl.lock();
try{
return map.get(key);
}finally {
rl.unlock();
}
} public static final Object put(String key, Object value){
wl.lock();
try{
return map.put(key, value);
}finally {
wl.unlock();
}
}
}

你瞧,使用就是这么简单。但是你知道的,AQS 的核心是锁的实现,即控制同步状态 state 的值,ReentrantReadWriteLock 也是应用AQS的 state 来控制同步状态的,那么问题来了:

一个 int 类型的 state 怎么既控制读的同步状态,又可以控制写的同步状态呢?

显然需要一点设计了

读写状态设计

如果要在一个 int 类型变量上维护多个状态,那肯定就需要拆分了。我们知道 int 类型数据占32位,所以我们就有机会按位切割使用state了。我们将其切割成两部分:

  1. 高16位表示读
  2. 低16位表示写

所以,要想准确的计算读/写各自的状态值,肯定就要应用位运算了,下面代码是 JDK1.8,ReentrantReadWriteLock 自定义同步器 Sync 的位操作

abstract static class Sync extends AbstractQueuedSynchronizer {

        static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; static int sharedCount(int c) {
return c >>> SHARED_SHIFT;
} static int exclusiveCount(int c) {
return c & EXCLUSIVE_MASK;
}
}

乍一看真是有些复杂的可怕,别慌,咱们通过几道小小数学题就可以搞定整个位运算过程

整个 ReentrantReadWriteLock 中 读/写状态的计算就是反复应用这几道数学题,所以,在阅读下面内容之前,希望你搞懂这简单的运算

基础铺垫足够了,我们进入源码分析吧

源码分析

写锁分析

由于写锁是排他的,所以肯定是要重写 AQS 中 tryAcquire 方法

        protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
// 获取 state 整体的值
int c = getState();
// 获取写状态的值
int w = exclusiveCount(c);
if (c != 0) {
// w=0: 根据推理二,整体状态不等于零,写状态等于零,所以,读状态大于0,即存在读锁
// 或者当前线程不是已获取写锁的线程
// 二者之一条件成真,则获取写状态失败
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
// 根据推理一第 1 条,更新写状态值
setState(c + acquires);
return true;
}
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}

上述代码 第 19 行 writerShouldBlock 也并没有什么神秘的,只不过是公平/非公平获取锁方式的判断(是否有前驱节点来判断)

你瞧,写锁获取方式就是这么简单

读锁分析

由于读锁是共享式的,所以肯定是要重写 AQS 中 tryAcquireShared 方法

        protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 写状态不等于0,并且锁的持有者不是当前线程,根据约定 3,则获取读锁失败
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// 获取读状态值
int r = sharedCount(c);
// 这个地方有点不一样,我们单独说明
if (!readerShouldBlock() &&
r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
if (r == 0) {
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
firstReaderHoldCount++;
} else {
HoldCounter rh = cachedHoldCounter;
if (rh == null || rh.tid != getThreadId(current))
cachedHoldCounter = rh = readHolds.get();
else if (rh.count == 0)
readHolds.set(rh);
rh.count++;
}
return 1;
}
// 如果获取读锁失败则进入自旋获取
return fullTryAcquireShared(current);
}

readerShouldBlockwriterShouldBlock 在公平锁的实现上都是判断是否有前驱节点,但是在非公平锁的实现上,前者是这样的:

final boolean readerShouldBlock() {
return apparentlyFirstQueuedIsExclusive();
} final boolean apparentlyFirstQueuedIsExclusive() {
Node h, s;
return (h = head) != null &&
// 等待队列头节点的下一个节点
(s = h.next) != null &&
// 如果是排他式的节点
!s.isShared() &&
s.thread != null;
}

简单来说,如果请求读锁的当前线程发现同步队列的 head 节点的下一个节点为排他式节点,那么就说明有一个线程在等待获取写锁(争抢写锁失败,被放入到同步队列中),那么请求读锁的线程就要阻塞,毕竟读多写少,如果还没有这点判断机制,写锁可能会发生【饥饿】

上述条件都满足了,也就会进入 tryAcquireShared 代码的第 14 行到第 25 行,这段代码主要是为了记录线程持有锁的次数。读锁是共享式的,还想记录每个线程持有读锁的次数,就要用到 ThreadLocal 了,因为这不影响同步状态 state 的值,所以就不分析了, 只把关系放在这吧

到这里读锁的获取也就结束了,比写锁稍稍复杂那么一丢丢,接下来就说明一下那个可能让你迷惑的锁升级/降级问题吧

读写锁的升级与降级

个人理解:读锁是可以被多线程共享的,写锁是单线程独占的,也就是说写锁的并发限制比读锁高,所以

在真正了解读写锁的升级与降级之前,我们需要完善一下本文开头 ReentrantReadWriteLock 的例子

	public static final Object get(String key) {
Object obj = null;
rl.lock();
try{
// 获取缓存中的值
obj = map.get(key);
}finally {
rl.unlock();
}
// 缓存中值不为空,直接返回
if (obj!= null) {
return obj;
} // 缓存中值为空,则通过写锁查询DB,并将其写入到缓存中
wl.lock();
try{
// 再次尝试获取缓存中的值
obj = map.get(key);
// 再次获取缓存中值还是为空
if (obj == null) {
// 查询DB
obj = getDataFromDB(key); // 伪代码:getDataFromDB
// 将其放入到缓存中
map.put(key, obj);
}
}finally {
wl.unlock();
}
return obj;
}

有童鞋可能会有疑问

在写锁里面,为什么代码第19行还要再次获取缓存中的值呢?不是多此一举吗?

其实这里再次尝试获取缓存中的值是很有必要的,因为可能存在多个线程同时执行 get 方法,并且参数 key 也是相同的,执行到代码第 16 行 wl.lock() ,比如这样:

线程 A,B,C 同时执行到临界区 wl.lock(), 只有线程 A 获取写锁成功,线程B,C只能阻塞,直到线程A 释放写锁。这时,当线程B 或者 C 再次进入临界区时,线程 A 已经将值更新到缓存中了,所以线程B,C没必要再查询一次DB,而是再次尝试查询缓存中的值

既然再次获取缓存很有必要,我能否在读锁里直接判断,如果缓存中没有值,那就再次获取写锁来查询DB不就可以了嘛,就像这样:

	public static final Object getLockUpgrade(String key) {
Object obj = null;
rl.lock();
try{
obj = map.get(key);
if (obj == null){
wl.lock();
try{
obj = map.get(key);
if (obj == null) {
obj = getDataFromDB(key); // 伪代码:getDataFromDB
map.put(key, obj);
}
}finally {
wl.unlock();
}
}
}finally {
rl.unlock();
} return obj;
}

这还真是不可以的,因为获取一个写入锁需要先释放所有的读取锁,如果有两个读取锁试图获取写入锁,且都不释放读取锁时,就会发生死锁,所以在这里,锁的升级是不被允许的

读写锁的升级是不可以的,那么锁的降级是可以的嘛?这个是 Oracle 官网关于锁降级的示例 ,我将代码粘贴在此处,大家有兴趣可以点进去连接看更多内容

 class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock(); void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// 必须在获取写锁之前释放读锁,因为锁的升级是不被允许的
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// 再次检查,原因可能是其他线程已经更新过缓存
if (!cacheValid) {
data = ...
cacheValid = true;
}
//在释放写锁前,降级为读锁
rwl.readLock().lock();
} finally {
//释放写锁,此时持有读锁
rwl.writeLock().unlock();
}
} try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}

代码中声明了一个 volatile 类型的 cacheValid 变量,保证其可见性。

  1. 首先获取读锁,如果cache不可用,则释放读锁
  2. 然后获取写锁
  3. 在更改数据之前,再检查一次cacheValid的值,然后修改数据,将cacheValid置为true
  4. 然后在释放写锁前获取读锁 此时
  5. cache中数据可用,处理cache中数据,最后释放读锁

这个过程就是一个完整的锁降级的过程,目的是保证数据可见性,听起来很有道理的样子,那么问题来了:

上述代码为什么在释放写锁之前要获取读锁呢?

如果当前的线程A在修改完cache中的数据后,没有获取读锁而是直接释放了写锁;假设此时另一个线程B 获取了写锁并修改了数据,那么线程A无法感知到数据已被修改,但线程A还应用了缓存数据,所以就可能出现数据错误

如果遵循锁降级的步骤,线程A 在释放写锁之前获取读锁,那么线程B在获取写锁时将被阻塞,直到线程A完成数据处理过程,释放读锁,从而保证数据的可见性


那问题又来了:

使用写锁一定要降级吗?

如果你理解了上面的问题,相信这个问题已经有了答案。假如线程A修改完数据之后, 经过耗时操作后想要再使用数据时,希望使用的是自己修改后的数据,而不是其他线程修改后的数据,这样的话确实是需要锁降级;如果只是希望最后使用数据的时候,拿到的是最新的数据,而不一定是自己刚修改过的数据,那么先释放写锁,再获取读锁,然后使用数据也无妨

在这里我要额外说明一下你可能存在的误解:

  • 如果已经释放了读锁再获取写锁不叫锁的升级

  • 如果已经释放了写锁在获取读锁也不叫锁的降级

相信你到这里也理解了锁的升级与降级过程,以及他们被允许或被禁止的原因了

总结

本文主要说明了 ReentrantReadWriteLock 是如何应用 state 做位拆分实现读/写两种同步状态的,另外也通过源码分析了读/写锁获取同步状态的过程,最后又了解了读写锁的升级/降级机制,相信到这里你对读写锁已经有了一定的理解。如果你对文中的哪些地方觉得理解有些困难,强烈建议你回看本文开头的两篇文章,那里铺垫了非常多的内容。接下来我们就看看在应用AQS的最后一个并发工具类 CountDownLatch 吧

灵魂追问

  1. 读锁也没修改数据,还允许共享式获取,那还有必要设置读锁吗?
  2. 在分布式环境中,你是如何保证缓存数据一致性的呢?
  3. 当你打开看ReentrantReadWriteLock源码时,你会发现,WriteLock 中可以使用 Condition,但是ReadLock 使用Condition却会抛出UnsupportedOperationException,这是为什么呢?
// WriteLock
public Condition newCondition() {
return sync.newCondition();
} // ReadLock
public Condition newCondition() {
throw new UnsupportedOperationException();
}

个人博客:https://dayarch.top

加我微信好友, 进群娱乐学习交流,备注「进群」

欢迎持续关注公众号:「日拱一兵」

  • 前沿 Java 技术干货分享
  • 高效工具汇总 | 回复「工具」
  • 面试问题分析与解答
  • 技术资料领取 | 回复「资料」

以读侦探小说思维轻松趣味学习 Java 技术栈相关知识,本着将复杂问题简单化,抽象问题具体化和图形化原则逐步分解技术问题,技术持续更新,请持续关注......


搞定ReentrantReadWriteLock 几道小小数学题就够了的更多相关文章

  1. 面试BAT前先搞定这18道MySQL经典面试题(含答案解析)

    一.MySQL的复制原理以及流程 (1)复制基本原理流程 1. 主:binlog线程——记录下所有改变了数据库数据的语句,放进master上的binlog中: 2. 从:io线程——在使用start ...

  2. 百道Python面试题实现,搞定Python编程就靠它

    对于一般的机器学习求职者而言,最基础的就是掌握 Python 编程技巧,随后才是相关算法或知识点的掌握.在这篇文章中,我们将介绍一个 Python 练习题项目,它从算法练习题到机试实战题提供了众多问题 ...

  3. APP设计师拿到APP产品原型开始,七步搞定APP设计(转)

    任何一款成功的APP都需要以坚实的产品概念作为基础,因为概念决定了产品最终完成的潜力. 一般情况下,交到app设计师手里的都是移动app产品原型图.当然这个是在移动产品经理反复斟酌,并且与大家开会讨论 ...

  4. 彻底搞定 C/C++ 指针

    1.语言中变量的实质 要理解C指针,我认为一定要理解C中“变量”的存储实质, 所以我就从“变量”这个东西开始讲起吧! 先来理解理解内存空间吧!请看下图: 内存地址→ 6 7 8 9 10 11 12 ...

  5. 彻底搞定C语言指针(精华版)

    1.语言中变量的实质 要理解C指针,我认为一定要理解C中“变量”的存储实质, 所以我就从“变量”这个东西开始讲起吧! 先来理解理解内存空间吧!请看下图: 内存地址→ 6 7 8 9 10 11 12 ...

  6. 轻松几句搞定【Javascript中的this指向】问题

    this关键字在JavaScript中扮演了至关重要的角色,每次它的出现都伴随着它的指向问题,这也是很多初学者容易出错的地方. 不过,这篇文章将会带你一次性搞定this指向的问题,望能给大家提供帮助! ...

  7. 告别set和get,两大利器轻松搞定model转换

    场景一:一般我们遇到需要新建model,常规做法就是创建一个类,老老实实的定义好model中的所有属性,一般来说属性对应的set方法和get方法都是少不了的,有时候还需要toString甚至equal ...

  8. oracle 字段自增 两段代码搞定

    (这几天做了个小小课程设计时用的是oracle数据库,第一次用,发现oracle和我们以前用的sql server .mysql是有如此多不同的地方,下面是遇到的问题之一和解决方法,和大家分享下) 用 ...

  9. 【数据结构】 最小生成树(四)——利用kruskal算法搞定例题×3+变形+一道大水题

    在这一专辑(最小生成树)中的上一期讲到了prim算法,但是prim算法比较难懂,为了避免看不懂,就先用kruskal算法写题吧,下面将会将三道例题,加一道变形,以及一道大水题,水到不用高级数据结构,建 ...

随机推荐

  1. Spring boot Sample 003之spring-boot-configuration-properties

    一.环境 1.1.Idea 2020.1 1.2.JDK 1.8 二.目的 通过properties文件配置spring boot 属性文件 三.步骤 3.1.点击File -> New Pro ...

  2. Java Word中的文本、图片替换功能

    Word中的替换功能以查找指定文本然后替换为新的文本,可单个替换或全部替换.以下将要介绍的内容,除常见的以文本替换文本外,还将介绍使用不同对象进行替换的方法,具体可包括: 1. 指定字符串内容替换文本 ...

  3. windows FTP工具,Windows FTP工具下载!

    IIS7服务器管理工具能够作为FTP的客户端,进行FTP的命令操作,可在客户端,下载,安装FTP软件! 同时,它也可以作为VNC的客户端,进行VNC的命令操作!它能够批量连接Windows和Linux ...

  4. Java实现 LeetCode 779 第K个语法符号(递归)

    779. 第K个语法符号 在第一行我们写上一个 0.接下来的每一行,将前一行中的0替换为01,1替换为10. 给定行数 N 和序数 K,返回第 N 行中第 K个字符.(K从1开始) 例子: 输入: N ...

  5. Java实现 LeetCode 546 移除盒子(递归,vivo秋招)

    546. 移除盒子 给出一些不同颜色的盒子,盒子的颜色由数字表示,即不同的数字表示不同的颜色. 你将经过若干轮操作去去掉盒子,直到所有的盒子都去掉为止.每一轮你可以移除具有相同颜色的连续 k 个盒子( ...

  6. Java实现 LeetCode 413 等差数列划分

    413. 等差数列划分 如果一个数列至少有三个元素,并且任意两个相邻元素之差相同,则称该数列为等差数列. 例如,以下数列为等差数列: 1, 3, 5, 7, 9 7, 7, 7, 7 3, -1, - ...

  7. Java实现 LeetCode 380 常数时间插入、删除和获取随机元素

    380. 常数时间插入.删除和获取随机元素 设计一个支持在平均 时间复杂度 O(1) 下,执行以下操作的数据结构. insert(val):当元素 val 不存在时,向集合中插入该项. remove( ...

  8. Java实现 LeetCode 152 乘积最大子序列

    152. 乘积最大子序列 给定一个整数数组 nums ,找出一个序列中乘积最大的连续子序列(该序列至少包含一个数). 示例 1: 输入: [2,3,-2,4] 输出: 6 解释: 子数组 [2,3] ...

  9. Java实现 洛谷 P1598 垂直柱状图

    题目描述 写一个程序从输入文件中去读取四行大写字母(全都是大写的,每行不超过100个字符),然后用柱状图输出每个字符在输入文件中出现的次数.严格地按照输出样例来安排你的输出格式. 输入格式 四行字符, ...

  10. java实现逻辑推断

    A.B.C.D.E.F.G.H.I.J 共10名学生有可能参加本次计算机竞赛,也可能不参加.因为某种原因,他们是否参赛受到下列条件的约束: 如果A参加,B也参加: 如果C不参加,D也不参加: A和C中 ...