java并发 - 构建高效且可伸缩的结果缓存
几乎所有的服务器应用程序都会使用某种形式的缓存。重用之前的结果能降低延迟,提高吞吐量,但却需要消耗更多的内存。像许多“重复发明的轮子”一样,缓存看上去都非常简单。然而,简单的缓存可能会将性能瓶颈转变成可伸缩性瓶颈,即使缓存是用于提升单线程的性能。我们将开发一个高效且可伸缩的缓存,用于改进一个高计算开销的函数,我们首先从简单的HashMap开始,然后分析它的并发性缺点,并讨论如何修复他们。
案例1 使用HashMap和同步机制开初始化缓存
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);
}
}
public class Memorizer1<A, V> implements Computable<A, V>{
private final Computable<A, V> compute;
private final Map<A, V> cache;
public Memorizer1(Computable<A, V> compute){
this.compute = compute;
cache = new HashMap<A, V>();
}
@Override
public synchronized V compute(A a) throws InterruptedException {
V result = cache.get(a);
if(result == null){
result = compute.compute(a);
cache.put(a, result);
}
return result;
}
}
如图所示可以发现HashMap不是一个线程安全的,因此要确保两个线程不会同时访问HashMap,Memoizer1采用了一种保守的方法,即对整个compute方法进行同步。这种方法虽然可以确保线程安全性,但会带来明显的可伸缩性问题:每次只有一个线程能够执行compute。如果另一个线程正在计算结果,那么其他调用compute的线程可能被阻塞很长时间。
案例2 用ConcurrentHashMap替换HashMap
public class Memorizer2<A, V> implements Computable<A, V>{
private final Computable<A, V> compute;
private final Map<A, V> cache;
public Memorizer2(Computable<A, V> compute){
this.compute = compute;
cache = new ConcurrentHashMap<A, V>();
}
@Override
public V compute(A a) throws InterruptedException {
V result = cache.get(a);
if(result == null){
result = compute.compute(a);
cache.put(a, result);
}
return result;
}
}
如上程序中Memorizer2用ConcurrentHashMap替代HashMap来改进Memoizer1中糟糕的并发行为。由于ConcurrentHashMap是线程安全的,因此在访问底层Map时就不需要进行同步,因而避免了在对Memoizer1中的compute方法进行同步时带来的串行性。然而,Memoizer2比Memoizer1有着更好的并发行为:多线程可以同时并发地使用它。但它在作为缓存时仍然存在一些不足——当两个线程同时调用compute时存在一个漏洞,可能会导致计算得到相同的值。在使用memoization的情况下,这只会带来低效,因为缓存的作用是避免相同的数据被计算多次。
案例3 基于FutureTask的Memoizing封装器
public class Memorizer3<A, V> implements Computable<A, V>{
private final Computable<A, V> compute;
private final Map<A, FutureTask<V>> cache;
public Memorizer3(Computable<A, V> compute){
this.compute = compute;
cache = new ConcurrentHashMap<A, FutureTask<V>>();
}
@Override
public V compute(A a) throws InterruptedException {
V f = cache.get(a);
if(f == null){
Callable<V> eval = new Callable<V>(){
public V call() throw InterruptedException{
return c.compute(arg);
}
}
FutureTask<V> ft = new FutureTask<V>(eval);
f = ft;
cache.put(a, ft);
ft.run();
}
try{
return f.get();
}cache(ExecutionException e){
throw launderThrowable(e.getCause());
}
}
}
程序中的Memoizer3将用于缓存键的Map重新定义为ConcurrentHashMap<A, Future>,替换原来的ConcurrentHash<A,V>。Memoizer3首先检查某个相应的计算是否已经开始(Memoizer2与之相反,它首先判断某个计算是否已经完成)。如果好没有启动就创建一个FutureTask,并注册到Map中,然后启动计算:如果已经启动,那么等待先有计算的结果。可以说,Memoizer3的实现已基本完美,它可以表现出较好的并发性,若结果已经计算出来,那么将立即返回。如果其他线程正在计算该结果,那么新到的线程将一直等待这个结果被计算出来。然而它有一个缺陷,即仍然可能会存在两个线程计算出相同值得漏铜。这个漏洞的发生概率要远小于Memoizer2中发生的概率,但由于compute方法中的if代码块仍然是非原子的“先检查在在执行”操作,因此两个线程仍然有可能在同一时间内调用compute来计算相同的值。
案例4 Memoizer的最终实现
public class Memorizer4<A, V> implements Computable<A, V>{
private final Computable<A, V> compute;
private final Map<A, FutureTask<V>> cache;
public Memorizer4(Computable<A, V> compute){
this.compute = compute;
cache = new ConcurrentHashMap<A, FutureTask<V>>();
}
@Override
public V compute(A a) throws InterruptedException {
while(true){
V f = cache.get(a);
if(f == null){
Callable<V> eval = new Callable<V>(){
public V call() throw InterruptedException{
return c.compute(arg);
}
}
FutureTask<V> ft = new FutureTask<V>(eval);
f = cache.putIfAbsent(a, ft);
if(f == null){
f = ft;
ft.run();
}
}
try{
return f.get();
}catch(CancellationException e){
cache.remove(arg, f);
}catch(ExecutionException e){
throw launderThrowable(e.getCause());
}
}
}
}
在最后的案例中我们发现,Memorizer3中往缓存中设置值得操作是在Map底层上执行的复合操作(“若没有则添加”),相比于Memorizer3这里所做的改进是使用了ConcurrentMap中的原子方法putIfAbsent预先对缓存值进行了判断从而避免了Memoizer3中出现的漏洞,即存在两个线程计算出相同值得情况。