死磕 Java 并发编程(7):读写锁 ReentrantReadWriteLock 源码解析

用户头像
七哥爱编程
关注
发布于: 2020 年 05 月 02 日
死磕Java并发编程(7):读写锁 ReentrantReadWriteLock 源码解析





这是《死磕Java并发编程》系列的第7篇文章 我们在一起来看看 读写锁 ReentrantReadWriteLock 的源码分析,基于Java8。



阅读建议:由于Java并发包中的锁都是基于AQS实现的,本篇的读写锁也不例外。如果你还不了解的话,阅读起来会比较吃力。建议先阅读上一篇文章关于 AbstractQueuedSynchronizer 的源码解析。

什么是读写锁?

提到锁,你可能会想到 synchronized 关键字 ReentrantLock等实现,这些都是排它锁。即同一时刻只能有一个线程进行访问,而读写锁在同一时刻,可以允许多个读线程访问,但是在写线程访问时,读线程和写线程都会被阻塞。 读写锁维护一对锁,一个读锁,一个写锁,通过读写锁分离使得并发性相比于排它锁有了很大的提升。

我们可以想到,读写锁存在的意义在于,一般情况下,读场景是远远大于写场景的。因此读大于写的场景下提供了比排它锁更高并发性和吞吐量。Java并发包中提供的读写锁实现就是 ReentrantReadWriteLock

下面列举了 ReentrantReadWriteLock 的主要特性,先有个大概的了解,后面会结合源码详细分析。

特性说明公平性选择支持公平和非公平(默认)两种锁获取方式,吞吐量非公平模式大重进入支持重进入,即读线程在获取到读锁后可以继续获取读锁,写线程在获取到写锁后可以继续获取到写锁,同时也可以获取读锁锁降级遵循先获取写锁,在获取读锁,在释放写锁的次序,实现写锁降级为读写的过程

读写锁接口与使用示例

ReadWriteLock 仅定义了获取读锁和写锁的两个方法,即 readLock() 方法 和 writeLock() 方法,而其实现——ReentrantReadWriteLock,除了接口方法之外,还提供了一些便于外界监控其内部工作状态的方法,列举如下:

方法名称说明getReadLockCount()所持有读锁的数量,非持有锁的线程数,因为一个线程可以多次获取读锁,这里返回的是获取读锁的总次数getReadHoldCount()当前线程持有读锁的次数,保存在ThradLocal中isWriteLocked()判断写锁是否被获取getWriteHoldCount()返回当前线程持有写锁的次数

使用示例

下面这个例子,非常形象的说明了读写锁的使用方式:

在读操作get(String key)方法中,需要获取读锁,这使得并发访问该方法时不会被阻塞。写操作put(String key,Object value)方法和clear()方法,在更新HashMap时必须提前获取写锁.

Cache使用读写锁提升读操作的并发性,也保证每次写操作对所有的读写操作的可见性,同时简化了编程方式。

ReentrantReadWriteLock 概览

大家仔细看看上图中的信息,读写锁分别对应了两个内部嵌套类的实例,并且自定义了Sync同步器继承了 AQS, ReadLock 和 WriteLock 共同持有一个Sync实例。

下面我们再来看看 ReadLock 和 WriteLock的具体代码,就能更加清晰的理解:

很清楚了,ReadLock 和 WriteLock 中的方法都是通过 Sync 这个类来实现的。Sync 是 AQS 的子类,然后再派生了公平模式和不公平模式。

从它们调用的 Sync 方法,我们可以看到: ReadLock 使用了共享模式WriteLock 使用了独占模式

这里问题来了,同一个Sync实例,只有一个state同步状态,如何做到可以同时使用共享模式和独占模式 ???

如果你们上面这个问题无法理解,那么可能你对AQS并不熟悉,这里我简要的列举AQS的共享模式和独占模式过程,你可以横向对比了解:

AQS 实现锁的精髓 就在于维护的内部属性 state

  1. 对于独占式获取同步状态,通过 0 代表可以获取锁, 1 代表已经被别人抢了,不可获取,当前重入是可以的;

  2. 共享式获取同步状态,每个线程都可以对 state 进行加减操作,所以和独占式区别在于要保证线程安全的操作同步状态,一般通过循环和 CAS 来保证。

也就是说,独占模式和共享模式对于 state 的操作完全不一样,那读写锁 ReentrantReadWriteLock 中是怎么使用 state 的呢?别着急,继续往下看,这块设计相当之巧妙。

读写锁源码分析

源码分析这块,主要包括 读写状态 state 设计写锁的获取和释放读锁的获取和释放以及锁降级

1. 读写状态设计

上面说到,读写锁的自定义同步器需要在同步状态(一个整型变量)上维护多个读线程和一个写线程的状态,答案就是“按位切割使用”。 读写锁将 32位的 state 分为高16位 和 低16位,分别表示 读和写。

那么读写锁是如何快速确定当前读和写的状态呢? 答案是通过位运算。假设当前同步状态值为 S,写状态等于 S&0x0000FFFF(将高16位全部抹去),读状态等于S>>>16(无符号补0右移16位)。当写状态增加1时,等于 S+1当读状态增加1时,等于 S+(1<<16),也就是 S+0x00010000。

根据状态的划分能得出一个推论:S不等于0时,当写状态(S&0x0000FFFF)等于0时,则读状态(S>>>16)大于0,即读锁已被获取。

这个结论很重要,下面代码会有体现。

有了上面这个基础,我们下面就不啰嗦了,直接进入正题,看看源码时如何实现的,代码不多,相信你如果理解上面说的,一行行代码往下看就是了。

2. 写锁的获取与释放

  • 写锁是独占锁。

  • 如果有读锁被占用,写锁获取是要进入到阻塞队列中等待的。

写锁获取

我们先来看下 ReentrantReadWriteLock 读写锁中的自定义同步器 Sync 实现的 写锁获取方法。

下面看一眼 writerShouldBlock() 的判定,结合代码注释一目了然

上面的代码你应该已经看懂了,这里在解释下为什么读锁已被获取则不能获取写锁的原因?

主要还是结合设计初衷以及使用的场景,读写锁要保证写锁的操作对于读锁可见,如果读锁已被获取,依然被其他线程获取写锁,那么已经获取读锁的线程就无法感知到获取写锁线程的操作。

写锁释放

接下里,我们看看写锁的释放:

3. 读锁的获取与释放

  • 读锁是共享锁;

  • 读锁可以被多线程同时获取,当写状态为0(写锁未被获取),读锁总是会被成功的访问;

读锁的获取源码还是比较复杂的,从 Java 5 到 Java 6 变得复杂许多,主要原因是新增了一些功能,例如 getReadHoldCount() 方法,作用是返回当前线程获取读锁的次数。读状态是所有线程获取读锁次数的总和,而每个线程各自获取读锁的次数只能选择保存在 ThreadLocal 中,由线程自身维护,这使获取读锁的实现变得复杂。

所以也特地放到了后面,毕竟写锁获取比较简单,可以很大的提升读者的自信,接下来,我们就一起来啃这个读锁的实现。

读锁获取

下面展示了读锁 ReadLock 的lock流程:

上述代码,主要是还是 读懂 tryAcquireShared(arg) 方法:

在 AQS 中,如果 tryAcquireShared(arg) 方法返回值小于 0 代表没有获取到共享锁(读锁),大于 0 代表获取到。

上面的代码中,要进入 if 分支(即获取到读锁),需要满足:readerShouldBlock() 返回 false,并且 CAS 要成功(我们先不要纠结 MAX_COUNT 溢出)。

那么根据上面的流程,我们思考下,如何才能进入到 fullTryAcquireShared(current) 方法呢?

  • readerShouldBlock() 返回 true,2 种情况:

在 FairSync 中说的是 hasQueuedPredecessors(),即阻塞队列中有其他元素在等待锁。也就是说,公平模式下,有人在排队呢,你新来的不能直接获取锁;

在 NonFairSync 中说的是 apparentlyFirstQueuedIsExclusive(),即判断阻塞队列中 head 的第一个后继节点是否是来获取写锁的,如果是的话,让这个写锁先来,避免写锁饥饿。作者给写锁定义了更高的优先级,所以如果碰上获取写锁的线程马上就要获取到锁了,获取读锁的线程不应该和它抢。如果 head.next 不是来获取写锁的,那么可以随便抢,因为是非公平模式,大家比比 CAS 速度;

  • compareAndSetState(c, c + SHARED_UNIT) 这里 CAS 失败,存在竞争。可能是和另一个读锁获取竞争,当然也可能是和另一个写锁获取操作竞争。

然后就会来到 fullTryAcquireShared 中再次尝试:

上面的源码分析应该说得非常详细了,如果到这里你不太能看懂上面的有些地方的注释,那么这里我为你总结下,去除 firstReader 、cachedHoldCounter 这些用于缓存第一个获取读锁的线程和最后一个获取读锁的线程,它们本质上是用于 提高性能的,基于的原理大概是这样的:通常读锁的获取很快就会伴随着释放,显然,在 获取->释放 读锁这段时间,如果没有其他线程获取读锁的话,此缓存就能帮助提高性能,因为这样就不用到 ThreadLocal 中查询 map 了。

总结下核心流程:

读锁释放

下面我们看看读锁释放的流程:

读锁释放的过程还是比较简单的,主要就是将 当前线程持有的读锁数量 count 减 1,如果减到 0 的话,还要将其对应的 HoldCounter 从 ThreadLocal 中 remove 掉。

然后是在 for 循环中将 state 的高 16 位减 1,如果发现读锁和写锁都释放光了,那么唤醒后继的获取写锁的线程。

锁降级

锁降级指的是写锁降级成为读锁。如果当前线程拥有写锁,然后将其释放,最后再获取读锁,这种分段完成的过程不能称之为锁降级。锁降级是指把持住(当前拥有的)写锁,再获取到读锁,随后释放(先前拥有的)写锁的过程。

接下来看一个锁降级的示例:

上述示例中,当数据发生变更后,update变量(布尔类型且volatile修饰)被设置为false,此时所有访问 processData() 方法的线程都能够感知到变化,但只有一个线程能够获取到写锁,其他线程会被阻塞在读锁和写锁的lock()方法上。当前线程获取写锁完成数据准备之后,再获取读锁,随后释放写锁,完成锁降级。

锁降级中的读锁的获取是否是必须?答案是必须的,为了保证数据可见性,因为当前线程如果不获取读锁直接释放了写锁,那么此时如果另外一个线程获取了写作并且修改了数据,那么当前线程是无法感知到获取写锁线程所做的变化的。

总结

  1. 读写锁内部定义了一把读锁和一把写锁,可以同时持有写锁、读锁,反之则不行;

  2. 当读锁被持有时,获取写锁必然失败,进入到阻塞队列,可以查看写锁获取的源码 tryAcquire(int acquires) 加深理解;

  3. 获取读锁时,如果写锁已被获取但是和获取写锁的线程是当前线程,那么依然可以获取到读锁,这里也正好理解锁降级的步骤;

  4. 读写锁的源码解析,读锁的获取理解起来有难度,主要是因为 jdk1.6 引入了获取当前线程锁次数等功能,而每个线程的读状态只能保存在 ThradLocal 中,由线程自身维护,同时考虑到大部分情况下 获取锁冲突的几率较小引入了 firstReader、cachedHoldCounter 等缓存第一个获取读锁和最后一个获取读锁的线程和重入次数。查看源码时,我的注释应该写的很细了,本着这个思维去查看应该是能看懂的。



(全文完)fighting!



参考资料:



  1. 周志明:《深入理解 Java 虚拟机》

  2. 方腾飞:《Java 并发编程的艺术》





笔者水平有限,文章难免会有纰漏,如有错误欢迎扫码 加好友 一起交流探讨,我会第一时间更正的。都看到这里了,码字不易,可爱的你记得 "点赞" 哦,我需要你的正向反馈。



发布于: 2020 年 05 月 02 日 阅读数: 89
用户头像

七哥爱编程

关注

专注于Java技术栈,热爱编程的你值得拥有 2018.03.21 加入

半路出家学习编程,脚踏实地,目前就职于某世界500强。现阶段坚持写作,分享知识,形成自己的体系。 从计划到坚持,再到形成自己的节奏。fighting

评论

发布
暂无评论
死磕Java并发编程(7):读写锁 ReentrantReadWriteLock 源码解析