本文分享自华为云社区《【高并发】根据ReadWriteLock开了个一款高功能缓存》,作者:冰 河。

写在前面

在实践工作中,有一种非常遍及的并发场景:那便是读多写少的场景。在这种场景下,为了优化程序的功能,咱们经常运用缓存来进步应用的拜访功能。因为缓存非常适合运用在读多写少的场景中。而在并发场景中,Java SDK中提供了ReadWriteLock来满足读多写少的场景。本文咱们就来说说运用ReadWriteLock如何完成一个通用的缓存中心。

本文涉及的知识点有:

如何用ReadWriteLock实现一个通用的缓存中心?

读写锁

说起读写锁,信任小伙伴们并不生疏。总体来说,读写锁需求遵循以下准则:

  • 一个同享变量允许一起被多个读线程读取到。
  • 一个同享变量在同一时刻只能被一个写线程进行写操作。
  • 一个同享变量在被写线程履行写操作时,此刻这个同享变量不能被读线程履行读操作。

这儿,需求小伙伴们留意的是:读写锁和互斥锁的一个重要的差异便是:读写锁允许多个线程一起读同享变量,而互斥锁不允许。所以,在高并发场景下,读写锁的功能要高于互斥锁。可是,读写锁的写操作是互斥的,也便是说,运用读写锁时,一个同享变量在被写线程履行写操作时,此刻这个同享变量不能被读线程履行读操作。

读写锁支撑公正形式和非公正形式,详细是在ReentrantReadWriteLock的结构办法中传递一个boolean类型的变量来操控。

public ReentrantReadWriteLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
readerLock = new ReadLock(this);
writerLock = new WriteLock(this);
}

别的,需求留意的一点是:在读写锁中,读锁调用newCondition()会抛出UnsupportedOperationException异常,也便是说:读锁不支撑条件变量。

缓存完成

这儿,咱们运用ReadWriteLock快速完成一个缓存的通用工具类,总体代码如下所示。

public class ReadWriteLockCache<K,V> {
private final Map<K, V> m = new HashMap<>();
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
private final Lock r = rwl.readLock();
// 写锁
private final Lock w = rwl.writeLock();
// 读缓存
public V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
public V put(K key, V value) {
w.lock();
try { return m.put(key, value); }
finally { w.unlock(); }
}
}

可以看到,在ReadWriteLockCache中,咱们定义了两个泛型类型,K代表缓存的Key,V代表缓存的value。在ReadWriteLockCache类的内部,咱们运用Map来缓存相应的数据,小伙伴都都知道HashMap并不是线程安全的类,所以,这儿运用了读写锁来保证线程的安全性,例如,咱们在get()办法中运用了读锁,get()办法可以被多个线程一起履行读操作;put()办法内部运用写锁,也便是说,put()办法在同一时刻只能有一个线程对缓存进行写操作。

这儿需求留意的是:无论是读锁还是写锁,锁的开释操作都需求放到finally{}代码块中。

在以往的经历中,有两种向缓存中加载数据的办法,一种是:项目发动时,将数据全量加载到缓存中,一种是在项目运转期间,按需加载所需求的缓存数据。

如何用ReadWriteLock实现一个通用的缓存中心?

接下来,咱们就分别来看看全量加载缓存和按需加载缓存的办法。

全量加载缓存

全量加载缓存相对来说比较简单,便是在项目发动的时分,将数据一次性加载到缓存中,这种状况适用于缓存数据量不大,数据变动不频繁的场景,例如:可以缓存一些体系中的数据字典等信息。整个缓存加载的大体流程如下所示。

如何用ReadWriteLock实现一个通用的缓存中心?

将数据全量加载到缓存后,后续就可以直接从缓存中读取相应的数据了。

全量加载缓存的代码完成比较简单,这儿,我就直接运用如下代码进行演示。

public class ReadWriteLockCache<K,V> {
private final Map<K, V> m = new HashMap<>();
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
private final Lock r = rwl.readLock();
// 写锁
private final Lock w = rwl.writeLock();
public ReadWriteLockCache(){
//查询数据库
List<Field<K, V>> list = .....;
if(!CollectionUtils.isEmpty(list)){
list.parallelStream().forEach((f) ->{
m.put(f.getK(), f.getV);
});
}
}
// 读缓存
public V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
public V put(K key, V value) {
w.lock();
try { return m.put(key, value); }
finally { w.unlock(); }
}
}

按需加载缓存

按需加载缓存也可以叫作懒加载,便是说:需求加载的时分才会将数据加载到缓存。详细来说:便是程序发动的时分,不会将数据加载到缓存,当运转时,需求查询某些数据,首要检测缓存中是否存在需求的数据,如果存在,则直接读取缓存中的数据,如果不存在,则到数据库中查询数据,并将数据写入缓存。后续的读取操作,因为缓存中现已存在了相应的数据,直接回来缓存的数据即可。

如何用ReadWriteLock实现一个通用的缓存中心?

这种查询缓存的办法适用于大多数缓存数据的场景。

咱们可以运用如下代码来表明按需查询缓存的业务。

class ReadWriteLockCache<K,V> {
private final Map<K, V> m = new HashMap<>();
private final ReadWriteLock rwl = new ReentrantReadWriteLock();
private final Lock r = rwl.readLock();
private 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;
}
}

这儿,在get()办法中,首要从缓存中读取数据,此刻,咱们对查询缓存的操作添加了读锁,查询回来后,进行解锁操作。判断缓存中回来的数据是否为空,不为空,则直接回来数据;如果为空,则获取写锁,之后再次从缓存中读取数据,如果缓存中不存在数据,则查询数据库,将成果数据写入缓存,开释写锁。最终回来成果数据。

这儿,有小伙伴可能会问:为啥程序都现已添加写锁了,在写锁内部为啥还要查询一次缓存呢?

这是因为在高并发的场景下,可能会存在多个线程来竞争写锁的现象。例如:第一次履行get()办法时,缓存中的数据为空。如果此刻有三个线程一起调用get()办法,一起运转到 w.lock()代码处,因为写锁的排他性。此刻只要一个线程会获取到写锁,其他两个线程则阻塞在w.lock()处。获取到写锁的线程持续往下履行查询数据库,将数据写入缓存,之后开释写锁。

此刻,别的两个线程竞争写锁,某个线程会获取到锁,持续往下履行,如果在w.lock()后没有v = m.get(key); 再次查询缓存的数据,则这个线程会直接查询数据库,将数据写入缓存后开释写锁。最终一个线程同样会依照这个流程履行。

这儿,实践上第一个线程现已查询过数据库,而且将数据写入缓存了,其他两个线程就没必要再次查询数据库了,直接从缓存中查询出相应的数据即可。所以,在w.lock()后添加v = m.get(key); 再次查询缓存的数据,可以有用的减少高并发场景下重复查询数据库的问题,提高体系的功能。

读写锁的升降级

关于锁的升降级,小伙伴们需求留意的是:在ReadWriteLock中,锁是不支撑升级的,因为读锁还未开释时,此刻获取写锁,就会导致写锁永久等待,相应的线程也会被阻塞而无法唤醒。

尽管不支撑锁升级,可是ReadWriteLock支撑锁降级,例如,咱们来看看官方的ReentrantReadWriteLock示例,如下所示。

class CachedData {
Object data;
volatile boolean cacheValid;
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// Must release read lock before acquiring write lock
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// Recheck state because another thread might have
// acquired write lock and changed state before we did.
if (!cacheValid) {
data = ...
cacheValid = true;
}
// Downgrade by acquiring read lock before releasing write lock
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // Unlock write, still hold read
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}}

数据同步问题

首要,这儿说的数据同步指的是数据源和数据缓存之间的数据同步,说的再直接一点,便是数据库和缓存之间的数据同步。

这儿,咱们可以采纳三种方案来处理数据同步的问题,如下图所示

如何用ReadWriteLock实现一个通用的缓存中心?

超时机制

这个比较好理解,便是在向缓存写入数据的时分,给一个超时时刻,当缓存超时后,缓存的数据会自动从缓存中移除,此刻程序再次拜访缓存时,因为缓存中不存在相应的数据,查询数据库得到数据后,再将数据写入缓存。

定时更新缓存

这种方案是超时机制的增强版,在向缓存中写入数据的时分,同样给一个超时时刻。与超时机制不同的是,在程序后台单独发动一个线程,定时查询数据库中的数据,然后将数据写入缓存中,这样可以在一定程度上避免缓存的穿透问题。

点击重视,第一时刻了解华为云新鲜技术~