并发编程之——读锁源码分析(解释关于锁降级的争议)
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; // 计数器为1 firstReaderHoldCount = 1; }// 如果读锁不是空闲的,且第一个读线程是当前线程。获取锁成功。 else if (firstReader == current) {// // 将计数器加一 firstReaderHoldCount++; } else {// 如果不是第一个线程,获取锁成功。 // cachedHoldCounter 代表的是最后一个获取读锁的线程的计数器。 HoldCounter rh = cachedHoldCounter; // 如果最后一个线程计数器是 null 或者不是当前线程,那么就新建一个 HoldCounter 对象 if (rh == null || rh.tid != getThreadId(current)) // 给当前线程新建一个 HoldCounter cachedHoldCounter = rh = readHolds.get(); // 如果不是 null,且 count 是 0,就将上个线程的 HoldCounter 覆盖本地的。 else if (rh.count == 0) readHolds.set(rh); // 对 count 加一 rh.count++; } return 1; } // 死循环获取读锁。包含锁降级策略。 return fullTryAcquireShared(current); }
总结一下上面代码的逻辑吧!
- 判断写锁是否空闲。
- 如果不是空闲,且当前线程不是持有写锁的线程,则返回 -1 ,表示抢锁失败。如果是空闲的,进入第三步。如果是当前线程,进入第三步。
- 判断持有读锁的数量是否超过 65535,然后使用 CAS 设置 int 高 16 位的值,也就是加一。
- 如果设置成功,且是第一次获取读锁,就设置 firstReader 相关的属性(为了性能提升)。
- 如果不是第一次,当当前线程就是第一次获取读锁的线程,对 “第一次获取读锁线程计数器” 加 1.
- 如果都不是,则获取最后一个读锁的线程计数器,判断这个计数器是不是当前线程的。如果是,加一,如果不是,自己创建一个新计数器,并更新 “最后读取的线程计数器”(也是为了性能考虑)。最后加一。返回成功。
- 如果上面的判断失败了(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; // 计数器为 1 firstReaderHoldCount = 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; } } }
这两个方法其实高度相似的。就不再解释了。
到这里,其实留下了几个问题:一个是 firstReader
和 firstReaderHoldCount
的作用,还有就是 cachedHoldCounter
的作用。最后是锁降级。
解释一下:
firstReader
是获取读锁的第一个线程。如果只有一个线程获取读锁,很明显,使用这样一个变量速度更快。
*firstReaderHoldCount
是firstReader
的计数器。同上。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,减一操作 else firstReaderHoldCount--; } 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; } }
释放还是很简单的,步骤如下:
- 如果当前线程是第一个持有读锁的线程,则只需要操作 firstReaderHoldCount 减一。如果不是,进入第二步。
- 获取到缓存计数器(最后一个线程的计数器),如果匹配到当前线程,就减一。如果不匹配,进入第三步。
- 获取当前线程自己的计数器(由于每个线程都会多次获取到锁,所以,每个线程必须保存自己的计数器。)。
- 做减一操作。
- 死循环修改 state 变量。
5. 总结
“读写锁没有想象中简单” 是此次阅读源码的最大感慨。事实上,花的最多时间是锁降级,因为对这块的不理解,参照了一些书籍和博客,但还是云里雾里,我也不敢确定我说的就是全对的,但我敢说,我写的是经过我思考的。
总结下读锁的获取逻辑。
读锁本质上是个共享锁。
但读锁对锁的获取做了很多优化,比如使用 firstReader 和 cachedHoldCounter 最第一个读锁线程和最后一个读锁线程做优化,优化点主要在释放的时候对计数器的获取。
同时,如果在获取读锁的过程中写锁被持有了,JUC 并没有让所有线程痴痴的等待,而是判断入如果获取读锁的线程是正巧是持有写锁的线程,那么当前线程就可以降级获取写锁,否则就会死锁了(为什么死锁,当持有写锁的线程想获取读锁,但却无法降级,进入了等待队列,肯定会死锁)。
还有一点就是性能上的优化,如果先释放写锁,再获取读锁,势必引起锁的争抢和线程上下文切换,影响性能。
还有一个就是,读书的时候,要有怀疑精神,一定要思考,而不是顺着他的思路去看书,诚然,书中大部分时候都是正确的,但我们贪婪的希望,那错误的一小部分尽量不要影响到我们。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 如何编写易于单元测试的代码
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 周边上新:园子的第一款马克杯温暖上架
· Open-Sora 2.0 重磅开源!
· 分享 3 个 .NET 开源的文件压缩处理库,助力快速实现文件压缩解压功能!
· Ollama——大语言模型本地部署的极速利器
· DeepSeek如何颠覆传统软件测试?测试工程师会被淘汰吗?