ReentrantReadWriteLock 最佳实践
以下是在实际开发中使用该锁的最佳实践建议,涵盖设计决策、性能优化、常见陷阱规避等方面。
1. 明确适用场景:读多写少
最佳实践:仅在读操作远多于写操作的场景下使用读写锁。
- 适用示例:配置中心、缓存系统、路由表、字典数据、树形结构的统计查询。
- 不适用示例:写操作超过 30% 的场景(如频繁更新的计数器、实时日志写入)。此时读写锁的 CAS 开销可能超过其收益,甚至比独占锁更慢。
判断标准:可以通过性能测试或监控评估读写比例。如果写比例 > 30%,考虑使用 ConcurrentHashMap、CopyOnWriteArrayList 或无锁数据结构。
2. 优先使用非公平模式(默认)
最佳实践:除非有明确的公平性需求,否则使用非公平锁(默认构造函数)。
- 原因:非公平锁吞吐量更高(比公平锁高 20%~40%),大多数业务对锁获取的绝对公平不敏感。
- 何时使用公平锁:
- 写线程必须及时执行,避免饥饿(例如实时交易系统)。
- 读线程不应对写线程造成无限延迟(但公平锁仍可能因读线程持续到来而延迟写线程,需结合其他策略)。
3. 严格控制读锁持有时间
最佳实践:读锁临界区应尽量短小,避免在持有读锁时执行耗时操作(如 I/O、远程调用、复杂计算)。
- 原因:读锁会阻塞写锁,长时间持有读锁会导致写线程饥饿,降低系统吞吐量和响应性。
- 改进方法:
- 将耗时操作移出读锁临界区,仅用读锁保护必要的共享变量读取。
- 如果必须读取大量数据,考虑使用“拷贝”模式:在读锁内快速复制数据到本地变量,然后释放读锁再处理副本。
- 对于极高并发读场景,考虑使用
StampedLock的乐观读。
示例(反模式):
// 错误:在读锁内执行耗时操作
readLock.lock();
try {
Data data = sharedData;
Thread.sleep(1000); // 模拟耗时
process(data);
} finally {
readLock.unlock();
}
改进后:
Data data;
readLock.lock();
try {
data = sharedData;
} finally {
readLock.unlock();
}
// 耗时操作在锁外执行
Thread.sleep(1000);
process(data);
4. 正确使用锁降级,避免锁升级
最佳实践:在“写后立即读”且需要保证数据一致性的场景下,使用锁降级;绝对不要尝试锁升级(读锁内获取写锁)。
-
锁降级标准模式:
writeLock.lock(); try { // 修改共享数据 data = newData; // 降级:获取读锁 readLock.lock(); } finally { writeLock.unlock(); // 释放写锁,保留读锁 } try { // 使用最新数据执行只读操作(此时其他读线程可并发) useData(data); } finally { readLock.unlock(); } -
锁升级死锁示例(禁止):
readLock.lock(); try { // ... writeLock.lock(); // 死锁!当前线程等待自己释放读锁 } finally { ... } -
注意事项:降级后务必在
finally中释放读锁,否则写线程永久阻塞。
5. 使用 tryLock() 避免无限等待
最佳实践:在可能发生锁竞争或需要避免死锁的场景下,使用 tryLock() 或 tryLock(long, TimeUnit) 替代 lock()。
- 适用场景:
- 需要获取多个锁时,使用
tryLock按顺序尝试并回退,避免死锁。 - 写操作可以容忍失败时(如缓存更新失败后可重试或降级)。
- 防止读锁长时间持有导致的写线程饥饿:写锁使用
tryLock带超时,若超时则记录日志或采取其他措施。
- 需要获取多个锁时,使用
示例(多锁顺序避免死锁):
ReentrantReadWriteLock lock1 = ...;
ReentrantReadWriteLock lock2 = ...;
if (lock1.writeLock().tryLock()) {
try {
if (lock2.writeLock().tryLock()) {
try {
// 安全操作
} finally {
lock2.writeLock().unlock();
}
}
} finally {
lock1.writeLock().unlock();
}
}
6. 避免读锁内调用未知代码(回调)
最佳实践:在持有读锁期间,不要调用可能获取其他锁(尤其是写锁)的外部方法,也不要调用可能阻塞的代码。
- 原因:外部方法可能尝试获取写锁(例如回调函数中更新数据),导致死锁。
- 解决方案:在读锁临界区内仅做简单的状态读取,所有可能产生副作用的调用移到锁外。
7. 使用 StampedLock 替代读写锁以获得更高性能(当不可重入可接受时)
最佳实践:在追求极致读性能且不需要锁重入的场景下,优先考虑 StampedLock。
-
对比:
特性 ReentrantReadWriteLock StampedLock 可重入 是 否 条件变量 写锁支持 不支持 乐观读 无 有 读多写少吞吐量 高 更高(乐观读无锁) API 复杂度 简单 复杂(需验证戳) -
典型
StampedLock乐观读模式:StampedLock sl = new StampedLock(); long stamp = sl.tryOptimisticRead(); Data data = sharedData; if (!sl.validate(stamp)) { stamp = sl.readLock(); try { data = sharedData; } finally { sl.unlockRead(stamp); } } process(data); -
迁移建议:如果现有代码使用
ReentrantReadWriteLock且没有重入需求,可以重构为StampedLock以提升性能。
8. 合理选择锁粒度
最佳实践:避免使用单一的全局读写锁保护大对象,考虑拆分为多个独立的读写锁(分段锁)。
- 示例:一个大型缓存可以按 key 的哈希值分段,每段使用独立的读写锁,减少竞争。
- 与
ConcurrentHashMap对比:ConcurrentHashMap已经实现了高效的分段锁(或 CAS),通常优于自己实现分段读写锁。仅在需要更复杂的读-写语义(如全局统计、批量写)时才自行实现。
9. 监控与调优
最佳实践:在生产环境中监控读写锁的竞争情况,必要时调整锁策略。
- 监控指标:
- 读锁和写锁的平均等待时间。
- 读锁和写锁的排队线程数。
- 锁降级发生的频率。
- 工具:使用 JConsole、VisualVM 或通过 AQS 提供的
getQueueLength()、getReadLockCount()等方法定期采集。 - 调优方向:
- 如果写锁等待时间过长,考虑降低读锁持有时间或改用
StampedLock。 - 如果写线程饥饿,考虑切换到公平锁或使用
tryLock超时机制。
- 如果写锁等待时间过长,考虑降低读锁持有时间或改用
10. 避免在 finally 块之外释放锁
最佳实践:始终在 finally 块中释放锁,确保即使发生异常也能释放。
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
反例:在 try 块末尾释放锁(若中间抛异常,锁永远不会释放)。
11. 注意重入次数限制
最佳实践:避免深度递归或循环中重复获取读锁/写锁超过 65535 次。
- 原因:
state的低 16 位和高 16 位分别限制了写锁和读锁总重入次数(最大 65535)。虽然正常情况下很难达到,但应避免代码错误导致无限重入(如递归中未正确释放锁)。
12. 文档化锁的使用约定
最佳实践:在代码注释中明确说明哪些字段受读锁保护,哪些受写锁保护,以及锁的获取顺序(如果有多个锁)。
/**
* 缓存容器。
* - 读锁保护所有读操作(get, size, containsKey)
* - 写锁保护所有写操作(put, remove, clear)
* - 不允许在持有读锁时获取写锁(禁止升级)
*/
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
总结:最佳实践速查表
| 实践项 | 核心原则 | 反模式 |
|---|---|---|
| 适用场景 | 读多写少(读比例 > 70%) | 写操作频繁 |
| 公平性 | 默认非公平 | 随意使用公平锁降低性能 |
| 读锁持有时间 | 尽量短小,耗时操作移出临界区 | 读锁内做 I/O 或复杂计算 |
| 锁降级 | 写后读使用降级,禁止升级 | 读锁内获取写锁(死锁) |
| 避免等待 | 使用 tryLock 带超时 | 盲目使用 lock() 可能导致死锁 |
| 重入限制 | 避免递归过深 | 忽略 65535 上限 |
| 锁释放 | finally 块释放 | 在 try 内释放 |
| 性能优化 | 考虑 StampedLock | 固守读写锁不评估 |
| 监控 | 采集等待队列长度 | 不监控锁竞争 |
| 文档 | 注释锁保护范围 | 无文档说明 |
遵循以上最佳实践,可以充分发挥 ReentrantReadWriteLock 的优势,避免常见的性能陷阱和并发错误。