1. 前言

在前面的文章 并发编程之——写锁源码分析中,我们分析了 1.8 JUC 中读写锁中的写锁的获取和释放过程,今天来分析一下读锁的获取和释放过程,读锁相比较写锁要稍微复杂一点,其中还有一点有争议的地方——锁降级。

今天就来解开迷雾。

2. 获取读锁 tryAcquireShared 方法

首先说明,获取读锁的过程是获取共享锁的过程。

代码加注释如下:

protected final int11 tryAcquireShared(int unused) {Thread current = Thread.currentThread();int c = getState();// exclusiveCount(c) != 0 ---》 用 state & 65535 得到低 16 位的值。如果不是0,说明写锁别持有了。// getExclusiveOwnerThread() != current----> 不是当前线程// 如果写锁被霸占了,且持有线程不是当前线程,返回 false,加入队列。获取写锁失败。// 反之,如果持有写锁的是当前线程,就可以继续获取读锁了。if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)// 获取锁失败return -1;// 如果写锁没有被霸占,则将高16位移到低16位。int r = sharedCount(c);// c >>> 16// !readerShouldBlock() 和写锁的逻辑一样(根据公平与否策略和队列是否含有等待节点)// 不能大于 65535,且 CAS 修改成功if (!readerShouldBlock() && r < 65535 && compareAndSetState(c, c + 65536)) {// 如果读锁是空闲的, 获取锁成功。if (r == 0) {// 将当前线程设置为第一个读锁线程firstReader = current;// 计数器为1firstReaderHoldCount = 1;}// 如果读锁不是空闲的,且第一个读线程是当前线程。获取锁成功。else if (firstReader == current) {// // 将计数器加一firstReaderHoldCount++;} else {// 如果不是第一个线程,获取锁成功。// cachedHoldCounter 代表的是最后一个获取读锁的线程的计数器。HoldCounter rh = cachedHoldCounter;// 如果最后一个线程计数器是 null 或者不是当前线程,那么就新建一个 HoldCounter 对象if (rh == null || rh.tid != getThreadId(current))// 给当前线程新建一个 HoldCountercachedHoldCounter = rh = readHolds.get();// 如果不是 null,且 count 是 0,就将上个线程的 HoldCounter 覆盖本地的。else if (rh.count == 0)readHolds.set(rh);// 对 count 加一rh.count++;}return 1;}// 死循环获取读锁。包含锁降级策略。return fullTryAcquireShared(current);
}
复制代码

总结一下上面代码的逻辑吧!

  1. 判断写锁是否空闲。
  2. 如果不是空闲,且当前线程不是持有写锁的线程,则返回 -1 ,表示抢锁失败。如果是空闲的,进入第三步。如果是当前线程,进入第三步。
  3. 判断持有读锁的数量是否超过 65535,然后使用 CAS 设置 int 高 16 位的值,也就是加一。
  4. 如果设置成功,且是第一次获取读锁,就设置 firstReader 相关的属性(为了性能提升)。
  5. 如果不是第一次,当当前线程就是第一次获取读锁的线程,对 “第一次获取读锁线程计数器” 加 1.
  6. 如果都不是,则获取最后一个读锁的线程计数器,判断这个计数器是不是当前线程的。如果是,加一,如果不是,自己创建一个新计数器,并更新 “最后读取的线程计数器”(也是为了性能考虑)。最后加一。返回成功。
  7. 如果上面的判断失败了(CAS 设置失败,或者队列有等待的线程(公平情况下))。就调用 fullTryAcquireShared 方法死循环执行上面的步骤。

步骤还是有点多哈,画个图吧,更清晰一点。

其实,上面的逻辑里,是有锁降级的逻辑在里面的。但我们等会放在后面说。

先看看 fullTryAcquireShared 方法,其实这个方法和 tryAcquireShared 高度类似。代码加注释如下:

final int fullTryAcquireShared(Thread current) {/** 这段代码与tryAcquireShared中的代码有部分重复,但整体更简单。*/HoldCounter rh = null;// 死循环for (;;) {int c = getState();// 如果存在写锁if (exclusiveCount(c) != 0) {// 并且不是当前线程,获取锁失败,反之,如果持有写锁的是当前线程,那么就会进入下面的逻辑。// 反之,如果存在写锁,但持有写锁的是当前线程。那么就继续尝试获取读锁。if (getExclusiveOwnerThread() != current)return -1;// 如果写锁空闲,且可以获取读锁。} else if (readerShouldBlock()) {// 第一个读线程是当前线程if (firstReader == current) {// 如果不是当前线程} else {if (rh == null) {rh = cachedHoldCounter;if (rh == null || rh.tid != getThreadId(current)) {// 从 ThreadLocal 中取出计数器rh = readHolds.get();if (rh.count == 0)readHolds.remove();}}if (rh.count == 0)return -1;}}// 如果读锁次数达到 65535 ,抛出异常if (sharedCount(c) == MAX_COUNT)throw new Error("Maximum lock count exceeded");// 尝试对 state 加 65536, 也就是设置读锁,实际就是对高16位加一。if (compareAndSetState(c, c + SHARED_UNIT)) {// 如果读锁是空闲的if (sharedCount(c) == 0) {// 设置第一个读锁firstReader = current;// 计数器为 1firstReaderHoldCount = 1;// 如果不是空闲的,查看第一个线程是否是当前线程。} else if (firstReader == current) {firstReaderHoldCount++;// 更新计数器} else {// 如果不是当前线程if (rh == null)rh = cachedHoldCounter;if (rh == null || rh.tid != getThreadId(current))// 如果最后一个读计数器所属线程不是当前线程。// 自己创建一个。rh = readHolds.get();else if (rh.count == 0)readHolds.set(rh);// 对计数器 ++rh.count++;// 更新缓存计数器。cachedHoldCounter = rh; // cache for release}return 1;}}
}
复制代码

这两个方法其实高度相似的。就不再解释了。

到这里,其实留下了几个问题:一个是 firstReaderfirstReaderHoldCount 的作用,还有就是 cachedHoldCounter 的作用。最后是锁降级。

解释一下:

  • firstReader 是获取读锁的第一个线程。如果只有一个线程获取读锁,很明显,使用这样一个变量速度更快。  * firstReaderHoldCountfirstReader的计数器。同上。
  • cachedHoldCounter是最后一个获取到读锁的线程计数器,每当有新的线程获取到读锁,这个变量都会更新。这个变量的目的是:当最后一个获取读锁的线程重复获取读锁,或者释放读锁,就会直接使用这个变量,速度更快,相当于缓存。

关于锁降级,重点解释一下,毕竟是我们的标题。

3. 锁降级的争议

首先,什么是锁降级?在读锁的哪个地方体现?

回答第一个问题,引自 JDK 的解释:

锁降级: 重入还允许从写入锁降级为读取锁,其实现方式是:先获取写入锁,然后获取读取锁,最后释放写入锁。但是,从读取锁升级到写入锁是不可能的。

体现在读锁哪里?

在 tryAcquireShared 方法和 fullTryAcquireShared 中都有体现,例如下面的判断:

if (exclusiveCount(c) != 0) {if (getExclusiveOwnerThread() != current)return -1;
复制代码

上面的代码的意思是:当写锁被持有时,如果持有该锁的线程不是当前线程,就返回 “获取锁失败”,反之就会继续获取读锁。称之为锁降级。

在很多书和文章中,对锁降级都会有类似下面的解释:

上面提到,锁降级中,读锁的获取的目的是 “为了保证数据的可见性”。而得到这个结论的依据是 “如果当前线程不获取读锁而是直接释放写锁,假设此刻另一个线程(记作线程 T)获取了写锁并修改了数据,那么当前线程无法感知线程 T 的数据更新”。

这里貌似有个漏洞:如果另一个线程获取了写锁(并修改了数据),那么这个锁就被独占了,没有任何其他线程可以读到数据,更不用谈 “感知数据更新”。

楼主认为,锁降级说白了就是写锁的一种特殊重入机制。通过这种重入,可以减少一步流程——释放写锁后 再次 获取读锁。

使用了锁降级,就可以减去释放写锁的步骤。直接获取读锁。效率更高。而且没有线程争用。和 “可见性” 并没有关系。

用一幅图来展示锁降级:

总的来说,锁降级就是一种特殊的锁重入机制,JDK 使用 先获取写入锁,然后获取读取锁,最后释放写入锁 这个步骤,是为了提高获取锁的效率,而不是所谓的可见性。

最后再总结一下获取锁的逻辑,首先判断写锁释放被持有了,如果被持有了,且是当前线程,使用锁降级,如果没有,读锁正常获取。

获取过程中,会使用 firstReader 和 cachedHoldCounter 提高性能。

4. 读锁的释放 tryReleaseShared 方法

代码加注释如下:


protected final boolean tryReleaseShared(int unused) {Thread current = Thread.currentThread();// 如果是第一个线程if (firstReader == current) {// 如果是 1,将第一个线程设置成 null。结束。if (firstReaderHoldCount == 1)firstReader = null;// 如果不是 1,减一操作elsefirstReaderHoldCount--;} else {//如果不是当前线程HoldCounter rh = cachedHoldCounter;// 如果缓存是 null 或者缓存所属线程不是当前线程,则当前线程不是最后一个读锁。if (rh == null || rh.tid != getThreadId(current))// 获取当前线程的计数器rh = readHolds.get();int count = rh.count;// 如果计数器小于等于一,就直接删除计数器if (count <= 1) {readHolds.remove();// 如果计数器的值小于等于0,说明有问题了,抛出异常if (count <= 0)throw unmatchedUnlockException();}// 对计数器减一--rh.count;}for (;;) {// 死循环使用 CAS 修改状态int c = getState();// c - 65536, 其实就是减去一个读锁。对高16位减一。int nextc = c - SHARED_UNIT;// 修改 state 状态。if (compareAndSetState(c, nextc))// 修改成功后,如果是 0,表示读锁和写锁都空闲,则可以唤醒后面的等待线程return nextc == 0;}
}
复制代码

释放还是很简单的,步骤如下:

  1. 如果当前线程是第一个持有读锁的线程,则只需要操作 firstReaderHoldCount 减一。如果不是,进入第二步。
  2. 获取到缓存计数器(最后一个线程的计数器),如果匹配到当前线程,就减一。如果不匹配,进入第三步。
  3. 获取当前线程自己的计数器(由于每个线程都会多次获取到锁,所以,每个线程必须保存自己的计数器。)。
  4. 做减一操作。
  5. 死循环修改 state 变量。

5. 总结

“读写锁没有想象中简单” 是此次阅读源码的最大感慨。事实上,花的最多时间是锁降级,因为对这块的不理解,参照了一些书籍和博客,但还是云里雾里,我也不敢确定我说的就是全对的,但我敢说,我写的是经过我思考的。

总结下读锁的获取逻辑。

读锁本质上是个共享锁。

但读锁对锁的获取做了很多优化,比如使用 firstReader 和 cachedHoldCounter 最第一个读锁线程和最后一个读锁线程做优化,优化点主要在释放的时候对计数器的获取。

同时,如果在获取读锁的过程中写锁被持有了,JUC 并没有让所有线程痴痴的等待,而是判断入如果获取读锁的线程是正巧是持有写锁的线程,那么当前线程就可以降级获取写锁,否则就会死锁了(为什么死锁,当持有写锁的线程想获取读锁,但却无法降级,进入了等待队列,肯定会死锁)。

还有一点就是性能上的优化,如果先释放写锁,再获取读锁,势必引起锁的争抢和线程上下文切换,影响性能。

还有一个就是,读书的时候,要有怀疑精神,一定要思考,而不是顺着他的思路去看书,诚然,书中大部分时候都是正确的,但我们贪婪的希望,那错误的一小部分尽量不要影响到我们。

并发编程之——读锁源码分析(解释关于锁降级的争议)相关推荐

  1. 并发编程之——写锁源码分析

    1.前言 Java 中的读写锁实现是 ReentrantReadWriteLock ,是一种锁分离策略.能有效提高读比写多的场景下的程序性能. 关于如何使用参见 并发编程之 Java 三把锁. 由于读 ...

  2. 多线程高并发编程(10) -- ConcurrentHashMap源码分析

    一.背景 前文讲了HashMap的源码分析,从中可以看到下面的问题: HashMap的put/remove方法不是线程安全的,如果在多线程并发环境下,使用synchronized进行加锁,会导致效率低 ...

  3. libevent c++高并发网络编程_【多线程高并发编程】Callable源码分析

    程序猿学社的GitHub,欢迎Starhttps://github.com/ITfqyd/cxyxs 本文已记录到github,形成对应专题. 前言 通过上一章实现多线程有几种方式,我们已经了解多线程 ...

  4. Java并发编程:从源码分析几道必问线程池的面试题?

    引言 上一篇文章我们有介绍过线程池的一个基本执行流程<[Java并发编程]面试必备之线程池>以及它的7个核心参数,以及每个参数的作用.以及如何去使用线程池 还留了几个小问题..建议看这篇文 ...

  5. Java 并发编程 -- 线程池源码实战

    一.概述 小编在网上看了好多的关于线程池原理.源码分析相关的文章,但是说实话,没有一篇让我觉得读完之后豁然开朗,完完全全的明白线程池,要么写的太简单,只写了一点皮毛,要么就是是晦涩难懂,看完之后几乎都 ...

  6. Java高并发程序设计学习笔记(五):JDK并发包(各种同步控制工具的使用、并发容器及典型源码分析(Hashmap等))...

    转自:https://blog.csdn.net/dataiyangu/article/details/86491786#2__696 1. 各种同步控制工具的使用 1.1. ReentrantLoc ...

  7. 【并发编程】 --- Reentrantlock源码解析5:再探不可中断性 + 线程unpark后诡异的Thread.interrupted()判断

    文章目录 1 想要读懂这篇文章必须要拥有的前置知识 2 想写这篇文章的原因 3 困扰我很久的Reentrantlock源代码1 --- 貌似无用的变量failed 4 困扰我很久的Reentrantl ...

  8. 3000门徒内部训练绝密视频(泄密版)第5课:彻底精通Scala隐式转换和并发编程及Spark源码阅读

    彻底精通Scala隐式转换和并发编程及Spark源码阅读 Akka ,Scala内部并发 隐式转换.隐式类.隐式参数 可以手动指定某种类型的对象或类转换成其他类型的对象或类.转换的原因是假设写好接口 ...

  9. java线程池_Java 并发编程 线程池源码实战

    作者 | 马启航 杏仁后端工程师.「我头发还多,你们呢?」 一.概述 笔者在网上看了好多的关于线程池原理.源码分析相关的文章,但是说实话,没有一篇让我觉得读完之后豁然开朗,完完全全的明白线程池,要么写 ...

最新文章

  1. python参数化_Python unittest 简单实现参数化的方法
  2. dorado学习笔记(二)
  3. 学java时的一些笔记(2)
  4. java opencv 调用摄像头_opencv C++实现调用摄像头动态识别人脸
  5. Windows下如何安装和使用git
  6. char与TCHAR相互转化
  7. scp错误 WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!
  8. python3 join函数_Python中.join()和os.path.join()两个函数的用法详解
  9. Lambda表达式和闭包Closure
  10. mmc检测到此管理单元发生一个错误_理解这八大优势,才算精通单元测试
  11. Android疯狂ListView之旅 第一季 《侧滑删除条目》
  12. [zz]KVM 虚拟机故障排除一例
  13. 获取map中的一个value值以及遍历map获得map里所有key、value的值
  14. 马云的B2B B2C 和C2C的“三合一”
  15. 【敏捷开发每日一贴】测试驱动开发
  16. 12306模拟登陆-超级鹰
  17. 【Golang】✔️实战✔️ 10 种加密方法实现 ☢️万字长文 建议手收藏☢️
  18. 牛散NO.2:MACD西施说风情,柳下惠高位勿迷情
  19. python打印标签脚本
  20. 对于自定义element 弹出框 文字提示样式

热门文章

  1. 图像处理之基础---极坐标系及其与直角坐标系的关系
  2. SQL IN 一定走索引吗?
  3. Ionic 存储目录 CORS
  4. /var/spool/clientmqueue文件分析
  5. JavaScript模块化开发技术概述
  6. Axure7.0 以及 中文汉化语言包下载 axure汉化包
  7. jQuery+ajax中,让window.open不被拦截(转)
  8. 华为×××+IPSEC实现安全连接
  9. 在C#中,Json的序列化和反序列化的几种方式总结
  10. easyui-datagrid行数据field原样输出html标签