【线程】结果缓存实现(future与concurrenthashmap)

本文转自: 【线程】结果缓存实现(future与concurrenthashmap)

Computable<A,V>接口中声明了一个函数Computable,其输入类型为A,输出类型为V,在ExpensiveFunction中实现的Computable,需要很长时间来计算结果,我们将创建一个Computable包装器,帮助记住之前的计算结果,并将缓存过程封装起来,(这项计算被称为“记忆(Memoization)”)

public interface Computable<A,V>{
  V compute(A arg) throws InterruptedException;
}
 
public class ExpensiveFunction implements Computable<String, BigInteger>{
   public BigInteger compute(String arg){
     //在经过长时间的计算后
      return new BigInteger(arg);
   }
}

public  class Memoizer1<A,V> implements Computable<A,V>{
  @GuardeBy("this")
  private final Map<A,V> cache = new HashMap<A,V>();
  private final Computable<A,V> c;
  public Memoizer1(Computable<A,V> c){
    this.c=c
  }
  public synchronized V compute(A arg) throws InterruptedException{
    V result = cache.get(arg);
    if(result ==null){
       result = c.compute(arg)
       cache.put(arg,result);
   }
   return result;
}

在上述程序中的Memoizer1给出了第一种尝试,使用hashMap来保存之前计算的结果。compute方法将首先检查需要的结果是否已经在缓存中,如果存在则返回之前计算的值。否则,将把计算结果缓存在HashMap中,然后再返回。

HashMap不是线程安全的,因此要确保两个线程不会同时访问HashMap,Memoizer1采用了一种保守的方法,则将对整个compute方法进行同步,这种方法能确保线程安全性,但会带来一个很明显的可伸缩性问题,每次只有一个线程能够执行compute。如果另一个线程正在计算结果,那么其他调用compute的线程可能被阻塞很长时间。如果有多个线程在排队等待还未出结果,那这个缓存就没有了意义

优化步骤一

Memoizer2用ConcurrentHashMap代替HashMap来改进Memoizer1中糟糕的并发行为,由于ConcurrentHashMap是线程安全的,因此在访问底层Map时就不需要进行同步,因而避免了在对Memoizer1中的compute方法进行同步时带来的串行性。

Memoizer2比Memoizer1有着更好的并发行为,多线程可以并发使用它。但它在作为缓存时仍然存在一些不足:当两个线程同时调用compute时存在一个漏洞,可能会导致计算得到相同的值,即传入的key是一样的。在使用memoizatin的情况下,这只会带来抵消,因为缓存的作用是避免相同的数据被计算多次。但对于更通用的缓存机制来说,这种情况将更为糟糕,对于只提供单次初始化的对象缓存来说,这个漏洞就会带来安全风险。

public class Memoizer2<A,V> implements Computable<A,V> {
   private final Map<A,V> cache = new ConcurrentHashMap<A,V>();
   private final Computable<A,V> c;
   public Memoizer2(Computable<A,V> c){
     this.c=c
  }

  public V compute(A arg) throws InterruptedException{
    V result = cache.get(arg);
    if(result == null){
       result = c.compute(arg);
       cache.put(arg,result);
    }
   result result;
 }
}

Memoizer2的问题在于,如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行,那么狠可能会重复这个计算,我们希望通过某种方法来表达“线程X正在计算 f(27)”这种情况,这样当另外一个线程查找f(27)时,它能够知道最高效的方法是等待线程X计算结束,然后再去查询缓存 “f(27)的结果是多少?”

我们已经知道有一个类能基本实现这个功能:FutureTask。 FutureTask表示一个计算的过程,这个过程可能已经计算完成,也有可能正在进行。如果有结果可用,那么FutureTask.get将立即返回结果,否则它会一直阻塞,知道结果计算出来再将其返回。

优化步骤二

下面的Memoizer3将用于缓存值的Map重新定义为ConcurrentHashMap<A,Future>,替换原来的ComcurrentHashMap(A,V)。Memoizer3首先检查某个相应的计算是否已经开始(Memoizer2与之相反,它首先判断某个计算是否已经完成)。如果还没有启动,那么就将创建一个FutureTask,并注册到Map中,然后启动计算;如果已经启动,那么等待现有计算的结果,结果可能很快会得到,也可能还在运行过程中,但这对于Future.get的调用者来说是透明的。

public class Memoizer3<A, V> implements Computable<A, V> {

	private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
	private final Computable<A,V> c;
	
	public Memoizer3(Computable<A, V> c) {
		this.c = c;
	}

	@Override
	public V compute(A arg) throws InterruptedException {
		Future<V> f = cache.get(arg);
		if(f == null) {
			Callable<V> eval = new Callable<V>() {
				public V call() throws InterruptedException {
					return c.compute(arg);
				}
			};
			FutureTask<V> ft = new FutureTask<>(eval);
			f = ft;
			cache.put(arg, ft);
		
			ft.run(); //这里将调用c.compute
		}
		try {
			return f.get();
		} catch (ExecutionException e) {
			throw LaunderThrowable.launderThrowable(e);
		}
	}

}

Memoizer3的实现几乎是完美的,它表现了非常好的并发性(基本上是源于ConcurrentHashMap的并发性),若结果已经计算出来,那么将立即返回。如果其他线程正在计算结果,那么新到的线程就一直等待这个结果被计算出来。它只有一个缺陷,即仍然存在两个线程计算出相同值得漏洞,这个漏洞的发生概率要远小于Memoizer2中发生的概率,但由于compute方法中的if代码块仍然是非原子的“先检查再执行”操作,因此两个线程仍然有可能同一时间内调用compute来计算相同的值,即二者都没有在缓存中找到期望的值,因此都开始计算。

优化步骤三

MemoIzer3中存在这个问题的原因是,复合操作(“若没有则添加”)是在底层的Map对象上执行的,而这个对象无法通过加锁来确保原子性,下面的Memoizer使用了ConcurrentMap中的原子方法putIfAbsent,避免了Memoizer3的漏洞。

public class Memoizer<A, V> implements Computable<A, V> {

	private final Map<A, Future<V>> cache = new ConcurrentHashMap<A, Future<V>>();
	private final Computable<A, V> c;

	public Memoizer(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){
				Callable<V> eval = new Callable<V>() {
					public V call() throws InterruptedException {
						return c.compute(arg);
					}
				};
				
				FutureTask<V> ft = new FutureTask<V> (eval);
				f = cache.putIfAbsent(arg, ft);
				if(f == null) {
					// 如果之前尚未写入,则写入成功,运行 run 方法
					f = ft;
					ft.run();
				}
			}
			
			try {
				return f.get();
			} catch(CancellationException e) {
				// 如果操作被取消
				cache.remove(arg, f);
			} catch (ExecutionException e) {
				throw LaunderThrowable.launderThrowable(e.getCause());
			}
		}
	}

}

当缓存的是Future而不是值时,将导致缓存污染问题:如果某个计算被取消或者失败,那么在计算这个结果时将指明计算过程被取消或者失败。为了避免这种情况,如果Memoizer发现计算被取消,那么将把Future从缓存中移除。如果检测到RuntimeException,那么也会移除Future,这样将来的计算才可能成功。Memoizer同样没有解决缓存逾期的问题,但它可以通过使用FutureTask的子类来解决,在子类中为每个结果指定一个逾期时间,并定期扫描缓存中逾期的元素(同样,它也没有解决缓存清理的问题,即移除旧的计算结果以便为新的计算结果腾出空间,从而使缓存不会消耗过多的内存。)

关于Future,FutureTask,Callable的区别,参考http://blog.csdn.net/bboyfeiyu/article/details/24851847

关于cocurrentHashMap 参考http://www.infoq.com/cn/articles/ConcurrentHashMap

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值