关于java:构建高效且可伸缩的结果缓存

40次阅读

共计 11650 个字符,预计需要花费 30 分钟才能阅读完成。

你好呀,我是歪歪。

我来填坑来啦。

上周公布了《当 Synchronized 遇到这玩意儿,有个大坑,要留神!》这篇文章。

文章的最初,我提到了《Java 并发编程实战》的第 5.6 节的内容,说大家能够去看看。

我不晓得有几个同学去看了,然而我晓得绝大部分同学都没去看的,所以这篇文章我也给大家安顿一下,怎么去比拟好的实现一个缓存性能。

感受一下巨匠的代码计划演进的过程。

需要

这不都二月中旬了嘛,马上就要出考研问题了,我就拿这个来举个例子吧。

需要很简略:从缓存中查问,查不到则从数据库获取,并放到缓存中去,供下次应用。

外围代码大略就是这样的:

Integer score = map.get("why");
if(score == null){score = loadFormDB("why");
   map.put("why",score);
}

有了外围代码,所以我把代码补全之后应该是这样的:

public class ScoreQueryService {private final Map<String, Integer> SCORE_CACHE = new HashMap<>();

    public Integer query(String userName) throws InterruptedException {Integer result = SCORE_CACHE.get(userName);
        if (result == null) {result = loadFormDB(userName);
            SCORE_CACHE.put(userName, result);
        }
        return result;
    }

    private Integer loadFormDB(String userName) throws InterruptedException {System.out.println("开始查问 userName=" + userName + "的分数");
        // 模仿耗时
        TimeUnit.SECONDS.sleep(1);
        return ThreadLocalRandom.current().nextInt(380, 420);
    }
}

而后搞一个 main 办法测试一下:

public class MainTest {public static void main(String[] args) throws InterruptedException {ScoreQueryService scoreQueryService = new ScoreQueryService();
        Integer whyScore = scoreQueryService.query("why");
        System.out.println("whyScore =" + whyScore);
        whyScore = scoreQueryService.query("why");
        System.out.println("whyScore =" + whyScore);
    }
}

把代码儿跑起来:

好家伙,第一把就跑了个 408 分,我考研要是真能考到这个分数,怕是做梦都得笑醒。

Demo 很简略,然而请你留神,我要开始变形了。

首先把 main 办法批改为这样:

public class MainTest {public static void main(String[] args) {ExecutorService executorService = Executors.newFixedThreadPool(5);
        ScoreQueryService scoreQueryService = new ScoreQueryService();
        for (int i = 0; i < 3; i++) {executorService.execute(()->{
                try {Integer why = scoreQueryService.query("why");
                    System.out.println("why =" + why);
                } catch (InterruptedException e) {e.printStackTrace();
                }
            });
        }
    }
}

利用线程池提交工作,模仿同一时间发动三次查问申请,因为 loadFormDB 办法外面有模仿耗时的操作,那么这三个申请都不会从缓存中获取到数据。

具体是不是这样的呢?

看一下运行后果:

输入三次,失去了三个不同的分数,阐明的确执行了三次 loadFormDB 办法。

好,同学们,那么问题就来了。

很显著,在这个场景下,我只想要一个线程执行 loadFormDB 办法就行了,那么应该怎么操作呢?

看到这个问题的霎时,不晓得你的脑袋外面有没有电光火石般的想起缓存问题三连击:缓存雪崩、缓存击穿、缓存穿透。

毕竟应答缓存击穿的解决方案之一就是只须要一个申请线程去做构建缓存,其余的线程就轮询等着。

而后脑海外面自然而然的就浮现出了 Redis 分布式锁的解决方案,甚至还想到了应该用 setNX 命令来保障只有一个线程放行胜利。嘴角漏出一丝不易觉察的笑容,甚至想要敞开这篇文章。

不好意思,收起你的笑容,不能用 Redis,不能用第三方组件,只能用 JDK 的货色。

别问为什么,问就是没有引入。

这个时候你怎么办?

初始计划

据说不能用第三方组件之后,你也一点不慌,大喊一声:键来。

拿着键盘只须要啪啪啪三下就写完了代码:

加上一个 synchronized 关键字就算完事,甚至你还记得程序员的自我涵养,实现了一波自测,发现的确没有问题:

loadFromDB 办法只执行了一次。

然而,敌人,你有没有想过你这个锁的粒度有点太大了啊。

间接把整个办法给锁了。

原本一个好好的并行办法,你给咔一下,搞成串行的了:

而且你这是无差别乱杀啊,比方下面这个示意图,你要是说当第二次查问 why 的问题的时候,把这个申请给拦下来,能够了解。

然而你同时也把第一次查问 mx 的问题给拦挡了。弄得 mx 同学一脸懵逼,搞不清啥状况。

留神,这个时候自然而然就会想到放大锁的粒度,把锁的范畴从全局批改为部分,拿出比方用 why 对象作为锁的这一类解决方案。

比方伪代码改成这样:

Integer score = map.get("why");
if(score == null){synchronized("why"){score = loadFormDB("why");
       map.put("why",score);
   }    
}

如果到这里你还没反馈过去,那么我再换个例子。

假如我这里的查问条件变 Integer 类型的编号呢?

比方我的编号是 200,是不是伪代码就变成了这样:

Integer score = map.get(200);
if(score == null){synchronized(200){score = loadFormDB(200);
       map.put(200,score);
   }    
}

看到这里你要是还没反馈过去的话我只能大喊一声:你个假读者!之前发的文章必定没看吧?

之前的《当 Synchronized 遇到这玩意儿,有个大坑,要留神!》这篇文章不全篇都在说这个事儿吗?

你要不晓得问题是啥,你就去翻一下。

这篇文章必定也不会往这个方向去写。不能死盯着 synchronize 不放,不然思路打不开。

咱们这里不能用 synchronized 这个玩意。

然而你认真一看,如果不必 synchronized 的话,这个 map 也不行啊:

private final Map<String, Integer> SCORE_CACHE = new HashMap<>();

这是个 HashMap,不是线程平安的呀。

怎么办?

演进呗。

演进一下

这一步非常简单,和最开始的程序相比,只是把 HashMap 替换为 ConcurrentHashMap。

而后就啥也没干了。

是不是感觉有点懵逼,甚至感觉有肯定被耍了的感觉?

有就对了,因为这一步扭转就是书外面的一个计划,我第一次看到的时候反正也是感觉有点懵逼:

我真没骗你,不信我拍照给你看:

.png)

这个计划和初始计划比,惟一的长处就是并发度上来了,因为 ConcurrentHashMap 是线程平安的。

然而,整个计划作为缓存来说,从下面的示意图也能看出,就是一句话:卵用没有。

因为基本就不能满足“雷同的申请下,如果缓存中没有,只有一个申请线程执行 loadFormDB 办法”这个需要,比方 why 的短时间内的两次查问操作就执行两次 loadFormDB 办法。

它的故障在哪儿呢?

如果多个线程都是查 why 这个人问题的前提下,如果一个线程去执行 loadFormDB 办法了,而另外的线程基本感知不到有线程在执行该办法,那么它们冲进来后一看:我去,缓存外面压根没有啊?那我也去执行 loadFormDB 办法吧。

完犊子了,反复执行了。

那么在 JDK 原生的办法外面有没有一种机制来示意曾经有一个申请查问 why 问题的线程在执行 loadFormDB 办法了,那么其余的查问 why 问题的线程就等这个后果就行了,没必要本人也去执行一遍。

这个时候就考验你的常识储备了。

你想到了什么?

持续演进

FutureTask 是异步编程外面的一个十分重要的组成部分。

比方线程池的利用中,当你应用 submit 的形式提交工作时,它的返回类型就是 Future:

反正基于 Future 这个货色,能够玩出花儿来。

比方咱们的这个场景中,如果要用到 FutureTask,那么咱们的 Map 就须要批改为这样:

Map<String, Future<Integer>> SCORE_CACHE = new ConcurrentHashMap<>();

通过保护姓名和 Future 的关系来达到咱们的目标。

Future 自身就代表一个工作,对于缓存保护这个需要来说,这个工作到底是在执行中还是曾经执行实现了它并不关怀,这个“它”指的是 SCORE_CACHE 这个 Map。

对于 Map 来说,只有有个工作放进来就行了。

而工作到底执行实现没有,应该是从 Map 外面 get 到对应 Future 的这个线程关怀的。

它怎么关怀?

通过调用 Future.get() 办法。

整个代码写进去就是这样的:

public class ScoreQueryService {private final Map<String, Future<Integer>> SCORE_CACHE = new ConcurrentHashMap<>();

    public Integer query(String userName) throws Exception {Future<Integer> future = SCORE_CACHE.get(userName);
        if (future == null) {Callable<Integer> callable = () -> loadFormDB(userName);
            FutureTask futureTask = new FutureTask<>(callable);
            future = futureTask;
            SCORE_CACHE.put(userName, futureTask);
            futureTask.run();}
        return future.get();}

    private Integer loadFormDB(String userName) throws InterruptedException {System.out.println("开始查问 userName=" + userName + "的分数");
        // 模仿耗时
        TimeUnit.SECONDS.sleep(1);
        return ThreadLocalRandom.current().nextInt(380, 420);
    }
}

怕你不相熟 futureTask,所以简略解释一下对于 futureTask 的四行代码,然而我还是强烈建议你把这个货色把握了,毕竟说它是异步编程的基石之一也不为过。

基石还是得拿捏明确,否则就容易被面试官拿捏。

Callable<Integer> callable = () -> loadFormDB(userName);
FutureTask futureTask = new FutureTask<>(callable);
futureTask.run();
return future.get();

首先我构建了一个 Callable 作为 FutureTask 构造函数的入参。

构造函数下面的形容翻译过去就是:创立一个 FutureTask,运行时将执行给定的 Callable。

“运行时”指的就是 futureTask.run() 这一行代码,而“给定的 Callable”就是 loadFormDB 工作。

也就是说调用了 futureTask.run() 之后,才有可能会执行到 loadFormDB 办法。

而后调用 future.get() 就是获取 Callable 的后果,即获取 loadFormDB 办法的后果。如果该办法还没有运行完结,就死等。

对于这个计划,书上是这样说的:

次要关注我划线的局部,我一句句的说

它只有一个缺点,即依然存在两个线程计算出雷同值的破绽。

这句话其实很好了解,因为代码外面始终有一个“①获取 -②判断 -③搁置”的动作。

这个动作就不是原子性的,所以有肯定的几率两个线程都冲进来,而后发现缓存中没有,就都走到 if 分支外面去了。

然而标号为 ① 和 ② 的中央,从需要实现的角度来说,必定是必不可少的。

能想方法的中央也就只有标号为 ③ 的中央了。

到底啥方法呢?

不焦急,下一大节说,我先把后半句话给解释了:

这个破绽的产生概率要远小于 Memoizer2 中产生的概率。

Memoizer2 就是指后面用 ConcurrentHashMap 替换 HashMap 后的计划。

那么为什么引入 Future 之后的这个计划,触发刚刚说到的 bug 的概率比之前的计划小呢?

答案就藏在这两行代码外面:

之前是要把业务逻辑执行实现,拿到返回值之后能力保护到缓存外面。

当初是先保护缓存,而后再执行业务逻辑,节约了执行业务逻辑的工夫。

而一般来说最耗时的中央就是业务逻辑的执行,所以这个“远小于”就是这样来的。

那怎么办呢?

接着演进呀。

最终版

书外面,针对下面那个“若没有则增加”这个非原子性的动作的时候,提到了 map 的一个办法:

.png)

Map 的 putIfAbsent,这个办法就厉害了。带你看一下:

首先从标号为 ① 的中央咱们能够晓得,这个办法传进来的 key 如果还没有与一个值相关联(或被映射为 null),则将其与给定的值进行映射并返回 null,否则返回以后值。

如果咱们只关怀返回值的话,那就是:如果有就返回对应的值,如果没有就返回 null。

标号为 ② 的中央说的是啥呢?

它说默认的实现没有对这个办法的同步性或原子性做出保障。如果你要提供原子性保障,那么就请笼罩此办法,本人去写。

所以,咱们接着就要关注一下 ConcurrentHashMap 的这个办法是怎么搞得了:

还是通过 synchronized 办法来保障了原子性,当操作的是同一个 key 的时候保障只有一个线程去执行 put 的操作。

所以书中给出的最终实现,是这样的:

public class ScoreQueryService {public static final Map<String, Future<Integer>> SCORE_CACHE = new ConcurrentHashMap<>();

    public Integer query(String userName) throws Exception {while (true) {Future<Integer> future = SCORE_CACHE.get(userName);
            if (future == null) {Callable<Integer> callable = () -> loadFormDB(userName);
                FutureTask futureTask = new FutureTask<>(callable);
                future = SCORE_CACHE.putIfAbsent(userName, futureTask);
                // 如果为空阐明之前这个 key 在 map 外面不存在
                if (future == null) {
                    future = futureTask;
                    futureTask.run();}
            }
            try {return future.get();
            } catch (CancellationException e) {System.out.println("查问 userName=" + userName + "的工作被移除");
                SCORE_CACHE.remove(userName, future);
            } catch (Exception e) {throw e;}
        }
    }

    private Integer loadFormDB(String userName) throws InterruptedException {System.out.println("开始查问 userName=" + userName + "的分数");
        // 模仿耗时
        TimeUnit.SECONDS.sleep(5);
        return ThreadLocalRandom.current().nextInt(380, 420);
    }
}

与前一个计划,有三个不一样的中央。

  • 第一个是采纳了 putIfAbsent 替换 put 办法。
  • 第二个是退出了 while(true) 循环。
  • 第三个是 future.get() 抛出 CancellationException 异样后执行了革除缓存的动作。

第一个没啥说的,后面曾经解释了。

第二个和第三个,说实话当他们组合在一起用的时候,我没看的太明确。

首先,从程序上讲,这两个是相辅相成的代码,因为 while(true) 循环我了解只有 future.get() 抛出 CancellationException 异样的时候才会起到作用。

抛出 CancellationException 异样,阐明以后的这个工作被其余中央调用了 cancel 办法,而因为 while(true) 的存在,且以后的这个工作被 remove 了,所以 if 条件胜利,就会再次构建一个一样的工作,而后继续执行:

也就是说移除的工作和放进去的工作是截然不同的。

那是不是就不必移除?

没转过弯的话没关系,我先给你上个代码看看,你就明确了:

其中 ScoreQueryService 的代码我后面曾经给了,就不截图了。

能够看到这次只往线程池外面扔了一个工作,而后接着把缓存外面的工作拿进去,调用 cancel 办法勾销掉。

这个程序的输入后果是这样的:

所以,因为 while(true) 的存在,导致 cancel 办法生效。

而后我后面说:移除的工作和放进去的工作是截然不同的。那是不是就不必移除?

体现在代码外面就是这样的:

不晓得作者为啥要专门搞个移除的动作,通过这一波剖析,这一行代码齐全是能够正文掉的嘛。

然而 …

对吗?

这是不对的,老铁。如果这行代码被正文了,那么程序的输入就是这样的:

变成一个死循环了。

为什么变成死循环了?

因为 FutureTask 这个玩意是有生命周期的:

被 cancelled 之后,生命周期就实现了,所以如果不从缓存外面移走那就芭比 Q 了,取出来的始终是被勾销的这个,那么就会始终抛出异样,而后持续循环。

死循环就是这样来的。

所以移除的动作必须得有,while(true) 就看你的需要了,加上就是 cannel 办法“生效”,去掉就是能够调用 cannel 办法。

对于 FutureTask 如果你不相熟的话,我写过两篇文章,你能够看看。

《老爷子这代码,看跪了!》

《Doug Lea 在 J.U.C 包外面写的 BUG 又被网友发现了。》

接着,咱们再验证一下最终代码是否运行失常:

三个线程最终查出来的分数是一样的,没故障。

如果你想察看一下阻塞的状况,那么能够把睡眠工夫拉长一点:

而后,把代码跑起来,看堆栈信息:

一个线程在 sleep,另外两个线程执行到了 FutureTask 的 get 办法。

sleep 的好了解,为什么另外两个线程阻塞在 get 办法上呢?

很简略,因为另外两个线程返回的 future 不是 null,这是由 putIfAbsent 办法的个性决定的:

好了,书中给出的最终计划的代码也解释完了。

然而书外面还留下了两个“坑”:

.png)

一个是不反对缓存过期机制。

一个是不反对缓存淘汰机制。

等下再说,先说说我的另一个计划。

还有一个计划

其实我也还有一个计划,拿进去给大家看看:

public class ScoreQueryService2 {public static final Map<String, Future<Integer>> SCORE_CACHE = new ConcurrentHashMap<>();

    public Integer query(String userName) throws Exception {while (true) {Future<Integer> future = SCORE_CACHE.get(userName);
            if (future == null) {Callable<Integer> callable = () -> loadFormDB(userName);
                FutureTask futureTask = new FutureTask<>(callable);
                FutureTask<Integer> integerFuture = (FutureTask) SCORE_CACHE.computeIfAbsent(userName, key -> futureTask);
                future = integerFuture;
                integerFuture.run();}
            try {return future.get();
            } catch (CancellationException e) {SCORE_CACHE.remove(userName, future);
            } catch (Exception e) {throw e;}
        }
    }

    private Integer loadFormDB(String userName) throws InterruptedException {System.out.println("开始查问 userName=" + userName + "的分数");
        // 模仿耗时
        TimeUnit.SECONDS.sleep(1);
        return ThreadLocalRandom.current().nextInt(380, 420);
    }
}

和书中给出的计划差别点在于用 computeIfAbsent 代替了 putIfAbsent:

V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction)

computeIfAbsent,首先它也是一个线程平安的办法,这个办法会查看 Map 中的 Key,如果发现 Key 不存在或者对应的值是 null,则调用 Function 来产生一个值,而后将其放入 Map,最初返回这个值;否则的话返回 Map 曾经存在的值。

putIfAbsent,如果 Key 不存在或者对应的值是 null,则将 Value 设置进去,而后返回 null;否则只返回 Map 当中对应的值,而不做其余操作。

所以这二者的区别之一在于返回值上。

用了 computeIfAbsent 之后,每次返回的都是同一个 FutureTask,然而因为 FutureTask 的生命周期,或者说是状态扭转的存在,即便三个线程都调用了它的 run 办法,这个 FutureTask 也只会执行胜利一次。

能够看一下,这个 run 办法的源码,一进来就是状态和以后操作线程的判断:

所以执行完一次 run 办法之后,再次调用 run 办法并不会真的执行。

然而从程序实现的优雅角度来说,还是 putIfAbsent 办法更好。

坑怎么办?

后面不是说最终的计划有两个坑嘛:

  • 一个是不反对缓存过期机制。
  • 一个是不反对缓存淘汰机制。

在应用 ConcurrentHashMap 的前提下,这两个个性如果要反对的话,须要进行对应的开发,比方引入定时工作来解决,想想就感觉麻烦。

同时也我想到了 spring-cache,我晓得这外面有 ConcurrentHashMap 作为缓存的实现计划。

我想看看这个组件外面是怎么解决这两个问题的。

二话不说,我先把代码拉下来看看:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

因为 spring-cache 也不是本文重点,所以我就间接说要害中央的源码了。

至于是怎么找到这里来的,就不具体介绍了,当前安顿文章具体解释。

另外我不得不说一句:spring-cache 这玩意真的是优雅的一比,不论是源码还是设计模式的利用,都十分的好。

首先,咱们能够看到 @Cacheable 注解外面有一个参数叫做 sycn,默认值是 false:

对于这个参数,官网上的解释是这样的:

https://docs.spring.io/spring…

就是针对咱们后面提到的缓存如何保护的状况的一个解决计划。应用办法也很简略。

该性能对应的外围局部的源码在这个地位:

org.springframework.cache.interceptor.CacheAspectSupport#execute(org.springframework.cache.interceptor.CacheOperationInvoker, java.lang.reflect.Method, org.springframework.cache.interceptor.CacheAspectSupport.CacheOperationContexts)

在下面这个办法中会判断是不是 sync=true 的办法,如果是则进入到 if 分支外面。

接着会执行到上面这个重要的办法:

org.springframework.cache.interceptor.CacheAspectSupport#handleSynchronizedGet

在这个办法外面,入参 cache 是一个抽象类,Spring 提供了六种默认的实现:

而我关怀的是 ConcurrentMapCache 实现,点进去一看,好家伙,这办法我熟啊:

org.springframework.cache.concurrent.ConcurrentMapCache#get

computeIfAbsent 办法,咱们不是刚刚才说了嘛。然而我左翻右翻就是找不到设置过期工夫和淘汰策略的中央。

于是,我又去翻官网了,发现答案就间接写在官网上的:

https://docs.spring.io/spring…

这里说了,官网提供的是一个缓存的形象,而不是具体的实现。而缓存过期和淘汰机制不属于形象的范畴内。

为什么呢?

比方拿 ConcurrentHashMap 来说,假如我提供了缓存过期和淘汰机制的形象,那你说 ConcurrentHashMap 怎么去实现这个形象办法?

实现不了,因为它原本就不反对这个机制。

所以官网认为这样的性能应该由具体的缓存实现类去实现而不是提供形象办法。

这里也就回复了后面的最终计划引申出的这两个问题:

  • 一个是不反对缓存过期机制。
  • 一个是不反对缓存淘汰机制。

別问,问就是原生的办法外面是反对不了的。如果要实现本人去写代码,或者换一个缓存计划。

再说两个点

最初,再补充两个点。

第一个点是之前的《当 Synchronized 遇到这玩意儿,有个大坑,要留神!》这篇文章外面,有一个中央写错了。

框起来的中央是我前面加上来的。

上周的文章收回去后,大略有十来个读者给我反馈这个问题。

我真的特地的开心,因为真的有人把我的示例代码拿去跑了,且认真思考了,而后来和我探讨,帮我斧正我写的不对的中央。

再给大家分享一下我的这篇文章《当我看技术文章的时候,我在想什么?》

外面表白了我对于看技术博客的态度:

看技术文章的时候多想一步,有时候会有更加粗浅的了解。

带着狐疑的眼光去看博客,带着求证的想法去证伪。

多想想 why,总是会有播种的。

第二个点是这样的。

对于 ConcurrentHashMap 的 computeIfAbsent 我其实也专门写过文章的:《震惊!ConcurrentHashMap 外面也有死循环,作者留下的“彩蛋”理解一下?》

老读者应该是读到过这篇文章的。

之前在 seata 官网上晃荡的时候,看到了这篇博客:

https://seata.io/zh-cn/blog/s…

名字叫做《ConcurrentHashMap 导致的 Seata 死锁问题》,我就轻易这么点进去一看:

这里提到的这篇文章,就是我写的。

在 seata 官网上偶遇本人的文章是一种很神奇的体验。

四舍五入,我也算是给 seata 有过奉献的男人。

而且你看这篇文章其实也提到了我之前写过的很多文章,这些常识都通过一个小小的点串起来了,由点到线,由线到面,这也是我保持写作的起因。

共勉之。

最初,响应一下文章的结尾局部,考研马上要查分了,我晓得我的读者外面还是有不少是往年考研的。

如果你看到了这里,那么上面这个图送给你:

本文已收录至集体博客,更多原创好文,欢送大家来玩:

https://www.whywhy.vip/

正文完
 0