上一篇通过Condition讨论了“业务条件什么时候满足”。接下来换一个常见场景:一份共享数据被频繁查询,偶尔更新。所有查询都排成一队,有时会浪费并行机会;放任读写交错,又容易看到更新到一半的数据。
ReadWriteLock为这类问题提供了读锁与写锁的配对协议。本文基于Java 21,以ReentrantReadWriteLock为例,先把访问边界划清,再讨论它适合解决什么问题。
一、读与写,先看操作是否改变共享状态
“查询方法”这个名字并不保证方法只读。例如获取数据时顺手更新访问次数、填充缓存,就已经包含写操作。划分读写应检查实际行为。
| 不同线程的访问组合 | 是否允许同时持锁 |
|---|---|
| 读与读 | 可以 |
| 读与写 | 不可以 |
| 写与写 | 不可以 |
同一个ReadWriteLock对象管理这一对锁。读路径和写路径若各自创建一个锁对象,就失去了共同约束。遵守同一锁协议时,写锁释放前的更新对后续获得对应读锁的线程可见。接口契约见Java 21 ReadWriteLock文档。
二、把锁边界放在数据结构外层
下面用商品目录作教学例子。内部是普通HashMap,更新持有写锁;读取时在读锁保护下复制快照,然后把不可修改的结果交给调用者。
图中的并排阅读对应多个读锁持有者,等待更新的卡片对应写请求。它是访问规则的示意,不表示公平队列中的精确调度顺序。
三、完整示例:读取快照,再更新目录
保存为ReadWriteDemo.java,使用JDK21运行:
importjava.util.HashMap;importjava.util.Map;importjava.util.concurrent.*;importjava.util.concurrent.locks.ReentrantReadWriteLock;publicclassReadWriteDemo{staticclassCatalog{privatefinalMap<String,Integer>prices=newHashMap<>();privatefinalReentrantReadWriteLocklock=newReentrantReadWriteLock();voidput(Stringname,intprice){lock.writeLock().lock();try{prices.put(name,price);}finally{lock.writeLock().unlock();}}Map<String,Integer>snapshot(){lock.readLock().lock();try{returnMap.copyOf(prices);}finally{lock.readLock().unlock();}}}publicstaticvoidmain(String[]args)throwsException{Catalogcatalog=newCatalog();catalog.put("book",42);try(ExecutorServicepool=Executors.newFixedThreadPool(2)){Future<Map<String,Integer>>first=pool.submit(catalog::snapshot);Future<Map<String,Integer>>second=pool.submit(catalog::snapshot);System.out.println("reader-1: "+first.get());System.out.println("reader-2: "+second.get());pool.submit(()->catalog.put("book",45)).get();System.out.println("after update: "+catalog.snapshot());}}}javac ReadWriteDemo.javajavaReadWriteDemo输出:
reader-1: {book=42} reader-2: {book=42} after update: {book=45}这里通过Future.get明确等待两个查询结束,再提交更新,因此输出便于复现。线程池提供了两个工作线程,但这段输出不能证明两次读取在时间上重叠,也不能当作性能测试。示例重点是共享数据的访问封装。
Map.copyOf在持锁期间复制结构,返回后调用者无法修改这个Map。示例的String和Integer也是不可变对象。如果value换成可变对象,浅复制仍会共享value引用,应进一步复制数据或约束对象的修改方式。
四、容易踩坑的地方:升级、降级与复查
ReentrantReadWriteLock支持写锁降级为读锁:持有写锁时先取得读锁,再释放写锁。这样从更新转入读取时,中间没有允许其他写线程插入的空档。
相反,线程持有读锁时直接调用writeLock().lock,不能完成读锁升级。需要更新时,应先释放读锁,再竞争写锁;拿到写锁后重新检查业务状态。释放与重新获取之间,别的线程可能已经完成了更新。具体约束见ReentrantReadWriteLock文档。
例如“缓存无效则刷新”的流程,应包含两次判断:读锁内发现无效;释放读锁并取得写锁后,再判断是否仍然无效。第二次判断能避免多个线程接连做同一份刷新工作。
降级适合“更新后需要继续在保护下使用数据”的情况。如果拿到的是完整不可变快照,通常可以释放锁后处理快照,结构也更简单。
五、使用前再核对四个问题
所有访问是否遵守协议?不能一部分方法加锁,另一部分直接操作内部Map,也不要把内部可变集合原样返回。
锁内是否有慢操作?网络调用、文件访问和耗时计算可能长时间挡住写线程。可以先在锁外准备候选数据,再用短写锁提交,但提交前要检查版本或前提是否仍成立。
读多是否就一定更快?读写锁自身也有协调成本。数据很小、读操作极短或写入频繁时,收益需要通过贴近实际负载的基准测试确认。本文不提供未经测量的加速比例。
是否需要公平性或Condition?默认构造是非公平策略,可使用new ReentrantReadWriteLock(true)选择公平策略;无参数tryLock不会遵守公平排队设置。只有写锁支持newCondition,读锁调用会抛出UnsupportedOperationException。这也衔接了上一篇Condition的使用边界。
对于独立key更新,ConcurrentHashMap可能更合适;需要协调多个字段、多个key或整体快照时,可以考虑在共同的数据边界上使用读写锁。选择之前,先写清楚一次业务操作必须一起保持一致的内容。
六、🧠 思维导图
七、总结
总结要点
读写分离的锁协议允许读取共享、更新独占。实际访问必须使用同一对锁,内部数据不能从其他路径绕开保护。
快照与对象边界同样重要。保护Map结构不等于保护其中所有可变对象,返回值也应符合并发约定。
锁转换与性能取舍需要具体分析。释放读锁后要复查状态,降级按顺序完成;是否值得引入读写锁,应由实际工作负载验证。
下一篇继续讨论StampedLock,理解乐观读取为什么需要校验,以及它与可重入读写锁的区别。
👉如果你觉得这篇文章对你有所帮助,欢迎点赞、收藏、分享!😊