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

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


相关文章
|
7月前
|
缓存 NoSQL 安全
Redis缓存雪崩、击穿、穿透解释及解决方法,缓存预热,布隆过滤器 ,互斥锁
Redis缓存雪崩、击穿、穿透解释及解决方法,缓存预热,布隆过滤器 ,互斥锁
201 5
|
22天前
|
缓存 NoSQL 数据库
关于高并发下缓存失效的问题(本地锁 && 分布式锁 && Redission 详解)
关于高并发下缓存失效的问题(本地锁 && 分布式锁 && Redission 详解)
163 0
|
10月前
|
缓存 NoSQL 关系型数据库
3种缓存读写策略都不了解?面试很难让你通过啊兄弟
看到很多小伙伴简历上写了“熟练使用缓存”,但是被我问到“缓存常用的 3 种读写策略”的时候却一脸懵逼。 造成这个问题的原因是我们在学习 Redis 的时候,可能只是简单了写一些 Demo,并没有去关注缓存的读写策略,或者说压根不知道这回事。
|
8月前
|
机器学习/深度学习 缓存 算法
缓存读写淘汰算法W-TinyLFU算法
缓存读写淘汰算法W-TinyLFU算法
113 0
|
11月前
|
存储 SQL 缓存
MySQL高级第三篇(共四篇)之应用优化、查询缓存优化、内存管理优化、MySQL锁问题、常用SQL技巧(二)
锁是计算机协调多个进程或线程并发访问某一资源的机制(避免争抢)。 在数据库中,除传统的计算资源(如 CPU、RAM、I/O 等)的争用以外,数据也是一种供许多用户共享的资源。如何保证数据并发访问的一致性、有效性是所有数据库必须解决的一个问题,锁冲突也是影响数据库并发访问性能的一个重要因素。从这个角度来说,锁对数据库而言显得尤其重要,也更加复杂。
378 0
|
11月前
|
存储 缓存 数据可视化
CPU缓存读写以及一致性问题,你大学课堂睡过去的现在再温习下,绝对受益颇多
CPU缓存读写以及一致性问题,你大学课堂睡过去的现在再温习下,绝对受益颇多
|
缓存
正在等待缓存锁:无法获得锁 /var/lib/dpkg/lock-frontend。锁正由进程 12836(unattended-upgr)持有
正在等待缓存锁:无法获得锁 /var/lib/dpkg/lock-frontend。锁正由进程 12836(unattended-upgr)持有
6215 0
正在等待缓存锁:无法获得锁 /var/lib/dpkg/lock-frontend。锁正由进程 12836(unattended-upgr)持有
|
Web App开发 缓存 前端开发
Chrome缓存锁,php非堵塞文件锁无效的问题详解追查
浏览器对一个资源发起请求前,会先检查本地缓存,此时这个请求对该资源对应的缓存的读写是独占的。此时后续的请求,在请求这个资源的时候,就需要等待拿锁。(在上面这个补丁发布之前,会无限等待,补丁是让等待最多20秒)
137 0
Chrome缓存锁,php非堵塞文件锁无效的问题详解追查
|
SQL 存储 缓存
浅析缓存读写策略
随着我们业务量的增长,系统面对的压力也陡然上升,大量的读写请求到数据库往往会伴随着各式各样的问题,可能仅仅是一条慢SQL,就有可能拖垮整个系统服务。通常这个时候,我们除了做数据库的读写分离架构,还会对数据库进行分库分表。但是可能有些一成不变或者极少时间触发变更的数据,像类目、类目属性等,大量的针对类目维度的读数据库也会给数据库带来各种压力,通常会以NoSql数据库与关系型数据库互相搭配的方式,以用来更好的服务与我们的业务发展。
217 0
浅析缓存读写策略
|
存储 缓存 NoSQL
读写 Redis RESP3 协议以及Redis 6.0客户端缓存
  在四月份的一篇翻译的文章中,我介绍了读写Redis RESP version 2的协议的Go 语言的实现,你可以使用它采用底层的方式读写5.0以及以下版本的Redis。Redis 6.0还在开发之中年底或者明年初就要发布了。Redis 6.0支持多线程I/O,还有客户端缓存。
392 0