1. 为什么需要读写锁?
在Java并发编程中,我们经常遇到这样的场景:一个共享资源被频繁读取但很少修改。如果使用普通的互斥锁(如synchronized或ReentrantLock),所有访问(包括读操作)都必须串行执行,这会导致性能瓶颈。
我曾在实际项目中遇到过这样的案例:一个配置中心服务需要处理每秒上万次的配置读取请求,但配置更新可能几小时才发生一次。最初使用synchronized实现时,系统吞吐量只有约2000QPS,而改用读写锁后性能直接提升了8倍。
读写锁的核心思想是"读读共享,读写互斥,写写互斥"。这种设计源于一个观察:多个线程同时读取共享资源不会引发线程安全问题,只有写入操作才需要独占访问。ReentrantReadWriteLock就是这种思想的Java实现。
提示:不要因为看到"读写锁"性能好就盲目使用。只有在读操作远多于写操作(通常10:1以上)且持有锁时间较长的场景下,读写锁的优势才能体现。对于简单的原子操作,volatile或原子类可能更合适。
2. ReentrantReadWriteLock的核心设计
2.1 锁的升降级问题
在分析源码前,我们需要理解一个关键概念:锁的升降级。这是面试中经常被问到的难点。
锁降级指的是持有写锁的线程同时获取读锁,然后释放写锁的过程。这种操作是安全的,也是ReentrantReadWriteLock明确支持的。例如:
java复制writeLock.lock();
try {
// 修改共享数据
readLock.lock(); // 降级开始
} finally {
writeLock.unlock(); // 降级完成
}
// 此时仍持有读锁
但锁升级(持有读锁时获取写锁)则会导致死锁。这是因为可能有多个线程同时持有读锁,它们都尝试升级为写锁时会相互等待。源码中通过抛出IllegalMonitorStateException来防止这种操作。
2.2 状态字段的巧妙设计
查看源码会发现一个有趣的设计:ReentrantReadWriteLock使用单个int类型的state字段同时维护读锁和写锁的状态。这个int被拆分为两部分:
- 高16位表示读锁的持有数量
- 低16位表示写锁的重入次数
这种设计带来了几个好处:
- 通过位运算可以快速获取读/写锁的状态
- 原子操作一个字段比操作多个字段更简单高效
- 减少了内存占用
对应的核心代码在Sync类中:
java复制static final int SHARED_SHIFT = 16;
static final int SHARED_UNIT = (1 << SHARED_SHIFT);
static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;
// 获取读锁数量
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 获取写锁重入次数
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
3. 读锁获取的深层逻辑
3.1 读锁的获取流程
读锁的获取比表面看起来复杂得多。当调用readLock.lock()时,主要经历以下步骤:
- 如果写锁未被其他线程持有,且获取读锁的线程不需要阻塞,则直接获取读锁
- 否则进入等待队列
- 获取读锁后,会记录每个线程的读锁持有计数(通过ThreadLocal实现)
这里有个关键优化:第一个获取读锁的线程会记录在firstReader字段中,它的计数保存在firstReaderHoldCount。这样设计是因为大多数情况下读锁只会被少数线程频繁获取,单独记录可以避免ThreadLocal的性能开销。
3.2 读锁的公平性问题
ReentrantReadWriteLock提供了公平和非公平两种模式。在非公平模式下,读锁可能会"插队"获取锁,即使等待队列中有先到的写锁请求。这虽然提高了吞吐量,但可能导致写锁饥饿。
我曾在生产环境遇到过一个典型问题:一个高频读取的系统在压力测试时,写操作经常超时。最终发现是因为非公平模式下大量读请求导致写请求长时间得不到执行。解决方案是改用公平模式,虽然吞吐量下降了15%,但写操作的延迟变得可控。
公平模式的实现关键在于hasQueuedPredecessors()方法的判断:
java复制public final boolean hasQueuedPredecessors() {
Node t = tail;
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
4. 写锁的独占特性
4.1 写锁的获取条件
写锁是独占锁,要成功获取写锁必须满足:
- 当前没有其他线程持有读锁(sharedCount == 0)
- 当前没有其他线程持有写锁(exclusiveCount == 0),除非是当前线程重入
源码中对应的检查在tryAcquire方法:
java复制protected final boolean tryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
int w = exclusiveCount(c);
if (c != 0) {
// 存在读锁,或者存在其他线程的写锁
if (w == 0 || current != getExclusiveOwnerThread())
return false;
if (w + exclusiveCount(acquires) > MAX_COUNT)
throw new Error("Maximum lock count exceeded");
}
// ...后续代码
}
4.2 写锁的释放过程
写锁释放时需要特别注意重入情况。每次unlock()只会减少重入计数,只有当计数归零时才会真正释放锁并唤醒等待线程。
一个常见的错误是在多次lock()后没有对应次数的unlock():
java复制writeLock.lock();
writeLock.lock(); // 重入
// 业务逻辑
writeLock.unlock(); // 错误:少了一次unlock,锁未完全释放
这种问题通常不会立即显现,但会导致其他线程长时间等待,形成难以排查的死锁。建议使用try-finally确保释放:
java复制writeLock.lock();
try {
writeLock.lock(); // 重入
try {
// 业务逻辑
} finally {
writeLock.unlock();
}
} finally {
writeLock.unlock();
}
5. 性能优化与注意事项
5.1 锁降级的正确用法
锁降级是ReentrantReadWriteLock的一个重要特性,但使用不当会导致问题。正确的锁降级模式应该是:
java复制// 获取写锁
rwlock.writeLock().lock();
try {
// 修改数据
// 获取读锁(不释放写锁)
rwlock.readLock().lock();
} finally {
// 释放写锁(此时仍持有读锁)
rwlock.writeLock().unlock();
}
// 此时处于锁降级状态(持有读锁)
try {
// 读取数据
} finally {
rwlock.readLock().unlock();
}
注意不能在释放写锁之前就释放读锁,否则其他写线程可能在你完成降级前修改数据。
5.2 避免死锁的策略
虽然读写锁本身不会导致死锁,但与其他锁配合使用时仍需小心。以下是一个典型的死锁场景:
java复制// 线程A
readLock.lock();
try {
// 尝试获取另一把锁
otherLock.lock();
try { /* ... */ } finally { otherLock.unlock(); }
} finally { readLock.unlock(); }
// 线程B
otherLock.lock();
try {
// 尝试获取写锁(会被线程A的读锁阻塞)
writeLock.lock();
try { /* ... */ } finally { writeLock.unlock(); }
} finally { otherLock.unlock(); }
为避免这种情况,建议:
- 统一锁的获取顺序
- 使用tryLock()设置超时时间
- 避免在持有锁时调用外部方法
6. 源码中的精妙设计
6.1 缓存行填充优化
在Sync类的实现中,JDK开发者使用了缓存行填充(Cache Line Padding)来避免伪共享:
java复制abstract static class Sync extends AbstractQueuedSynchronizer {
// 为每个线程的读锁计数
static final class HoldCounter {
int count;
// 使用伪随机数作为线程ID的种子
final long tid = LockSupport.getThreadId(Thread.currentThread());
}
// 使用填充防止伪共享
static final class ThreadLocalHoldCounter
extends ThreadLocal<HoldCounter> {
public HoldCounter initialValue() {
return new HoldCounter();
}
}
private transient ThreadLocalHoldCounter readHolds;
private transient HoldCounter cachedHoldCounter;
// ...其他字段
}
这种优化在高并发场景下可以显著提升性能,因为不同CPU核心不会因为频繁访问同一缓存行而导致无效的缓存同步。
6.2 锁获取的快速路径
在非公平模式下,获取锁时会先尝试快速路径(fast path):
java复制final boolean tryReadLock() {
Thread current = Thread.currentThread();
for (;;) {
int c = getState();
// 如果有写锁且不是当前线程持有,失败
if (exclusiveCount(c) != 0 &&
getExclusiveOwnerThread() != current)
return false;
// ...其他检查
}
}
这种设计减少了CAS操作的开销,是ReentrantReadWriteLock高性能的关键之一。在实际测试中,快速路径成功率能达到90%以上。
7. 实际应用中的经验分享
7.1 监控锁竞争情况
在生产环境中,我们需要监控读写锁的竞争情况。可以通过以下方式获取有用信息:
java复制ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
// 获取等待读锁的线程数
int readersWaiting = rwLock.getQueueLength();
// 获取等待写锁的线程数(需要额外实现)
我曾经基于这些指标实现了一个简单的熔断机制:当写锁等待线程超过阈值时,自动拒绝部分读请求,防止系统完全卡死。
7.2 与StampedLock的对比
Java 8引入了性能更好的StampedLock,但它不是可重入的,也没有条件变量支持。选择时需要考虑:
- 如果需要条件变量或可重入特性,用ReentrantReadWriteLock
- 如果追求极致性能且场景简单,用StampedLock
- 如果读操作非常频繁,考虑使用乐观读模式
在我的性能测试中,对于纯读操作,StampedLock的吞吐量是ReentrantReadWriteLock的2-3倍,但写操作差异不大。
8. 常见问题排查
8.1 读锁不释放导致写锁饥饿
这是最常见的问题之一。现象是写操作长时间无法执行,系统响应变慢。排查步骤:
- 使用jstack获取线程堆栈
- 查找"waiting to lock <0x000000076..."这样的条目
- 确认是否有线程长时间持有读锁
- 检查是否有忘记释放读锁的代码路径
解决方案包括:
- 使用try-finally确保锁释放
- 为读锁设置超时(tryLock(timeout))
- 限制单个线程的最大读锁持有时间
8.2 锁重入导致的死锁
虽然ReentrantReadWriteLock支持重入,但复杂的重入逻辑可能导致死锁。例如:
java复制// 线程A
writeLock.lock();
readLock.lock(); // 锁降级,没问题
writeLock.lock(); // 死锁!因为持有读锁时不能升级为写锁
这种问题通常会在代码审查时发现,但有时只在特定执行顺序下才会触发。建议使用静态分析工具检查锁的使用模式。
9. 性能调优实战
9.1 调整锁的公平性
公平模式和非公平模式的选择对性能影响很大。我的经验法则是:
- 默认使用非公平模式(吞吐量更高)
- 当写操作延迟敏感且读操作非常频繁时,考虑公平模式
- 在测试环境中对比两种模式的性能差异
可以通过以下方式创建公平锁:
java复制ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(true);
9.2 减少锁的持有时间
无论使用哪种模式,减少锁的持有时间都是提高性能的关键。一些优化技巧:
- 将锁范围内的计算移到锁外
- 使用局部变量暂存需要保护的数据
- 避免在锁内执行IO操作
- 对大对象进行分段加锁
我曾经优化过一个日志处理系统,通过将JSON序列化移到锁外,使吞吐量提升了40%。
10. 替代方案探讨
10.1 使用CopyOnWriteArrayList
对于读多写少的集合场景,CopyOnWriteArrayList可能是更好的选择。它的特点是:
- 读操作完全不加锁
- 写操作通过复制整个数组实现
- 适合集合较小且读操作非常频繁的场景
与读写锁的对比:
- 读写锁更灵活,适用于任何数据结构
- CopyOnWriteArrayList内存开销更大,但读性能更好
10.2 使用并发容器
Java的java.util.concurrent包提供了许多高性能并发容器,如ConcurrentHashMap。这些容器通常使用更细粒度的锁或CAS操作,在特定场景下比读写锁更高效。
选择依据:
- 如果需要保护的是简单的Map/Set/List操作,优先考虑并发容器
- 如果需要保护复杂的数据结构或业务逻辑,使用读写锁
- 如果访问模式不明确,进行性能测试后再决定
在实际项目中,我通常会先使用并发容器,只有当性能测试表明需要更精细的控制时才会转向读写锁。
