读写锁在并发编程中常用于读多写少场景,但如果使用不当,很容易导致死锁、性能下降甚至系统崩溃。从实际案例出发,分析三种典型读写锁死锁场景,并给出规避方法,能帮助开发者避免常见陷阱。
读写锁(ReadWriteLock)允许多个线程同时读取共享资源,但只允许一个线程写入。读锁(ReadLock)与写锁(WriteLock)互斥,写锁与任何锁互斥。这种设计在读多写少的场景下能大幅提升并发性能。例如,在Java中,ReentrantReadWriteLock是标准实现,但使用不当极易引发死锁。据Oracle官方文档显示,读写锁的死锁风险主要集中在锁升级、锁降级以及嵌套锁场景。
锁升级是指一个线程持有了读锁后,试图获取写锁。由于写锁需要等待所有读锁释放,而该线程自身持有读锁,导致循环等待,形成死锁。以下代码演示了这种错误:
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
rwLock.readLock().lock();
try {
// 执行读操作
rwLock.writeLock().lock(); // 死锁发生!
} finally {
rwLock.readLock().unlock();
}正确的做法是:如果需要升级,先释放读锁,再获取写锁,或者使用不支持锁升级的StampedLock的乐观读模式。
锁降级是指线程持有写锁时获取读锁,这是允许的,可以防止其他线程在释放写锁前插入。但降级后如果不正确释放写锁,可能导致其他线程无法获取写锁。例如,在降级后先释放读锁再释放写锁,会造成写锁的持有者释放后,读锁仍被持有,但写锁已释放,可能引发状态不一致。更危险的是,在降级过程中如果逆序释放,会导致死锁。正确顺序是:先获取读锁,再释放写锁,最后释放读锁。
| 操作 | 正确顺序 | 错误顺序 |
|---|---|---|
| 获取写锁 | 1.写锁.lock() | 1.写锁.lock() |
| 获取读锁(降级) | 2.读锁.lock() | 2.读锁.lock() |
| 释放写锁 | 3.写锁.unlock() | 3.写锁.unlock() |
| 释放读锁 | 4.读锁.unlock() | 4.读锁.unlock() |
如果步骤3和4颠倒,在写锁释放后,读锁仍在持有,其他线程无法获取写锁,但也不影响读,不会立即死锁。但若在降级过程中持有读锁而再次尝试获取写锁,则会死锁。
当读写锁与synchronized或其他锁嵌套使用时,容易产生死锁。例如,一个线程先获取synchronized锁,再获取读写锁的写锁;另一个线程先获取读写锁的读锁,再获取synchronized锁,形成循环等待。这种场景常出现在重构遗留代码时,开发者未考虑锁的顺序。规避方法是:统一锁的获取顺序,或使用tryLock超时机制避免无限等待。
以下是经过验证的规避策略:
StampedLock的乐观读模式,它不支持锁升级,可减少死锁风险。tryLock方法,在指定时间内无法获取锁则放弃,避免永久阻塞。在缓存实现中,读多写少的场景最适合使用读写锁。例如,使用ReentrantReadWriteLock保护ConcurrentHashMap的批量更新。但要注意,如果写操作频繁,读写锁的性能可能不如synchronized或StampedLock。据《Java并发编程实战》一书指出,读写锁的写锁竞争激烈时,会导致读线程饥饿。因此,推荐使用StampedLock的乐观读模式,它在读多写多场景下表现更优。以下是一个使用StampedLock的缓存示例片段:
private final StampedLock sl = new StampedLock();
public String get(String key) {
long stamp = sl.tryOptimisticRead();
String value = cache.get(key);
if (!sl.validate(stamp)) {
stamp = sl.readLock();
try {
value = cache.get(key);
} finally {
sl.unlockRead(stamp);
}
}
return value;
}这个示例利用乐观读避免写锁竞争,只有在数据被修改时才会升级为读锁,显著降低了死锁风险。
总之,读写锁是一把双刃剑,正确使用能大幅提升并发性能,但使用不当则可能引发死锁。通过理解上述三种典型场景及规避方法,开发者可以更安全地运用读写锁,构建高并发系统。
评论
加载中...