1. 概述
实际工作中我们会遇到一种并发场景:读多写少,这个时候为了优化性能,我们就会使用缓存。针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReentrantReadWriteLock,非常容易使用,并且性能很好。通过本文学会如何写出一个缓存组件,以及锁降级是什么?
2. 什么是读写锁?
读写锁遵循以下三个基本原则:
- 允许多个线程同时读共享变量。
- 只允许一个线程写共享变量。
- 如果有一个线程正在执行写操作,此时禁止读线程读共享变量。
读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。
3.ReentrantReadWriteLock
3.1 ReentrantReadWriteLock 快速实现一个缓存工具类。
首先声明一个 Cache<K, V>
,其中泛型 V 代表缓存的 value 类型,缓存的数据我们保存在 hashMap 中,不是线程安全的,这时候我们通过 ReentrantReadWriteLock
的读写锁来保证其线程安全,其次它也是可重入锁。很简单,写的时候上写锁,读取的时候上读锁。
public class Cache<K, V> { private final Map<K, V> DATA_MAP = new HashMap<>(); private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); /** * 读锁 */ private final Lock readLock = readWriteLock.readLock(); /** * 写锁 */ private final Lock writeLock = readWriteLock.writeLock(); /** * 读取缓存 * @param key * @return */ public V get(K key) { readLock.lock(); try { return DATA_MAP.get(key); } finally { readLock.unlock(); } } /** * 写缓存 * @param key * @param value * @return */ public V put(K key, V value) { writeLock.lock(); try { return DATA_MAP.put(key, value); } finally { writeLock.unlock(); } } /** * 清空缓存 */ public void clear() { writeLock.lock(); try { DATA_MAP.clear(); } finally { writeLock.unlock(); } } }
3.2 按需加载缓存
使用缓存首先要解决的就是初始化问题。对应数据量不大可以采取一次性加载,这种方式很简单。只需要在应用启动的时候把数据源头加载调用 put 方法。
但是当数据量很大就需要按需加载了,也就是懒加载,指的是查询的时候数据不再缓存里面则加载对应数据并放到缓存。
先上读锁,假如缓存存在则直接返回,否则尝试获取写锁并再次验证缓存是否存在 我们才去查询数据库并更新本地缓存。为什么我们要再次验证呢?
public class Cache<String, V> { private final Map<String, V> DATA_MAP = new HashMap<>(); private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock(); /** * 读锁 */ private final Lock readLock = readWriteLock.readLock(); /** * 写锁 */ private final Lock writeLock = readWriteLock.writeLock(); /** * 读取缓存,若缓存不存在则查找数据库并放到缓存 * @param key * @return */ public V get(String key) { V value = null; //读取缓存 readLock.lock(); // 1 try { value = DATA_MAP.get(key); //2 } finally { readLock.unlock(); // 3 } // 缓存中存在,直接返回 if (Objects.nonNull(value)) { // 4 return value; } writeLock.lock(); //5 try { // 再次验证是否为空,可能其他写线程已经查询过数据库写到缓存中了,后续再次获取到写锁的线程就不用再次查数据库 value = DATA_MAP.get(key); // 6 if (Objects.isNull(value)) { // 7 //模拟查询数据库 value = (V) "queryFromDB"; DATA_MAP.put(key, value); } } finally { writeLock.unlock(); } return value; }
原因是在高并发的场景下,有可能会有多线程竞争写锁。假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。那么它们会同时执行到代码 ⑤ 处,但此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。T2 释放写锁之后,T3 也会再次查询一次数据库。而实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。
3.3 读写锁的升级与降级
首先来一个错误示范,获取读锁,发现缓存不存在,直接升级写锁,这个是错误的。虽然逻辑上没问题,但是 ReentrantReadWriteLock 并不支持这样升级。
public V get(String key) { V value = null; //读取缓存 readLock.lock(); // 1 try { value = DATA_MAP.get(key); //2 if (value == null) { // 4 w.lock(); try { // 再次验证并更新缓存 // 省略详细代码 } finally{ w.unlock(); } } } finally { readLock.unlock(); // 3 } // 缓存中存在,直接返回 if (Objects.nonNull(value)) { // 4 return value; } writeLock.lock(); //5 try { // 再次验证是否为空,可能其他写线程已经查询过数据库写到缓存中了,后续再次获取到写锁的线程就不用再次查数据库 value = DATA_MAP.get(key); // 6 if (Objects.isNull(value)) { // 7 //模拟查询数据库 value = (V) "queryFromDB"; DATA_MAP.put(key, value); } } finally { writeLock.unlock(); } return value; }
首先要注意的是,ReentrantReadWriteLock 不支持读锁直接升级写锁,也就是读锁还没有释放就去写锁,会导致写锁永久等待, 最终导致相关线程都被阻塞,永远也没有机会被唤醒。
虽然锁升级不可以,但是锁降级是可以的。如下代码所示
public V get(String key) { readLock.lock(); System.out.println("开始读取缓存数据....." + Thread.currentThread().getName()); V value = DATA_MAP.get(key); if (Objects.isNull(value)) { // 若 value 为空则先释放读锁,并且让该线程获取写锁,而其他线程只能等待该写锁释放 // 必须先释放读锁才能去获取写锁,读锁还没有释放,此时获取写锁,会导致写锁永久等待,最终导致相关线程都被阻塞,永远也没有机会被唤醒。锁的升级是不允许的,这个你一定要注意。 readLock.unlock(); writeLock.lock(); // 再次检查状态,因为其他线程可能已经获取到写锁且更新了状态 try { if (Objects.isNull(value)) { //模拟查询数据库 value = (V) "queryFromDB"; DATA_MAP.put(key, value); } // 通过在释放写锁之前获取读锁来实现锁降级,降级为读锁 readLock.lock(); } finally { // 释放写锁,依然持有读锁 writeLock.unlock(); } } //当前还持有读锁,所以最后还要释放读锁 try { System.out.println(Thread.currentThread().getName() + "-- 读 : {key:" + key + ",value: " + value + "}"); return value; } finally { System.out.println("结束读取缓存数据....." + Thread.currentThread().getName()); readLock.unlock(); }
这样当数据在缓存中的时候只需要上读锁,而当查询数据需要从数据库加载则释放读锁上写锁,然后操作数据,接着释放写锁降级为读锁,提高了并发吞吐量。