当前位置: 首页 > 后端技术 > Java

原来ReadWriteLock也可以开发高性能的缓存,看完也能和面试官好好聊一聊!

时间:2023-04-01 21:16:47 Java

大家好,我是冰河~~在实际工作中,有一种很常见的并发场景:即读多写少的场景。在这种场景下,为了优化程序性能,我们往往会使用缓存来提高应用访问性能。因为缓存非常适合在读多写少的场景下使用。在并发场景下,JavaSDK提供了ReadWriteLock来满足读多写少的场景。在这篇文章中,我们将讨论如何使用ReadWriteLock来实现一个通用的缓存中心。本文涉及的知识点有:文章已收录:https://github.com/sunshinelyz/technology-binghehttps://gitee.com/binghe001/technology-binghe读写锁说到读写锁,相信小伙伴们不会陌生。一般来说,读写锁需要遵循以下原则:一个共享变量允许多个读线程同时读取。共享变量一次只能由一个编写器线程写入。当一个共享变量被写线程写入后,这个共享变量此时不能被读线程读取。在这里,朋友们需要注意:读写锁和互斥锁的一个重要区别是:读写锁允许多个线程同时读取共享变量,而互斥锁则不允许。所以在高并发场景下,读写锁的性能要高于互斥锁。但是读写锁的写操作是互斥的,也就是说,在使用读写锁的时候,当一个共享变量被写线程写入时,这个共享变量是不能被读线程读取的这次。读写锁支持公平模式和非公平模式,通过在ReentrantReadWriteLock的构造方法中传递一个布尔变量来控制。公共ReentrantReadWriteLock(布尔公平){同步=公平?newFairSync():newNonfairSync();readerLock=newReadLock(这个);writerLock=newWriteLock(这个);,为读锁调用newCondition()会抛出UnsupportedOperationException,也就是说:读锁不支持条件变量。缓存实现这里我们使用ReadWriteLock快速实现一个缓存的常用工具类。整体代码如下。公共类ReadWriteLockCache{privatefinalMapm=newHashMap<>();privatefinalReadWriteLockrwl=newReentrantReadWriteLock();//读锁privatefinalLockr=rwl.readLock();//写锁privatefinalLockw=rwl.writeLock();//读取缓存publicVget(Kkey){r.lock();尝试{返回m.get(key);}最后{r.unlock();}}//写入缓存publicVput(Kkey,Vvalue){w.lock();尝试{returnm.put(key,value);}最后{w.unlock();}}}可以看出,在ReadWriteLockCache中,我们定义了两个泛型,K代表缓存的key,V代表缓存的value。在ReadWriteLockCache类内部,我们使用Map来缓存相应的数据。大家都知道HashMap不是线程安全的类,所以这里我们使用读写锁来保证线程安全。比如我们的get()方法使用了读锁,get()方法可以同时被多个线程读取;put()方法内部使用了写锁,即put()方法在同一时刻只能有一个线程对缓存进行写操作。这里需要注意的是,无论是读锁还是写锁,锁的释放操作都需要放在finally{}代码块中。在以往的经验中,将数据加载到缓存中有两种方式,一种是:在项目启动时,将全量数据加载到缓存中,另一种是在运行过程中按需加载需要的缓存数据的项目。下面我们就来看看如何全量加载缓存和按需加载缓存。全量加载缓存全量加载缓存比较简单,即在项目启动时,一次性将数据加载到缓存中。这种情况适用于缓存数据量不大,数据变化不频繁的场景,例如:在某些系统中可以缓存数据字典等信息。整个缓存加载的大致流程如下。全量数据加载到缓存后,后面可以直接从缓存中读取相应的数据。完全加载缓存的代码实现比较简单。这里,我直接使用下面的代码进行演示。公共类ReadWriteLockCache{privatefinalMapm=newHashMap<>();privatefinalReadWriteLockrwl=newReentrantReadWriteLock();//读锁privatefinalLockr=rwl.readLock();//写锁privatefinalLockw=rwl.writeLock();publicReadWriteLockCache(){//查询数据库List>list=.....;if(!CollectionUtils.isEmpty(list)){list.parallelStream().forEach((f)->{m.put(f.getK(),f.getV);});}}//读取缓存publicVget(Kkey){r.lock();尝试{返回m.get(key);}最后{r.unlock();}}//写入缓存publicVput(Kkey,Vvalue){w.lock();尝试{returnm.put(key,value);}最后{w.unlock();按需加载缓存按需加载缓存也可以称为懒加载,也就是说:数据需要加载的时候才会加载到缓存中。具体来说:程序启动时,数据不会加载到缓存中。运行时,需要查询一些数据。首先检查缓存中是否存在需要的数据。如果有,直接读取缓存中的数据。如果不存在,则查询数据库中的数据,并将数据写入缓存。后续的读操作,因为缓存中已经存在对应的数据,所以直接返回缓存的数据即可。这种查询缓存的方式适用于大部分缓存数据的场景。我们可以用下面的代码来表示按需查询缓存业务。类ReadWriteLockCache{privatefinalMapm=newHashMap<>();privatefinalReadWriteLockrwl=newReentrantReadWriteLock();privatefinalLockr=rwl.readLock();私人最终锁w=rwl.writeLock();Vget(Kkey){Vv=null;//读取缓存r.lock();尝试{v=m.get(key);}最后{r.unlock();}//存在于缓存中,returnif(v!=null){returnv;}//缓存中不存在,查询数据库w.lock();try{//再次验证缓存中是否存在数据v=m.get(key);if(v==null){//查询数据库v=从数据库中查询数据m.put(key,v);}}最后{w.unlock();}返回v;}}这里,在get()方法中,首先从缓存中读取数据。这个时候我们在查询缓存的操作上加上读锁。查询返回后,执行解锁操作。判断缓存中返回的数据是否为空,不为空则直接返回数据;如果为空,则获取写锁,然后再次从缓存中读取数据,如果缓存中没有数据,则查询数据库并将结果保存将数据写入缓存,释放写锁。最后返回结果数据。说到这里,可能有朋友会问:为什么程序已经加了写锁,写锁里面为什么还要查询一次缓存呢?这是因为在高并发场景下,可能会有多个线程竞争写锁。例如:第一次执行get()方法时,缓存中的数据为空。如果三个线程同时调用get()方法,同时运行到w.lock()代码,由于写锁的排他性。此时只有一个线程会获取写锁,另外两个线程会阻塞在w.lock()处。获得写锁的线程继续查询数据库,将数据写入缓存,然后释放写锁。这时另外两个线程竞争写锁,某个线程会获取到锁继续执行。如果没有v=m.get(key);在w.lock()之后;再次查询缓存数据,则该线程会直接查询数据库,将数据写入缓存并释放写锁。最后一个线程也会按照这个流程执行。这里,其实第一个线程已经查询了数据库,并将数据写入了缓存。另外两个线程不需要再去查询数据库,直接从缓存中查询相应的数据即可。因此,添加v=m.get(key);w.lock()后再次查询缓存数据,可以有效减少高并发场景下重复查询数据库的问题,提高系统性能。读写锁的升级和降级关于锁的降级和降级,小伙伴们需要注意的是:在ReadWriteLock中,锁是不支持升级的,因为当读锁没有释放的时候,此时获取写锁会导致在一个写锁中永远等待,相应的线程也会被阻塞,无法被唤醒。虽然不支持锁升级,但是ReadWriteLock是支持锁降级的,举个例子,我们看看官方的ReentrantReadWriteLock例子,如下图。类CachedData{对象数据;易失性布尔缓存有效;finalReentrantReadWriteLockrwl=newReentrantReadWriteLock();voidprocessCachedData(){rwl.readLock().lock();if(!cacheValid){//在获取写锁之前必须释放读锁rwl.readLock().unlock();rwl.writeLock().lock();try{//重新检查状态,因为另一个线程可能在我们之前获取了写锁并更改了状态。如果(!cacheValid){数据=...cacheValid=true;}//通过在释放写锁之前获取读锁来降级rwl.readLock().lock();}最后{rwl.writeLock().unlock();//解锁写入,仍然保持读取}}try{use(data);}最后{rwl.readLock().unlock();}}}}数据同步问题首先,这里所说的数据同步是指数据源和数据缓存之间的数据同步。更直接的说,就是数据库和缓存之间的数据同步。这里,我们可以采用三种解决方案来解决数据同步的问题。下图所示的超时机制比较好理解。在向缓存写入数据时,会给出一个超时时间。当缓存超时时,缓存的数据会自动从缓存中移除。这时程序再次访问缓存时,由于缓存中不存在对应的数据,所以查询数据库获取数据后,将数据写入缓存。定时更新缓存的方案是超时机制的加强版。向缓存写入数据时,也会给出一个超时时间。不同于超时机制,在程序后台启动一个单独的线程,定时查询数据库中的数据,然后将数据写入缓存,一定程度上可以避免缓存穿透问题。实时更新缓存该方案可以实时同步数据库中的数据和缓存的数据。可以使用阿里开源的Canal框架实现MySQL数据库与缓存数据的实时同步。也可以使用我个人开源的mykit-data框架(推荐)~~mykit-data开源地址:https://github.com/sunshinelyz/mykit-datahttps://gitee.com/binghe001/mykit-data不错好了,今天就到这里吧,我是冰河,下次见~~