1.實戰java高併發程序設計--走入並行世界

1.2.1 同步(Synchronous)和異步(Asynchronous)

同步和異步通常用來形容一次方法調用。同步方法調用一旦開始,調用者必須等到方法調用返回後,才能繼續後續的行爲。異步方法調用更像一個消息傳遞,一旦開始,方法調用就會立即返回,調用者就可以繼續後續的操作。而異步方法通常會在另外一個線程中“真實”地執行。整個過程,不會阻礙調用者的工作。圖1.4顯示了同步方法調用和異步方法調用的區別。對於調用者來說,異步調用似乎是一瞬間就完成的。如果異步調用需要返回結果,那麼當這個異步調用真實完成時,則會通知調用者。

1.2.2 併發(Concurrency)和並行(Parallelism)

併發和並行是兩個非常容易被混淆的概念。它們都可以表示兩個或者多個任務一起執行,但是側重點有所不同。併發偏重於多個任務交替執行,而多個任務之間有可能還是串行的,而並行是真正意義上的“同時執行”

從嚴格意義上來說,並行的多個任務是真的同時執行,而對於併發來說,這個過程只是交替的,一會兒執行任務A,一會兒執行任務B,系統會不停地在兩者之間切換。但對於外部觀察者來說,即使多個任務之間是串行併發的,也會造成多任務間並行執行的錯覺。

真實的並行也只可能出現在擁有多個CPU的系統中(比如多核CPU)。

1.2.3 臨界區

臨界區用來表示一種公共資源或者說共享數據,可以被多個線程使用。但是每一次,只能有一個線程使用它,一旦臨界區資源被佔用,其他線程要想使用這個資源就必須等待。

1.2.4 阻塞(Blocking)和非阻塞(Non-Blocking)

阻塞和非阻塞通常用來形容多線程間的相互影響。比如一個線程佔用了臨界區資源,那麼其他所有需要這個資源的線程就必須在這個臨界區中等待。等待會導致線程掛起,這種情況就是阻塞。此時,如果佔用資源的線程一直不願意釋放資源,那麼其他所有阻塞在這個臨界區上的線程都不能工作。

非阻塞的意思與之相反,它強調沒有一個線程可以妨礙其他線程執行,所有的線程都會嘗試不斷前向執行。

1.2.5 死鎖(Deadlock)、飢餓(Starvation)和活鎖(Livelock)

死鎖、飢餓和活鎖都屬於多線程的活躍性問題。如果發現上述幾種情況,那麼相關線程可能就不再活躍,也就是說它可能很難再繼續往下執行了。

死鎖應該是最糟糕的一種情況了(當然,其他幾種情況也好不到哪裏去),圖1.6顯示了一個死鎖的發生。

A、B、C、D四輛小車在這種情況下都無法繼續行駛了。它們彼此之間相互佔用了其他車輛的車道,如果大家都不願意釋放自己的車道,那麼這個狀態將永遠持續下去,誰都不可能通過。死鎖是一個很嚴重的並且應該避免和時時小心的問題,我們將安排在“鎖的優化及注意事項”中進行更詳細的討論。

飢餓是指某一個或者多個線程因爲種種原因無法獲得所需要的資源,導致一直無法執行。比如它的線程優先級可能太低,而高優先級的線程不斷搶佔它需要的資源,導致低優先級線程無法工作.飢餓還是有可能在未來一段時間內解決的(比如,高優先級的線程已經完成任務,不再瘋狂執行)。

如果線程的智力不夠,且都秉承着“謙讓”的原則,主動將資源釋放給他人使用,那麼就會導致資源不斷地在兩個線程間跳動,而沒有一個線程可以同時拿到所有資源正常執行。這種情況就是活鎖

1.3 併發級別

由於臨界區的存在,多線程之間的併發必須受到控制。根據控制併發的策略,我們可以把併發的級別分爲阻塞、無飢餓、無障礙、無鎖、無等待幾種。

1.3.1 阻塞

一個線程是阻塞的,那麼在其他線程釋放資源之前,當前線程無法繼續執行。當我們使用synchronized關鍵字或者重入鎖時(我們將在第2、3章介紹這兩種技術),我們得到的就是阻塞的線程。synchronized關鍵字和重入鎖都試圖在執行後續代碼前,得到臨界區的鎖,如果得不到,線程就會被掛起等待,直到佔有了所需資源爲止。

1.3.2 無飢餓(Starvation-Free)

如果線程之間是有優先級的,那麼線程調度的時候總是會傾向於先滿足高優先級的線程。也就說是,對於同一個資源的分配,是不公平的!圖1.7顯示了非公平鎖與公平鎖兩種情況(五角星表示高優先級線程)。對於非公平鎖來說,系統允許高優先級的線程插隊。這樣有可能導致低優先級線程產生飢餓。但如果鎖是公平的,按照先來後到的規則,那麼飢餓就不會產生,不管新來的線程優先級多高,要想獲得資源,就必須乖乖排隊,這樣所有的線程都有機會執行。

1.3.3 無障礙(Obstruction-Free)

無障礙是一種最弱的非阻塞調度。兩個線程如果無障礙地執行,那麼不會因爲臨界區的問題導致一方被掛起。換言之,大家都可以大搖大擺地進入臨界區了。那麼大家一起修改共享數據,把數據改壞了怎麼辦呢?對於無障礙的線程來說,一旦檢測到這種情況,它就會立即對自己所做的修改進行回滾,確保數據安全。但如果沒有數據競爭發生,那麼線程就可以順利完成自己的工作,走出臨界區。

如果說阻塞的控制方式是悲觀策略,也就是說,系統認爲兩個線程之間很有可能發生不幸的衝突,因此以保護共享數據爲第一優先級,相對來說,非阻塞的調度就是一種樂觀的策略。它認爲多個線程之間很有可能不會發生衝突,或者說這種概率不大。因此大家都應該無障礙地執行,但是一旦檢測到衝突,就應該進行回滾

從這個策略中也可以看到,無障礙的多線程程序並不一定能順暢運行。因爲當臨界區中存在嚴重的衝突時,所有的線程可能都會不斷地回滾自己的操作,而沒有一個線程可以走出臨界區。這種情況會影響系統的正常執行。所以,我們可能會非常希望在這一堆線程中,至少可以有一個線程能夠在有限的時間內完成自己的操作,而退出臨界區。至少這樣可以保證系統不會在臨界區中進行無限的等待。

一種可行的無障礙實現可以依賴一個“一致性標記”來實現。線程在操作之前,先讀取並保存這個標記,在操作完成後,再次讀取,檢查這個標記是否被更改過,如果兩者是一致的,則說明資源訪問沒有衝突。如果不一致,則說明資源可能在操作過程中與其他寫線程衝突,需要重試操作。而任何對資源有修改操作的線程,在修改數據前,都需要更新這個一致性標記,表示數據不再安全。

1.3.4 無鎖(Lock-Free)

無鎖的並行都是無障礙的。在無鎖的情況下,所有的線程都能嘗試對臨界區進行訪問,但不同的是,無鎖的併發保證必然有一個線程能夠在有限步內完成操作離開臨界區。在無鎖的調用中,一個典型的特點是可能會包含一個無窮循環。在這個循環中,線程會不斷嘗試修改共享變量。如果沒有衝突,修改成功,那麼程序退出,否則繼續嘗試修改。但無論如何,無鎖的並行總能保證有一個線程是可以勝出的,不至於全軍覆沒。至於臨界區中競爭失敗的線程,它們必須不斷重試,直到自己獲勝。如果運氣很不好,總是嘗試不成功,則會出現類似飢餓的現象,線程會停止。下面就是一段無鎖的示意代碼,如果修改不成功,那麼循環永遠不會停止。

1.3.5 無等待(Wait-Free)

無鎖只要求有一個線程可以在有限步內完成操作,而無等待則在無鎖的基礎上更進一步擴展。它要求所有的線程都必須在有限步內完成,這樣就不會引起飢餓問題。如果限制這個步驟的上限,還可以進一步分解爲有界無等待和線程數無關的無等待等幾種,它們之間的區別只是對循環次數的限制不同。一種典型的無等待結構就是RCU(Read Copy Update)。它的基本思想是,對數據的讀可以不加控制。因此,所有的讀線程都是無等待的,它們既不會被鎖定等待也不會引起任何衝突。但在寫數據的時候,先取得原始數據的副本,接着只修改副本數據(這就是爲什麼讀可以不加控制),修改完成後,在合適的時機回寫數據。

1.4 有關並行的兩個重要定律

目前,主要有兩個定律對這個問題進行解答,一個是Amdahl定律,另外一個是Gustafson定律。

1.4.1 Amdahl定律

Amdahl定律是計算機科學中非常重要的定律。它定義了串行系統並行化後的加速比的計算公式和理論上限。

加速比定義:加速比 = 優化前系統耗時 / 優化後系統耗時

注意:根據Amdahl定律,使用多核CPU對系統進行優化,優化的效果取決於CPU的數量,以及系統中的串行化程序的比例。CPU數量越多,串行化比例越低,則優化效果越好。僅提高CPU數量而不降低程序的串行化比例,也無法提高系統性能。

1.4.2 Gustafson定律

Gustafson定律也試圖說明處理器個數、串行化比例和加速比之間的關係,如圖1.12所示,但是Gustafson定律和Amdahl定律的角度不同。同樣,加速比都被定義爲優化前的系統耗時除以優化後的系統耗時。

從Gustafson定律中,我們可以更容易地發現,如果串行化比例很小,並行化比例很大,那麼加速比就是處理器的個數。只要不斷地累加處理器,就能獲得更快的速度。

1.4.3 是否相互矛盾

說的啥玩意,卡不懂!


1.5 回到Java:JMM

由於併發程序要比串行程序複雜很多,其中一個重要原因是併發程序中數據訪問的一致性和安全性將會受到嚴重挑戰。

JMM的關鍵技術點都是圍繞着多線程的原子性、可見性和有序性來建立的。因此,我們首先必須瞭解這些概念。

1.5.1 原子性(Atomicity)

原子性是指一個操作是不可中斷的。即使是在多個線程一起執行的時候,一個操作一旦開始,就不會被其他線程干擾。比如,對於一個靜態全局變量int i,兩個線程同時對它賦值,線程A給它賦值1,線程B給它賦值爲-1。那麼不管這兩個線程以何種方式、何種步調工作,i的值要麼是1,要麼是-1。線程A和線程B之間是沒有干擾的。這就是原子性的一個特點,不可被中斷。但如果我們不使用int型數據而使用long型數據,可能就沒有那麼幸運了。對於32位系統來說,long型數據的讀寫不是原子性的(因爲long型數據有64位)。也就是說,如果兩個線程同時對long型數據進行寫入(或者讀取),則對線程之間的結果是有干擾的。

1.5.2 可見性(Visibility)

可見性是指當一個線程修改了某一個共享變量的值時,其他線程是否能夠立即知道這個修改。顯然,對於串行程序來說,可見性問題是不存在的。因爲你在任何一個操作步驟中修改了某個變量,在後續的步驟中讀取這個變量的值時,讀取的一定是修改後的新值。但是這個問題存在於並行程序中。如果一個線程修改了某一個全局變量,那麼其他線程未必可以馬上知道這個改動。圖1.14展示了發生可見性問題的一種可能。如果在CPU1和CPU2上各運行了一個線程,它們共享變量t,由於編譯器優化或者硬件優化的緣故,在CPU1上的線程將變量t進行了優化,將其緩存在cache中或者寄存器裏。在這種情況下,如果在CPU2上的某個線程修改了變量t的實際值,那麼CPU1上的線程可能無法意識到這個改動,依然會讀取cache中或者寄存器裏的數據。因此,就產生了可見性問題。外在表現爲:變量t的值被修改,但是CPU1上的線程依然會讀到一箇舊值。可見性問題也是並行程序開發中需要重點關注的問題之一。

1.5.3 有序性(Ordering)

有序性問題可能是三個問題中最難理解的了。對於一個線程的執行代碼而言,我們總是習慣性地認爲代碼是從前往後依次執行的。這麼理解也不能說完全錯誤,因爲就一個線程內而言,確實會表現成這樣。但是,在併發時,程序的執行可能就會出現亂序。給人的直觀感覺就是:寫在前面的代碼,會在後面執行。聽起來有些不可思議,是嗎?有序性問題的原因是程序在執行時,可能會進行指令重排,重排後的指令與原指令的順序未必一致。下面來看一個簡單的例子:

注意:這裏說的是可能存在。因爲如果指令沒有重排,這個問題就不存在了,但是指令是否發生重排、如何重排,恐怕是我們無法預測的。因此,對於這類問題,我認爲比較嚴謹的描述是:線程A的指令執行順序在線程B看來是沒有保證的。如果運氣好的話,線程B也許真的可以看到和線程A一樣的執行順序。

注意:指令重排可以保證串行語義一致,但是沒有義務保證多線程間的語義也一致

指令重排對於提高CPU處理性能是十分必要的。雖然確實帶來了亂序的問題,但是這點犧牲是完全值得的。

1.5.4 哪些指令不能重排:Happen-Before規則

在前文已經介紹了指令重排,雖然Java虛擬機和執行系統會對指令進行一定的重排,但是指令重排是有原則的,並非所有的指令都可以隨便改變執行位置,以下羅列了一些基本原則,這些原則是指令重排不可違背的。● 程序順序原則:一個線程內保證語義的串行性。● volatile規則:volatile變量的寫先於讀發生,這保證了volatile變量的可見性。● 鎖規則:解鎖(unlock)必然發生在隨後的加鎖(lock)前。● 傳遞性:A先於B,B先於C,那麼A必然先於C。● 線程的start()方法先於它的每一個動作。● 線程的所有操作先於線程的終結(Thread.join())。● 線程的中斷(interrupt())先於被中斷線程的代碼。● 對象的構造函數的執行、結束先於finalize()方法。

此外,鎖規則強調,unlock操作必然發生在後續的對同一個鎖的lock之前。也就是說,如果對一個鎖解鎖後,再加鎖,那麼加鎖的動作絕對不能重排到解鎖的動作之前。很顯然,如果這麼做,則加鎖行爲是無法獲得這把鎖的。其他幾條原則也是類似的,這些原則都是爲了保證指令重排不會破壞原有的語義結構。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章