17---读写锁ReadwriteLock——实现一个完备的缓存
管程和信号量这两个同步原语在 Java 语言中的实现,理论上用这两个同步原语中任何一个都可以解决所有的并发问题。那 Java SDK 并发包里为什么还有很多其他的工具类呢?原因很简单:分场景优化性能,提升易用性。
其中有个非常普遍的并发场景:读多写少场景。实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock,非常容易使用,并且性能很好。
一、读写锁都遵守以下三条基本原则:
- 允许多个线程同时读共享变量;
- 只允许一个线程写共享变量;
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
- 只有等待其他读线程都释放了读锁,写锁才能被当前线程获取,而写锁一旦被获取,则其他读写线程的后续访问均被阻塞。
最后一点可以反过来想,假如一个线程获取了读锁,而另外一个线程获取了写锁,那不是写的同时还可以读了吗,这与第三点矛盾了。
性能优于互斥锁的原因:允许多个线程同时读共享变量。
二、实现一个简单的缓存(按需加载)
1 class Cache<K,V> { 2 final Map<K, V> m = 3 new HashMap<>(); 4 final ReadWriteLock rwl = 5 new ReentrantReadWriteLock(); 6 final Lock r = rwl.readLock(); 7 final Lock w = rwl.writeLock(); 8 9 V get(K key) { 10 V v = null; 11 //读缓存 12 r.lock(); ① 13 try { 14 v = m.get(key); ② 15 } finally{ 16 r.unlock(); ③ 17 } 18 //缓存中存在,返回 19 if(v != null) { ④ 20 return v; 21 } 22 //缓存中不存在,查询数据库 23 w.lock(); ⑤ 24 try { 25 //再次验证 26 //其他线程可能已经查询过数据库 27 v = m.get(key); ⑥ 28 if(v == null){ ⑦ 29 //查询数据库 30 v=省略代码无数 31 m.put(key, v); 32 } 33 } finally{ 34 w.unlock(); 35 } 36 return v; 37 } 38 39 // 写缓存 40 void put(K key, V value) { 41 w.lock(); 42 try { 43 m.put(key, v); 44 }finally { w.unlock(); } } 45 }
这个缓存没有解决缓存数据与源头数据的同步问题,这里的数据同步指的是保证缓存数据和源头数据的一致性。解决数据同步问题的一个最简单的方案就是超时机制。所谓超时机制指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。
还有一些方案采取的是数据库和缓存的双写方案。具体看场景。
三、锁的升降级
1 //读缓存 2 r.lock(); ① 3 try { 4 v = m.get(key); ② 5 if (v == null) { 6 w.lock(); 7 try { 8 //再次验证并更新缓存 9 //省略详细代码 10 } finally{ 11 w.unlock(); 12 } 13 } 14 } finally{ 15 r.unlock(); ③ 16 }
在上面的代码示例中,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫锁的升级。可惜 ReadWriteLock 并不支持这种升级。在上面的代码示例中,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。锁的升级是不允许的。
1 class CachedData { 2 Object data; 3 volatile boolean cacheValid; 4 final ReadWriteLock rwl = 5 new ReentrantReadWriteLock(); 6 // 读锁 7 final Lock r = rwl.readLock(); 8 //写锁 9 final Lock w = rwl.writeLock(); 10 11 void processCachedData() { 12 // 获取读锁 13 r.lock(); 14 if (!cacheValid) { 15 // 释放读锁,因为不允许读锁的升级 16 r.unlock(); 17 // 获取写锁 18 w.lock(); 19 try { 20 // 再次检查状态 21 if (!cacheValid) { 22 data = ... 23 cacheValid = true; 24 } 25 // 释放写锁前,降级为读锁 26 // 降级是可以的 27 r.lock(); ① 28 } finally { 29 // 释放写锁 30 w.unlock(); 31 } 32 } 33 // 此处仍然持有读锁 34 try {use(data);} 35 finally {r.unlock();} 36 } 37 }
But,锁的降级是允许的。如上的代码这种锁的降级是支持的。
四、注意
1.有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。
2.读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。
五、思考
有同学反映线上系统停止响应了,CPU 利用率很低,你怀疑有同学一不小心写出了读锁升级写锁的方案,那你该如何验证自己的怀疑呢?
1. 源代码分析。查找ReentrantReadWriteLock在项目中的引用,看下写锁是否在读锁释放前尝试获取
2. 如果线上是Web应用,应用服务器比如说是Tomcat,并且开启了JMX,则可以通过JConsole等工具远程查看下线上死锁的具体情况