构建高效且可伸缩的结果缓存 (中)

简介: 构建高效且可伸缩的结果缓存 (中)

继续演进


FutureTask 是异步编程里面的一个非常重要的组成部分。

image.png

反正基于 Future 这个东西,可以玩出花儿来。

比如我们的这个场景中,如果要用到 FutureTask,那么我们的 Map 就需要修改为这样:

Map<String, Future> 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 构造函数的入参。

image.png

构造函数上面的描述翻译过来就是:创建一个 FutureTask,运行时将执行给定的 Callable。

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

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

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

对于这个方案,书上是这样说的:

image.png

主要关注我划线的部分,我一句句的说

它只有一个缺陷,即仍然存在两个线程计算出相同值的漏洞。

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

image.png

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

但是标号为 ① 和 ② 的地方,从需求实现的角度来说,肯定是必不可少的。

能想办法的地方也就只有标号为 ③ 的地方了。

到底啥办法呢?

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

这个漏洞的发生概率要远小于 Memoizer2 中发生的概率。

Memoizer2 就是指前面用 ConcurrentHashMap 替换 HashMap 后的方案。

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

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

image.png

之前是要把业务逻辑执行完成,拿到返回值之后才能维护到缓存里面。

现在是先维护缓存,然后再执行业务逻辑,节约了执行业务逻辑的时间。

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

那怎么办呢?

接着演进呀。


最终版


书里面,针对上面那个“若没有则添加”这个非原子性的动作的时候,提到了 map 的一个方法:

image.png

image.png

首先从标号为 ① 的地方我们可以知道,这个方法传进来的 key 如果还没有与一个值相关联(或被映射为null),则将其与给定的值进行映射并返回 null ,否则返回当前值。

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

标号为 ② 的地方说的是啥呢?

它说默认的实现没有对这个方法的同步性或原子性做出保证。如果你要提供原子性保证,那么就请覆盖此方法,自己去写。

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


image.png

还是通过 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 条件成功,就会再次构建一个一样的任务,然后继续执行:

image.png

也就是说移除的任务和放进去的任务是一模一样的。

那是不是就不用移除?

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

image.png

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

可以看到这次只往线程池里面扔了一个任务,然后接着把缓存里面的任务拿出来,调用 cancel 方法取消掉。

这个程序的输出结果是这样的:

image.png

所以,由于 while(true) 的存在,导致 cancel 方法失效。

然后我前面说:移除的任务和放进去的任务是一模一样的。那是不是就不用移除?

表现在代码里面就是这样的:

image.png

不知道作者为啥要专门搞个移除的动作,经过这一波分析,这一行代码完全是可以注释掉的嘛。

但是...

对吗?


image.png

image.png

变成一个死循环了。

为什么变成死循环了?

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

image.png

被 cancelled 之后,生命周期就完成了,所以如果不从缓存里面移走那就芭比Q了,取出来的始终是被取消的这个,那么就会一直抛出异常,然后继续循环。

死循环就是这样来的。

所以移除的动作必须得有, while(true) 就看你的需求了,加上就是 cannel 方法“失效”,去掉就是可以调用 cannel 方法。

关于 FutureTask 如果你不熟悉的话,我写过两篇文章,你可以看看。

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

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

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

image.png

image.png

微信图片_20220429081108.png

目录
相关文章
|
17天前
|
存储 缓存 NoSQL
构建高性能Web应用:缓存的重要性及其实现
构建高性能Web应用:缓存的重要性及其实现
|
2月前
|
Java 开发者 JavaScript
Struts 2 开发者的秘籍:隐藏的表单标签库功能,能否成为你下个项目的大杀器?
【8月更文挑战第31天】Struts 2表单标签库是提升Web页面交互体验的神器。它提供丰富的标签,如`&lt;s:textfield&gt;`和`&lt;s:select&gt;`,简化表单元素创建与管理,支持数据验证和动态选项展示。结合示例代码,如创建文本输入框并与Action类属性绑定,显著提升开发效率和用户体验。通过自定义按钮样式等功能,Struts 2表单标签库让开发者更专注于业务逻辑实现。
45 0
|
2月前
|
缓存 NoSQL 数据库
【超实用秘籍】FastAPI高手教你如何通过最佳实践构建高效Web应用:从代码组织到异步编程与缓存优化的全方位指南!
【8月更文挑战第31天】FastAPI凭借出色性能和易用性成为现代Web应用的首选框架。本文通过示例代码介绍构建高效FastAPI应用的最佳实践,包括开发环境搭建、代码模块化组织、异步编程及性能优化等。通过模块化设计和异步数据库操作,结合缓存技术,大幅提升应用性能与可维护性,助您轻松应对高并发场景。
108 0
|
4月前
|
敏捷开发 缓存 测试技术
阿里云云效产品使用问题之构建Vue3项目,怎么让node_modules缓存下来
云效作为一款全面覆盖研发全生命周期管理的云端效能平台,致力于帮助企业实现高效协同、敏捷研发和持续交付。本合集收集整理了用户在使用云效过程中遇到的常见问题,问题涉及项目创建与管理、需求规划与迭代、代码托管与版本控制、自动化测试、持续集成与发布等方面。
|
3月前
|
敏捷开发 缓存 测试技术
阿里云云效产品使用合集之在自建构建机中,如何不使用缓存进行构建
云效作为一款全面覆盖研发全生命周期管理的云端效能平台,致力于帮助企业实现高效协同、敏捷研发和持续交付。本合集收集整理了用户在使用云效过程中遇到的常见问题,问题涉及项目创建与管理、需求规划与迭代、代码托管与版本控制、自动化测试、持续集成与发布等方面。
|
3月前
|
缓存 NoSQL Java
使用Java构建高效的分布式缓存系统
使用Java构建高效的分布式缓存系统
|
3月前
|
存储 缓存 NoSQL
使用Java构建高性能的分布式缓存系统
使用Java构建高性能的分布式缓存系统
|
17天前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(一)
数据的存储--Redis缓存存储(一)
53 1
|
17天前
|
存储 缓存 NoSQL
数据的存储--Redis缓存存储(二)
数据的存储--Redis缓存存储(二)
33 2
数据的存储--Redis缓存存储(二)
|
13天前
|
消息中间件 缓存 NoSQL
Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。
【10月更文挑战第4天】Redis 是一个高性能的键值对存储系统,常用于缓存、消息队列和会话管理等场景。随着数据增长,有时需要将 Redis 数据导出以进行分析、备份或迁移。本文详细介绍几种导出方法:1)使用 Redis 命令与重定向;2)利用 Redis 的 RDB 和 AOF 持久化功能;3)借助第三方工具如 `redis-dump`。每种方法均附有示例代码,帮助你轻松完成数据导出任务。无论数据量大小,总有一款适合你。
50 6