基于Map的简易记忆化缓存

Posted Mr.袋鼠

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了基于Map的简易记忆化缓存相关的知识,希望对你有一定的参考价值。

看到文章后,自己也想写一些关于这个方面的,但是觉得写的估计没有那位博主好,而且又会用到里面的许多东西,所以干脆转载。但是会在文章末尾写上自己的学习的的东西。

原文出处如下:

http://www.cnblogs.com/micrari/p/6921661.html

 

背景

在应用程序中,时常会碰到需要维护一个map,从中读取一些数据避免重复计算,如果还没有值则计算一下塞到map里的的小需求(没错,其实就是简易的缓存或者说实现记忆化)。在公司项目里看到过有些代码中写了这样简易的缓存,但又忽视了线程安全、重复计算等问题。本文主要就是谈谈这个小需求的实现。

实现

HashMap的实现

在公司项目里看到过有类似如下的代码。


public class SimpleCacheDemo {

    private Map<Integer, Integer> cache = new HashMap<>();

    public synchronized Integer retrieve(Integer key) {
        Integer result = cache.get(key);
        if (result == null) {
            result = compute(key);
            cache.put(value,result);
        }
        return result;
    }

    private Integer compute(Integer key) {
        // 模拟代价很高的计算
        return key;
    }
}

只是那位同事写的代码比这段代码更糟,连synchronized关键字都没加。

这段代码的问题还在于由于在compute方法上进行了同步,所以大大降低了并发性,在具体场景中,如果compute代价很高,那么其他线程会长时间阻塞。

基于ConcurrentHashMap的改进

一种改进的策略是将上述map的实现类替换为ConcurrentHashMap并去除compute上的synchronized。这样可以规避在compute上同步带来的伸缩性问题。

但与上面的方法一样还有一个问题在于,由于compute的耗时可能不少,在另一个线程读到map中还没有值时可能同样会开始进行计算,这样就出现了重复高代价计算的问题。

基于Future的改进

为了规避重复计算的问题,可以将map中的值类型用Future封起来。代码如下:


public class SimpleCacheDemo {

    private Map<Integer, Future<Integer>> cache = new HashMap<>();

    public Integer retrieve(Integer key) {
        Future<Integer> result = cache.get(key);
        if (result == null) {
            FutureTask<Integer> task = new FutureTask<>(() -> compute(key));
            cache.put(key, task);
            result = task;
            task.run();
        }
        try {
            return result.get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    }

    private Integer compute(Integer value) {
        // 模拟代价很高的计算
        return value;
    }

}

当在map中读取到result为null时,建一个FutureTask塞到map并进行计算,最后获取结果。但实际上这样的实现仍然有可能出现重复计算的问题,问题在于判断map中是否有值,无值则插入的操作是一个复合操作。上面的代码中这样的无则插入的复合操作既不是原子的,也没有同步。

putIfAbsent

上面的问题无非就只剩下了无则插入这样的先检查后执行的操作不是原子的也没有同步。

事实上,解决的方法很简单,在JDK8中Map提供putIfAbsent,也即若没有则插入的方法。本身是不保证原子性、同步性的,但是在ConcurrentHashMap中的实现是具有原子语义的。我们可以将上面的代码再次改写为如下形式:


public class SimpleCacheDemo {

    private Map<Integer, Future<Integer>> cache = new ConcurrentHashMap<>();

    public Integer retrieve(Integer key) {
        FutureTask<Integer> task = new FutureTask<>(() -> compute(key));
        
        Future<Integer> result = cache.putIfAbsent(key, task);
        if (result == null) {
            result = task;
            task.run();
        }

        try {
            return result.get();
        } catch (InterruptedException | ExecutionException e) {
            throw new RuntimeException(e);
        }
    }

    private Integer compute(Integer value) {
        // 模拟代价很高的计算
        return value;
    }

}

这个实现的缺陷在于,每次都要new一个FutureTask出来。可以作一个小优化,通过先get判断是否为空,如果为空再初始化一个FutrueTask用putIfAbsent扔到map中。

computeIfAbsent

实际上以上介绍的几种实现在《Java并发编程实战》中都有描述
这本大师之作毕竟写作时还是JDK5和6的时代。在JDK8中,Map以及ConcurrentMap接口新增了computeIfAbsent的接口方法。在ConcurrentHashMap中的实现是具有原子语义的。所以实际上,上面的程序我们也可以不用FutureTask,直接用computeIfAbsent,代码如下:


public class SimpleCacheDemo {

    private Map<Integer, Integer> cache = new ConcurrentHashMap<>();

    public Integer retrieve(Integer key) {
        return cache.computeIfAbsent(key, this::compute);
    }

    private Integer compute(Integer value) {
        // 模拟代价很高的计算
        return value;
    }

}

总结

上面用简易的代码展示了在开发小型应用中时常需要的基于Map的简易缓存方案,考虑到的点在于线程安全、伸缩性以及避免重复计算等问题。如果代码还有其他地方有这样的需求,不妨抽象出一个小的框架出来。上面的代码中没有考虑到地方在于内存的使用消耗等,然而在实战中这是不能忽视的一点。

参考资料

  • 《Java并发编程实战》
  • 《Java并发编程的艺术》

--------------------------------------------------------------------P.S.一个知识点整理-------------------------------------------------------------

在最后一个例子中,用到了Map的computeIfAbsent()方法,

 1 default V computeIfAbsent(K key,
 2             Function<? super K, ? extends V> mappingFunction) {
 3         Objects.requireNonNull(mappingFunction);
 4         V v;
 5         if ((v = get(key)) == null) {
 6             V newValue;
 7             if ((newValue = mappingFunction.apply(key)) != null) {
 8                 put(key, newValue);
 9                 return newValue;
10             }
11         }
12 
13         return v;
14     }

这个方法是JDB1.8开始出现的。用了的Lambda表达式。

正如方法名锁揭示的那样,key值不存在的话,调用mappingFunction.apply(key)这个方法,进行key的相关处理,之后,会将key-value放进map里,最后返回新生成的value值。我们用Lambda表达式重写的也就是mappingFunction.apply(key)这个方法。

与之类似的computeIfPresent、compute都是8中新出现的方法,通过名字就可以理解,实现的代码上面的类似。

 

 

 

以上是关于基于Map的简易记忆化缓存的主要内容,如果未能解决你的问题,请参考以下文章

函数式编程-记忆化缓存

记忆化搜索

基于递归学习法以及记忆化的学习方法论

LeetCode 1269. 停在原地的方案数 (Java 记忆化,动态规划)

LeetCode 1269. 停在原地的方案数 (Java 记忆化,动态规划)

使用LinkedHashMap实现一个简易的LRU缓存