ReentrantReadWriteLock:读写分离与AQS实现
ReentrantReadWriteLock通过AQS实现读写分离:读锁共享,写锁排他。它专为“读多写少”场景设计以提升性能,并支持锁降级。本文深入剖析其状态位设计、公平性及实现原理。
我们来深入剖析一下 ReentrantReadWriteLock 的工作原理。这部分内容会稍微有点硬核,但理解后会对 Java 并发编程有更深的认识。
ReentrantReadWriteLock(可重入读写锁)是 Java 并java.util.concurrent.locks包下的一个核心工具,它解决了ReentrantLock这种排他锁在“读多写少”场景下性能低下的问题。
1. 核心思想:读写分离与锁降级
ReentrantReadWriteLock的核心思想是读写分离。它内部维护了两个锁:一个读锁 (Read Lock) 和一个写锁 (Write Lock)。这两个锁共享同一个等待队列,并遵循以下规则:
- 读锁是共享的 (Shared):多个线程可以同时持有读锁。只要没有线程持有写锁,任何线程都可以成功获取读锁。
- 写锁是排他的 (Exclusive):同一时间只能有一个线程持有写锁。当一个线程持有写锁时,其他任何线程(无论是想获取读锁还是写锁)都必须等待。
- 互斥关系:
- 读锁和写锁是互斥的。
- 写锁和写锁是互斥的。
这个设计极大地提高了在读多写少环境下的并发性能。想象一个缓存系统,绝大多数操作是读取数据,只有少数操作是更新数据。如果使用ReentrantLock,那么即使是并发的读操作也必须串行执行,严重影响效率。而使用ReentrantReadWriteLock,多个读操作可以同时进行,只有在写操作时才需要互斥。
2. 底层实现:AQS 与 “状态” 的精妙设计
和ReentrantLock一样,ReentrantReadWriteLock也是基于AbstractQueuedSynchronizer (AQS) 实现的。但它的实现更为巧妙,因为它需要在一个int类型的state变量中同时表示读锁和写锁的状态。
AQS 中的 state 是一个32位的int类型。ReentrantReadWriteLock将这32位“劈”成了两半:
- 高16位:用来表示读锁的持有数量。
- 低16位:用来表示写锁的重入次数。
// ReentrantReadWriteLock.Sync (AQS的子类)
// 伪代码,方便理解
int state; // AQS中的状态变量
// 获取读锁的数量
int readCount = state >>> 16;
// 获取写锁的重入次数
int writeCount = state & 0x0000FFFF; // 0xFFFF 是 16个1
这个设计是整个实现的核心。通过位运算,它能高效地在一个整型变量中管理两种不同锁的状态。
3. 原理详解
我们分别来看读锁和写锁的获取与释放过程。
A. 写锁的获取与释放 (Exclusive)
写锁是一个完全的排他锁,其逻辑相对简单,和ReentrantLock类似。
获取写锁 (writeLock.lock()):
- 检查状态:首先读取
state变量。 - 判断锁是否空闲:
- 如果
state为0,表示没有任何锁被持有。当前线程可以尝试通过 CAS (Compare-And-Set) 操作将state的低16位(写锁计数)设置为1。如果成功,表示获取锁成功,并记录当前线程为锁的持有者。 - 如果
state不为0,说明已经有锁被持有了。此时需要进一步判断:- 检查读锁:如果高16位(读锁计数)不为0,意味着有线程持有读锁。当前线程获取写锁失败,进入AQS的等待队列。
- 检查写锁:如果低16位(写锁计数)不为0,意味着有线程持有写锁。
- 判断是否重入:检查持有写锁的线程是否是当前线程。如果是,则将
state的低16位加1(写锁重入次数+1),获取锁成功。 - 如果不是当前线程,则获取锁失败,进入AQS的等待队列。
- 判断是否重入:检查持有写锁的线程是否是当前线程。如果是,则将
- 如果
释放写锁 (writeLock.unlock()):
- 将
state的低16位减1(写锁重入次数-1)。 - 如果减1后,写锁计数变为0,说明锁已完全释放。
- 唤醒在AQS队列中等待的下一个线程(可能是读线程,也可能是写线程)。
B. 读锁的获取与释放 (Shared)
读锁是一个共享锁,逻辑比写锁复杂。
获取读锁 (readLock.lock()):
- 检查写锁:首先检查
state的低16位(写锁计数)。- 如果写锁计数大于0,并且持有写锁的线程不是当前线程,那么获取读锁失败,当前线程进入AQS等待队列。
- 例外情况(锁降级):如果持有写锁的线程是当前线程,那么可以成功获取读锁。这是锁降级的基础。
- 尝试获取:如果不存在写锁(或者写锁被当前线程持有),当前线程就可以尝试获取读锁。它会通过 CAS 操作将
state的高16位加1(读锁计数+1)。 - 处理并发:由于多个线程可以同时获取读锁,所以CAS操作可能会失败(因为其他线程也在修改
state)。如果失败,会进入一个循环,不断重试,直到成功获取读锁。
释放读锁 (readLock.unlock()):
- 将
state的高16位减1(读锁计数-1)。 - 这个过程也需要通过 CAS 循环来保证线程安全。
- 当读锁计数变为0时,如果AQS队列中有等待的写线程,会将其唤醒。
4. 关键特性
A. 可重入性 (Reentrancy)
- 写锁可重入:持有写锁的线程可以再次获取写锁。
- 读锁可重入:持有读锁的线程可以再次获取读锁。
- 写锁可以重入读锁:持有写锁的线程可以获取读锁。
B. 锁升降级 (Lock Upgrading/Downgrading)
锁降级 (支持):一个线程在持有写锁的情况下,可以继续获取读锁,然后释放写锁。这个过程是安全的。
- 场景:当你需要更新一块数据,更新完后,需要读取这块数据进行其他操作,并且希望在读取时允许其他读线程进入。
- 过程:
getWriteLock -> getReadLock -> releaseWriteLock。
锁升级 (不支持):一个线程在持有读锁的情况下,不能直接获取写锁。如果尝试这样做,会导致死锁。
- 原因:假设线程A和线程B都持有读锁,然后都想升级为写锁。它们都会等待对方释放读锁,但对方也在等待,于是形成死锁。因此,
ReentrantReadWriteLock不允许这种行为。 - 正确做法:必须先释放所有读锁,再去竞争写锁。
- 原因:假设线程A和线程B都持有读锁,然后都想升级为写锁。它们都会等待对方释放读锁,但对方也在等待,于是形成死锁。因此,
C. 公平与非公平策略
和ReentrantLock一样,ReentrantReadWriteLock也支持公平和非公平两种模式。
- 非公平锁 (默认):允许插队。无论等待队列中是否有线程,新来的线程都可以尝试获取锁。吞吐量高,但可能导致某些线程“饥饿”。
- 公平锁:严格按照线程在AQS队列中的顺序来分配锁。可以防止饥饿,但上下文切换开销大,吞吐量较低。
- 在公平模式下,如果等待队列的头部是一个写线程,那么后来的读线程请求会被阻塞,即使当前没有写锁。这是为了防止写锁饥饿。
5. 潜在问题:写锁饥饿
在“读多写少”且读操作非常频繁的场景下,特别是使用非公平策略时,可能会出现写锁饥饿问题。
原因:当一个写线程正在等待获取锁时,如果不断有新的读线程请求进来,并且它们能够成功获取读锁(因为读锁是共享的),那么写线程就可能一直等待,无法获取到执行机会。
解决方案:
- 使用公平锁策略。公平锁会保证等待时间最长的线程(无论是读还是写)优先获得锁。
- 如果性能要求极高,可以考虑使用 Java 8 引入的
StampedLock,它提供了更复杂的“乐观读”模式,性能更好,但使用也更复杂,且不是可重入的。
6. 代码示例
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReentrantReadWriteLock;
class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
private final ReentrantReadWriteLock.ReadLock readLock = lock.readLock();
private final ReentrantReadWriteLock.WriteLock writeLock = lock.writeLock();
// 读操作,使用读锁,可以并发执行
public V get(K key) {
readLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在读取...");
Thread.sleep(100); // 模拟耗时
return map.get(key);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
System.out.println(Thread.currentThread().getName() + " 读取完毕。");
readLock.unlock();
}
}
// 写操作,使用写锁,独占执行
public V put(K key, V value) {
writeLock.lock();
try {
System.out.println(Thread.currentThread().getName() + " 正在写入...");
Thread.sleep(100); // 模拟耗时
return map.put(key, value);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return null;
} finally {
System.out.println(Thread.currentThread().getName() + " 写入完毕。");
writeLock.unlock();
}
}
}
总结
| 特性 | 描述 |
|---|---|
| 核心原理 | 基于 AQS,通过将 int state 变量拆分为高16位(读锁)和低16位(写锁)来管理两种锁。 |
| 锁类型 | 读锁(共享)、写锁(排他)。 |
| 并发规则 | 读-读可并发,读-写互斥,写-写互斥。 |
| 适用场景 | 读多写少的高并发场景,如缓存、配置中心等。 |
| 关键特性 | 可重入、支持锁降级、不支持锁升级(会死锁)、支持公平/非公平策略。 |
| 潜在问题 | 在高并发读场景下,可能导致写锁饥饿(尤其是在非公平模式下)。 |