背景
在應用程序中,時常會碰到需要維護一個map,從中讀取一些數據避免重復計算,如果還沒有值則計算一下塞到map里的的小需求(沒錯,其實就是簡易的緩存或者說實現記憶化)。在公司項目里看到過有些代碼中寫了這樣簡易的緩存,但又忽視了線程安全、重復計算等問題。本文主要就是談談這個小需求的實現。
實現
HashMap的實現
在公司項目里看到過有類似如下的代碼。
public class SimpleCacheDemo {
private Map<Integer, Integer> cache = new HashMap<>();
public synchronized Integer retrieve(Integer key) {
Integer result = cache.get(key);
if (result == null) {
result = compute(key);
cache.put(value,result);
}
return result;
}
private Integer compute(Integer key) {
// 模擬代價很高的計算
return key;
}
}
只是那位同事寫的代碼比這段代碼更糟,連synchronized關鍵字都沒加。
這段代碼的問題還在於由於在compute方法上進行了同步,所以大大降低了並發性,在具體場景中,如果compute代價很高,那么其他線程會長時間阻塞。
基於ConcurrentHashMap的改進
一種改進的策略是將上述map的實現類替換為ConcurrentHashMap
並去除compute上的synchronized
。這樣可以規避在compute上同步帶來的伸縮性問題。
但與上面的方法一樣還有一個問題在於,由於compute的耗時可能不少,在另一個線程讀到map中還沒有值時可能同樣會開始進行計算,這樣就出現了重復高代價計算的問題。
基於Future的改進
為了規避重復計算的問題,可以將map中的值類型用Future封起來。代碼如下:
public class SimpleCacheDemo {
private Map<Integer, Future<Integer>> cache = new HashMap<>();
public Integer retrieve(Integer key) {
Future<Integer> result = cache.get(key);
if (result == null) {
FutureTask<Integer> task = new FutureTask<>(() -> compute(key));
cache.put(key, task);
result = task;
task.run();
}
try {
return result.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
private Integer compute(Integer value) {
// 模擬代價很高的計算
return value;
}
}
當在map中讀取到result為null時,建一個FutureTask塞到map並進行計算,最后獲取結果。但實際上這樣的實現仍然有可能出現重復計算的問題,問題在於判斷map中是否有值,無值則插入的操作是一個復合操作。上面的代碼中這樣的無則插入的復合操作既不是原子的,也沒有同步。
putIfAbsent
上面的問題無非就只剩下了無則插入這樣的先檢查后執行的操作不是原子的也沒有同步。
事實上,解決的方法很簡單,在JDK8中Map提供putIfAbsent
,也即若沒有則插入的方法。本身是不保證原子性、同步性的,但是在ConcurrentHashMap
中的實現是具有原子語義的。我們可以將上面的代碼再次改寫為如下形式:
public class SimpleCacheDemo {
private Map<Integer, Future<Integer>> cache = new ConcurrentHashMap<>();
public Integer retrieve(Integer key) {
FutureTask<Integer> task = new FutureTask<>(() -> compute(key));
Future<Integer> result = cache.putIfAbsent(key, task);
if (result == null) {
result = task;
task.run();
}
try {
return result.get();
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
private Integer compute(Integer value) {
// 模擬代價很高的計算
return value;
}
}
這個實現的缺陷在於,每次都要new一個FutureTask出來。可以作一個小優化,通過先get判斷是否為空,如果為空再初始化一個FutrueTask用putIfAbsent
扔到map中。
computeIfAbsent
實際上以上介紹的幾種實現在《Java並發編程實戰》中都有描述。
這本大師之作畢竟寫作時還是JDK5和6的時代。在JDK8中,Map
以及ConcurrentMap
接口新增了computeIfAbsent
的接口方法。在ConcurrentHashMap
中的實現是具有原子語義的。所以實際上,上面的程序我們也可以不用FutureTask
,直接用computeIfAbsent
,代碼如下:
public class SimpleCacheDemo {
private Map<Integer, Integer> cache = new ConcurrentHashMap<>();
public Integer retrieve(Integer key) {
return cache.computeIfAbsent(key, this::compute);
}
private Integer compute(Integer value) {
// 模擬代價很高的計算
return value;
}
}
總結
上面用簡易的代碼展示了在開發小型應用中時常需要的基於Map的簡易緩存方案,考慮到的點在於線程安全、伸縮性以及避免重復計算等問題。如果代碼還有其他地方有這樣的需求,不妨抽象出一個小的框架出來。上面的代碼中沒有考慮到地方在於內存的使用消耗等,然而在實戰中這是不能忽視的一點。
參考資料
- 《Java並發編程實戰》
- 《Java並發編程的藝術》