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 }
View Code

这个缓存没有解决缓存数据与源头数据的同步问题,这里的数据同步指的是保证缓存数据和源头数据的一致性。解决数据同步问题的一个最简单的方案就是超时机制。所谓超时机制指的是加载进缓存的数据不是长久有效的,而是有时效的,当缓存的数据超过时效,也就是超时之后,这条数据在缓存中就失效了。而访问缓存中失效的数据,会触发缓存重新从源头把数据加载进缓存。

还有一些方案采取的是数据库和缓存的双写方案。具体看场景。

三、锁的升降级

 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 }
View Code

在上面的代码示例中,先是获取读锁,然后再升级为写锁,对此还有个专业的名字,叫锁的升级。可惜 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 }
View Code

But,锁的降级是允许的。如上的代码这种锁的降级是支持的。

四、注意

1.有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。

2.读写锁类似于 ReentrantLock,也支持公平模式和非公平模式。读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。

五、思考

有同学反映线上系统停止响应了,CPU 利用率很低,你怀疑有同学一不小心写出了读锁升级写锁的方案,那你该如何验证自己的怀疑呢?

1. 源代码分析。查找ReentrantReadWriteLock在项目中的引用,看下写锁是否在读锁释放前尝试获取
2. 如果线上是Web应用,应用服务器比如说是Tomcat,并且开启了JMX,则可以通过JConsole等工具远程查看下线上死锁的具体情况

 

posted @ 2019-10-25 11:52  45°仰望星空  阅读(295)  评论(0编辑  收藏  举报