Day40 | Java中的ReadWriteLock读写锁

在之前的文章中,我们已经学习了synchronized和ReentrantLock。通过学习我们知道这两者都是属于排它锁,也叫做互斥锁。

什么叫互斥锁呢?就是不管哪个线程去读取数据还是修改数据,只要他持有锁,那其他的线程就必须等着。

这在大多数情况下肯定是线程安全的,没什么问题。

但是在实际的开发过程中,有一些很常见的读多写少的场景。像配置信息,热点数据的缓存。这些数据实际上被大量的线程进行频繁的读取,而只在极少的情况下会去修改。

在这类场景下,如果读取操作也使用排他锁,想想都会降低系统性能。因为读取操作本身并不会改变数据,多个线程同时读取同一个数据是完全安全的。

那有没有一种锁能把读取和写入区别开呢?

那肯定是有的,JUC包下的ReadWriteLock就是为解决这个问题而生的。

今天我们就一起来看看ReadWriteLock。

一、什么是ReadWriteLock

ReadWriteLock是java.util.concurrent.locks中的一个接口。既然是接口,那他就不是一把具体的锁。

可以把他理解成一个锁的管理者。他内部管理了两把相互关联的锁:

一个读取锁,也叫做共享锁。

一个写入所,也叫排它锁。

再提一句,源码中的注释真的有必要去看一看,不管是学习前还是学习后,尽量都去阅读下源码及注释。

ReentrantReadWriteLock是ReadWriteLock接口最常用的实现类。

这套锁机制有以下的核心原则:

读-读 共享:多个线程可以同时持有读取锁,进行并发读取。

读-写 互斥:当有线程持有读取锁时,写入线程必须等待。

写-读 互斥:当有线程持有写入锁时,所有其他线程(不管读写)都必须等待。

写-写 互斥:当有线程持有写入锁时,其他写入线程也必须等待。

其实很好理解,读锁大家用,写锁只能一个人用。

大家都是读,其乐融融,随便读。

我在读,你想写,你得等着,等我读完了,不然就存在边读边写的诡异事情。

我在写,你想读或者你也想写,那都得等着,不然又会混乱了。

简单的总结就是:读锁大家用,写锁我独占。

二、读写锁使用

我们通过一个小案例来看一下读写锁的使用:

java 复制代码
package com.lazy.snail.day40;

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

/**
 * @ClassName Day40Demo
 * @Description TODO
 * @Author lazysnail
 * @Date 2025/8/12 9:52
 * @Version 1.0
 */
public class Day40Demo {
    private final Map<String, String> map = new HashMap<>();
    private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
    private final Lock rLock = rwLock.readLock();
    private final Lock wLock = rwLock.writeLock();

    public String get(String key) {
        rLock.lock();
        try { return map.get(key); }
        finally { rLock.unlock(); }
    }

    public void put(String key, String value) {
        wLock.lock();
        try { map.put(key, value); }
        finally { wLock.unlock(); }
    }
}

之前的集合框架学习中,我们已经知道HashMap自身不是线程安全的,但是我们可以用锁把所有的访问都包起来。来保证线程安全。

rwLock是我们创建的读写锁管理器,生成两把关联的锁:读锁、写锁。

rLock和wLock分别取出读锁和写锁。

get方法代表只读路径,也就是读取。在没有线程持有写锁/等待写的时候,多个线程可以同时获取读锁。

在持锁期间进行读取(map.get()),避免了跟写线程并发导致的数据竞争。

最后在finally里面释放锁。

put方法代表写路径,也就是写入。

写锁是独占的。只要有线程持有写锁,其他读/写都得等;反过来,只要有读锁在,写锁也进不来。

唯一写入口(map.put),确保不会出现同时写或读写交叠。

同样在finally中释放锁。

三、内部原理

3.1 锁实现及获取

ReentrantReadWriteLock内部并不是维护两把完全独立的锁,而是用一个32位int变量state同时记录读写状态:

其中高16位表示读锁计数,允许多个线程同时增加。

低16位表示写锁计数,同一个线程可以重入。

ReentrantReadWriteLock中相关的核心常量:

获取写锁:

1.检查state是不是为0。如果不是0,意味着要么有读锁存在 (readCount > 0),要么已经有其他线程持有了写锁 (writeCount > 0)。

2.如果state不是0,但持有写锁的是当前线程,就可以重入,把writeCount加1。

3.如果state是0,那么当前线程可以获取写锁,把writeCount加1,把当前线程设置成锁的持有者。

获取读锁:

1.检查是不是存在写锁 (writeCount > 0),且持有写锁的不是当前线程。如果是,则获取失败,进入等待队列。

2.如果没有写锁,或者写锁被当前线程持有,那么当前线程可以获取读锁,通过CAS操作把readCount加1。

3.2 公平策略

ReentrantReadWriteLock跟ReentrantLock一样,也支持公平和非公平两种模式。

非公平锁:new ReentrantReadWriteLock()和new ReentrantReadWriteLock(false)都是非公平锁构造。允许新来的线程插队。举个例子,当写锁被释放的时候,如果等待队列里有读线程和写线程,同时又有一个新的读线程请求锁,那么这个新的读线程可能优先获得锁。从例子就能看出优点肯定是吞吐量更高,但可能会导致写线程饥饿。

公平锁:new ReentrantReadWriteLock(true)。这种模式就严格按照线程在等待队列里的FIFO顺序来分配锁。如果队列头部是写线程在等待,那所有后来的读锁请求都必须排队。优点就是能防止饥饿,保证公平性,一般情况下性能会低于非公平锁,毕竟要维持公平性。

3.3 锁升降级

锁降级指的是线程在持有写锁的情况下,继续获取读锁,然后释放写锁。

锁升级指的是线程在持有读锁的情况下,去获取写锁。ReentrantReadWriteLock是不支持的。

锁降级

考虑这样一个场景,如果有一个线程更新了共享数据后,还需要读取这些数据处理其他业务,但这个时候已经不希望再独占资源,而是允许其他读线程进来。

java 复制代码
wLock.lock(); // 获取写锁
try {
    // 修改数据...
    map.put("name", "懒惰蜗牛");

    // 锁降级:在持有写锁的情况下获取读锁
    rLock.lock();

    // 释放写锁(这个时候仍然持有读锁)
    // 其他线程现在可以获取读锁了
    wLock.unlock();

    // 使用读锁状态读取数据,执行后续操作...
    // 保证了在读取期间,数据不会被其他写线程修改
    System.out.println("读取数据: " + map.get("name"));

} finally {
    // 释放读锁
    rLock.unlock(); 
}

锁升级

为什么不支持锁升级,如果两个线程(T1, T2)同时持有读锁,然后他们都尝试升级成写锁。T1要等待T2释放读锁,而T2也要等待T1释放读锁,就这样相互等着,最后就形成死锁了。

所以必须先rLock.unlock(),然后再wLock.lock()。但还是要注意的是,在释放读锁和获取写锁的间隙,数据可能被其他线程修改,原子性被破坏了,这就需要我们自己的业务逻辑来处理。

四、小结

目前我们已经接触了synchronized、ReentrantLock和ReentrantReadWriteLock。

下面对这三种形式的锁用表格进行清晰的对比:

特性 synchronized ReentrantLock ReentrantReadWriteLock
锁类型 互斥锁 互斥锁 读写分离锁(共享/互斥)
底层实现 JVM指令 AQS AQS
公平性 非公平 可选(默认非公平) 可选(默认非公平)
可中断 不可中断 可中断 可中断
尝试获取锁 不支持 支持 (tryLock) 支持 (tryLock)
Condition wait/notify Condition 仅写锁支持Condition
适用场景 简单同步代码块 需高级功能的复杂同步 读多写少的场景

每种形式的锁都有各自的优缺点,都有自己的应用场景。

结语

通过这几篇文章,初体验了一下Java中的synchronized、ReentrantLock和ReentrantReadWriteLock。

了解了锁在实际中的使用,对多线程场景下锁的应用也有了一定的了解。

下一篇文章我们一起来看下基于特性或者实现,到底有哪些锁的分类。

对于这些锁的分类有了一定了解后,我们继续后续关于锁的深入学习。

下一篇预告

Day41 | Java中的锁分类

如果你觉得这系列文章对你有帮助,欢迎关注专栏,我们一起坚持下去!

更多文章请关注我的公众号《懒惰蜗牛工坊》

相关推荐
用户7344028193421 小时前
Spring Boot 2.x(十二):Swagger2的正确玩法
后端
微学AI1 小时前
【征文计划】基于Rokid 眼镜的AI天气应用+GPS定位+AI旅游规划
后端
用户020742201752 小时前
从零构建一个现代化的 Node.js 脚手架工具:不只是生成文件
后端
用户020742201752 小时前
从零到一:构建一个现代化的 React 组件库
后端
用户020742201752 小时前
从零到一:用 Rust 和 WebAssembly 构建高性能前端应用
后端
用户020742201752 小时前
从零到一:构建你的第一个智能合约并部署到以太坊测试网
后端
掘金者阿豪2 小时前
数据库的第一道防线:从金仓KES看企业级身份验证体系的设计逻辑
后端
颜酱2 小时前
从0到1实现LFU缓存:思路拆解+代码落地
javascript·后端·算法