一.缓存
缓存是一种非常好的机制,几乎所有服务器应用程序都会使用某种形式的缓存,缓存的目的是以牺牲一些内存的方式重用之前计算结果的方式达到降低延迟,提高吞吐量的效果。
二.开发高效且可伸缩的缓存
1.第一次尝试
1).在下面代码中的Computable<A,V>接口中声明了一个方法Computable,其输入类型为A,输出类型为V。
2).在ExpensiveFunction中实现了Computable,并且其中的compute方法需要很长时间的计算,如果每次都调用这个方法,性能将会很低,所以我们决定创建一个Computable包装器来帮助记住之前的计算结果,并将缓存过程封装起来。
public interface Computable<A,V>{
V compute(A arg) throws InterruptedException;
}
public class ExpensiveFunction implements Computeable<String,BigInteger>{
public BigInteger compute(String arg){
//我们假设这里经过了很长时间
return new BigInteger(arg);
}
}
public class Memooizer1<A,V> implements Computable<A,V>{
private final Map<A,V> cache=new HashMap<A,V>();
private final Computable<A,V> c;
public Memoizer1(Computeable<A,V> c){
this.c=c;
}
public synchronized V compute(A arg) throws InterruptedException{
V result=cache.get(arg);//首先在hashmap中查找键arg对应的值
if(result == null){//如果没有对应的值
result=c.compute(arg);//计算之
cache.put(arg,result);//将其存入hashmap中
}
return result;//返回值
}
}
我们可以看到上面代码的关键就是:
public synchronized V compute(A arg) throws InterruptedException{
V result=cache.get(arg);//首先在hashmap中查找键arg对应的值
if(result == null){//如果没有对应的值
result=c.compute(arg);//计算之
cache.put(arg,result);//将其存入hashmap中
}
return result;//返回值
}
在很大程度上,这个方法的并发度决定了缓存性能,显然,这里采取的方式是对这整个方法进行同步,这样确实保证了线程安全性,但是,对于多线程并发来说,它是将并行变成串行来解决安全性问题的,显然不可取。
2.对上面的方法进行改进
我们尝试用并发类ConcurrentHashMap来代替HashMap:
public V compute(A arg) throws InterruptedException{
V result=cache.get(arg);//首先在hashmap中查找键arg对应的值
if(result == null){//如果没有对应的值
result=c.compute(arg);//计算之
cache.put(arg,result);//将其存入hashmap中
}
return result;//返回值
}
1)由于ConcurrentHashMap是线程安全的,因此在访问底层Map时就不需要进行同步,因而避免了使用HashMap带来的串行性。
2)显然我们改进后的方法有着更好的并行性:多个线程可以并发地使用它。
3)但是它在作为缓存时仍然存在一些不足,当两个线程同时调用compute方法时存在一个漏洞,可能导致计算得到相同的值:如果某个线程启动了一个开销很大的计算,而其他线程并不知道这个计算正在进行而进行相同的计算,最后重复计算了。
如下图:
3.再次改进
那么有没有一种方法能避免上述情况呢?
我们知道有一个类能基本实现这个功能:FutureTask. 它表示一个计算的过程,这个过程可能已经计算完成,也可能正在进行,如果结果可用,那么FutureTask.get将立即返回结果,否则它会一直阻塞,知道结果计算出来再将其返回。
private final Map<A,Future<V>> cache=new ConcurrentHash<A,Future<V>>();
public V compute(A arg) throws InterruptedException{
Future<V> f=cache.get(arg);//首先在Concurrenthashmap中查找键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=ft;
cache.put(arg,ft);//先将键值对存入
ft.run();//在这里将会调用计算
}
try{
return f.get();//这里返回计算结果
}catch(ExecutionException e){
throw launderThrowable(e.getCause());
}
}
上面的关键是先将键值存入到map中再进行计算,这样就避免了重复计算,但是还有一种情况我们应该要想到,尽管发生的概率极小:两个线程同时进入且同时执行cache.put(arg,ft);这样还是会发生重复计算。
4.终极改进
上面的的情况存在的原因是复合操作是在底层的Map对象上执行的,而这个对象无法通过加锁来确保原子性,下面是针对上面的改进:
使用putIfAbsent()方法避免重复插入键值对。
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);
//V putIfAbsent(K key, V value)
// 如果指定的键尚未与值相关联,请将其与给定值相关联。
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 launderThrowable(e.getCause());
}
}
}
上面的代码已经无懈可击了!
总结:编写高并发的程序是个循循渐进的过程,我们需要有耐心,和知识广度来解决我们遇到的问题。