「Java 进阶之路」系列 Day06
写在前面
Day05 讲了 ReentrantLock 这种独占锁------同一时刻只能有一个线程持有。但现实里有大量"读多写少"的场景:一个缓存被上千个请求并发读取,写操作却一天也遇不到几次。用独占锁保护这种缓存,相当于把并发读也全部串行化了,性能白白浪费。
这篇讲的 ReentrantReadWriteLock(读写锁)就是为这种场景而生的;顺带讲讲 Condition------Lock 体系里替代 wait/notify 的精准通知机制。两者都在同一份 AQS 基础上构建,Day05 打的底子这篇会直接用上。
一、是什么:读写锁解决了什么问题
核心动机 :读操作之间互不冲突,没必要互斥;只有写操作之间、以及读写之间才需要互斥。ReentrantReadWriteLock 把这两种场景拆成两把锁:
- 读锁(共享锁):多个线程可以同时持有,不互斥
- 写锁(独占锁):同时只能有一个线程持有,且和任何读锁都互斥
读写规则一张表看懂
| 当前状态 | 读锁请求 | 写锁请求 |
|---|---|---|
| 无锁 | 允许 | 允许 |
| 有读锁 | 允许(共享) | 阻塞 |
| 有写锁(别的线程持有) | 阻塞 | 阻塞 |
| 有写锁(自己持有) | 允许(锁降级) | 允许(重入) |
二、为什么能做到"读共享写独占":state 的高低位设计
还记得 Day05 讲的 AQS 核心是一个 state 变量吗?ReentrantReadWriteLock 复用了同一个 state,只是把这个 32 位整数拆成了两半:
perl
int state(32位)
高16位:读锁持有数
低16位:写锁重入次数
读锁数 = state 无符号右移16位
写锁数 = state 与 0xFFFF 做按位与
这是一个很巧妙的设计:不需要两个独立的计数器,用位运算就能同时表达"当前有几个线程持有读锁"和"写锁被重入了几次",读写状态的变化都通过对同一个 state 做 CAS 操作完成,复用了 AQS 现成的排队/唤醒机制。
三、怎么用:一个缓存场景的标准写法
java
private final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock readLock = rwl.readLock();
private final Lock writeLock = rwl.writeLock();
private Map<String, Object> cache = new HashMap<>();
public Object get(String key) {
readLock.lock();
try { return cache.get(key); }
finally { readLock.unlock(); }
}
public void put(String key, Object value) {
writeLock.lock();
try { cache.put(key, value); }
finally { writeLock.unlock(); }
}
用法和普通 Lock 一模一样,区别只是从 rwl 上分别拿"读锁"和"写锁"两把独立的 Lock 对象。
锁降级:持有写锁时可以直接获取读锁
java
readWriteLock.writeLock().lock();
try {
data = newValue; // 先更新数据
readWriteLock.readLock().lock(); // 降级:在释放写锁之前先拿到读锁
} finally {
readWriteLock.writeLock().unlock(); // 释放写锁,降级完成
}
try {
use(data); // 此时只持有读锁,其他读线程也能进来
} finally {
readWriteLock.readLock().unlock();
}
为什么要锁降级:如果写完数据后直接释放写锁、再重新申请读锁,中间会有一个"完全没有锁"的空隙,别的写线程可能会插进来把数据再改一遍,导致你后面读到的不是自己刚写的值。锁降级能保证从"写"到"读"这个过程中,数据的可见性和一致性不被打断。
注意反过来不行:不支持锁升级(先持有读锁,再申请写锁),持有读锁时申请写锁会导致死锁------因为写锁需要等所有读锁释放,而你自己就持有着一个读锁,永远不会释放,自己把自己锁死了。
一个容易被忽略的风险:写线程饥饿
如果读请求源源不断地涌入,非公平模式下写线程可能一直抢不到锁(因为读锁之间不互斥,新来的读请求可以持续插队),这就是写饥饿。缓解办法是用公平锁模式,或者控制读锁的持有时间不要太长。
四、Condition:Lock 体系里的精准唤醒机制
为什么需要 Condition,wait/notify 还不够用吗
Object.wait()/notify() 有个明显限制:每个对象只有一个等待队列 。如果一个场景里有两类完全不同的等待条件(比如"队列满了,生产者要等"和"队列空了,消费者要等"),用 wait/notify 只能把两类线程混在同一个等待队列里,notify() 唤醒谁完全随缘,容易出现"该醒的没醒,不该醒的被吵醒"。
Condition 就是为了解决这个问题:一个 Lock 可以创建多个 Condition,每个 Condition 有自己独立的等待队列,唤醒可以做到"精准打击"。
| 对比项 | Object.wait/notify |
Condition.await/signal |
|---|---|---|
| 依赖 | synchronized |
Lock |
| 等待队列数 | 1 个 | 多个(每次 newCondition() 一个) |
| 超时等待 | wait(timeout) |
await(time, unit) |
| 中断响应 | wait() 可中断 |
await() 可中断 |
| 等到指定时间点 | 不支持 | 支持 awaitUntil(Date) |
原理:两个队列之间的转移
每个 Condition 内部维护一个独立的等待队列,与 Day05 讲过的 AQS 同步队列相互配合:
一句话理解:await() 相当于"先把自己从抢锁的队伍里挪到专门的等候室,同时把锁让出来";signal() 则是"把等候室里排最前面的人重新放回抢锁的队伍"。
signal 和 signalAll
java
condition.signal(); // 只唤醒等待队列的队头节点(一个)
condition.signalAll(); // 唤醒等待队列中的所有节点
有界缓冲区:生产者消费者的标准实现
java
class BoundedBuffer<T> {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition(); // 生产者等待队列
private final Condition notEmpty = lock.newCondition(); // 消费者等待队列
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
BoundedBuffer(int capacity) { this.capacity = capacity; }
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity)
notFull.await(); // 满了,生产者进notFull队列等待
queue.offer(item);
notEmpty.signal(); // 精准唤醒notEmpty队列里的消费者
} finally { lock.unlock(); }
}
public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty())
notEmpty.await(); // 空了,消费者进notEmpty队列等待
T item = queue.poll();
notFull.signal(); // 精准唤醒notFull队列里的生产者
return item;
} finally { lock.unlock(); }
}
}
关键细节:用 while 而不是 if 检查条件 ------线程被唤醒后,可能有别的线程抢先一步把数据消费/填满了,条件已经又不满足了,所以醒来后必须重新检查一遍,而不能想当然地认为条件一定成立。这个坑在 wait/notify 里同样存在,是并发编程里最容易被面试官抓出来的细节之一。
这个例子也直观展示了 Condition 相比 wait/notify 的优势 :生产者只会被"队列不满"这个条件精准唤醒,消费者只会被"队列不空"精准唤醒,两类线程互不干扰,不会出现用 notifyAll() 时"把所有等待线程都吵醒、结果大部分又发现条件不满足只能重新睡回去"的无效唤醒问题。
一个容易被忽略的限制
读写锁的读锁不支持 Condition ------调用 readLock.newCondition() 会直接抛异常,因为"多个线程共享持有的锁"和"某一个线程专属的等待/唤醒"这两个语义本身是冲突的。
五、面试追问
Q1:ReentrantReadWriteLock 解决了什么问题,适合什么场景?
解决"读多写少"场景下用独占锁导致并发读被不必要串行化的问题。它把锁拆成读锁(共享,多个线程可同时持有)和写锁(独占)两把,读操作之间不互斥,只有写操作之间、以及读写之间才互斥。典型场景是缓存:大量并发读、偶尔才有一次写。
Q2:ReentrantReadWriteLock 底层是怎么用一个 state 表示两种锁状态的?
把 32 位的 state 拆成高 16 位和低 16 位:高 16 位记录当前持有读锁的线程数,低 16 位记录写锁的重入次数。读锁数 = state 无符号右移 16 位,写锁数 = state 与 0xFFFF 做按位与。这样只用一个整数配合位运算,就能同时表达两种锁的状态,复用了 AQS 现成的 CAS 和排队机制。
Q3:为什么支持锁降级(写转读),不支持锁升级(读转写)?
锁降级(持有写锁时,先获取读锁再释放写锁)能保证从写到读这段过程数据的可见性不被其他写线程打断,是安全的。锁升级(持有读锁时申请写锁)则会导致死锁:写锁需要等所有读锁释放才能获取,而自己持有的这个读锁又不会主动释放,相当于自己等自己,永远无法完成。
Q4:Condition 相比 Object 的 wait/notify 有什么优势?
wait/notify 依赖 synchronized,且每个对象只有一个等待队列,多种等待条件的线程会混在一起,notify() 唤醒谁是不确定的,容易出现无效唤醒。Condition 依赖 Lock,一个 Lock 可以创建多个 Condition,每个都有独立的等待队列,可以针对不同的等待条件做"精准唤醒"(比如生产者消费者场景里 notFull/notEmpty 分开),避免无关线程被无效唤醒。
Q5:BoundedBuffer 示例里为什么要用 while 而不是 if 判断条件?
因为线程被唤醒后不代表条件一定还成立------可能在它被唤醒、真正抢到锁执行之前,条件又被其他线程改变了(比如队列刚被唤醒说"不满",结果被另一个更快的生产者抢先塞满了)。用 while 能保证每次从等待中恢复执行后都重新检查一遍条件,条件不满足就继续 await(),这是写等待通知模型时必须遵守的写法,用 if 是经典的并发 bug 来源。
下一篇预告
Day07 讲 CAS 和原子类------AtomicInteger 这类无锁数据结构到底是怎么在不加锁的情况下保证线程安全的,以及 CAS 经典的 ABA 问题是怎么回事。