ReentrantReadWriteLock 与 StampedLock
问题
读写锁的实现原理是什么?什么是锁降级?StampedLock 解决了什么问题?
答案
为什么需要读写锁?
普通的互斥锁(ReentrantLock / synchronized)在读多写少场景下性能不佳:多个线程同时读取共享数据不会产生竞态,但互斥锁让所有读操作也串行化了。
读写锁的核心思想:
- 读读共享:多个读线程可以同时获取读锁
- 读写互斥:读锁与写锁互斥
- 写写互斥:同一时刻只能有一个写线程
ReentrantReadWriteLock
import java.util.concurrent.locks.*;
public class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
// 读操作:多线程可并发读取
public V get(K key) {
readLock.lock();
try {
return map.get(key);
} finally {
readLock.unlock();
}
}
// 写操作:独占访问
public void put(K key, V value) {
writeLock.lock();
try {
map.put(key, value);
} finally {
writeLock.unlock();
}
}
}
底层原理:state 的高低 16 位
ReentrantReadWriteLock 基于 AQS 实现,巧妙地将 state 的 32 位拆分为两部分:
|<--- 高 16 位(读锁计数)--->|<--- 低 16 位(写锁重入次数)--->|
| 读锁持有数 | 写锁重入次数 |
static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT); // 65536
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; // 65535
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 0x0000FFFF
// 读锁数量 = state 无符号右移 16 位
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 写锁重入数 = state & 低 16 位掩码
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
高 16 位只记录了读锁的总持有数(所有线程的读锁总和),每个线程各自的读锁重入次数通过 ThreadLocal<HoldCounter> 单独维护。
获取写锁流程
protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c); // 写锁重入次数
if (c != 0) {
// state != 0 说明有锁被持有
// w == 0 说明有读锁(高位不为 0),写锁不能获取(读写互斥)
// w != 0 但不是当前线程(写写互斥)
if (w == 0 || current != getExclusiveOwnerThread())
return false;
// 当前线程持有写锁,重入
setState(c + acquires);
return true;
}
// state == 0,无锁状态,CAS 获取写锁
if (writerShouldBlock() || !compareAndSetState(c, c + acquires))
return false;
setExclusiveOwnerThread(current);
return true;
}
获取读锁流程
protected final int tryAcquireShared(int unused) {
Thread current = Thread.currentThread();
int c = getState();
// 如果有写锁且不是当前线程持有 → 失败(读写互斥)
// 注意:如果写锁是当前线程持有 → 可以获取读锁(锁降级)
if (exclusiveCount(c) != 0 && getExclusiveOwnerThread() != current)
return -1;
int r = sharedCount(c); // 读锁计数
// CAS 增加读锁计数(高 16 位 +1)
if (!readerShouldBlock() && r < MAX_COUNT &&
compareAndSetState(c, c + SHARED_UNIT)) {
// 更新当前线程的 HoldCounter
return 1;
}
return fullTryAcquireShared(current); // 完整版处理
}
锁降级
锁降级:持有写锁的线程获取读锁,然后释放写锁,最终持有读锁。
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
// 锁降级过程
writeLock.lock(); // 1. 获取写锁
try {
// 修改数据
data = newData;
readLock.lock(); // 2. 获取读锁(降级的关键步骤)
} finally {
writeLock.unlock(); // 3. 释放写锁(此时仍持有读锁)
}
try {
// 读取数据(此时是读锁保护)
use(data);
} finally {
readLock.unlock(); // 4. 释放读锁
}
ReentrantReadWriteLock 不支持从读锁升级为写锁。如果持有读锁时尝试获取写锁,会导致死锁(写锁等待读锁释放,而读锁等待获取写锁后才释放)。
锁降级的意义:写操作完成后,保持读锁可以防止其他写线程在中间修改数据,确保当前线程能读到自己刚写入的最新值。
写锁饥饿问题
在读多写少的场景下,读锁持续被获取,写线程可能长时间无法拿到写锁,造成写锁饥饿。
公平模式 new ReentrantReadWriteLock(true) 可以缓解,但会降低吞吐量。
StampedLock(JDK 8)
StampedLock 是 JDK 8 引入的新读写锁,解决了 ReentrantReadWriteLock 的写锁饥饿问题,并提供更高性能的乐观读:
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock sl = new StampedLock();
// 写锁(独占)
public void move(double deltaX, double deltaY) {
long stamp = sl.writeLock();
try {
x += deltaX;
y += deltaY;
} finally {
sl.unlockWrite(stamp);
}
}
// 乐观读(无锁)—— 性能最高
public double distanceFromOrigin() {
// 1. 获取乐观读戳记(不加锁,不阻塞写线程)
long stamp = sl.tryOptimisticRead();
double currentX = x, currentY = y;
// 2. 检查期间是否有写操作发生
if (!sl.validate(stamp)) {
// 3. 有写操作,升级为悲观读锁
stamp = sl.readLock();
try {
currentX = x;
currentY = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
// 悲观读锁(与 ReadWriteLock 的读锁类似)
public double getX() {
long stamp = sl.readLock();
try {
return x;
} finally {
sl.unlockRead(stamp);
}
}
}
StampedLock 三种模式
| 模式 | 特点 | 适用场景 |
|---|---|---|
| 写锁 | 独占,与其他所有锁互斥 | 写操作 |
| 悲观读锁 | 共享,与写锁互斥 | 读操作(需要一致性保证) |
| 乐观读 | 无锁,不阻塞写 | 读操作(读多写少,允许重试) |
StampedLock vs ReentrantReadWriteLock
| 对比 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| 乐观读 | 不支持 | 支持(tryOptimisticRead) |
| 可重入 | 是 | 否 |
| Condition | 支持 | 不支持 |
| 写锁饥饿 | 可能 | 不会 |
| 锁降级/升级 | 支持降级 | 支持 tryConvertToReadLock |
- 不可重入:同一线程再次获取会死锁
- 不支持 Condition
- 不要在 interrupt 中使用:如果线程在
readLock()/writeLock()时被中断,可能导致 CPU 100%(JDK bug,后续版本已修复) - 使用乐观读时必须复制变量到局部变量再 validate
常见面试问题
Q1: 读写锁的适用场景是什么?
答案:
读写锁适用于读多写少的场景,如缓存、配置管理、数据统计等。在这些场景下,读操作占绝大多数,用读写锁可以让读操作并发执行,显著提升吞吐量。
如果读写比例差不多甚至写多读少,读写锁的开销(state 拆分、HoldCounter 维护)反而不如普通的 ReentrantLock。
Q2: 什么是锁降级?为什么不支持锁升级?
答案:
- 锁降级:写锁 → 获取读锁 → 释放写锁 → 持有读锁。目的是保证写操作后能安全读取自己刚写入的数据。
- 锁升级:读锁 → 获取写锁。不支持,因为如果两个线程同时持有读锁并都尝试升级为写锁,会导致死锁(互相等待对方释放读锁)。
Q3: StampedLock 的乐观读是怎么实现的?
答案:
乐观读不是真正的加锁,只是记录一个 stamp(版本号)。读取数据后通过 validate(stamp) 检查期间是否有写操作。如果没有则数据有效,如果有则升级为悲观读锁重新读取。
这种方式类似数据库的 MVCC 或 CAS 思想:先假设不会冲突,读完再验证。
Q4: ReentrantReadWriteLock 的公平性如何体现?
答案:
- 非公平模式(默认):读线程在队头是写线程时让步(
apparentlyFirstQueuedIsExclusive),写线程直接 CAS 竞争 - 公平模式:严格按队列顺序,读写都先检查
hasQueuedPredecessors()
非公平模式写线程优先的设计可以一定程度缓解写锁饥饿。
Q5: ReentrantReadWriteLock 中读锁和写锁共用一个 AQS 吗?
答案:
是的,共用同一个 AQS 实例。state 的高 16 位表示读锁持有数量,低 16 位表示写锁重入次数。这也意味着读锁和写锁的最大重入/持有数量都是 。
相关链接
- ReentrantReadWriteLock - Java 17 API
- StampedLock - Java 17 API
- Lock 接口与 AQS - AQS 核心原理
- synchronized 关键字 - synchronized 锁机制对比
- CAS 与原子类 - 乐观锁底层原理