读写锁模式 Read-Write Lock
Read-Write Lock(读写锁模式):允许多个线程同时读取共享资源,但在进行写入操作时必须独占资源,不再允许其他线程读或写。
⨳ 共享读(Shared Read):读取操作可以同时被多个线程执行,因为读取操作不修改共享资源的状态,所以可以并行进行。
⨳ 独占写(Exclusive Write):写入操作则必须在没有其他线程正在读取或写入时进行,以确保写入操作的原子性和一致性。
读写锁模式就是在读取操作和写入操作之间进行区分,想想也是,当线程"读取"实例的状态时,实例的状态不会发生变化。实例的状态仅在线程执行"写入"操作时才会发生变化。从实例的状态变化这个观点来看,"读取"和"写入"有着本质的区别。
基本实现
读写锁 ReadWriteLock
读写锁该怎么实现呢?如果以守护条件的角度:
⨳ 读锁的获取 :如有线程正在执行写入,则 wait 等待,也就是说,读锁获取的守护条件是无正在写入的线程。
⨳ 写锁的获取 :如果有线程正在写入,则 wait 等待,如果有线程正在读取,也要wait 等待,也就是说,写锁获取的守护条件是无正在写入和读取的线程。
⨳ 读锁的释放:读操作执行完了即可释放,可以唤醒正在等待获取写锁的线程。
⨳ 写锁的释放:写操作执行完了即可释放,可以唤醒正在等待获取写锁和读锁的线程。
有了守护条件,就可以根据其设置成受保护资源,然后对其的操作加 synchronized 锁保护了:
java
public class ReadWriteLock {
private int readings = 0; // 正在读的线程数
private int writings = 0; // 正在写的线程数
// 获取读锁
public synchronized void readLock() throws InterruptedException {
while(writings>0){
wait();
}
readings++;
}
// 写锁的获取
public synchronized void writeLock() throws InterruptedException {
while (readings>0 || writings>0){
wait();
}
writings++;
}
// 释放读锁
public synchronized void readUnLock(){
readings--;
notifyAll();
}
// 释放写锁
public synchronized void writeUnLock(){
writings--;
notifyAll();
}
}
当然这个实现很简陋,比如读锁是不支持重入的,同一线程持有读锁后再调 writeLock() 会自死锁(自己等自己释放)。
受保护对象 GuardedObject
用读写锁代替排他锁保护受保护资源:
java
public class GuardedObject {
// 被守护的数字
private int guardedNum=0;
private ReadWriteLock lock = new ReadWriteLock();
// 读锁保护 对 guardedNum 的读取
public int getGuardedNum() throws InterruptedException {
lock.readLock(); // 获取读锁
try{
Thread.sleep(50); // 模拟读操作
System.out.println(Thread.currentThread().getName()+"正在读取:"+guardedNum);
return guardedNum;
}finally {
lock.readUnLock(); // 释放读锁
}
}
// 写锁保护 对 guardedNum 的更改
public int inc() throws InterruptedException {
lock.writeLock(); // 获取读锁
try{
Thread.sleep(70); // 模拟读操作
System.out.println(Thread.currentThread().getName()+"正在更改:"+guardedNum);
guardedNum++;
System.out.println(Thread.currentThread().getName()+"更改后:"+guardedNum);
return guardedNum;
}finally {
lock.writeUnLock(); // 释放读锁
}
}
}
注意,上述代码受保护资源 guardedNum,由一把 锁保护(ReadWriteLock)。
如多线程同时调用 getGuardedNum 方法:
⨳ 获取读锁 lock.readLock() 操作是由排他锁 synchronized 保护,所以同一时刻只有一个线程进入readLock() 方法。
⨳ 获取读锁后续业务逻辑操作,不受任何任何排他锁 synchronized 保护,也就是说理论上,获取读锁后续操作允许多个线程同时执行,但这多个线程只有在执行完获取读锁操作后,才能继续执行,获取读锁会将不符合守护条件(无正在写入的线程)的线程挡在了外边。
如多线程同时调用 inc() 递增方法:
⨳ 获取写锁 lock.writeLock() 操作同样由排他锁 synchronized 保护。
⨳ 获取写锁后续业务逻辑操作,也不受任何任何排他锁 synchronized 保护,也允许多个线程同时执行,只是获取写锁的线程如不符合写锁的保护条件(无正在写入和读取的线程),同样被阻塞在获取写锁的时刻。
注意,无论是使用读锁保护资源还是写锁保护资源,都需要合理的释放锁。
客户端 CLient
下面使用三个读线程,两个写线程同时访问 GuardedObject 中的受保护资源:
java
final Random random = new Random();
final GuardedObject guardedObject = new GuardedObject();
new Thread(()->{
while (true){
try {
Thread.sleep(random.nextInt(3000));
guardedObject.inc();
} catch (InterruptedException e) {
break;
}
}
},"递增线程A").start();
new Thread(()->{
while (true){
try {
Thread.sleep(random.nextInt(3000));
guardedObject.inc();
} catch (InterruptedException e) {
break;
}
}
},"递增线程B").start();
new Thread(()->{
while (true){
try {
Thread.sleep(random.nextInt(3000));
guardedObject.getGuardedNum();
} catch (InterruptedException e) {
break;
}
}
},"读取线程A").start();
new Thread(()->{
while (true){
try {
Thread.sleep(random.nextInt(3000));
guardedObject.getGuardedNum();
} catch (InterruptedException e) {
break;
}
}
},"读取线程B").start();
new Thread(()->{
while (true){
try {
Thread.sleep(random.nextInt(3000));
guardedObject.getGuardedNum();
} catch (InterruptedException e) {
break;
}
}
},"读取线程C").start();
}
输出如下:
java
读取线程A正在读取:0
递增线程B正在更改:0
递增线程B更改后:1
递增线程A正在更改:1
递增线程A更改后:2
读取线程A正在读取:2
递增线程A正在更改:2
递增线程A更改后:3
读取线程C正在读取:3
递增线程A正在更改:3
递增线程A更改后:4
读取线程B正在读取:4
读取线程C正在读取:4
读取线程A正在读取:4
递增线程A正在更改:4
递增线程A更改后:5
...
可以看到用读写锁代替排它锁保护 guardedNum 时,guardedNum 也不会出现并发问题。
可重入读写锁
JUC包下就有现成读写锁实现 ------ ReentrantReadWriteLock
java
public class ReentrantReadWriteLock
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
final Sync sync;
public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}
和可重入锁 ReentrantLock 类似,核心功能也是静态内部类实现的。
ReentrantLock 是内部有个 Sync 实现 AQS,有个 FairSync 和 NonfairSync 分别实现 AQS, 重新 tryAcquire 方法,实现公平锁和非公平锁。
ReentrantReadWriteLock 也一样,也有一个实现 AQS 的 内部类 Sync,也有 FairSync 和 NonfairSync 。
AQS 上节刚讲完,里面有个 volatile 修饰的成员变量 state,记录线程占用状态(state = 0 表示锁空闲)与可重入次数,AQS 的父类 AOS 还记录着当前占用的线程引用。
js
public abstract class AbstractQueuedSynchronizer
extends AbstractOwnableSynchronizer
implements java.io.Serializable {
private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;
public abstract class AbstractOwnableSynchronizer
implements java.io.Serializable {
private transient Thread exclusiveOwnerThread;
AQS 就一个 state,怎么记录两个锁(读锁和写锁)的线程占用状态与可重入次数呢?
ReentrantReadWriteLock 很聪明, state 是 int 类型的变量,int 一共 32 位 ,能存储上亿的数字,那就让高 16 位 存放读锁重入总次数,低 16 位 记录写锁重入次数,这样拆分,还能记录 65,535 次重入次数,谁家好线程能重入这么多。
读写锁线程占用状态和对重入次数的问题解决,那占用的线程记录在哪呢?AOS 就一个 exclusiveOwnerThread 肯定是不够的。
所以对于写锁来说,同一时刻最多只有一个写线程持有,所以直接复用 AQS 的 exclusiveOwnerThread 就够了。
对于读锁来说,就要 ReentrantReadWriteLock 的内部类 Sync 自己搞一个地方存读线程的信息了。
下面结合代码看看具体是怎么实现的。
抽象队列同步器 AQS
java
abstract static class Sync extends AbstractQueuedSynchronizer {
/**
* state 的位分割参数。
* AQS 只有一个 int state,读写锁需要同时记录两类计数,
* 因此约定:高 16 位 = 读锁(共享)重入总次数,低 16 位 = 写锁(独占)重入次数。
*/
static final int SHARED_SHIFT = 16;
/** 共享计数每次增减的步长,即 1 << 16,用于对高 16 位做加减 */
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
/** 读锁计数上限,65535;超出说明使用方式异常,直接抛 Error */
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
/** 独占(写锁)计数的掩码,用于从 state 中剥离出低 16 位 */
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
/**
* 单个线程的读锁持有计数。
* tid 在构造时即固化,用于后续校验缓存是否属于当前线程,
* 避免线程复用(线程池场景)导致的误命中。
*/
static final class HoldCounter {
int count; // initially 0
final long tid = LockSupport.getThreadId(Thread.currentThread());
}
/**
* 读锁计数的兜底存储:每个线程一份独立的 HoldCounter。
* 之所以用 ThreadLocal,是因为读锁是共享的,可能有 N 个线程同时持有,
* AQS 的 exclusiveOwnerThread 只能标识一个独占线程,无法覆盖读线程。
*/
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}
/**
* 缓存"上一次释放读锁"的线程的 HoldCounter。
* 嵌套读释放时,同一线程往往连续操作,可直接命中此字段,
* 省掉一次 ThreadLocal 查询。属于纯性能优化,不参与正确性判断。
*/
private transient HoldCounter cachedHoldCounter;
/**
* 记录第一个获取读锁的线程。
* 针对"系统中只有一个读者"这一最高频场景做零开销优化。
*/
private transient Thread firstReader;
/** 与 firstReader 配对,记录其读锁重入次数 */
private transient int firstReaderHoldCount;
Sync() {
readHolds = new ThreadLocalHoldCounter();
// 空写一次 state,借助 volatile 写语义建立 happens-before,
// 保证其他线程能看到 readHolds 的初始化结果
setState(getState());
}
}
代码还是很清晰的,HoldCounter 同时承担了"身份标识"和"计数"两个职责,既记录线程的id,又记录重入次数。
这里用 LockSupport.getThreadId(current) 而不是直接比对象引用,是为了在线程池复用场景下避免缓存误命中。
每个读线程自己的存储柜 ThreadLocal 记录自己的 HoldCounter。
而且对于读线程状态的查询,还有三层缓存的完整查找链
js
final int getReadHoldCount() {
if (getReadLockCount() == 0)
return 0;
Thread current = Thread.currentThread();
// 第1层:firstReader 是否就是当前线程?
// 直接返回 firstReaderHoldCount(零开销,无 ThreadLocal 查询)
if (firstReader == current)
return firstReaderHoldCount;
// cachedHoldCounter 的 tid 是否匹配当前线程?
// 直接返回 count
HoldCounter rh = cachedHoldCounter;
if (rh != null && rh.tid == LockSupport.getThreadId(current))
return rh.count;
// 第3层:ThreadLocal 查询(兜底,覆盖所有其他线程)
int count = readHolds.get().count;
if (count == 0) readHolds.remove();
return count;
}
下面就结合读锁、写锁具体获取锁,释放锁的方法,看看这三层缓存是怎么协作的?
读锁 ReadLock
java
public static class ReadLock implements Lock, java.io.Serializable {
private final Sync sync;
protected ReadLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
public void lock() {
sync.acquireShared(1);
}
public void unlock() {
sync.releaseShared(1);
}
读锁的 lock 方法是委派 AQS 的 acquireShared 方法实现的。
js
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
acquire(null, arg, true, false, false, 0L);
}
AQS 的子类也就是 ReentrantReadWriteLock 的内部类 Sync 实现了 tryAcquireShared 方法,标准的模板方法。
js
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// ① 写锁互斥检查:低 16 位非 0 说明有写锁被持有;
// 若持有者不是当前线程,读锁直接获取失败(返回 -1 表示未获取到)。
// 若持有者正是当前线程,则属于"写锁降级为读锁"的合法路径,继续往下走。
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return -1;
// ② 取出高 16 位,即当前读锁的总重入次数
int r = sharedCount(c);
// ③ CAS 成功:把高 16 位加一个 SHARED_UNIT,原子地增加读锁计数。
if (compareAndSetState(c, c + SHARED_UNIT)) {
// ④ state 更新成功后,再维护"每个线程的读锁持有计数",分三种情况:
if (r == 0) {
// 之前没有任何读线程,当前线程就是第一个读者,
// 用两个普通字段直接记录,避免走 ThreadLocal
firstReader = current;
firstReaderHoldCount = 1;
} else if (firstReader == current) {
// 当前线程就是第一个读者,属于其重入,直接自增字段计数
firstReaderHoldCount++;
} else {
// 其他线程:走缓存 + ThreadLocal 兜底
HoldCounter rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current)) {
// 缓存为空或 tid 不匹配(可能是线程池复用导致),
// 从 ThreadLocal 取当前线程自己的 HoldCounter,并刷新缓存
cachedHoldCounter = rh = readHolds.get();
} else if (rh.count == 0) {
// 缓存命中但计数为 0,说明之前被清理过,需要重新放回 ThreadLocal
readHolds.set(rh);
}
rh.count++;
}
return 1; // 返回 >= 0 表示获取成功,AQS 会据此决定是否传播唤醒
}
// ⑤ 快速路径没走通
// 交给带完整自旋重试逻辑的 full 版本处理,其中也包含重入场景
return fullTryAcquireShared(current);
}
代码还是很清晰的,线程进入读锁的前提是没有其他线程的写锁;或者虽有写锁,但调用线程和持有写锁的线程是同一个。
因为读与读之间不互斥,所以不需要检查"有没有其他读锁"。
如果获取读锁时,有线程持有写锁,tryAcquireShared 会返回 -1,读线程依旧会加入阻塞队列,如果只是其他线程已抢先获取到了读锁, 那会 CAS 失败,到 fullTryAcquireShared 方法中自旋再次获取读锁 。
读锁的释放同样是委派 AQS 的 releaseShared 方法实现的。
java
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
signalNext(head);
return true;
}
return false;
}
同样,tryReleaseShared 方法的实现还是由ReentrantReadWriteLock 的内部类 Sync 完成。
js
protected final boolean tryReleaseShared(int unused) {
Thread current = Thread.currentThread();
// ① 先维护"当前线程自己的读锁重入计数",再动全局 state,
// 顺序不能反:线程级计数是释放合法性的前置校验
if (firstReader == current) {
// 当前线程是第一个读者,走零开销的普通字段路径,不碰 ThreadLocal
// assert firstReaderHoldCount > 0;
if (firstReaderHoldCount == 1)
firstReader = null; // 计数归零,第一个读者的身份随之清除
else
firstReaderHoldCount--; // 仍有重入,仅递减计数
} else {
// ② 非 firstReader:先尝试命中缓存,未命中再走 ThreadLocal 兜底
HoldCounter rh = cachedHoldCounter;
if (rh == null ||
rh.tid != LockSupport.getThreadId(current))
// 缓存为空或 tid 不匹配(线程池复用场景),取当前线程自己的计数器
rh = readHolds.get();
int count = rh.count;
if (count <= 1) {
// 本次释放后该线程的读锁计数将归零,直接从 ThreadLocal 移除,
// 避免线程池场景下 Entry 长期驻留造成内存泄漏
readHolds.remove();
if (count <= 0)
// count 为 0 说明当前线程并未持有读锁,属于未配对解锁
throw unmatchedUnlockException();
}
--rh.count;
}
// ③ 全局计数更新:自旋 CAS,把 state 高 16 位减一个 SHARED_UNIT
for (;;) {
int c = getState();
int nextc = c - SHARED_UNIT;
if (compareAndSetState(c, nextc))
// Releasing the read lock has no effect on readers,
// but it may allow waiting writers to proceed if
// both read and write locks are now free.
// 释放读锁对其它读者没有影响,但若此时读写锁都已空闲,
// 则可能让等待中的写线程得以继续
return nextc == 0;
}
}
解锁是加锁的相反操作,其实从这个代码中也能看出来 AQS 中的 state 高 16 位记录的不是某一个读线程的重入次数,是所有读线程的重入次数。
写锁 WriteLock
js
public static class WriteLock implements Lock, java.io.Serializable {
private final Sync sync;
protected WriteLock(ReentrantReadWriteLock lock) {
sync = lock.sync;
}
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
写锁的加锁和释放就简单了,走的是独占模式,不需要像读锁那样维护 per-thread 的计数体系。
和 ReentrantLock 一样,都是通过模板方法模式,调用自己内部类 Sync,直接看模板方法tryAcquire:
java
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
// 取出低 16 位,即当前写锁的重入计数
int w = exclusiveCount(c);
if (c != 0) {
// (Note: if c != 0 and w == 0 then shared count != 0)
// 注意:c 非零但 w 为零,说明高 16 位有值,即当前有读锁被持有
if (w == 0 || current != getExclusiveOwnerThread())
// 两种失败情形:
// ① w == 0:有读锁存在,写锁不能获取(不支持锁升级,避免多读线程同时升级死锁)
// ② 写锁已被其他线程持有:独占语义下直接失败
return false;
// 走到这里说明:写锁持有者就是当前线程,属于重入场景
if (w + exclusiveCount(acquires) > MAX_COUNT)
// 重入次数超出低 16 位上限,属于使用异常
throw new Error("Maximum lock count exceeded");
// Reentrant acquire
// 重入时直接 setState 即可,无需 CAS:
// 独占语义下已确认持有者是自己,不存在其他线程并发修改低 16 位
setState(c + acquires);
return true;
}
// c == 0:读写锁都空闲,尝试首次获取
if (writerShouldBlock() ||
!compareAndSetState(c, c + acquires))
// 两个失败原因:
// ① writerShouldBlock():按队列策略当前写线程需要排队
// (公平实现用 hasQueuedPredecessors() 判断队列中是否有前驱;非公平实现直接返回 false)
// ② CAS 失败:并发下有别的线程抢先修改了 state
return false;
// 获取成功,登记独占持有者
setExclusiveOwnerThread(current);
return true;
}
应用场景
读写锁的价值在于区分读写,让读并发、写独占。如果读操作远多于写操作,相比互斥锁能显著提升并发性能。也就是说只有在"读多写少"的场景下才真正有价值。
- 缓存系统:读缓存用读锁,更新缓存用写锁
- 配置中心 / 配置管理:查询配置走读锁,更新配置走写锁
- 路由表、字典数据、树形结构的统计查询:这类"初始化后极少修改"的结构比较合适
- 商品详情/库存查询:浏览详情页看库存是高频读,下单更新库存相对较少,是读写锁分离的典型适用场景
有观点认为写操作超过约 30% 的场景(如频繁更新的计数器、实时日志写入)不太合适,此时 CAS 开销可能超过收益,甚至不如独占锁;可考虑 ConcurrentHashMap、CopyOnWriteArrayList 或无锁结构,这个并发数据结构后面再讲。
总结
读写锁的核心就一句话:读可以并发,写必须独占。
本节从守护条件出发,用一段 synchronized 简易实现讲清读写锁语义,再深入 ReentrantReadWriteLock 源码,拆解 state 高低 16 位拆分技巧、读锁三层缓存查找链、读/写锁的获取与释放流程。