ReadWriteLock:如何快速实现一个完备的缓存?
- 实际工作中,为了优化性能,我们经常会使用缓存,例如缓存元数据、缓存基础数据等,这就是一种典型的读多写少应用场景。
- 缓存之所以能提升性能,一个重要的条件就是缓存的数据一定是读多写少的,例如元数据和基础数据基本上不会发生变化(写少),但是使用它们的地方却很多(读多)。
- 针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReadWriteLock,非常容易使用,并且性能很好。
什么是读写锁?
- 读写锁,并不是 Java 语言特有的,而是一个广为使用的通用技术,所有的读写锁都遵守以下三条基本原则:
- 允许多个线程同时读共享变量;
- 只允许一个线程写共享变量;
- 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。
- 读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。
快速实现一个缓存
public class Cache<K, V> {
final Map<K, V> m = new HashMap<>();
final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
final Lock r = readWriteLock.readLock();
final Lock w = readWriteLock.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, value);
} finally {
w.unlock();
}
}
}
- 如果你曾经使用过缓存的话,你应该知道使用缓存首先要解决缓存数据的初始化问题。
- 缓存数据的初始化,可以采用一次性加载的方式,也可以使用按需加载的方式。
- 如果源头数据的数据量不大,就可以采用一次性加载的方式,只需在应用启动的时候把源头数据查询出来,依次调用类似上面示例代码中的 put() 方法就可以了。
- 如果源头数据量非常大,那么就需要按需加载了,按需加载也叫懒加载,指的是只有当应用查询缓存,并且数据不在缓存里的时候,才触发加载源头相关数据进缓存的操作。
实现缓存的按需加载
- 我们假设缓存的源头是数据库。
- 需要注意的是,如果缓存中没有缓存目标对象,那么就需要从数据库中加载,然后写入缓存,写缓存需要用到写锁。
- 在获取写锁之后,我们并没有直接去查询数据库,而是重新验证了一次缓存中是否存在,再次验证如果还是不存在,我们才去查询数据库并更新本地缓存。
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class CacheByCondition<K, V> {
final Map<K, V> m = new HashMap<>();
final ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
final Lock r = readWriteLock.readLock();
final Lock w = readWriteLock.writeLock();
V get(K key, V value) {
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) {
m.put(key, value);
}
} finally {
w.unlock();
}
return v;
}
V put(K key, V value) {
w.lock();
try {
return m.put(key, value);
} finally {
w.unlock();
}
}
}
- 在高并发的场景下,有可能会有多线程竞争写锁。
- 假设缓存是空的,没有缓存任何东西,如果此时有三个线程 T1、T2 和 T3 同时调用 get() 方法,并且参数 key 也是相同的。
- 此时只有一个线程能够获得写锁,假设是线程 T1,线程 T1 获取写锁之后查询数据库并更新缓存,最终释放写锁。
- 此时线程 T2 和 T3 会再有一个线程能够获取写锁,假设是 T2,如果不采用再次验证的方式,此时 T2 会再次查询数据库。
- T2 释放写锁之后,T3 也会再次查询一次数据库。
- 实际上线程 T1 已经把缓存的值设置好了,T2、T3 完全没有必要再次查询数据库。
- 所以,再次验证的方式,能够避免高并发场景下重复查询数据的问题。
读写锁的升级与降级
- ReadWriteLock 并不支持锁的升级,不过,虽然锁的升级是不允许的,但是锁的降级却是允许的。
- 读写锁类似于 ReentrantLock,也⽀持公平模式和非公平模式。
- 读锁和写锁都实现了 java.util.concurrent.locks.Lock 接口,所以除了支持 lock() 方法外,tryLock()、lockInterruptibly() 等方法也都是支持的。
- 但是有一点需要注意,那就是只有写锁支持条件变量,读锁是不支持条件变量的,读锁调用 newCondition() 会抛出 UnsupportedOperationException 异常。