并發(fā)與并行
分布式系統(tǒng)的一個(gè)重要特征就是計(jì)算能力是可以并發(fā)或者并行的。
在分布式系統(tǒng)中,往往會(huì)將一個(gè)大任務(wù)進(jìn)行分解,而后下發(fā)給不同的節(jié)點(diǎn)去計(jì)算,從而節(jié)省整個(gè)任務(wù)的計(jì)算時(shí)間。
并發(fā)與并行的區(qū)別
計(jì)算機(jī)用戶很容易認(rèn)為他們的系統(tǒng)在一段時(shí)間內(nèi)可以做多件事。比如,用戶一邊用瀏覽器下載視頻文件,一邊可以繼續(xù)在瀏覽器上瀏覽網(wǎng)頁(yè)。可以做這樣的事情的軟件被稱為并發(fā)軟件(ConcurrentSoftware)。
計(jì)算機(jī)能實(shí)現(xiàn)多個(gè)程序的同時(shí)執(zhí)行,主要基于以下因素。
·資源利用率。某些情況下,程序必須等待其他外部的某個(gè)操作完成,才能往下繼續(xù)執(zhí)行,而在等待的過(guò)程中,該程序無(wú)法執(zhí)行其他任何工作。因此,如果在等待的同時(shí)可以運(yùn)行另外一個(gè)程序,無(wú)疑將提高資源的利用率。
·公平性。不同的用戶和程序?qū)τ谟?jì)算機(jī)上的資源有著同等的使用權(quán)。一種高效的運(yùn)行方式是粗粒度的時(shí)間分片(Time Slicing)使這些用戶和程序能共享計(jì)算機(jī)資源,而不是一個(gè)程序從頭運(yùn)行到尾,然后再啟動(dòng)下一個(gè)程序。
·便利性。通常來(lái)說(shuō),在計(jì)算多個(gè)任務(wù)時(shí),應(yīng)該編寫多個(gè)程序,每個(gè)程序執(zhí)行一個(gè)任務(wù)并在必要時(shí)相互通信,這比只編寫一個(gè)程序來(lái)計(jì)算所有任務(wù)更加容易實(shí)現(xiàn)。
那么并發(fā)與并行到底是如何區(qū)別的呢?
The Practice of Programming一書(shū)的作者Rob Pike對(duì)并發(fā)與并行做了如下描述。
并發(fā)是同一時(shí)間應(yīng)對(duì)(Dealing With)多件事情的能力;并行是同一時(shí)間動(dòng)手做(Doing)多件事情的能力。并發(fā)(Concurrency)屬于問(wèn)題域(Problem Domain),并行(Parallelism)屬于解決域(SolutionDomain)。并行和并發(fā)的區(qū)別在于有無(wú)狀態(tài),并行計(jì)算適合無(wú)狀態(tài)應(yīng)用,而并發(fā)解決的是有狀態(tài)的高性能;有狀態(tài)要著力解決并發(fā)計(jì)算,無(wú)狀態(tài)要著力并行計(jì)算,云計(jì)算要能做到這兩種計(jì)算自動(dòng)伸縮擴(kuò)展。
上述的描述貌似有點(diǎn)抽象。舉一個(gè)生活中的例子,某些人工作很忙,那么會(huì)請(qǐng)鐘點(diǎn)工來(lái)打掃衛(wèi)生。一個(gè)鐘點(diǎn)工在一小時(shí)可以幫你掃地、擦桌子、洗菜、做飯。客戶并不關(guān)心哪件活兒先干哪件活兒后做,客戶所關(guān)心的是,付費(fèi)的這一小時(shí),需要看到所有活兒都完成。在客戶看來(lái),這一小時(shí)內(nèi),所有的活兒都是一個(gè)鐘點(diǎn)工干的,這就是并發(fā)。
再舉一例子,客戶的親戚要來(lái)看望客戶,還有不到一小時(shí)親戚就要登門了。那么平時(shí)要花一小時(shí)才能做完的家務(wù),如何才能提前做完呢?
答案是加人。多請(qǐng)幾個(gè)鐘點(diǎn)工來(lái)一起做事,比如某個(gè)鐘點(diǎn)工專門掃地,某人專門抹桌子,某人專門負(fù)責(zé)洗菜、做飯。這樣3人同時(shí)開(kāi)工,就能縮短整體的時(shí)間,這就是并行。
從計(jì)算機(jī)的角度來(lái)說(shuō),單個(gè)CPU是需要被某個(gè)任務(wù)獨(dú)占的,就如同鐘點(diǎn)工,在掃地的同時(shí)不能做擦桌子的動(dòng)作。如果她想去擦桌子,就需要將手頭的掃地任務(wù)先停下來(lái)。當(dāng)然,由于多個(gè)任務(wù)是不斷切換的,因此,在外界看來(lái),就有了“同時(shí)”執(zhí)行多個(gè)任務(wù)的錯(cuò)覺(jué)。
現(xiàn)代的計(jì)算機(jī)大多是多核的,因此,多個(gè)CPU同時(shí)執(zhí)行任務(wù),就實(shí)現(xiàn)了任務(wù)的并行。

線程與并發(fā)
早期的分時(shí)系統(tǒng)中,每個(gè)進(jìn)程以串行方式執(zhí)行指令,并通過(guò)一組I/O指令來(lái)與外部設(shè)備通信。每條被執(zhí)行的指令都有相應(yīng)的“下一條指令”,程序中的控制流就是按照指令集的規(guī)則來(lái)確定的。
串行編程模型的優(yōu)勢(shì)是直觀性和簡(jiǎn)單性,因?yàn)樗7铝巳祟惖墓ぷ鞣绞剑好看沃蛔鲆患拢鐾暝僮銎渌虑椤@纾缟掀鸫玻却┮拢缓笙聵牵栽顼垺T诰幊陶Z(yǔ)言中,這些現(xiàn)實(shí)世界的動(dòng)作可以進(jìn)一步被抽象為一組粒度更細(xì)的動(dòng)作。例如,喝茶的動(dòng)作可以被細(xì)化為:打開(kāi)櫥柜,挑選茶葉,將茶葉倒入杯中,查看茶壺的水是否夠,不夠要加水,將茶壺放在火爐上,點(diǎn)燃火爐,然后等水煮沸,等等。在等水煮沸這個(gè)過(guò)程中包含了一定程序的異步性。例如,在燒水過(guò)程中,你可以一直等,也可以做其他事情,比如開(kāi)始烤面包,或者看報(bào)紙(這就是另一個(gè)異步任務(wù)),同時(shí)留意水是否煮沸了。但凡做事高效的人,總能在串行性和異步性之間找到合理的平衡,程序也是如此。
線程允許在同一個(gè)進(jìn)程中同時(shí)存在多個(gè)線程控制流。線程會(huì)共享進(jìn)程范圍內(nèi)的資源,例如內(nèi)存句柄和文件句柄,但每個(gè)線程都有各自的程序計(jì)數(shù)器、棧以及局部變量。線程還提供了一種直觀的分解模式來(lái)充分利用操作系統(tǒng)中的硬件并行性,而在同一個(gè)程序中的多個(gè)線程也可以被同時(shí)調(diào)度到多個(gè)CPU上運(yùn)行。
毫無(wú)疑問(wèn),多線程編程使得程序任務(wù)并發(fā)成為可能。而并發(fā)控制主要是為了解決多個(gè)線程之間資源爭(zhēng)奪等問(wèn)題。并發(fā)一般發(fā)生在數(shù)據(jù)聚合的地方,只要有聚合,就有爭(zhēng)奪發(fā)生,傳統(tǒng)解決爭(zhēng)奪的方式是采取線程鎖機(jī)制,這是強(qiáng)行對(duì)CPU管理線程進(jìn)行人為干預(yù),線程喚醒成本高,新的無(wú)鎖并發(fā)策略來(lái)源于異步編程、非阻塞I/O等編程模型。
并發(fā)帶來(lái)的風(fēng)險(xiǎn)
多線程并發(fā)會(huì)帶來(lái)以下問(wèn)題。
·安全性問(wèn)題。在沒(méi)有充足同步的情況下,多個(gè)線程中的操作執(zhí)行順序是不可預(yù)測(cè)的,甚至?xí)a(chǎn)生奇怪的結(jié)果。線程間的通信主要是通過(guò)共享訪問(wèn)字段及其字段所引用的對(duì)象來(lái)實(shí)現(xiàn)的。這種形式的通信是非常有效的,但可能導(dǎo)致兩種錯(cuò)誤:線程干擾(Thread Interference)和內(nèi)存一致性錯(cuò)誤(Memory Consistency Errors)。
·活躍度問(wèn)題。一個(gè)并行應(yīng)用程序的及時(shí)執(zhí)行能力被稱為它的活躍度(Liveness)。安全性的含義是“永遠(yuǎn)不發(fā)生糟糕的事情”,而活躍度則關(guān)注另外一個(gè)目標(biāo),即“某件正確的事情最終會(huì)發(fā)生”。當(dāng)某個(gè)操作無(wú)法繼續(xù)執(zhí)行下去,就會(huì)發(fā)生活躍度問(wèn)題。在串行程序中,活躍度問(wèn)題形式之一就是無(wú)意中造成的無(wú)限循環(huán)(死循環(huán))。而在多線程程序中,常見(jiàn)的活躍度問(wèn)題主要有死鎖、饑餓以及活鎖。
·性能問(wèn)題。在設(shè)計(jì)良好的并發(fā)應(yīng)用程序中,線程能提升程序的性能,但無(wú)論如何,線程總是帶來(lái)某種程度的運(yùn)行時(shí)開(kāi)銷。而這種開(kāi)銷主要是在線程調(diào)度器臨時(shí)關(guān)閉活躍線程并轉(zhuǎn)而運(yùn)行另外一個(gè)線程的上下文切換操作(Context Switch)上,因?yàn)閳?zhí)行上下文切換,需要保存和恢復(fù)執(zhí)行上下文,丟失局部性,并且CPU時(shí)間將更多地花在線程調(diào)度而不是在線程運(yùn)行上。當(dāng)線程共享數(shù)據(jù)時(shí),必須使用同步機(jī)制,而這些機(jī)制往往會(huì)抑制某些編譯器優(yōu)化,使內(nèi)存緩存區(qū)中的數(shù)據(jù)無(wú)效,以及增加貢獻(xiàn)內(nèi)存總線的同步流量。所以這些因素都會(huì)帶來(lái)額外的性能開(kāi)銷。
死鎖
死鎖(Deadlock)是指兩個(gè)或兩個(gè)以上的線程永遠(yuǎn)被阻塞,一直等待對(duì)方的資源。下面是一個(gè)用JAVA編寫的死鎖的例子。
Alphonse和Gaston是朋友,都很有禮貌。禮貌的一個(gè)嚴(yán)格的規(guī)則是,當(dāng)你給一個(gè)朋友鞠躬時(shí),你必須保持鞠躬,直到你的朋友回給你鞠躬。不幸的是,這條規(guī)則有個(gè)缺陷,那就是如果兩個(gè)朋友同一時(shí)間向?qū)Ψ骄瞎蔷陀肋h(yuǎn)不會(huì)完了。這個(gè)示例應(yīng)用程序中,死鎖模型如下。
package com.waylau.java.demo.concurrency;
public class Deadlock {
public static void main(String[] args) {
final Friend alphonse = new Friend("Alphonse");
final Friend gaston = new Friend("Gaston");
new Thread(new Runnable() {
public void run() {
alphonse.bow(gaston);
}
}).start();
new Thread(new Runnable() {
public void run() {
gaston.bow(alphonse);
}
}).start();
}
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s" + " has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s" + " has bowed back to me!%n",
this.name, bower.getName());
}
}
}
當(dāng)它們嘗試調(diào)用bowBack時(shí)兩個(gè)線程將被阻塞。無(wú)論是哪個(gè)線程,都永遠(yuǎn)不會(huì)結(jié)束,因?yàn)槊總€(gè)線程都在等待對(duì)方鞠躬。這就是死鎖了。
本節(jié)示例,可以在java-concurrency項(xiàng)目下找到。
饑餓
饑餓(Starvation)描述了一個(gè)線程由于訪問(wèn)足夠的共享資源而不能執(zhí)行程序的現(xiàn)象。這種情況一般出現(xiàn)在共享資源被某些“貪婪”線程占用,而導(dǎo)致資源長(zhǎng)時(shí)間不被其他線程可用。例如,假設(shè)一個(gè)對(duì)象提供一個(gè)同步的方法,往往需要很長(zhǎng)時(shí)間返回。如果一個(gè)線程頻繁調(diào)用該方法,其他線程若也需要頻繁地同步訪問(wèn)同一個(gè)對(duì)象則通常會(huì)被阻塞。
活鎖
一個(gè)線程常常處于響應(yīng)另一個(gè)線程的動(dòng)作,如果其他線程也常常響應(yīng)該線程的動(dòng)作,那么就可能出現(xiàn)活鎖(Livelock)。與死鎖的線程一樣,程序無(wú)法進(jìn)一步執(zhí)行。然而,線程是不會(huì)阻塞的,它們只是會(huì)忙于應(yīng)對(duì)彼此的恢復(fù)工作。現(xiàn)實(shí)中的例子是,兩人面對(duì)面試圖通過(guò)一條走廊:Alphonse移動(dòng)到他的左側(cè)給Gaston讓路,而Gaston移動(dòng)到他的右側(cè)想讓Alphonse過(guò)去,兩個(gè)人同時(shí)讓路,但其實(shí)兩人都擋住了對(duì)方,他們?nèi)匀槐舜俗枞?/p>
下節(jié)將介紹幾種解決并發(fā)風(fēng)險(xiǎn)的常用方法。
解決并發(fā)風(fēng)險(xiǎn)
同步(Synchronization)和原子訪問(wèn)(Atomic Access),是解決并發(fā)風(fēng)險(xiǎn)的兩種重要方式。
同步
同步是避免線程干擾和內(nèi)存一致性錯(cuò)誤的常用手段。下面就用Java來(lái)演示這幾種問(wèn)題,以及如何用同步解決這類問(wèn)題。
1.線程干擾
下面描述當(dāng)多個(gè)線程訪問(wèn)共享數(shù)據(jù)時(shí)錯(cuò)誤是如何出現(xiàn)的。考慮下面的一個(gè)簡(jiǎn)單的類Counter。
public class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
其中的increment方法用來(lái)對(duì)c加1;decrement方法用來(lái)對(duì)c減1。然而,多個(gè)線程中都存在對(duì)某個(gè)Counter對(duì)象的引用,那么線程間的干擾就可能導(dǎo)致出現(xiàn)我們不想要的結(jié)果。
線程間的干擾出現(xiàn)在多個(gè)線程對(duì)同一個(gè)數(shù)據(jù)進(jìn)行多個(gè)操作的時(shí)候,也就是出現(xiàn)了“交錯(cuò)(Interleave)”。這就意味著操作是由多個(gè)步驟構(gòu)成的,而此時(shí),在這多個(gè)步驟的執(zhí)行上出現(xiàn)了疊加。Counter類對(duì)象的操作貌似不可能出現(xiàn)這種“交錯(cuò)”,因?yàn)槠渲械膬蓚€(gè)關(guān)于c的操作都很簡(jiǎn)單,只有一條語(yǔ)句。然而,即使是一條語(yǔ)句也會(huì)被虛擬機(jī)翻譯成多個(gè)步驟,在這里,我們不深究虛擬機(jī)具體將上面的操作翻譯成了什么樣的步驟。只需要知道即使簡(jiǎn)單的像c++這樣的表達(dá)式也會(huì)被翻譯成3個(gè)步驟。
(1)獲取c的當(dāng)前值。
(2)對(duì)其當(dāng)前值加1。
(3)將增加后的值存儲(chǔ)到c中。
表達(dá)式c--也會(huì)被按照同樣的方式進(jìn)行翻譯,只不過(guò)第二步變成了減1,而不是加1。
假定線程A中調(diào)用increment方法,線程B中調(diào)用decrement方法,而調(diào)用時(shí)間基本相同。如果c的初始值為0,那么這兩個(gè)操作的“交錯(cuò)”順序可能如下。
(1)線程A:獲取c的值。
(2)線程B:獲取c的值。
(3)線程A:對(duì)獲取到的值加1,其結(jié)果是1。
(4)線程B:對(duì)獲取到的值減1,其結(jié)果是-1。
(5)線程A:將結(jié)果存儲(chǔ)到c中,此時(shí)c的值是1。
(6)線程B:將結(jié)果存儲(chǔ)到c中,此時(shí)c的值是-1。
這樣線程A計(jì)算的值就丟失了,也就是被線程B的值覆蓋了。上面的這種“交錯(cuò)”只是其中的一種可能性。在不同的系統(tǒng)環(huán)境中,有可能是線程B的結(jié)果丟失了,或者是根本就不會(huì)出現(xiàn)錯(cuò)誤。由于這種“交錯(cuò)”是不可預(yù)測(cè)的,線程間相互干擾造成的Bug是很難定位和修改的。
2.內(nèi)存一致性錯(cuò)誤
下面介紹通過(guò)共享內(nèi)存出現(xiàn)的不一致的錯(cuò)誤。
內(nèi)存一致性錯(cuò)誤發(fā)生在不同線程對(duì)同一數(shù)據(jù)產(chǎn)生不同的“看法”。導(dǎo)致內(nèi)存一致性錯(cuò)誤的原因很復(fù)雜,超出了本書(shū)的描述范圍。慶幸的是,程序員并不需要知道出現(xiàn)這些原因的細(xì)節(jié),我們需要的是一種可以避免這種錯(cuò)誤的方法。
避免出現(xiàn)內(nèi)存一致性錯(cuò)誤的關(guān)鍵在于理解hAppens-before關(guān)系。這種關(guān)系是一種簡(jiǎn)單的方法,能夠確保一條語(yǔ)句中對(duì)內(nèi)存的寫操作對(duì)于其他特定的語(yǔ)句都是可見(jiàn)的。為了理解這點(diǎn),我們可以考慮如下的示例。假設(shè)定義了一個(gè)簡(jiǎn)單的int類型的字段并對(duì)其進(jìn)行初始化。
int counter = 0;
該字段由兩個(gè)線程共享:A和B。假定線程A對(duì)counter進(jìn)行了自增操作。
counter ++;
然后,線程B輸出counter的值。
System.out.println(counter);
如果以上兩條語(yǔ)句是在同一個(gè)線程中執(zhí)行的,那么輸出的結(jié)果自然是1。但是如果這兩條語(yǔ)句是在兩個(gè)不同的線程中,那么輸出的結(jié)果有可能是0。這是因?yàn)闆](méi)有保證線程A對(duì)counter的修改操作對(duì)線程B來(lái)說(shuō)是可見(jiàn)的,除非程序員在這兩條語(yǔ)句間建立了一定的happens-before關(guān)系。
我們可以采取多種方式建立這種happens-before關(guān)系。使用同步就是其中之一。到目前為止,我們已經(jīng)看到了兩種建立這種happens-before的方式。
·當(dāng)一條語(yǔ)句中調(diào)用了Thread.start方法,那么每一條和該語(yǔ)句已經(jīng)建立了happens-before關(guān)系的語(yǔ)句都和新線程中的每一條語(yǔ)句有這種happens-before關(guān)系。引入并創(chuàng)建這個(gè)新線程的代碼產(chǎn)生的結(jié)果對(duì)該新線程來(lái)說(shuō)都是可見(jiàn)的。
·當(dāng)一個(gè)線程終止了并導(dǎo)致另外的線程中調(diào)用Thread.join的語(yǔ)句返回時(shí),這個(gè)終止了的線程中執(zhí)行了的所有語(yǔ)句都與隨后的join語(yǔ)句中的所有語(yǔ)句建立了這種happens-before關(guān)系。也就是說(shuō),終止了的線程中的代碼效果對(duì)調(diào)用join方法的線程來(lái)說(shuō)是可見(jiàn)的。
3.同步方法
Java編程語(yǔ)言中提供了兩種基本的同步用語(yǔ):同步方法(Synchronized Method)和同步語(yǔ)句(Synchronized Statement)。同步語(yǔ)句相對(duì)而言更為復(fù)雜,本節(jié)重點(diǎn)討論同步方法。我們只需要在聲明方法的時(shí)候增加關(guān)鍵字synchronized。
public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
如果count是SynchronizedCounter類的實(shí)例,設(shè)置其方法為同步方法會(huì)有兩個(gè)效果。
·首先,不可能出現(xiàn)對(duì)同一對(duì)象的同步方法的兩個(gè)調(diào)用的“交錯(cuò)”。
當(dāng)一個(gè)線程在執(zhí)行一個(gè)對(duì)象的同步方法的時(shí)候,其他所有調(diào)用該對(duì)象的同步方法的線程都會(huì)被“掛起”,直到第一個(gè)線程對(duì)該對(duì)象操作完畢。
·其次,當(dāng)一個(gè)同步方法退出時(shí),會(huì)自動(dòng)與該對(duì)象的同步方法的后續(xù)調(diào)用建立happens-before關(guān)系。這就確保了對(duì)該對(duì)象的修改對(duì)于其他線程是可見(jiàn)的。同步方法是一種簡(jiǎn)單的、可以避免線程相互干擾和內(nèi)存一致性錯(cuò)誤的策略:如果一個(gè)對(duì)象對(duì)多個(gè)線程都是可見(jiàn)的,那么所有對(duì)該對(duì)象的變量的讀寫都應(yīng)該是通過(guò)同步方法完成的(一個(gè)例外就是final字段,它在對(duì)象創(chuàng)建完成后是不能被修改的。因此,在對(duì)象創(chuàng)建完畢后,可以通過(guò)非同步的方法對(duì)其進(jìn)行安全的讀取)。這種策略是有效的,但是可能導(dǎo)致“活躍度問(wèn)題”。這點(diǎn)我們會(huì)在后面進(jìn)行描述。
4.內(nèi)部鎖和同步
同步是構(gòu)建在被稱為“內(nèi)部鎖(Intrinsic Lock)”或者是“監(jiān)視鎖(Monitor Lock)”的內(nèi)部實(shí)體上的。在API中通常被稱為“監(jiān)視器(Monitor)”。內(nèi)部鎖在兩個(gè)方面都扮演著重要的角色:保證對(duì)對(duì)象狀態(tài)訪問(wèn)的排他性,建立對(duì)象可見(jiàn)性相關(guān)的happens-before關(guān)系。每一個(gè)對(duì)象都有一個(gè)與之相關(guān)聯(lián)的內(nèi)部鎖。按照傳統(tǒng)的做法,當(dāng)一個(gè)線程需要對(duì)一個(gè)對(duì)象的字段進(jìn)行排他性訪問(wèn)并保持訪問(wèn)的一致性時(shí),它必須在訪問(wèn)前先獲取該對(duì)象的內(nèi)部鎖,然后才能訪問(wèn),最后釋放該內(nèi)部鎖。在線程獲取對(duì)象的內(nèi)部鎖到釋放對(duì)象的內(nèi)部鎖的這段時(shí)間,我們說(shuō)該線程擁有該對(duì)象的內(nèi)部鎖。只要有一個(gè)線程已經(jīng)擁有了一個(gè)內(nèi)部鎖,其他線程就不能再擁有該鎖了,其他線程在試圖獲取該鎖的時(shí)候會(huì)被阻塞。當(dāng)一個(gè)線程釋放了一個(gè)內(nèi)部鎖,那么就會(huì)建立起該動(dòng)作和后續(xù)獲取該鎖之間的happens-before關(guān)系。
5.同步方法中的鎖
當(dāng)一個(gè)線程調(diào)用一個(gè)同步方法的時(shí)候,它就自動(dòng)地獲得了該方法所屬對(duì)象的內(nèi)部鎖,并在方法返回的時(shí)候釋放該鎖。即使由于出現(xiàn)了沒(méi)有被捕獲的異常而導(dǎo)致方法返回,該鎖也會(huì)被釋放。
我們可能會(huì)感到疑惑:當(dāng)調(diào)用一個(gè)靜態(tài)的同步方法的時(shí)候會(huì)怎樣?
靜態(tài)方法是和類相關(guān)的,而不是和對(duì)象相關(guān)的。在這種情況下,線程獲取的是該類的類對(duì)象的內(nèi)部鎖。這樣對(duì)于靜態(tài)字段的方法來(lái)說(shuō),這是由和類的實(shí)例的鎖相區(qū)別的另外的一個(gè)鎖來(lái)進(jìn)行操作的。
6.同步語(yǔ)句
另外一種創(chuàng)建同步代碼的方式就是使用同步語(yǔ)句。和同步方法不同,使用同步語(yǔ)句必須指明要使用哪個(gè)對(duì)象的內(nèi)部鎖。
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
在上面的示例中,方法addName需要對(duì)lastName和nameCount的修改進(jìn)行同步,還要避免同步調(diào)用其他對(duì)象的方法(在同步代碼段中調(diào)用其他對(duì)象的方法可能導(dǎo)致出現(xiàn)“活躍度”中描述的問(wèn)題)。如果沒(méi)有使用同步語(yǔ)句,那么將不得不使用一個(gè)單獨(dú)、未同步的方法來(lái)完成對(duì)nameList.add的調(diào)用。
在改善并發(fā)性時(shí),巧妙地使用同步語(yǔ)句能起到很大的幫助作用。例如,我們假定類MsLunch有兩個(gè)實(shí)例字段,c1和c2,這兩個(gè)變量絕不會(huì)一起使用。所有對(duì)這兩個(gè)變量的更新都需要進(jìn)行同步。但是沒(méi)有理由阻止對(duì)c1的更新和對(duì)c2的更新出現(xiàn)交錯(cuò)——這樣做會(huì)創(chuàng)建不必要的阻塞,進(jìn)而降低并發(fā)性。此時(shí),我們沒(méi)有使用同步方法或者使用和this相關(guān)的鎖,而是創(chuàng)建了兩個(gè)單獨(dú)的對(duì)象來(lái)提供鎖。
public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object();
public void inc1() {
synchronized(lock1) {
c1++;}
}
public void inc2() {
synchronized(lock2) {
c2++;
}
}
}
采用這種方式時(shí)需要特別小心,我們必須確保相關(guān)字段的訪問(wèn)交錯(cuò)是完全安全的。
7.重入同步
回憶前面提到的:線程不能獲取已經(jīng)被別的線程獲取的鎖。但是線程可以獲取自身已經(jīng)擁有的鎖。允許一個(gè)線程能重復(fù)獲得同一個(gè)鎖就稱為重入同步(Reentrant Synchronization)。它是這樣的一種情況:在同步代碼中直接或者間接地調(diào)用了還有同步代碼的方法,兩個(gè)同步代碼段中使用的是同一個(gè)鎖。如果沒(méi)有重入同步,在編寫同步代碼時(shí)需要額外小心,以避免線程將自己阻塞。
原子訪問(wèn)
下面介紹另外一種可以避免被其他線程干擾的做法的總體思路——原子訪問(wèn)。
在編程中,原子性動(dòng)作就是指一次性有效完成的動(dòng)作。原子性動(dòng)作是不能在中間停止的:要么一次性完全執(zhí)行完畢,要么就不執(zhí)行。在動(dòng)作沒(méi)有執(zhí)行完畢之前,是不會(huì)產(chǎn)生可見(jiàn)結(jié)果的。
通過(guò)前面的示例,我們已經(jīng)發(fā)現(xiàn)了諸如c++這樣的自增表達(dá)式并不屬于原子性動(dòng)作。即使是非常簡(jiǎn)單的表達(dá)式也包含了復(fù)雜的動(dòng)作,這些動(dòng)作可以被解釋成許多別的動(dòng)作。然而,的確存在一些原子性動(dòng)作。
·對(duì)幾乎所有的原生數(shù)據(jù)類型變量(除了long和double)的讀寫以及引用變量的讀寫都是原子的。
·對(duì)所有聲明為volatile的變量的讀寫都是原子的,包括long和double類型。
原子性動(dòng)作是不會(huì)出現(xiàn)交錯(cuò)的,因此,使用這些原子性動(dòng)作時(shí)不用考慮線程間的干擾。然而,這并不意味著可以移除對(duì)原子性動(dòng)作的同步,因?yàn)閮?nèi)存一致性錯(cuò)誤還是有可能出現(xiàn)的。使用volatile變量可以降低內(nèi)存一致性錯(cuò)誤的風(fēng)險(xiǎn),因?yàn)槿魏螌?duì)volatile變量的寫操作都和后續(xù)對(duì)該變量的讀操作建立了happens-before關(guān)系。這就意味著對(duì)volatile類型變量的修改對(duì)于別的線程來(lái)說(shuō)是可見(jiàn)的。更重要的是,這意味著當(dāng)一個(gè)線程讀取一個(gè)volatile類型的變量時(shí),它看到的不僅僅是對(duì)該變量的最后一次修改,還看到了導(dǎo)致這種修改的代碼帶來(lái)的其他影響。
使用簡(jiǎn)單的原子變量訪問(wèn)比通過(guò)同步代碼來(lái)訪問(wèn)變量更高效,但是需要程序員更多細(xì)心的考慮,以避免出現(xiàn)內(nèi)存一致性錯(cuò)誤。這種額外的付出是否值得完全取決于應(yīng)用程序的大小和復(fù)雜度。
提升系統(tǒng)并發(fā)能力
除了使用多線程外,還有以下方式可以提升系統(tǒng)的并發(fā)能力。
無(wú)鎖化設(shè)計(jì)提升并發(fā)能力
加鎖是為了避免在并發(fā)環(huán)境下,同時(shí)訪問(wèn)共享資源產(chǎn)生的風(fēng)險(xiǎn)問(wèn)題。那么,在并發(fā)環(huán)境下,必須加鎖嗎?答案是否定的。并非所有的并發(fā)都需要加鎖。適當(dāng)降低鎖的粒度,甚至是采用無(wú)鎖化的設(shè)計(jì),更能提升并發(fā)能力。
比如,JDK中的ConcurrentHashMap,巧妙采用了桶粒度的鎖,避免了put和get中對(duì)整個(gè)map的鎖定,尤其在get中,只對(duì)一個(gè)HashEntry做鎖定操作,性能提升是顯而易見(jiàn)的。
又比如,程序中可以合理考慮業(yè)務(wù)數(shù)據(jù)的隔離性,實(shí)現(xiàn)無(wú)鎖化的并發(fā)。比如,程序中預(yù)計(jì)會(huì)有2個(gè)并發(fā)任務(wù),那么每個(gè)任務(wù)可以對(duì)所需要處理的數(shù)據(jù)進(jìn)行分組,任務(wù)1去處理尾數(shù)為0~4的業(yè)務(wù)數(shù)據(jù),任務(wù)2處理尾數(shù)為5~9的業(yè)務(wù)數(shù)據(jù)。那么,這兩個(gè)并發(fā)任務(wù)所要處理的數(shù)據(jù),就是天然隔離的,也就無(wú)須加鎖。
緩存提升并發(fā)能力
有時(shí),為了提升整個(gè)網(wǎng)站的性能,我們會(huì)將經(jīng)常需要訪問(wèn)的數(shù)據(jù)緩存起來(lái),這樣,在下次查詢的時(shí)候,能快速地找到這些數(shù)據(jù)。緩存系統(tǒng)往往有著比傳統(tǒng)的數(shù)據(jù)存儲(chǔ)設(shè)備(如關(guān)系型數(shù)據(jù)庫(kù))更快的訪問(wèn)速度。
緩存的使用與系統(tǒng)的時(shí)效性有著非常大的關(guān)系。當(dāng)我們的系統(tǒng)時(shí)效性要求不高時(shí),選擇使用緩存是極好的。當(dāng)系統(tǒng)要求的時(shí)效性比較高時(shí),則并不適合用緩存。在第14章中,我們還將詳細(xì)探討緩存的應(yīng)用。
更細(xì)顆粒度的并發(fā)單元
在前面章節(jié)中,我們也討論了線程是操作系統(tǒng)內(nèi)核級(jí)別最小的并發(fā)單元。雖然與進(jìn)程相比,創(chuàng)建線程的開(kāi)銷要小很多,但當(dāng)在高并發(fā)場(chǎng)景下,創(chuàng)建大量的線程仍然會(huì)耗費(fèi)系統(tǒng)大量的資源。為此,某些編程語(yǔ)言提供了更細(xì)顆粒度的并發(fā)單元,比如纖程,類似于Golang的goroutine、Erlang風(fēng)格的actor。與線程相比,纖程可以輕松實(shí)現(xiàn)百萬(wàn)級(jí)的并發(fā)量,而且占用更加少的硬件資源。
Java雖然沒(méi)有定義纖程,但仍有一些第三方庫(kù)可供選擇,比如Quasar。讀者有興趣的話,可以參閱Quasar在線手冊(cè)。