概述

现在的软件开发中简直一切的运用都会用到某种形式的缓存,重用之前的核算成果能够下降推迟,提高体系吞吐量,可是需求耗费更多的内存,是一种以空间换时间的办法。和许多重复造的轮子一样,缓存看起来很简单,无非便是把一切的核算成果保存下来,下次运用的时分优先运用缓存中现已保存的成果,没有的状况下才去从头核算。可是不合理的缓存机制规划却会让程序的功能受到影响,本文就经过对一个核算成果缓存的规划迭代介绍,分析每个版别的并发缺点,并分析如何修正这些缺点,最终完结一个高效可伸缩的核算成果缓存。

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 第2次核算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);这句代码注释调就行了:如下图所示

使用Java设计实现一个高效可伸缩的计算结果缓存
定论:运用缓存时,核算成果会很快得到,不运用缓存时,每次核算都会耗时。

2.并发技巧总结

至 此:一个可伸缩性的高效缓存就规划完了,至此咱们能够总结下并发编程的技巧,如下所示:

1.尽量将域声明为final类型,除非它们是可变的,即规划域的时分要考虑是可变还是不可变的 2.不可变的目标一定是线程安全的,能够恣意同享而无需运用加锁或许维护性复制等机制。 3.运用锁维护每个可变变量 4.当维护同一个不变性条件中的一切变量时,要运用同一个锁 5.在履行复合操作期间,要持有锁 6.在规划进程中要考虑线程安全。