使用Java设计实现一个高效可伸缩的计算结果缓存

news2024/11/26 3:34:49

目录

  • 概述
  • 1.缓存实现
    • 1.1 使用HashMap+Synchronized实现缓存
    • 1.2 使用ConcurrentHashMap代替HashMap改进缓存的并发
    • 1.3 完成可伸缩性高效缓存的最终方案
    • 1.4 测试代码
  • 2.并发技巧总结

概述

现在的软件开发中几乎所有的应用都会用到某种形式的缓存,重用之前的计算结果能够降低延迟,提高系统吞吐量,但是需要消耗更多的内存,是一种以空间换时间的方法。和许多重复造的轮子一样,缓存看起来很简单,无非就是把所有的计算结果保存下来,下次使用的时候优先使用缓存中已经保存的结果,没有的情况下才去重新计算。但是不合理的缓存机制设计却会让程序的性能受到影响,本文就通过对一个计算结果缓存的设计迭代介绍,分析每个版本的并发缺陷,并分析如何修复这些缺陷,最终完成一个高效可伸缩的计算结果缓存。

1.缓存实现

为了演示,我们定义一个计算接口Computable<A,V>,并在接口中声明一个函数compute(A arg),其输入的值为A类型的,返回的值为V类型的,接口定义如下所示:

public interface Computable<A,V> {
    V compute(A arg) throws InterruptedException;
}

1.1 使用HashMap+Synchronized实现缓存

第一种方式是我们使用HashMap做缓存的容器,因为HashMap不是线程安全的,所以我们需要加上synchronized同步机制来保证数据的存取安全。

代码如下:

public class HashMapMemoizer<A,V> implements Computable<A,V>{
    private final Map<A,V> cache = new HashMap<>();
    private final Computable<A,V> computable;

    private HashMapMemoizer(Computable<A,V> computable){
        this.computable = computable;
    }


    @Override
    public synchronized V compute(A arg) throws InterruptedException {
        V res = cache.get(arg);
        if (res == null) {
            res = computable.compute(arg);
            cache.put(arg,res);
        }

        return res;
    }
}

如上面的代码所示,我们使用HashMap保存之前的计算结果,我们每次在计算结果时,先去检查缓存中是否存在,如果存在则返回缓存中的结果,否则重新计算结果并将其放到缓存中,然后再返回结果。由于HashMap不是线程安全的,所以我们无法确保两个线程不会同时访问HashMap,所以我们对整个compute方法添加synchronized关键字对方法进行同步。这种方法可以保证线程安全型,但是会有一个明显的问题,那就是每次只有一个线程能够执行compute,如果另一个线程正在计算结果,由于计算是很耗时的,那么其他调用compute方法的线程可能会被阻塞很长时间。如果多个线程在排队等待还未计算出的结果,那么compute方法的计算时间可能比没有缓存操作的计算时间更长,那么缓存就失去了意义。

1.2 使用ConcurrentHashMap代替HashMap改进缓存的并发

由于ConcurrentHashMap是线程安全的,因此在访问底层Map时就不需要进行同步,因此可以避免在对compute方法进行同步时带来的多个线程排队等待还未计算出的结果的问题

改进后的代码如下所示:

public class ConcurrentHashMapMemoizer<A,V> implements Computable<A,V>{
    private final Map<A,V> cache = new ConcurrentHashMap<>();
    private final Computable<A,V> computable;

    private ConcurrentHashMapMemoizer(Computable<A,V> computable){
        this.computable = computable;
    }


    @Override
    public V compute(A arg) throws InterruptedException {
        V res = cache.get(arg);
        if (res == null) {
            res = computable.compute(arg);
            cache.put(arg,res);
        }

        return res;
    }
}

注意:这种方式有着比第一种方式更好的并发行为,多个线程可以并发的使用它,但是它在做缓存时仍然存在一些不足,这个不足就是当两个线程同时调用compute方法时,可能会导致计算得到相同的值。因为缓存的作用就是避免相同的数据被计算多次。对于更通用的缓存机制来说,这种情况将更严重。而假设用于只提供单次初始化的对象来说,这个问题就会带来安全风险。

1.3 完成可伸缩性高效缓存的最终方案

使用ConcurrentHashMap的问题在于如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么就很有可能重复这个计算。所以我们希望能通过某种方法来表达“线程X正在进行f(10)这个耗时计算”,这样当另外一个线程查找f(10)时,它能够知道目前已经有线程在计算它想要的值了,目前最高效的办法是等线程X计算结束,然后再去查缓存找到f(10)的结果是多少。而FutureTask正好可以实现这个功能。我们可以使用FutureTask表示一个计算过程,这个过程可能已经计算完成,也可能正在进行。如果有结果可以用,那么FutureTask.get()方法将会立即返回结果,否则它会一直阻塞,知道结果计算出来再将其返回

我们将前面用于缓存值的Map重新定义为ConcurrentHashMap<A, Future<V>>,替换原来的ConcurrentHashMap<A, V>,代码如下所示:

public class PerfectMemoizer<A, V> implements Computable<A, V> {
    private final ConcurrentHashMap<A, Future<V>> cache
            = new ConcurrentHashMap<>();
    private final Computable<A, V> computable;

    public PerfectMemoizer(Computable<A, V> computable) {
        this.computable = computable;
    }

    @Override
    public V compute(final A arg) throws InterruptedException {
        while (true) {
            Future<V> f = cache.get(arg);
            if (f == null) {
                Callable<V> eval = new Callable<V>() {
                    @Override
                    public V call() throws Exception {
                        return computable.compute(arg);
                    }
                };

                FutureTask<V> ft = new FutureTask<>(eval);
                f = cache.putIfAbsent(arg, ft);
                if (f == null) {
                    f = ft;
                    ft.run();
                }
            }

            try {
                return f.get();
            } catch (CancellationException e) {
                cache.remove(arg);
            } catch (ExecutionException e) {
                throw new RuntimeException(e);
            }
        }
    }
 }

如上面代码所示,我们首先检测某个相应的计算是否已经开始,如果还没开始,就创建一个FutureTask并注册到Map中,然后启动计算,如果已经开始计算,那么就等待计算的结果。结果可能很快得到,也可能还在运算过程中。但是对于Future.get()方法来说是透明的。

注意:我们在代码中用到了ConcurrentHashMap的putIfAbsent(arg, ft)方法,为啥不能直接用put方法呢?因为如果使用put方法,那么仍然会出现两个线程计算出相同的值的问题。我们可以看到compute方法中的if代码块是非原子的,如下所示:

// compute方法中的if部分代码
   if (f == null) {
                Callable<V> eval = new Callable<V>() {
                    @Override
                    public V call() throws Exception {
                        return computable.compute(arg);
                    }
                };

                FutureTask<V> ft = new FutureTask<>(eval);
                f = cache.putIfAbsent(arg, ft);
                if (f == null) {
                    f = ft;
                    ft.run();
                }
            }

因此两个线程仍有可能在同一时间调用compute方法来计算相同的值,只是概率比较低。即两个线程都没有在缓存中找到期望的值,因此都开始计算。而引起这个问题的原因复合操作(若没有则添加)是在底层的Map对象上执行的,而这个对象无法通过加锁来确保原子性,所以需要使用ConcurrentHashMap中的原子方法putIfAbsent,避免这个问题

1.4 测试代码

本来想弄一个动态图展示使用缓存和不使用缓存的速度对比的,但是弄出来的图太大,传不上来,所以给测试代码读者自己验证下:

   public static void main(String[] args) throws InterruptedException {
        Computable<Integer, List<String>> cache = arg -> {
            List<String> res = new ArrayList<>();
            for (int i = 0; i < arg; i++) {
                Thread.sleep(50);
                res.add("zhongjx==>" + i);
            }

            return res;
        };

        PerfectMemoizer<Integer, List<String>> memoizer = new PerfectMemoizer<>(cache);
        new Thread(new Runnable() {
            @Override
            public void run() {
                List<String> compute = null;
                try {
                    compute = memoizer.compute(100);
                    System.out.println("zxj 第一次计算100的结果========: " 
                    + Arrays.toString(compute.toArray()));
                    compute = memoizer.compute(100);
                    System.out.println("zxj 第二次计算100的结果: " + Arrays.toString(compute.toArray()));
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }).start();
        System.out.println("zxj====>start===>");
    }

测试代码中我们使用Thread.sleep()方法模拟耗时操作。我们要测试不使用缓存的情况就是把 f = cache.putIfAbsent(arg, ft);这句代码注释调就行了:如下图所示
在这里插入图片描述
结论:使用缓存时,计算结果会很快得到,不使用缓存时,每次计算都会耗时。

2.并发技巧总结


此:一个可伸缩性的高效缓存就设计完了,至此我们可以总结下并发编程的技巧,如下所示:

1.尽量将域声明为final类型,除非它们是可变的,即设计域的时候要考虑是可变还是不可变的
2.不可变的对象一定是线程安全的,可以任意共享而无需使用加锁或者保护性复制等机制。
3.使用锁保护每个可变变量
4.当保护同一个不变性条件中的所有变量时,要使用同一个锁
5.在执行复合操作期间,要持有锁
6.在设计过程中要考虑线程安全。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/665932.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

回收站数据恢复方法有哪些?五招走起,趁早上手

回收站数据恢复方法是我们在日常操作电脑时不可避免需要面对的问题。本文将对几种常用的回收站数据恢复方法进行介绍&#xff0c;为大家解决恢复回收站数据的常见问题。 一、使用快捷键恢复回收站文件 在我们的电脑中&#xff0c;有很多实用的快捷键&#xff0c;其中有效地恢…

分享干货,多编程语言代码生成神器 CodeGeeX,编码效率提升十倍

CodeGeeX 是一个具有 130 亿参数的多编程语言代码生成预训练模型&#xff0c;采用华为 MindSpore 框架实现&#xff0c;在鹏城实验室“鹏城云脑 II”上使用 1536 个国产昇腾 910 AI 处理器训练而成。 CodeGeexX 支持十多种主流编程语言的高精度代码生成、跨语言代码翻译等功能&…

Django网络空间微博管理信息系统-计算机毕设 附源码85633

Django网络空间微博管理信息系统 摘 要 本论文主要论述了如何使用django框架开发一个网络空间微博管理信息系统&#xff0c;本系统将严格按照软件开发流程进行各个阶段的工作&#xff0c;面向对象编程思想进行项目开发。在引言中&#xff0c;作者将论述该系统的当前背景以及系统…

MidJourney使用教程:一 第一次怎么用Midjourney

实际我是先写的prompts提示这部分&#xff0c;觉得Midjurney使用的方式&#xff0c;市面上已经有一大把文章了&#xff0c;另一方面觉得也没什么可写的。注册一个discard账号写个prompts描述出图就可以了&#xff0c;但其实有很多点其实忽略掉。比如图出来了&#xff0c;这四幅…

cesium封装实现卫星视锥扫描效果

废话不多说,先看效果 先封装视锥效果函数 // 绘制卫星锥体const radarScanner = (position,height,radarId,bottomRadius,color) => {viewer.entities.add({

基于Springboot+vue的垃圾分类网站设计与实现

博主介绍&#xff1a; 大家好&#xff0c;我是一名在Java圈混迹十余年的程序员&#xff0c;精通Java编程语言&#xff0c;同时也熟练掌握微信小程序、Python和Android等技术&#xff0c;能够为大家提供全方位的技术支持和交流。 我擅长在JavaWeb、SSH、SSM、SpringBoot等框架…

学生速看!免费领取一台阿里云服务器申请全流程

阿里云学生服务器优惠活动&#xff1a;高效计划&#xff0c;可以免费领取一台阿里云服务器&#xff0c;如果你是一名高校学生&#xff0c;想搭建一个linux学习环境、git代码托管服务器&#xff0c;或者创建个人博客网站记录自己的学习成长历程&#xff0c;拥有一台云服务器是很…

MT4开户平台交易注意事项有哪些?

很多投资者都会选择MT4平台进行开户交易&#xff0c;毕竟MT4平台的起步时间比较早&#xff0c;对一些关注资金安全的投资者来说&#xff0c;MT4平台无疑是他们最佳的选择&#xff0c;那么&#xff0c;在MT4开户平台交易就一定不会发生失误吗&#xff1f;答案就是&#xff1a;不…

红帽考试常见问题解答

问&#xff1a;红帽考试结束后&#xff0c;何时可以收到成绩&#xff1f; 答&#xff1a;美国认证中心会在 3&#xff5e;5 个工作日内将成绩通知邮件发给考生&#xff0c;请注意提供正确的联系信息。例外情况&#xff1a;一些邮件服务器会错误地将结果电子邮件作为垃圾邮件处…

【Python 随练】相反顺序输出字符串

题目 利用递归函数调用方式&#xff0c;将所输入的 5 个字符&#xff0c;以相反顺序打印出来。 简介 在本篇博客中&#xff0c;我们将使用递归函数来解决一个字符打印的问题。我们将介绍递归的概念&#xff0c;并提供一个完整的代码示例来实现将输入的字符以相反顺序打印出来…

驱动开发:基于事件同步的反向通信

在之前的文章中LyShark一直都在教大家如何让驱动程序与应用层进行正向通信&#xff0c;而在某些时候我们不仅仅只需要正向通信&#xff0c;也需要反向通信&#xff0c;例如杀毒软件如果驱动程序拦截到恶意操作则必须将这个请求动态的转发到应用层以此来通知用户&#xff0c;而这…

Apache Superset 身份认证绕过漏洞(CVE-2023-27524)

漏洞简介 Apache Superset是一个开源的数据可视化和数据探测平台&#xff0c;它基于Python构建&#xff0c;使用了一些类似于Django和Flask的Python web框架。提供了一个用户友好的界面&#xff0c;可以轻松地创建和共享仪表板、查询和可视化数据&#xff0c;也可以集成到其他…

二进制搭建 Kubernetes v1.20

k8s集群master01&#xff1a;192.168.179.25 kube-apiserver kube-controller-manager kube-scheduler etcd k8s集群master02&#xff1a;192.168.179.26 k8s集群node01&#xff1a;192.168.179.23 kubelet kube-proxy docker k8s集群node02&#xff1a;192.168.179.22 …

.env 环境变量使用,React项目中使用 .env.*等文件使用

一、公共.env环境变量 二、.env.*环境变量(例如&#xff1a;.env.test 环境变量) 公共 .env 环境变量 在项目开发中&#xff0c;我们不可避免的会需要使用 .env 环境变量&#xff0c;例如在定义接口 api 的 baseURL 时&#xff0c;会根据不同的环境&#xff0c;配置不同的根…

偶数分频器电路设计

目录 偶数分频器电路设计 1、偶数分频器电路简介 2、实验任务 3、程序设计 方法1&#xff1a; 3.1、8分频电路代码如下&#xff1a; 3.2、仿真验证 3.2.1、编写 TB 文件 3.2.2、仿真验证 方法2&#xff1a; 4、计数器进行分频 4.1、仿真测试 偶数分频器电路设计 分…

软件设计原则与设计模式

设计中各各原则同时兼有或冲突&#xff0c;不存在包含所有原则的设计 一&#xff1a;单一职责原则又称单一功能原则 核心&#xff1a;解耦和增强内聚性&#xff08;高内聚&#xff0c;低耦合&#xff09; 描述&#xff1a;类被修改的几率很大&#xff0c;因此应该专注于单一的…

YOLOv5 vs YOLOv8

1 概述 YOLOv8 是 ultralytics 公司在 2023 年 1月 10 号开源的 YOLOv5 的下一个重大更新版本。 https://github.com/ultralytics/yolov5 https://github.com/ultralytics/ultralytics 2 网络结构 YOLOv5 N/S/M/L/X 骨干网络的通道数设置使用同一套缩放系数&#xff1b; YOLO…

Axure教程——滑动解锁

本文将教大家如何用AXURE中的动态面板制作滑动解锁 一 、效果 预览地址&#xff1a;https://6dnu91.axshare.com 二、功能 滑动滑块从左到右&#xff0c;提示验证成功 三、制作 拖入一个动态面板组件&#xff0c;如图&#xff1a; 点击动态面板进入&#xff0c;拖入一个矩形…

【生态经济学】R语言机器学习方法在生态经济学领域中的实践技术

查看原文>>>基于R语言机器学习方法在生态经济学领域中的实践技术 近年来&#xff0c;人工智能领域已经取得突破性进展&#xff0c;对经济社会各个领域都产生了重大影响&#xff0c;结合了统计学、数据科学和计算机科学的机器学习是人工智能的主流方向之一&#xff0c…

RK3288 Android logo

一、Android 系统开机logo的修改 安卓系统的开机分为u-boot logo 和 kernel logo开机logo图片必须是 bmp 格式&#xff0c;并且分辨率必须为偶数将制作好的BMP格式logo图片放置Android源码kernel目录下&#xff0c;重新编译即可 二、Android logo常见问题分析 1、RK3288 Andr…