最近阅读《java并发编程实战》第5章中提到的一步步建立高效可伸缩缓存代码,若有所思。
缓存是最容易引起并发问题的场景之一,因为缓存的内容经常为可变状态,而作用域又相对较大。解决并发问题最简单粗暴的方式就是加锁,但这样会降低执行效率,多线程执行到同步代码区,只能等自己活得锁的情况下才能继续执行。我们看下代码:
作者先使用泛型技术提供了一个可伸缩的接口类:
package simple.article.five;
import java.util.concurrent.ExecutionException;
/***
* 计算接口
* @param <A> 执行参数
* @param <V> 返回结果
*/
public interface Computable<A,V> {
V compute(A arg) throws InterruptedException, ExecutionException;
}
使用同步关键字制作缓存:
package simple.article.five;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
public class Memozier1<A,V> implements Computable<A,V> {
//缓存使用容器类map进行盛装
private final Map<A,V> cache=new HashMap<A, V>();
//真正计算类,定义为final确保引用不会重新指向其他对象
private final Computable<A,V> c;
public Memozier1(Computable<A, V> c) {
this.c = c;
}
//使用同步关键字对缓存方法进行加锁
public synchronized V compute(A arg) throws InterruptedException, ExecutionException {
V result=cache.get(arg);
if(result==null){
result=c.compute(arg);
cache.put(arg,result);
}
return result;
}
}
虽然并发问题解决,但会导致两个线程同时访问compute方法时效率低下的问题。所以可考虑修改容器为线程安全的容器类ConcurrentHashMap(),去掉方法同步关键字:
package simple.article.five;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutionException;
public class Memozier2<A,V> implements Computable<A,V> {
//使用线程安全容器
private final Map<A,V> cache=new ConcurrentHashMap<A,V>();
private final Computable<A,V> c;
public Memozier2(Computable<A, V> c) {
this.c = c;
}
public V compute(A arg) throws InterruptedException, ExecutionException {
//该容器类没有控制并发读,两个线程同时执行此步骤时,会导致同一个key下写入两次
V result=cache.get(arg);
if(result==null){
result=c.compute(arg);
cache.put(arg,result);
}
return result;
}
}
但当两个线程执行错误的时间顺序时,A线程的缓存并不能被B线程读到,导致重新执行并再次存放一遍。因此可以考虑先缓存任务(Future),后执行线程任务:
public class Memorizer3<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 Memorizer3(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws InterruptedException {
Future<V> future=cache.get(arg);
if(future==null){
Callable<V> val=new Callable<V>() {
public V call() throws Exception {
return c.compute(arg);
}
};
FutureTask<V> ft=new FutureTask<V>(val);
future=ft;
//先存放缓存任务
cache.put(arg,future);
//后执行返回结果
ft.run();
}
try {
return future.get();
} catch (ExecutionException e) {
e.printStackTrace();
}
return null;
}
}
这样,因为直接将执行任务存入缓存,没有了中间耗时的计算过程,这样大大降低了两个线程同时读取到缓存不存在上一个线程的执行任务的可能性,但是仍然存在两个线程同时读取到缓存没有内容的情况,因此最终的解决方式可以考虑选用putIfAbsent,在存放时再次确认是否arg已经存在值:
public class Memorizer4<A,V> implements Computable<A,V> {
private final ConcurrentMap<A,Future<V>> cache=new ConcurrentHashMap<A, Future<V>>();
private final Computable<A,V> c;
public Memorizer4(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws InterruptedException, ExecutionException {
Future<V> value=cache.get(arg);
if(value==null) {
Callable<V> cal = new Callable<V>() {
public V call() throws Exception {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(cal);
value=ft;
//在存放过程中再次判断是否存在
cache.putIfAbsent(arg,value);
ft.run();
}
return value.get();
}
}
总结:
此实例说明高效并可伸缩的并发程序并不是单纯使用同步关键字进行控制的,而是借用并发容器与线程任务future提前缓存避开费时的计算过程,同时在同步写的时候再次进行判断。