高速缓存:复用已有的计算结果,目的缩短等待时间,提高吞吐量。代价是占用更多的内存。
/**
* Computable接口提供一个功能,输入一个A,计算返回一个V
* @param <A>
* @param <V>
*/
public interface Computable<A,V> {
V compute(A a)throws InterruptedException;
}
/**
* Computable的一个实现类
*/
public class ExpensiveFunction implements Computable<String,Integer> {
@Override
public Integer compute(String s) throws InterruptedException {
//有很大很大的一断逻辑,这里省略
// 假设耗时100秒
return new Integer(s);
}
}
一个包装器,缓存Computable计算的结果,也就是备忘录技术(memoization)
想想下单例模式。OK!
第一个人写的代码:
/**
* HashMap并不是线程安全的,所以为了安全,添加了synchronized锁
* 然而,这个重量级的锁一次只能有一个线程访问,若是执行compute方法时间超长,
* 其他所有线程都得等待,这不是我们希望的。
* sad!
* @param <A>
* @param <V>
*/
public class Memoizer1<A,V> implements Computable<A,V> {
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;
}
@Override
public synchronized V compute(A a) throws InterruptedException {
V result=cache.get(a);
if(result==null){
result=c.compute(a);
cache.put(a, result);
}
return result;
}
}
由于HashMap不是线程安全的,为了不让两个线程同时访问HashMap,就同步了整个compute方法。
问题:首先这么做,肯定不会错,是正确的。但是,这样做耗时,带来一个可伸缩性问题。一次只有一个线程能够执行compute方法,如果一个线程正在忙于计算结果,假如它计算时间要100秒,那其他线程就只有等待了,而这些线程可能计算很快,1纳秒,1微秒,难道让他们都等着吗?
第一个人的解决办法:
/**
* 针对HashMap问题改用了ConcurrentHashMap,
* 解决了map的问题,程序拥有了更好的并发性。
* 然而,还存在一个问题,就是
* 当两个线程同时调用compute时,会计算相同的值,
* 而我们希望只计算一次。会计算100秒,甚至更长时,怎么办。
* sad!
* 我们希望,当一个线程计算时,其他线程到达时,等待计算结果就可以了。
* @param <A>
* @param <V>
*/
public class Memoizer2<A,V> implements Computable<A,V> {
private final Map<A,V> cache=new ConcurrentHashMap<>();
private final Computable<A,V> c;
public Memoizer2(Computable<A, V> c) {
this.c = c;
}
@Override
public V compute(A a) throws InterruptedException {
V result=cache.get(a);
if(result==null){
result=c.compute(a);
cache.put(a, result);
}
return result;
}
}
这个人的解决办法就是用 ConcurrentHashMap 替换HashMap,ConcurrentHashMap是线程安全的,所以就不需要在访问底层Map时对它进行同步。
但是,当两个线程同时调用compute时,它会计算相同的值。违反了避免重复计算相同的数据原则。另外一个缓存对象不应该只能被初始化一次。
我们希望:
用一种方法,能够表现出“线程X正在计算f(1)”,这样当另一个线程到达并查找f(1)时,它能够判断出最有效的方法时,等下线程X,直到线程结束,然后拿到结果。
第一个人的第三种方法代码:
/**
* 针对Memoizer2中 我们希望,当一个线程计算时,其他线程到达时,等待计算结果就可以了。
* 采用Future<V>取代 V。
* 首先检查一个相应的计算是否已经开始
* 如果不是,就创建一个FutureTask,把他注册到Map中,并开始计算。
* 如果是,他会等待正在进行的计算,直到结果计算出来。
* 附注: futureTask.get()只要结果可用就会立刻返回结果;否则会一直阻塞,直到结果被计算出来,并返回。
* 但是,它依然存在问题,
*if代码块非原子,可能 两个线程几乎同时调用compute计算相同的值,双方在缓存Map中都没有找到期望的值,
* 就都开始了计算。
* 另外缓存一个Future<V>带来了缓存污染的可能性。
* ->如果一个计算被取消或者失败,未来尝试对这个值进行计算都会表现为取消或失败。
*
* @param <A>
* @param <V>
*/
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 a) throws InterruptedException {
Future<V> future=cache.get(a);
if(future==null){//计算没开始
Callable<V> eval=new Callable<V>() {
@Override
public V call() throws Exception {
return c.compute(a);
}
};
FutureTask<V> futureTask=new FutureTask<>(eval);//新建FutureTask任务
future=futureTask;
cache.put(a, futureTask);//注册到map
futureTask.run();//调用c.compute在此处
}
try {
return future.get();
} catch (ExecutionException e) {
e.printStackTrace();
throw new InterruptedException(e.getMessage());
}
}
}
这段代码是用Future取代了V。Memoizer3收下检查一个相应的计算是否已经开始,
如果没开始,就创建一个FutureTask,把它注册到Map中,并开始计算;
如果开始了,那么它会等待正在进行的计算,知道结果出来。
Memoizer3已经是几乎完美了,但是,它依然存在问题——两个线程几乎在同一时间调用compute方法计算相同的值,双方都没在缓存中找到期望的值,并都开始计算。
为啥memoizer3这个是个漏洞,因为复合操作(缺少即加入)运行在底层的map中,不能加锁来使它原子化。(这段没看懂,因为put方法不安全吗?if代码块确实是不安全的,会重新计算,但是用putIfAbsent方法,为啥就能消除这个隐患?put方法会放两次,putIfAbsent只能放一次,用putIfAbsent后,后面的FutureTask就只会运行一次吗?还是put放两次这个问题?)
另一个问题:引入Future,带来一个缓存污染(cache pollution)的可能性——如果一个
计算被取消或者失败,未来尝试对这个值进行计算都会表现为取消或失败。
第一个人第四次的代码:
/**
* 代码中的putIfAbsent消除了Memoizer3的隐患——if代码块非原子
* 也消除了缓存污染
* 但是,存在缓存过期的问题(可以通过FutureTask的一个子类来完成,它会为每个结果关联一个过期时间,
* 并周期性地扫描缓存中过期的访问)
* @param <A>
* @param <V>
*/
public class Memoizer4<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 Memoizer4(Computable<A, V> c) {
this.c = c;
}
@Override
public V compute(final A a) throws InterruptedException {
while (true) {
Future<V> future = cache.get(a);
if (future == null) {
Callable<V> eval = new Callable<V>() {
@Override
public V call() throws InterruptedException {
return c.compute(a);
}
};
FutureTask<V> futureTask = new FutureTask<>(eval);
future = cache.putIfAbsent(a, futureTask);
if (future == null) {
future = futureTask;
futureTask.run();//调用c.compute在此处
}
}
try {
return future.get();
} catch (CancellationException e) {
cache.remove(a, future);
} catch (ExecutionException e) {
e.printStackTrace();
throw new InterruptedException(e.getMessage());
}
}
}
}
这段代码采用了putIfAbsent就不会放两次了,不会重复计算了。
另外当FutureTask失败或取消时,可以移除失败或取消的FutureTask,这样就可以重复了,成功计算了。
当然,这段代码也不是完善的,它存在缓存过期的问题。
本文转载于《Java并发编程实战》第五章,如有问题,欢迎指正。