本集概要:
- 怎樣構(gòu)建一個(gè)線程安全而又高效、可伸縮的緩存?
- 怎樣利用設(shè)計(jì)模式,把緩存做成通用的工具?
- 除了synchronize和volatile,我們還能使用哪些工具來開發(fā)線程安全的代碼?
前情回顧: Volatile趣談——我是怎么把貝克漢姆的進(jìn)球弄丟的
大雄開發(fā)的門線傳感器在曼聯(lián)和阿森納的比賽中一鳴驚人,越來越多的客戶向公司訂購這款軟件......
糙版緩存
一天,哆啦對大雄說,“大雄,你看我們后臺這個(gè)統(tǒng)計(jì)用戶消費(fèi)信息的報(bào)表,每次統(tǒng)計(jì)都要去查數(shù)據(jù)庫,既消耗資源,查詢起來也慢,你看看能不能給做一個(gè)緩存?”
“緩存?這個(gè)簡單呀,緩存的原理其實(shí)就是一個(gè)鍵值對,也就是Map,只要把用戶的id作為key,把對應(yīng)的統(tǒng)計(jì)結(jié)果作為value,放到Map里頭緩存起來,下次再來查詢時(shí),先到Map去搜,搜到了就直接返回,搜不到,再去查數(shù)據(jù)庫計(jì)算,這樣就ok了”
“可以呀,思路很清晰嘛,一下子就講出了一份設(shè)計(jì)文檔”
“那是,那我開始寫代碼啦!”,大雄泡了杯茶,關(guān)掉QQ和Outlook,開始寫起了代碼。
很快,第一版代碼出爐了,UserCostStatComputer(本文的示例代碼,可到Github下載):
public class UserCostStatComputer {
private final Map<String, BigInteger> cache = new HashMap();
public synchronized BigInteger compute(String userId) throws InterruptedException {
BigInteger result = cache.get(userId);
if (result == null) {
result = doCompute(userId);
cache.put(userId, result);
}
return result;
}
private BigInteger doCompute(String userId) throws InterruptedException {
// assume it cost a long time...
TimeUnit.SECONDS.sleep(3);
return new BigInteger(userId);
}
}
“為了線程安全,我給整個(gè)compute方法加上了synchronized修飾符,保證每次只有一個(gè)線程可以進(jìn)入compute方法”,大雄向哆啦介紹他的代碼。
哆啦看了看,說,“大雄,你這代碼寫的快是快,但是有點(diǎn)糙啊”
“這。。。”
“咱先不說性能,就說你這設(shè)計(jì),你有沒有考慮過,要是還有其他地方也需要緩存,你難道也把這份緩存的邏輯拷貝過去?”
“啊,對呀,總不能每次要用到緩存,就在原先代碼里加個(gè)Map,然后再寫上if判斷吧......”
"小伙子覺悟不錯(cuò),好好想想,怎樣給原先沒有緩存的統(tǒng)計(jì)函數(shù),加上緩存的功能?"
“給原先沒有某某功能的函數(shù),加上某某功能,這話聽著很熟悉啊...”
通用緩存
大雄在大腦中檢索著,“對了!裝飾模式!哎,之前學(xué)過的,怎么到實(shí)際運(yùn)用中就忘了呢!”
利用設(shè)計(jì)模式,大雄很快把代碼進(jìn)行了重構(gòu)。
首先,定義一個(gè)Computable接口:
public interface Computable<A, V> {
V compute(A arg) throws Exception;
}
接著,每個(gè)要使用緩存功能的計(jì)算器,都要去實(shí)現(xiàn)這個(gè)接口,比如UserCostStatComputer:
public class UserCostStatComputer implements Computable<String, BigInteger> {
@Override
public BigInteger compute(String userId) throws Exception {
// assume there is a long time compute...
TimeUnit.SECONDS.sleep(3);
return new BigInteger(userId);
}
}
現(xiàn)在這個(gè)UserCostStatComputer是沒有緩存功能的,沒關(guān)系,來一個(gè)裝飾器就OK了,Memoizer1:
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;
}
public synchronized V compute(A arg) throws Exception {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
}
Memoizer1即實(shí)現(xiàn)了Computable接口,又持有一個(gè)Computable對象,同時(shí)在自己的compute方法中,調(diào)用了Computable對的compute方法,在這個(gè)compute方法的外圈,可以看到就是之前的緩存邏輯。
那么要怎樣使用Memoizer1給UserCostStatComputer裝飾上緩存功能呢?很簡單:
Computable computer = new Memoizer1(new UserCostStatComputer());
computer.compute("1");
利用Memoizer1這個(gè)裝飾器,我們成功的給原先不具有緩存功能的UserCostStatComputer加上了緩存。
“后面要是有其他的計(jì)算器,比如用戶地區(qū)分布統(tǒng)計(jì)計(jì)算器、用戶年齡分布統(tǒng)計(jì)計(jì)算器,我們都可以像UserCostStatComputer一樣,實(shí)現(xiàn)Computable,就可以使用裝飾器,裝飾上緩存功能了“,大雄得意洋洋地說。
“不錯(cuò),小伙子悟性很強(qiáng)啊”,哆啦拍拍大雄的肩膀,“好了,現(xiàn)在通過設(shè)計(jì)模式解決了代碼通用性問題,是時(shí)候看看你這個(gè)性能問題了”
雇個(gè)保姆
“性能問題?我這個(gè)代碼性能有問題么”,大雄翻著白眼。
“你看看,你把synchronize加在了什么地方?你加在了函數(shù)簽名的位置,意味著一次只能有一個(gè)線程能夠進(jìn)入compute方法,這就導(dǎo)致了一個(gè)用戶的id在計(jì)算的時(shí)候,其他用戶無法進(jìn)行計(jì)算,只能等到正在計(jì)算的用戶計(jì)算完了,才能進(jìn)入compute方法”
“啊,這對性能影響挺大的。。。”
“沒錯(cuò),換句話說,你這個(gè)加鎖的粒度太大了,我們先把synchronize去掉,看看有什么線程安全問題,再來逐一解決”
去掉synchronize的compute方法:
public V compute(A arg) throws Exception {
V result = cache.get(arg);
if (result == null) {
result = c.compute(arg);
cache.put(arg, result);
}
return result;
}
“很明顯,由于你用的是線程不安全的HashMap,cache.put(arg, result)這一行代碼就有線程安全問題”,哆啦說。
“那給這行代碼加鎖?像這樣”,大雄說著在紙上寫出草稿:
synchronized(this) {
cache.put(arg, result);
}
“哈哈,你怎么那么喜歡用synchronized,這樣可以是可以,但是粒度還是太大了,Java早就給你提供了一個(gè)叫ConcurrentHashMap的保姆,我們只要把線程安全委托給它去處理就好了”
“啊,我怎么又忘了...”
說完,大雄修改了代碼:
private final Map<A, V> cache = new ConcurrentHashMap<A, V>();
// private final Map<A, V> cache = new HashMap<A, V>();
未來任務(wù)
"現(xiàn)在再來看其他地方,由于把synchronize去掉了,現(xiàn)在不同的用戶id可以同時(shí)計(jì)算,但是也導(dǎo)致了相同用戶id也會同時(shí)被計(jì)算,也就是說同樣的數(shù)據(jù)會被計(jì)算多次,這樣緩存就沒意義了"
“啊,假設(shè)用戶id為1的數(shù)據(jù)正在計(jì)算,這個(gè)時(shí)候又來了一個(gè)線程,也是計(jì)算用戶id為1的,如果這個(gè)線程可以知道,當(dāng)前有沒有線程正在計(jì)算id為1的數(shù)據(jù),如果有,就等待這個(gè)線程返回計(jì)算結(jié)果,如果沒有,就自己計(jì)算,那問題不就解決了?可是這個(gè)邏輯挺復(fù)雜的,好難實(shí)現(xiàn)啊”
“哈哈,很簡單,Java早就提供了Future和FutureTask,來實(shí)現(xiàn)你說的這種功能,來,把鍵盤給我,好久沒寫代碼了”,哆啦說完,馬上敲起了鍵盤。
終極版Memoizer:
public class Memoizer <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 Memoizer(Computable<A, V> c) {
this.c = c;
}
public V compute(final A arg) throws Exception {
// use loop for retry when CancellationException
while (true) {
Future<V> f = cache.get(arg);
if (f == null) {
Callable<V> callable = new Callable<V>() {
public V call() throws Exception {
return c.compute(arg);
}
};
FutureTask<V> ft = new FutureTask<V>(callable);
// use putIfAbsent to avoid multi thread compute the same value
f = cache.putIfAbsent(arg, ft);
if (f == null) {
f = ft;
ft.run();
}
}
try {
return f.get();
} catch (CancellationException e) {
// remove cache and go into next loop to retry
cache.remove(arg, f);
} catch (ExecutionException e) {
// throw it and then end
e.printStackTrace();
throw e;
}
}
}
}
簡單解釋一下代碼邏輯,線程進(jìn)了compute方法后:
- 首先去存放future的Map里,搜索有沒有已經(jīng)其他線程已經(jīng)創(chuàng)建好的future
- 如果有(不等于null),那就調(diào)用future的get方法
- 如果已經(jīng)計(jì)算完成,get方法會立刻返回計(jì)算結(jié)果
- 否則,get方法會阻塞,直到結(jié)果計(jì)算出來再將其返回。
- 如果沒有已經(jīng)創(chuàng)建的future,那么就自己創(chuàng)建future,進(jìn)行計(jì)算
有幾個(gè)小點(diǎn)要單獨(dú)解釋的:
1、為什么要使用putIfAbsent
這里之所以用putIfAbsent而不用put,原因很簡單。
如果有兩個(gè)都是計(jì)算userID=1的線程,同時(shí)調(diào)用put方法,那么返回的結(jié)果都不會為null,后面還是會創(chuàng)建兩個(gè)任務(wù)去計(jì)算相同的值。
而putIfAbsent,當(dāng)map里面已經(jīng)有對應(yīng)的值了,則會返回已有的值,否則,會返回null,這樣就可以解決相同的值計(jì)算兩次的問題。
2、為什么要while (true)
這是因?yàn)閒uture的get方法會由于線程被中斷而拋出CancellationException。
我們對于CancellationException的處理是cache.remove(arg, f);,也就是把緩存清理掉,然后進(jìn)入下一個(gè)循環(huán),重新計(jì)算,直到計(jì)算成功,或者拋出ExecutionException。
總結(jié)
這篇文章中,我們先是用裝飾模式,改良了代碼的設(shè)計(jì),接著通過使用并發(fā)容器ConcurrentHashMap以及同步工具Future,取代了原先的synchronize,實(shí)現(xiàn)了線程安全性的委托。
《Java并發(fā)編程實(shí)踐》中,將委托稱為實(shí)現(xiàn)線程安全的一個(gè)最有效策略。委托其實(shí)就是把原來通過使用synchronize和volatile來實(shí)現(xiàn)的線程安全,委托給Java提供的一些基礎(chǔ)構(gòu)建模塊(當(dāng)然你也可以寫自己的基礎(chǔ)構(gòu)建模塊)去實(shí)現(xiàn),這些基礎(chǔ)構(gòu)建模塊包括:
- 同步容器:比如Vector,同步容器的原理大多就是synchronize,所以用的不多;
- 并發(fā)容器:比如本文用到的ConcurrentHashMap,當(dāng)然還有CopyonWriteArrayList、LinkedBlockingQueue等,根據(jù)你的需求使用不同數(shù)據(jù)結(jié)構(gòu)的并發(fā)容器;
- 同步工具類:比如本文用到的Future和FutureTask,還有閉鎖(Latches)、信號量(Semaphores)、柵欄(Barriers)等
這些基礎(chǔ)構(gòu)建模塊,在加上之前所講的synchronize和volatile,就形成了Java并發(fā)的基礎(chǔ)知識:
其實(shí)這也就是《Java并發(fā)編程實(shí)踐》第一部分的迷你版,總結(jié)成一句話就是:
要想寫出線程安全的代碼,我們需要用到使用synchronize、volatile,或者將線程安全委托給基礎(chǔ)構(gòu)建模塊。
當(dāng)然,在實(shí)際應(yīng)用中,只有這些基礎(chǔ)知識是不夠的,最簡單的例子,你不可能每次請求過來都創(chuàng)建線程,這會吃光內(nèi)存的。所以,你需要一個(gè)線程池,來限制線程的數(shù)量,這也就引出了《Java并發(fā)編程實(shí)踐》的第二部分,結(jié)構(gòu)化并發(fā)應(yīng)用程序(Structuring Concurrent Applications),聽名字確實(shí)不知道想講什么,所以我也將把它啃下來,然后像第一部分一樣,用盡可能通俗易懂的語言和大家分享學(xué)習(xí)心得。
參考
- 《Java并發(fā)編程實(shí)踐》
- Future (Java Platform SE 7 )