[Java并发编程实战]构建一个高效可复用缓存程序(含代码)

一屋不扫何以扫天下?———《后汉书·陈蕃传》
它告诉我们,要从一点一滴的小事开始积累,才能做成一番大事业。

PS: 如果觉得本文有用的话,请帮忙点赞,留言评论支持一下哦,您的支持是我最大的动力!谢谢啦~

这几天更新了这么多篇文章,其实这些都是并发编程中最基础的知识。现在,我们是时候利用这些知识来写一个小程序了。本篇文章就来介绍如何构建一个用来存储计算结果的高效、可伸缩高速缓存,虽然简单,但也可以说算是对前面知识的一次运用了。

高速缓存,无论是在服务器端还是在前端程序中都有不同形式的实现。复用缓存里的结果可以大大缩短程序的等待时间,提高吞吐量。说的通俗一点就是利用空间换时间。在本篇文章中,将展示构建缓存程序的过程,并且分析每次优化的过程主要是针对什么问题,直至搭建好我们缓存程序。

缓存相关介绍可以参考我的这篇文章,基本概念了解一下:https://blog.csdn.net/amd123456789/article/details/52092927

下面的例子,我们想构建一个存放计算结果的缓存程序,其中计算的过程是耗时操作。

第一个版本

首先,创建一个接口 Compute,V 表示计算出来结果的类型,A表示输入对象的类型。

其次,创建一个实现类继承这个接口,ExpensiveCompute,用来实现这个计算耗时操作。

然后,创建一个 Compute 接口的包装器,Memorizer,程序的核心类;用于存储计算结果和封装整个缓存的逻辑实现。到时候,如果我们要更改方案,只需要简单修改这个包装器就可以了,其他实现类都不用修改。(装饰器的好处体现在这里)

最后,创建一个控制类 Controller 来模拟缓存的过程。

下面直接看代码,已经详细注释。

(1)Compute 接口。

public interface Compute {
    public V compute(A arg) throws InterruptedException;
}

(2)ExpensiveFunction,计算耗时操作的实现类。

import java.util.concurrent.atomic.AtomicInteger;
//实现计算的类
public class ExpensiveFunction implements Compute{
    //原子变量
    private static AtomicInteger count = new AtomicInteger(0);
    //实现计算函数
    public Integer compute(String arg) throws RuntimeException{
        //这里表示耗时操作
        for(int i = 0; i < 999898999; i++);
        //这里返回计算结果
        return count.incrementAndGet();
    }
}

(3)Memorizer,缓存核心类,封装缓存实现机制,存储计算结果。

import java.util.HashMap;
import java.util.Map;

//包装器,缓存程序的核心类
public class Memorizer implements Compute{
    //创建一个非线程安全的Map,用以存储计算结果
    public final Map cache = new HashMap();
    //存放计算的实现类,计算最终是调用这个实现类
    private Compute c;
    //构造函数,将具体计算实现类传递进来
    public Memorizer(Compute c) {
            this.c = c;
    }
    //同步方法,确保线程安全,每次只能有一个程序进来
    @Override
    public synchronized V compute(A arg){
        //先看是否有存储的结构了,如果有直接返回
        V result = cache.get(arg);
        if(result == null) {
            System.out.println(arg+" 没有缓存,开始结算");
            try {
                //计算结果
                result = this.c.compute(arg);
                //存进map
                cache.put(arg, result);
            } catch (Exception e) {
                e.printStackTrace();
            }
        } else {
            System.out.println(arg+" 已经存有计算结果");
        }
        return result;
    }
}

(4)Controller, 控制类,启动整个程序。

import java.util.Iterator;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;

public class Controller {
    //子线程数量
    private static final int THREAD_SIZE = 10;
    //设置key的个数,小于线程的个数
    private static final int STR_SIZE = 5;
    //传入需要计算结果的字符参数
    private static final String str[] = {"key1", "key2", "key3", "key4", "key5"};
    //包装类,缓存核心类
    private volatile Memorizer mCacheImpl ;
    //闭锁,必须等待所有子线程计算结果出来,才可以打印map里面的对象
    private volatile static CountDownLatch mLdc; 
    //构造函数
    public Controller(Compute c) {
        mCacheImpl = new Memorizer(c);
        mLdc = new CountDownLatch(THREAD_SIZE);
    }
    
    public static void main(String[] arg) {
        //创建控制器
        Controller mController = new Controller(new ExpensiveFunction());
        //创建多个子线程,获取计算结果
        for(int i = 0; i < THREAD_SIZE; i++) {
            new Thread(new ComputeRunnable(mController, mLdc, i)).start();
        }

        try {
            //等待所有线程执行完毕
            mLdc.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //打印缓存信息,即map里面的内容
        Set set = mController.mCacheImpl.cache.keySet();
        Iterator it = set.iterator();
        while(it.hasNext()) {
            String key = it.next().toString();
            System.out.println(key +":"+ mController.mCacheImpl.cache.get(key));
        }
    }
    
    //子线程的具体调用过程
    static class ComputeRunnable implements Runnable {
        
        private Controller controller;
        private CountDownLatch cdl;
        private int index;
        
        public ComputeRunnable(Controller c, CountDownLatch cdl, int i) {
            this.controller = c;
            this.cdl = cdl;
            this.index = i;
        }
        
        public void run() {
            //调用计算函数
            this.controller.mCacheImpl.compute(getKeyStr(index));
            //计算完毕,打开闭锁
            this.cdl.countDown();
        }
    }
    //获取每次输入的参数
    public static String getKeyStr(int index) {
        return str[index % STR_SIZE];
    }
}

程序的执行结果如下:
[图片上传失败...(image-2a4007-1532128707419)]

从打印的 log 可以看出执行过程了,存储过的结果不会再重复计算,能够正确的把计算结果缓存起来。

但是这样的程序,面对高并发的情况,效率会很低。为什么?

因为 compute 是同步方法,每次只能有一个线程访问,其他线程全部阻塞!假如当前操作非常耗时,那么其他线程将会全部阻塞,程序的吞吐量和响应性将会变得十分糟糕。

面对这样潜在的问题,那么我们可以用一个线程安全的 Map 来替代 HashMap。

第二个版本

Memorizer的实现用 ConcurrentHashMap 来代替 HashMap,同时去掉compute方法同步锁。
修改如下:

//创建一个线程安全的Map,用以存储计算结果
public final Map cache = new ConcurrentHashMap();

//去掉同步锁
@Override
public V compute(A arg){}

但是执行结果还是有些小问题:
[图片上传失败...(image-eeb6cc-1532128707419)]

这里我们可以看到,key1 计算了两次,说明现在的程序还不能保证线程安全。我们来看下面时序图,就明白了为啥程序执行会出错:
[图片上传失败...(image-50a34e-1532128707419)]

那么,怎么样才能避免这种情况发生?这又是一个需要我们思考的问题。有没有办法 B 线程去访问的时候,知道 A 线程已经在计算了,避免重复计算呢?

问题的原因就是这个代码中存在 [检查没有则更新] 的复合操作。如果想要这个复合操作能够保证同步,那么必须将这个复合代码全部加上锁。然而还有更好的方法,因为并发容器提供了这个同步操作ConcurrentHashMap@putIfAbsent(),保证这个复合操作是同步的。所以省去了手动加锁,直接用这个容器便解决了问题。

另外,我们还想把耗时操作在存进缓存的时候提取出来,不必等待计算出结果,再把结果存进缓存。FutureTask 具备这种功能,它代表一个计算可能在运行中,也可能已经结束。把这个耗时操作封装在 FutureTask 里面,然后直接把 FutureTask 存进缓存。同时,上图 B 线程访问的时候,它可以阻塞 B 线程,直至 A 计算出结果,然后 B 直接拿到 A 计算的结果。

第三个版本

现在知道方案了,这次我们同样修改 Memorizer 类。

//创建一个非线程安全的Map,用以存储计算结果
public final Map> cache = new ConcurrentHashMap>();

   //去掉同步锁
@Override
public V compute(A arg){
    //先看是否有存储的结构了,如果有直接返回
    Future f = cache.get(arg);
    if(f == null) {
        //创建 Callable 对象,返回结果
        Callable eval = new Callable() {
            @Override
            public V call() throws Exception {
                //存放计算结果
                return c.compute(arg);
            }
        };
        //FutureTask对象
        FutureTask ft = new FutureTask(eval);
        //存储数据
        f = cache.putIfAbsent(arg, ft);
        if(f == null) {
            //没有缓存
            System.out.println(arg+" 没有缓存,开始结算");
            f = ft;
            ft.run(); //调用 c.compute 的耗时操作发生在这里
        } else
        {
            //已有缓存
            System.out.println(arg+" 已经存有计算结果");    
        }
    } else {
        System.out.println(arg+" 已经存有计算结果");
    }
    try {
        return f.get();
    } catch (InterruptedException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    } catch (ExecutionException e) {
        // TODO Auto-generated catch block
        e.printStackTrace();
    }
    return null;
}

按照上面修改后,执行结果已经达到我们的预期:
[图片上传失败...(image-4b604b-1532128707419)]

这便是我们想要构建的高效可复用的缓存程序。但是真正实用的话,远远不止这些。比如,实际运用中如何处理下面这个问题:

  1. 缓存过期怎么处理?

这个问题留给我们去思考,当然,如果你想知道怎么处理,可以关注我的微信公众号:[林里少年](id:seaicelin)

回复 [缓存] 获取这个问题的答案源代码。

你可能感兴趣的:([Java并发编程实战]构建一个高效可复用缓存程序(含代码))