高速緩存:複用已有的計算結果,目的縮短等待時間,提高吞吐量。代價是佔用更多的內存。
/**
* 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併發編程實戰》第五章,如有問題,歡迎指正。