前面介绍过ReentrantLock
,它实现的是一种标准的互斥锁:每次最多只有一个线程能持有ReentrantLock。这是一种强硬的加锁规则,在某些场景下会限制并发性致使没必要要的抑制性能。互斥是一种保守的加锁策略,虽然能够避免“写/写”冲突和“写/读”冲突,可是一样也避免了“读/读”冲突。java
在读多写少的状况下,若是可以放宽加锁需求,容许多个执行读操做的线程同时访问数据结构,那么将提高程序的性能。只要每一个线程都能确保读到最新的数据,而且在读取数据时不会有其余的线程修改数据,那么就不会发生问题。在这种状况下,就可使用读写锁:一个资源能够被多个读操做访问,或者被一个写操做访问,但二者不能同时进行。数据库
Java中读写锁的实现是ReadWriteLock
。下面咱们先介绍什么是读写锁,而后利用读写锁快速实现一个缓存,最后咱们再来介绍读写锁的升级与降级。编程
读写锁是一种性能优化措施,在读多写少场景下,能实现更高的并发性。读写锁的实现须要遵循如下三项基本原则:缓存
读写锁与互斥锁的一个重要区别就是:读写锁容许多个线程同时读共享变量,而互斥锁是不容许的。读写锁的写操做时互斥的。安全
下面是ReadWriteLock
接口:性能优化
public interface ReadWriteLock{ Lock readLock(); Lock writeLock(); }
其中,暴露了两个Lock对象,一个用于读操做,一个用于写操做。要读取由ReadWriteLock保护的数据,必须首先得到读取锁,当须要修改由ReadWriteLock保护的数据时,必须首先得到写入锁。尽管这两个锁看上去是彼此独立的,但读取锁和写入锁只是读写锁对象的不一样视图。数据结构
与Lock同样,ReadWriteLock能够采用多种不一样的实现方式,这些方式在性能、调度保证、获取优先性、公平性以及加锁语义等方面可能有些不一样。读取锁与写入锁之间的交互方式也能够采用多种方式实现。多线程
ReadWriteLock中有一些可选实现包括:并发
ReentrantReadWriteLock
是ReadWriteLock的一个实现,它为读取锁和写入锁都提供了可重入的加锁语义。与ReentrantLock类似,ReentrantReadWriteLock在构造时也能够选择是一个非公平的锁(默认)仍是一个公平的锁。高并发
在公平的锁中,等待时间最长的线程将优先得到锁。若是这个线程是由读线程持有,而另外一个线程请求写入锁,那么其余读线程都不能得到读取锁,直到写线程使用完而且释放了写入锁。
在非公平的锁中,线程得到访问许可的顺序是不肯定的。写线程降级为读线程是能够的,但从读线程升级为写线程则是不能够的(容易致使死锁)。
下面使用ReentrantReadWriteLock来实现一个通用的缓存工具类。
实现一个Cache<K,V>
类,类型参数K表明缓存中key类型,V表明缓存里的value类型。咱们将缓存数据存储在Cache类中的HashMap中,可是HashMap不是线程安全的,因此咱们使用读写锁来保证其线程安全。
Cache工具类提供了两个方法,读缓存方法get()
和写缓存方法put()
。读缓存须要用到读取锁,读取锁的使用方法同Lock使用方式一致,都须要使用try{}finally{}
编程范式。写缓存须要用到写入锁,写入锁和读取锁使用相似。
代码参考以下:(代码来自参考[1])
class Cache<K,V> { final Map<K, V> m = new HashMap<>(); final ReadWriteLock rwl = new ReentrantReadWriteLock(); final Lock r = rwl.readLock(); // 读取锁 final Lock w = rwl.writeLock(); // 写入锁 // 读缓存 V get(K key) { r.lock(); // 获取读取锁 try { return m.get(key); }finally { r.unlock(); // 释放读取锁 } } // 写缓存 V put(K key, V value) { w.lock(); // 获取写入锁 try { return m.put(key, v); }finally { w.unlock(); // 释放写入锁 } } }
使用缓存首先要解决缓存数据的初始化问题。缓存数据初始化,能够采用一次性加载的方式,也可使用按需加载的方式。
若是源头数据的数据量不大,就能够采用一次性加载的方式,这种方式也最简单。只须要在应用启动的时候把源头数据查询出来,依次调用相似上面代码的put()
方式就能够了。可参考下图(图来自参考[1])
若是源头数据量很是大,那么就须要按需加载,按需加载也叫作懒加载。指的是只有当应用查询缓存,而且数据不在缓存里的时候,才触发加载源头相关数据进行缓存的操做。可参考下图(图来自参考[1])
下面代码实现了按需加载的功能(代码来自参考[1])。
这里假设缓存的源头时数据库。若是缓存中没有缓存目标对象,那么就须要从数据库中加载,而后写入缓存,写缓存是须要获取写入锁。
class Cache<K,V> { final Map<K, V> m = new HashMap<>(); final ReadWriteLock rwl = new ReentrantReadWriteLock(); final Lock r = rwl.readLock(); // 读取锁 final Lock w = rwl.writeLock(); // 写入锁 V get(K key) { V v = null; //读缓存 r.lock(); // 获取读取锁 try { v = m.get(key); } finally{ r.unlock(); // 释放读取锁 } //缓存中存在目标对象,返回 if(v != null) { return v; } //缓存中不存在目标对象,查询数据库并写入缓存 w.lock(); // 获取写入锁 ① try { //再次验证 其余线程可能已经查询过数据库 v = m.get(key); if(v == null){ //查询数据库 v=省略代码无数 m.put(key, v); } } finally{ w.unlock(); //释放写入锁 } return v; } }
当缓存中不存在目标对象时,须要查询数据库,在上述代码中,咱们在执行真正的查库以前,又查看了缓存中是否已经存在目标对象,这样作的好处是能够避免重复查询提高效率。咱们举例说明这样作的益处。
在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,若是此时有三个线程 T一、T2 和 T3 同时调用get()
方法,而且参数 key
也是相同的。那么它们会同时执行到代码①处,但此时只有一个线程可以得到写锁。
假设是线程 T1,线程 T1 获取写锁以后查询数据库并更新缓存,最终释放写锁。
此时线程 T2 和 T3 会再有一个线程可以获取写锁,假设是 T2,若是不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁以后,T3 也会再次查询一次数据库。
而实际上线程 T1 已经把缓存的值设置好了,T二、T3 彻底没有必要再次查询数据库。
上面读取锁的获取释放与写入锁的读取和释放是没有嵌套的。若是咱们改一改代码,将再次验证并更新缓存的逻辑换个位置放置:
//读缓存 r.lock(); // 获取读取锁 try { v = m.get(key); if (v == null) { w.lock(); // 获取写入锁 try { //再次验证并更新缓存 //省略详细代码 } finally{ w.unlock(); // 释放写入锁 } } } finally{ r.unlock(); // 释放读取锁 }
上述代码,在获取读取锁后,又试图获取写入锁,即咱们前面介绍的锁的升级。可是,ReadWriteLock是不支持这种升级,在代码中,读取锁尚未释放,又尝试获取写入锁,将致使相关线程被阻塞(读取锁和写入锁只是读写锁对象的不一样视图),永远没有机会被唤醒。
虽然锁的升级不被容许,可是锁的降级倒是被容许的。(下例代码来自参考[1])
class CachedData { Object data; volatile boolean cacheValid; final ReadWriteLock rwl = new ReentrantReadWriteLock(); final Lock r = rwl.readLock(); // 读取锁 final Lock w = rwl.writeLock(); //写入锁 void processCachedData() { // 获取读取锁 r.lock(); if (!cacheValid) { r.unlock(); // 释放读取锁,由于不容许读取锁的升级 w.lock(); // 获取写入锁 try { // 再次检查状态 if (!cacheValid) { data = ... cacheValid = true; } // 释放写入锁前,降级为读取锁 降级是能够的 r.lock(); } finally { w.unlock(); // 释放写入锁 } } // 此处仍然持有读取锁,要记得释放读取锁 try { use(data); } finally { r.unlock(); } } }
读写锁的读取锁和写入锁都实现了java.util.concurrent.locks.Lock
接口,因此除了支持lock()
方法外,tryLock()
,lockInterruptibly()
等方法也都是支持的。可是须要注意,只有写入锁支持条件变量,读取是不支持条件变量的,读取锁调用newCondition()
会泡池UnsupporteOperationException
异常。
咱们实现的简单缓存是没有解决缓存数据与源头数据同步的,即保持与源头数据的一致性。解决这个问题的一个简单方案是超时机制:当缓存的数据超过期效后,这条数据在缓存中就失效了;访问缓存中失效的数据,会触发缓存从新从源头把数据加载进缓存。也能够在源头数据发生变化时,快速反馈给缓存。
虽然说读写锁在读多写少场景下性能优于互斥锁(独占锁),可是在其余状况下,性能可能要略差于互斥锁,由于读写锁的复杂性更高。因此,咱们要根据场景来具体考虑使用哪种同步方案。
参考: [1]极客时间专栏王宝令《Java并发编程实战》 [2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016