如何实现缓存
几乎所有的服务器应用都会使用缓存。重用之前的结果可以降低延迟,提高吞吐量
那么,代价是什么呢? 消耗更多的内存!
和许多“重复发明的轮子”一样,实现一个缓存看上去十分简单,其实不然。设计不佳的简单缓存不仅可能达不到足够的性能提升,甚至可能返回错误的结果。
public interface Computable<A,V> {
V compute(A arg) throws InterruptedException;
}
public class ExpensiveFunction implements Computable<String,BigInteger>{
@Override
public BigInteger compute(String arg) throws InterruptedException {
// 很长时间的计算后
return new BigInteger(arg);
}
}
现有以上任务,以下将给出几个错误的示例
示例1:并发错误
public class Memorizer<A,V> implements Computable<A,V> {
private final Map<A,V> cache = new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memorizer(Computable<A, V> c) {
this.c = c;
}
@Override
public V compute(A arg) throws InterruptedException {
V result = cache.get(arg);
//以下是一个先检查后执行操作
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}result;
}
}
我们采用一个ConcurrentHashMap来储存计算结果,并发类的ConcurrentHashMap保证了单个cache对象内部不会出现并发问题,再通过一个装饰器模式将Memorizer的compute操作转发到c.compute上,可谓是十分的优雅!
然而,注释中已经指出,这是一个十分典型的先检查后执行操作。如果这个操作是一个很费时间的操作,那么触发这种并发错误将很十分常见。这种并发错误会导致程序做重复的计算任务,还有优化空间!
这个先检查后执行操作肯定是不能加锁的,那怎么办呢
示例2:完美的缓存实现
public class Memorizer2<A,V> implements Computable<A,V> {
private final Map<A, Future<V>> cache = new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memorizer2(Computable<A, V> c) {
this.c = c;
}
@Override
public V compute(A arg) throws InterruptedException {
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
FutureTask<V> ft = new FutureTask<>(() -> c.compute(arg));
f = cache.putIfAbsent(arg, ft);
if (f == null) {
f = ft;
ft.run();
}
}
try {
return f.get();
} catch (CancellationException e) {
//计算失败,移除缓存,防止缓存污染
cache.remove(arg, f);
} catch (ExecutionException e) {
throw new RuntimeException(e);
}
}
}
}
我们更改了ConcurrentHashMap的值类型,从值本身变成了Future,核心思想在于将任务注册到ConcurrentHashMap中。这样一来,其他线程能以最快速度得知“已经有别人在做这个任务了”。并通过putIfAbsent方法防止重复提交。
至此,缓存代码已经足够健壮。当然可以在此基础上实现其他功能,如缓存逾期,缓存清理等等。