背景

在应用程序中,时常会碰到需要维护一个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并发编程的艺术》
05-11 13:32