摘要:并发场景,Java 为了满足读多写少的场景,SDK提供了ReadWritelock。
本文分享了基于ReadWritelock的华为云社区“[高并发]开设的高性能缓存” 河。
写在前面在实际工作中,有一个非常常见的并发场景:读多写少。在这种情况下,我们经常使用缓存来提高应用程序的访问性能,以优化程序的性能。因为缓存非常适合在读多写少的场景中使用。并发场景中,Java 为了满足读多写少的场景,SDK提供了ReadWritelock。让我们来谈谈如何利用ReadWritelock实现一个通用的缓存中心。
本文涉及的知识点有:
读写锁说到读写锁,相信朋友们并不陌生。一般来说,读写锁需要遵循以下原则:
- 多个读线程可以同时读取共享变量。
- 在同一时刻,共享变量只能由一个写线程编写。
- 当被写线程执行写作操作时,共享变量不能由读线程执行。
在这里,我们需要注意的是,读写锁和互斥锁的一个重要区别是,读写锁允许多线程同时阅读共享变量,而互斥锁不允许。因此,在高并发场景下,读写锁的性能高于互斥锁。然而,读写锁的写作操作是相互排斥的,也就是说,当使用读写锁时,当被写线程执行写作操作时,共享变量不能由读线程执行。
读写锁支持公平模式和非公平模式,特别是在ReeentrantreadWritelock的结构方法中传输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()方法只能在同一时间用一个线程写缓存。
需要注意的是,无论是读锁还是写锁,都需要将锁的释放操作放入finaly{}代码块中。
在以往的经验中,有两种方法可以将数据加载到缓存中,一种是在项目启动时将所有数据加载到缓存中,另一种是在项目运行过程中按需加载所需的缓存数据。
接下来,让我们来看看全量加载缓存和按需加载缓存的方法。
全量加载缓存全加载缓存相对简单,即在项目启动时,将数据一次加载到缓存中。这种情况适用于缓存数据量小、数据变化不频繁的场景,如系统中的一些数据字典。整个缓存加载过程如下所示。
将数据全部加载到缓存后,可以直接从缓存中读取相应的数据。
实现缓存代码的全量加载相对简单。在这里,我将直接使用以下代码进行演示。
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(); } }}
按需加载缓存按需加载缓存也可称为懒加载,即:需要加载时才能将数据加载到缓存中。具体来说,当程序启动时,数据不会加载到缓存中。当运行时,您需要查询某些数据。首先,检测缓存中是否存在所需的数据。如果存在,请直接读取缓存中的数据。如果没有,请访问数据库,并将数据写入缓存中。随后的读取操作,由于缓存中已经存在相应的数据,可以直接返回缓存数据。
这种查询缓存的方法适用于大多数缓存数据的场景。
我们可以使用以下代码来表示按需查询缓存的业务。
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(); } }}}
数据同步问题首先,这里的数据同步是指数据源和数据缓存之间的数据同步。更直接地说,数据库和缓存之间的数据同步。
在这里,我们可以采取三种解决数据同步问题的方法,如下图所示
超时机制这很容易理解,当将数据写入缓存时,给予超时间。当缓存超时时,缓存数据将自动从缓存中删除。此时,当程序再次访问缓存时,由于缓存中没有相应的数据,在查询数据库获取数据后,将数据写入缓存。
定期更新缓存该方案是加班机制的增强版,在将数据写入缓存中时也会给出加班时间。与加班机制不同的是,在程序后台单独启动线程,定期查询数据库中的数据,然后将数据写入缓存中,以避免缓存的穿透。
点击关注,第一次了解华为云新技术~