ReadWriteLock 读写锁实现一个缓存

简介: ReadWriteLock 读写锁实现一个缓存

1. 概述

实际工作中我们会遇到一种并发场景:读多写少,这个时候为了优化性能,我们就会使用缓存。针对读多写少这种并发场景,Java SDK 并发包提供了读写锁——ReentrantReadWriteLock,非常容易使用,并且性能很好。通过本文学会如何写出一个缓存组件,以及锁降级是什么?

2. 什么是读写锁?

读写锁遵循以下三个基本原则:

  1. 允许多个线程同时读共享变量。
  2. 只允许一个线程写共享变量。
  3. 如果有一个线程正在执行写操作,此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。

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();
        }

这样当数据在缓存中的时候只需要上读锁,而当查询数据需要从数据库加载则释放读锁上写锁,然后操作数据,接着释放写锁降级为读锁,提高了并发吞吐量。


相关文章
|
1月前
|
存储 缓存 NoSQL
大数据-38 Redis 高并发下的分布式缓存 Redis简介 缓存场景 读写模式 旁路模式 穿透模式 缓存模式 基本概念等
大数据-38 Redis 高并发下的分布式缓存 Redis简介 缓存场景 读写模式 旁路模式 穿透模式 缓存模式 基本概念等
60 4
|
3月前
|
缓存 NoSQL Java
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
这篇文章介绍了如何在SpringBoot项目中整合Redis,并探讨了缓存穿透、缓存雪崩和缓存击穿的问题以及解决方法。文章还提供了解决缓存击穿问题的加锁示例代码,包括存在问题和问题解决后的版本,并指出了本地锁在分布式情况下的局限性,引出了分布式锁的概念。
SpringBoot整合Redis、以及缓存穿透、缓存雪崩、缓存击穿的理解、如何添加锁解决缓存击穿问题?分布式情况下如何添加分布式锁
|
3月前
|
缓存 NoSQL Redis
【Azure Redis 缓存】Azure Redis读写比较慢/卡的问题排查
【Azure Redis 缓存】Azure Redis读写比较慢/卡的问题排查
|
缓存 NoSQL 安全
Redis缓存雪崩、击穿、穿透解释及解决方法,缓存预热,布隆过滤器 ,互斥锁
Redis缓存雪崩、击穿、穿透解释及解决方法,缓存预热,布隆过滤器 ,互斥锁
249 5
|
缓存 NoSQL 关系型数据库
3种缓存读写策略都不了解?面试很难让你通过啊兄弟
看到很多小伙伴简历上写了“熟练使用缓存”,但是被我问到“缓存常用的 3 种读写策略”的时候却一脸懵逼。 造成这个问题的原因是我们在学习 Redis 的时候,可能只是简单了写一些 Demo,并没有去关注缓存的读写策略,或者说压根不知道这回事。
|
6月前
|
缓存 NoSQL 数据库
关于高并发下缓存失效的问题(本地锁 && 分布式锁 && Redission 详解)
关于高并发下缓存失效的问题(本地锁 && 分布式锁 && Redission 详解)
255 0
|
机器学习/深度学习 缓存 算法
缓存读写淘汰算法W-TinyLFU算法
缓存读写淘汰算法W-TinyLFU算法
224 0
|
SQL 存储 缓存
MySQL高级第三篇(共四篇)之应用优化、查询缓存优化、内存管理优化、MySQL锁问题、常用SQL技巧(一)
前面章节,我们介绍了很多数据库的优化措施。但是在实际生产环境中,由于数据库本身的性能局限,就必须要对前台的应用进行一些优化,来降低数据库的访问压力。
16109 7
|
存储 SQL 缓存
MySQL高级第三篇(共四篇)之应用优化、查询缓存优化、内存管理优化、MySQL锁问题、常用SQL技巧(二)
锁是计算机协调多个进程或线程并发访问某一资源的机制(避免争抢)。 在数据库中,除传统的计算资源(如 CPU、RAM、I/O 等)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂。
411 0
|
存储 缓存 数据可视化
CPU缓存读写以及一致性问题,你大学课堂睡过去的现在再温习下,绝对受益颇多
CPU缓存读写以及一致性问题,你大学课堂睡过去的现在再温习下,绝对受益颇多

热门文章

最新文章