天天看點

深入并發線程、程序、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

作者:JAVA我要發大财

一、程序、線程、纖程、協程、管程概念了解

在現在你可能會經常看到程序、線程、纖程、協程、管程、微線程、綠色線程....一大堆xx程的概念,其實這些本質上都是為了滿足并行執行、異步執行而出現的一些概念。

因為随着如今的科技越來越發達,計算機目前多以多核機器為主,是以之前單線程的串行執行方式注定無法100%程度發揮出硬體該有的性能。同時,為了滿足網際網路時代中日益漸增的使用者基數,我們開發的程式往往需要更優異的性能,更快地執行效率,更大的吞吐量才可。

為了友善了解,我們可以先把作業系統抽象為了一個帝國。并且為了友善了解這些概念,下面也不會太過官方死闆的做概念介紹。

1.1、程序(Progress)

程序也就是平時所說的程式,比如在作業系統上運作一個谷歌浏覽器,那麼就代表着谷歌浏覽器就是一個程序。程序是作業系統中能夠獨立運作的個體,并且也作為資源配置設定的基本機關,由指令、資料、堆棧等結構組成。

安裝好一個程式之後,在程式未曾運作之前也僅是一些檔案存儲在磁盤上,當啟動程式時會向作業系統申請一定的資源,如CPU、存儲空間和I/O裝置等,OS為其配置設定資源後,會真正的出現在記憶體中成為一個抽象的概念:程序。

其實作業系統這個帝國之上,在運作時往往有着很多個程序存在,你可以把這些程序了解成一個個的工廠,根據各自的代碼實作各司其職。如通過Java編寫一個程式後運作在作業系統上,那麼就相當于在OS帝國上注冊了一家工廠,該工廠具體的工作則由Java代碼的業務屬性決定。

随着計算機硬體技術的不斷進步,慢慢的CPU架構更多都是以多核的身份出現在市面上,是以對于程式而言,CPU使用率的要求會更高。但是程序的排程開銷是比較大的,并且在并發中切換過程效率也很低,是以為了更高效地排程和滿足日益複雜的程式需求,最終發明了線程。

1.2、線程(Thread)

在作業系統早期的時候其實并沒有線程的概念,到了後來為了滿足并發處理才推出的一種方案,線程作為程式執行的最小機關,一個程序中可以擁有多條線程,所有線程可以共享程序的記憶體區域,線程通常在運作時也需要一組寄存器、記憶體、棧等資源的支撐。現如今,程式之是以可以運作起來的根本原因就是因為内部一條條的線程在不斷地執行對應的代碼邏輯。

假設程序現在是OS帝國中的一個工廠,那麼線程就是工廠中一個個工位上的勞工。工廠之是以能夠運轉的根本原因就在于:内部每個工位上的勞工都各司其職地處理自己配置設定到的工作。

多核CPU中,一個核心往往在同一時刻隻能支援一個核心線程的運作,是以如果你的機器為八核CPU,那麼理論上代表着同一時刻最多支援八條核心線程同時并發執行。當然,現在也采用了超線程的技術,把一個實體晶片模拟成兩個邏輯處理核心,讓單個處理器都能使用線程級并行計算,進而相容多線程作業系統和軟體,減少了CPU的閑置時間,提高的CPU的運作效率。比如四核八線程的CPU,在同一時刻也支援最大八條線程并發執行。

在OS中,程式一般不會去直接申請核心線程進行操作,而是去使用核心線程提供的一種名為LWP的輕量級程序(Lightweight Process)進行操作,這個LWP也就是平時所謂的線程,也被成為使用者級線程。

1.2.1、線程模型

在如今的作業系統中,使用者線程與核心線程主要存在三種模型:一對一模型、多對一模型以及多對多模型。而Java中使用的則是一對一模型,在之前分析Java記憶體模型JMM時曾詳細分析過。

一對一模型

一對一模型是指一條使用者線程對應着核心中的一條線程,而Java中采用的就是這種模型,如下:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

一對一模型是真正意義上的并行執行,因為這種模型下,建立一條Java的Thread線程是真正的在核心中建立并映射了一條核心線程的,執行過程中,一條線程不會因為另外一條線程的原因而發生阻塞等情況。不過因為是直接映射核心線程的模式,是以數量會存在上限。并且同一個核心中,多條線程的執行需要頻繁地發生上下文切換以及核心态與使用者态之間的切換,是以如果線程數量過多,切換過于頻繁會導緻線程執行效率下降。

多對一模型

顧名思義,多對一模型是指多條使用者線程映射同一條核心線程的情況,對于使用者線程而言,它們的執行都由使用者态的代碼完成切換。

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解
這種模式優點很明顯,一方面可以節省核心态到使用者态切換的開銷,第二方面線程的數量不會受到核心線程的限制。但是缺點也很明顯,因為線程切換的工作是由使用者态的代碼完成的,是以如果當一條線程發生阻塞時,與該核心線程對應的其他使用者線程也會一起陷入阻塞。

多對多模型

多對多模型就可以避免上面一對一和多對一模型帶來的弊端,也就是多條使用者線程映射多條核心線程,這樣即可以避免一對一模型的切換效率問題和數量限制問題,也可以避免多對一的阻塞問題,如下:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

1.3、協程(Coroutines)

協程是一種基于線程之上,但又比線程更加輕量級的存在,這種由程式管理的輕量級線程也被稱為使用者空間線程,對于核心而言是不可見的。正如同程序中存在多條線程一樣,線程中也可以存在多個協程。

協程在運作時也有自己的寄存器、上下文和棧,協程的排程完全由使用者控制,協程排程切換時,會将寄存器上下文和棧儲存到配置設定的私有記憶體區域中,在切回來的時候,恢複先前儲存的寄存器上下文和棧,直接操作棧則基本沒有核心切換的開銷,可以不加鎖的通路全局變量,是以上下文的切換非常快。

前面把線程比作了工廠工位上的固定勞工,那麼協程更多的就可以了解為:工廠中固定工位上的不固定勞工。一個固定工位上允許有多個不同的勞工,當輪到某個勞工工作時,就把上一個勞工的換下來,把這個要工作的勞工換上去。或者目前勞工在工作時要上廁所,那麼就會先把目前工作的勞工撤下去,換另一個勞工上來,等這個勞工上完廁所回來了,會再恢複它的工作。

協程有些類似于線程的多對一模型。

1.4、纖程(Fiber)

纖程(Fiber)是Microsoft組織為了幫助企業程式的更好移植到Windows系統,而在操做系統中增加的一個概念,由作業系統核心根據對應的排程算法進行控制,也是一種輕量級的線程。

纖程和協程的概念一緻,都是線程的多對一模型,但有些地方會區分開來,但從協程的本質概念上來談:纖程、綠色線程、微線程這些概念都屬于協程的範圍。纖程和協程的差別在于:

  • 纖程是OS級别的實作,而協程是語言級别的實作,纖程被OS核心控制,協程對于核心而言不可見。

1.5、管程(Monitors)

管程(Monitors)提供了一種機制,線程可以臨時放棄互斥通路,等待某些條件得到滿足後,重新獲得執行權恢複它的互斥通路。相信這個概念對于熟悉多線程程式設計的Java程式員而言并不是陌生,因為在Java中Synchronized關鍵字就是基于它實作的,不太了解的可以去看之前的文章:全面剖析Synchronized關鍵字。

1.6、XX程小結

先如今各種程出現的根本原因是由于多核機器的流行,是以程式實作中也需要最大程度上考慮并行、并發、異步執行,在最大程式上去将硬體機器應有的性能發揮出來。以Java而言,本身多線程的方式是已經可以滿足這些需求的,但Java中的線程資源比較昂貴,是直接與核心線程映射的,是以在上下文切換、核心态和使用者态轉換上都需要浪費很多的資源開銷,同時也受到作業系統的限制,允許一個Java程式中建立的纖程數量是有限的。是以對于這種一對一的線程模型有些無法滿足需求了,最終才出現了各種程的概念。

從實作級别上來看:程序、線程、纖程是OS級别的實作,而綠色線程、協程這些則是語言級别上的實作。

從排程方式上而言:程序、線程、綠色線程屬于搶占式執行,而纖程、協程則屬于合作式排程。

從包含關系上來說:一個OS中可以有多個程序,一個程序中可以有多條線程,而一條線程中則可以有多個協程、纖程、微線程等。

二、死鎖、活鎖與鎖饑餓概念了解

在多核時代中,多線程、多程序的程式雖然大大提高了系統資源的使用率以及系統的吞吐量,但并發執行也帶來了新的一系列問題:死鎖、活鎖與鎖饑餓。

死鎖、活鎖與鎖饑餓都是程式運作過程中的一種狀态,而其中死鎖與活鎖狀态在程序中也是可能存在這種情況的,接下來先簡單闡述一下這些狀态的含義。

2.1、何謂死鎖(DeadLock)?

死鎖是指兩個或兩個以上的線程(或程序)在運作過程中,因為資源競争而造成互相等待的現象,若無外力作用則不會解除等待狀态,它們之間的執行都将無法繼續下去。舉個栗子:

某一天竹子和熊貓在森林裡撿到一把玩具弓箭,竹子和熊貓都想玩,原本說好一人玩一次的來,但是後面竹子耍賴,想再玩一次,是以就把弓一直拿在自己手上,而本應該輪到熊貓玩的,是以熊貓跑去撿起了竹子前面剛剛射出去的箭,然後跑回來之後便發生了如下狀況:

熊貓道:竹子,快把你手裡的弓給我,該輪到我玩了....

竹子說:不,你先把你手裡的箭給我,我再玩一次就給你....

最終導緻熊貓等着竹子的弓,竹子等着熊貓的箭,雙方都不肯退步,結果陷入僵局場面....。

相信這個場景各位小夥伴多多少少都在自己小時候發生過,這個情況在程式中發生時就被稱為死鎖狀況,如果出現後則必須外力介入,然後破壞掉死鎖狀态後推程序式繼續執行。如上述的案例中,此時就必須第三者介入,把“違反約定”的竹子手中的弓拿過去給熊貓......

當然,類似于這樣的死鎖案例還有很多現實中的例子,比如:哲學家進餐等。

2.2、活鎖(LiveLock)是什麼?

活鎖是指正在執行的線程或程序沒有發生阻塞,但由于某些條件沒有滿足,導緻反複重試-失敗-重試-失敗的過程。與死鎖最大的差別在于:活鎖狀态的線程或程序是一直處于運作狀态的,在失敗中不斷重試,重試中不斷失敗,一直處于所謂的“活”态,不會停止。而發生死鎖的線程則是互相等待,雙方之間的狀态是不會發生改變的,處于所謂的“死”态。

死鎖沒有外力介入是無法自行解除的,而活鎖狀态有一定幾率自行解除。

其實本質上來說,活鎖狀态就是指兩個線程雖然在反複地執行,但是卻沒有任何效率。正如生活中那句名言:“雖然你看起來很努力,但結果卻沒有因為你的努力而發生任何改變”,也是所謂的做無用功。同樣舉個生活中的栗子了解:

生活中大家也都遇見過的一個事情:在一條走廊上兩個人低頭玩手機往前走,突然雙方一起擡頭都發現面對面快撞上了,然後雙方同時往左側跨了一步讓開路,然後兩個人都發現對方也到左邊來了,兩個人想着再回到右邊去給對方讓路,然後同時又向右邊跨了一步,然後不斷重複這個過程,再同時左邊跨、右邊跨、左邊跨........

這個栗子中,雖然雙方都在不斷地移動,但是做的卻是無用功,如果一直這樣重複下去,可能從太陽高照到滿天繁星的時候,雙方還是沒有走出這個困境。

這個狀态又該如何打破呢?主要有兩種方案,一種是單方的,其中有一方打破“同步”的頻率。另一種方案則是雙方之間先溝通好,制定好約定之後再讓路,比如其中一方開口說:你等會兒走我這邊,我往那邊走。而另一方則說:好。

在程式中,如果兩條線程發生了某些條件的碰撞後重新執行,那麼如果再次嘗試後依然發生了碰撞,長此下去就有可能發生如上案例中的情況,這種情況就被稱為協同導緻的活鎖。

比如同時往某處位置寫入資料,但同時隻能允許一條線程寫入資料,是以在寫入之前會檢測是否有其他線程存在,如果有則放棄本次寫入,過一段時間之後再重試。而此時正好有兩條線程同時寫入又互相檢測到了對方,然後都放棄了寫入,而重試的時間間隔都為1s,結果1s後這兩條線程又碰頭了,然後來回重複這個過程.....

當然,在程式中除開上述這種多線程之間協調導緻的活鎖情況外,單線程也會導緻活鎖産生,比如遠端RPC調用中就經常出現,A調用B的RPC接口,需要B的資料傳回,結果B所在的機器網絡出問題了,A就不斷的重試,最終導緻反複調用,不斷失敗。

活鎖解決方案

活鎖狀态是有可能自行解除的,但時間會久一點,不過在編寫程式時,我們可以盡量避免活鎖情況發生,一方面可以在重試次數上加上限制,第二個方面也可以把重試的間隔時間加點随機數,第三個則是前面所說的,多線程協同式工作時則可以先在全局内約定好重試機制,盡量避免線程沖突發生。

2.3、啥又叫鎖饑餓(LockStarving)?

鎖饑餓是指一條長時間等待的線程無法擷取到鎖資源或執行所需的資源,而後面來的新線程反而“插隊”先擷取了資源執行,最終導緻這條長時間等待的線程出現饑餓。

ReetrantLock的非公平鎖就有可能導緻線程饑餓的情況出現,因為線程到來的先後順序無法決定鎖的擷取,可能第二條到來的線程在第十八條線程擷取鎖成功後,它也不一定能夠成功擷取鎖。

鎖饑餓這種問題可以采用公平鎖的方式解決,這樣可以確定線程擷取鎖的順序是按照請求鎖的先後順序進行的。但實際開發過程中,從性能角度而言,非公平鎖的性能會遠遠超出公平鎖,非公平鎖的吞吐量會比公平鎖更高。

當然,如果你使用了多線程程式設計,但是在配置設定纖程組時沒有合理的設定線程優先級,導緻高優先級的線程一直吞噬低優先級的資源,導緻低優先級的線程一直無法擷取到資源執行,最終也會使低優先級的線程産生饑餓。

三、死鎖産生原因/如何避免死鎖、排查死鎖詳解

關于鎖饑餓和活鎖前面闡述的内容便已足夠了,不過對于死鎖這塊的内容,無論在面試過程中,還是在實際開發場景下都比較常見,是以再單獨拿出來分析一個段落。

在前面提及過死鎖的概念:死鎖是指兩個或兩個以上的線程(或程序)在運作過程中,因為資源競争而造成互相等待的現象。而此時可以進一步拆解這句話,可以得出死鎖如下結論:

  • ①參與的執行實體(線程或程序)必須要為兩個或兩個以上。
  • ②參與的執行實體都需要等待資源方可執行。
  • ③參與的執行實體都均已占據對方等待的資源。
  • ④死鎖情況下會占用大量資源而不工作,如果發生大面積的死鎖情況可能會導緻程式或系統崩潰。

3.1、死鎖産生的四個必要條件

而誘發死鎖的根本從前面的分析中可以得知:是因為競争資源引起的。當然,産生死鎖存在四個必要條件,如下:

  • ①互斥條件:指配置設定到的資源具備排他使用性,即在一段時間内某資源隻能由一個執行實體使用。如果此時還有其它執行實體請求資源,則請求者隻能等待,直至占有資源的執行實體使用完成後釋放才行。
  • ②不可剝奪條件:指執行實體已持有的資源,在未使用完之前,不能被剝奪,隻能在使用完時由自己釋放。
  • ③請求與保持條件:指運作過程中,執行實體已經擷取了至少一個資源,但又提出了新的資源請求,而該資源已被其它實體占用,此時目前請求資源的實體阻塞,但在阻塞時卻不釋放自己已獲得的其它資源,一直保持着對其他資源的占用。
  • ④環狀等待條件:指在發生死鎖時,必然存在一個執行實體的資源環形鍊。比如:線程T1等待T2占用的一個資源,線程T2在等待線程T3占用的一個資源,而線程T3則在等待T1占用的一個資源,最終形成了一個環狀的資源等待鍊。

以上是死鎖發生的四個必要條件,隻要系統或程式内發生死鎖情況,那麼這四個條件必然成立,隻要上述中任意一條不符合,那麼就不會發生死鎖。

3.2、系統資源的分類

作業系統以及硬體平台上存在各種各樣不同的資源,而資源的種類大體可以分為永久性資源、臨時性資源、可搶占式資源以及不可搶占式資源。

3.2.1、永久性資源

永久性資源也被稱為可重複性資源,即代表着一個資源可以被執行實體(線程/程序)重複性使用,它們不會因為執行實體的生命周期改變而發生變化。比如所有的硬體資源就是典型的永久性資源,這些資源的數量是固定的,執行實體在運作時即不能建立,也不能銷毀,要使用這些資源時必須要按照請求資源、使用資源、釋放資源這樣的順序操作。

3.2.2、臨時性資源

臨時性資源也被稱為消耗性資源,這些資源是由執行實體在運作過程中動态的建立和銷毀的,如硬體中斷信号、緩沖區内的消息、隊列中的任務等,這些都屬于臨時性資源,通常是由一個執行實體建立出來之後,被另外的執行實體處理後銷毀。比如典型的一些消息中間件的使用,也就是生産者-消費者模型。

3.2.3、可搶占式資源

可搶占式資源也被稱為可剝奪性資源,是指一個執行實體在擷取到某個資源之後,該資源是有可能被其他實體或系統剝奪走的。可剝奪性資源在程式中也比較常見,如:

  • 程序級别:CPU、主記憶體等資源都屬于可剝奪性資源,系統将這些資源配置設定給一個程序之後,系統是可以将這些資源剝奪後轉交給其他程序使用的。
  • 線程級别:比如Java中的ForkJoin架構中的任務,配置設定給一個線程的任務是有可能被其他線程竊取的。

可剝奪性資源還有很多,諸如上述過程中的一些類似的資源都可以被稱為可剝奪性資源。

3.2.4、不可搶占式資源

同樣,不可搶占式資源也被稱為不可剝奪性資源,不可剝奪性是指把一個執行實體擷取到資源之後,系統或程式不能強行收回,隻能在實體使用完後自行釋放。如:

  • 程序級别:錄音帶機、列印機等資源,配置設定給程序之後隻能由程序使用完後自行釋放。
  • 線程級别:鎖資源就是典型的線程級别的不可剝奪性資源,當一條線程擷取到鎖資源後,其他線程不能剝奪該資源,隻能由擷取到鎖的線程自行釋放。

3.2.5、資源引發的死鎖問題

前面曾提到過一句,死鎖情況的發生必然是因為資源問題引起的,而在上述資源中,競争臨時性資源和不可剝奪性資源都可能引起死鎖發生,也包括如果資源請求順序不當也會誘發死鎖問題,如兩條并發線程同時執行,T1持有資源M1,線程T2持有M2,而T2又在請求M1,T1又在請求M2,兩者都會因為所需資源被占用而阻塞,最終造成死鎖。

當然,也并非隻有資源搶占會導緻死鎖出現,有時候沒有發生資源搶占,就單純的資源等待也會造成死鎖場面,如:服務A在等待服務B的信号,而服務B恰巧也在等待服務A的信号,結果也會導緻雙方之間無法繼續向前推進執行。不過從這裡可以看出:A和B不是因為競争同一資源,而是在等待對方的資源導緻死鎖。

對于這個例子有人可能會疑惑,這不是活鎖情況嗎?

答案并非如此,因為活鎖情況講究的是一個“活”字,而上述這個案例,雙方之間都是處于互相等待的“死”态。

3.3、死鎖案例分析

上述對于死鎖的理論進行了大概闡述,下來來個簡單例子感受一下死鎖情景:

public class DeadLock implements Runnable {
    public boolean flag = true;

    // 靜态成員屬于class,是所有執行個體對象可共享的
    private static Object o1 = new Object(), o2 = new Object();

    public DeadLock(boolean flag){
        this.flag = flag;
    }

    @Override
    public void run() {
        if (flag) {
            synchronized (o1) {
                System.out.println("線程:" + Thread.currentThread()
                        .getName() + "持有o1....");
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("線程:" + Thread.currentThread()
                        .getName() + "等待o2....");
                synchronized (o2) {
                    System.out.println("true");
                }
            }
        }
        if (!flag) {
            synchronized (o2) {
                System.out.println("線程:" + Thread.currentThread()
                        .getName() + "持有o2....");
                try {
                    Thread.sleep(500);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                System.out.println("線程:" + Thread.currentThread()
                        .getName() + "等待o1....");
                synchronized (o1) {
                    System.out.println("false");
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread t1 = new Thread(new DeadLock(true),"T1");
        Thread t2 = new Thread(new DeadLock(false),"T2");
        // 因為線程排程是按時間片切換決定的,
        // 是以先執行哪個線程是不确定的,也就代表着:
        //  後面的t1.run()可能在t2.run()之前運作
        t1.start();
        t2.start();
    }
}

// 運作結果如下:
    /*
      線程:T1持有o1....
      線程:T2持有o2....
      線程:T2等待o1....
      線程:T1等待o2....
    */
複制代碼           

如上是一個簡單的死鎖案例,在該代碼中:

  • 當flag==true時,先擷取對象o1的鎖,擷取成功之後休眠500ms,而發生這個動作的必然是t1,因為在main方法中,我們将t1任務的flag顯式的置為了true。
  • 而當t1線程睡眠時,t2線程啟動,此時t2任務的flag=false,是以會去擷取對象o2的鎖資源,然後擷取成功之後休眠500ms。
  • 此時t1線程睡眠時間結束,t1線程被喚醒後會繼續往下執行,然後需要擷取o2對象的鎖資源,但此時o2已經被t2持有,此時t1會阻塞等待。
  • 而此刻t2線程也從睡眠中被喚醒會繼續往下執行,然後需要擷取o1對象的鎖資源,但此時o1已經被t1持有,此時t2會阻塞等待。
  • 最終導緻線程t1、t2互相等待對象的資源,都需要擷取對方持有的資源之後才可繼續往下執行,最終導緻死鎖産生。

3.4、死鎖處理

對于死鎖的情況一旦出現都是比較麻煩的,但這也是設計并發程式避免不了的問題,當你想要通過多線程程式設計技術提升你的程式處理速度和整體吞吐量時,對于死鎖的問題也是必須要考慮的一項,而處理死鎖問題總的歸納來說可以從如下四個角度出發:

  • ①預防死鎖:通過代碼設計或更改配置來破壞掉死鎖産生的四個條件其中之一,以此達到預防死鎖的目的。
  • ②避免死鎖:在資源配置設定的過程中,盡量保證資源請求的順序性,防止推進順序不當引起死鎖問題産生。
  • ③檢測死鎖:允許系統在運作過程中發生死鎖情況,但可設定檢測機制及時檢測死鎖的發生,并采取适當措施加以清除。
  • ④解除死鎖:當檢測出死鎖後,便采取适當措施将程序從死鎖狀态中解脫出來。

3.4.1、預防死鎖

前面提過,預防死鎖的手段是通過破壞死鎖産生的四個必要條件中的一個或多個,以此達到預防死鎖的目的。

破壞“互斥”條件

在程式中将所有“互斥”的邏輯移除,如果一個資源不能被獨占使用時,那麼死鎖情況必然不會發生。但一般來說在所列的四個條件中,“互斥”條件是不能破壞的,因為程式設計中必須要考慮線程安全問題,是以“互斥”條件是必需的。是以,在死鎖預防裡主要是破壞其他幾個必要條件,不會去破壞“互斥”條件。

破壞“不可剝奪”條件

破壞“不可剝奪性”條件的含義是指取消資源獨占性,一個執行實體擷取到的資源可以被别的實體或系統強制剝奪,在程式中可以這樣設計:

  • ①如果占用資源的實體下一步資源請求失敗,那麼則釋放掉之前擷取到的所有資源,後續再重新請求這些資源和另外的資源(和分布式事務的概念有些類似)。
  • ②如果一個實體需要請求的資源已經被另一個實體持有,那麼則由程式或系統将該資源釋放,然後讓給目前實體擷取執行。這種方式在Java中也有實作,就是設定線程的優先級,優先級高的線程是可以搶占優先級低的資源先執行的。

破壞“請求與保持”條件

破壞“請求與保持”條件的意思是:系統或程式中不允許出現一個執行實體在擷取到資源的情況下再去申請其他資源,主要有兩種方案:

  • ①一次性配置設定方案:對于執行實體所需的資源,系統或程式要麼一次性全部給它,要麼什麼都不給。
  • ②要求每個執行實體提出新的資源申請前,釋放它所占有的資源。
但總歸來說,這種情況也比較難滿足,因為程式中難免會有些情況下要占用多個資源後才能一起操作,就比如最簡單的資料庫寫入操作,在Java程式這邊需要先擷取到鎖資源後才能通過連接配接對象進行操作,但擷取到的連接配接對象在往DB表中寫入資料的時候還需要再和DB中其他連接配接一起競争DB那邊的鎖資源方可真正寫表。

破壞“環狀等待鍊”條件

破壞“環狀等待鍊”條件實際上就是要求控制資源的請求順序性,防止請求順序不當導緻的環狀等待鍊閉環出現。

這個點主要是在編碼的時候要注意,對于一些鎖資源的擷取、連接配接池、RPC調用、MQ消費等邏輯,盡量保證資源請求順序合理,避免由于順序性不當引起死鎖問題出現。

預防死鎖小結

因為預防死鎖的政策需要實作會太過苛刻,是以如果真正的在程式設計時考慮這些方面,可能會導緻系統資源使用率下降,也可能會導緻系統/程式整體吞吐量降低。

總的來說,預防死鎖隻需要在系統設計、程序排程、線程排程、業務編碼等方面刻意關注一下:如何讓死鎖的四個必要條件不成立即可。

3.4.2、避免死鎖

避免死鎖是指系統或程式對于每個能滿足的執行實體的資源請求進行動态檢查,并且根據檢查結果決定是否配置設定資源,如果配置設定後系統可能發生死鎖,則不予配置設定,反之則給予資源配置設定,這是一種保證系統不進入死鎖狀态的動态政策。

避免死鎖的常用算法

  • ①有序資源配置設定法:這種方式大多數被作業系統應用于程序資源配置設定。假設此時有兩個程序P1、P2,程序P1需要請求資源順序為R1、R2,而程序P2使用資源的順序則為R2、R1。如果這個情況下兩個程序并發執行,采用動态配置設定法的情況下是有一定幾率發生死鎖的,是以可以采用有序資源配置設定法,把資源配置設定的順序改為如下情況,進而做到破壞環路條件,避免死鎖發生。 P1:R1,R2 P2:R1,R2
  • ②銀行家算法:銀行家算法顧名思義是來源于銀行的借貸業務,有限的本金要應多個客戶的借貸周轉,為了防止銀行家資金無法周轉而倒閉,對每一筆貸款,必須考察其借貸者是否能按期歸還。在作業系統中研究資源配置設定政策時也有類似問題,系統中有限的資源要供多個程序使用,必須保證得到的資源的程序能在有限的時間内歸還資源,以供其他程序使用資源,確定整個作業系統能夠正常運轉。如果資源配置設定不得到就會發生程序之間環狀等待資源,則程序都無法繼續執行下去,最終造成死鎖現象。 OS實作:把一個程序需要的、已占有的資源情況記錄在程序控制塊中,假定程序控制塊PCB其中“狀态”有就緒态、等待态和完成态。當程序在處于等待态時,表示系統不能滿足該程序目前的資源申請。“資源需求總量”表示程序在整個執行過程中總共要申請的資源量。顯然,每個程序的資源需求總量不能超過系統擁有的資源總數,通過銀行家算法進行資源配置設定可以避免死鎖。

上述的兩種算法更多情況下是作業系統層面對程序級别的資源配置設定算法,而在程式開發中又該如何編碼才能盡量避免死鎖呢?大概有如下兩種方式:

  • ①順序加鎖
  • ②逾時加鎖

對于上述中的兩種方式從字面意思就可以了解出:前者是保證鎖資源的請求順序性,防止請求順序不當引起資源互相等待,最終造成死鎖發生。而後者則是擷取鎖逾時中斷的意思,在JDK級别的鎖,如ReetrantLock、Redisson等,都支援該方式,也就是在指定時間内未擷取到鎖資源則放棄擷取鎖資源。

3.4.3、檢測死鎖

檢測死鎖這塊也分為兩個方向來談,也就是分别從程序和線程兩個角度出發。程序級别來說,作業系統在設計的時候就考慮到了程序并行執行的情況,是以有專門設計死鎖的檢測機制,該機制能夠檢測到死鎖發生的位置和原因,如果檢測到死鎖時會暴力破壞死鎖條件,進而使得并發程序從死鎖狀态中恢複。

而對于Java程式員而言,如果線上上程式運作中發生了死鎖又該如何排查檢測呢?我們接着來進行詳細分析。

Java線上排查死鎖問題實戰

先借用前面3.3階段的DeadLock死鎖案例代碼,操作如下:

D:\> javac -encoding utf-8 DeadLock.java
D:\> java DeadLock
線程:T1持有o1....
線程:T2持有o2....
線程:T2等待o1....
線程:T1等待o2....
複制代碼           

在前面3.3案例中,實際上T1永遠擷取不到o1,而T2永遠也擷取不到o2,是以此時發生了死鎖情況。那假設如果線上上我們并不清楚死鎖是發生在那處代碼呢?其實可以通過多種方式定位問題:

  • ①通過jps+jstack工具排查。
  • ②通過jconsole工具排查。
  • ③通過jvisualvm工具排查。
  • PS:當然你也可以通過其他一些第三方工具排查問題,但前面兩種都是JDK自帶的工具。
先來看看jps+jstack的方式,此時保持原先的cmd/shell視窗不關閉,再新開一個視窗,輸入jps指令:
D:\> jps
19552 Jps
2892 DeadLock
複制代碼           

jps是JDK安裝位置bin目錄下自帶的工具,其作用是顯示目前系統的Java程序情況及其程序ID,可以從上述結果中看出:ID為2892的程序是剛剛前面産生死鎖的Java程式,此時我們可以拿着這個ID再通過jstack工具檢視該程序的dump日志,如下:

D:\> jstack -l 2892
複制代碼           

顯示結果如下:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

可以從dump日志中明顯看出,jstack工具從該程序中檢測到了一個死鎖問題,是由線程名為T1、T2的線程引起的,而死鎖問題的誘發原因可能是DeadLock.java:41、DeadLock.java:25行代碼引起的。而到這一步之後其實就已經确定了死鎖發生的位置,我們就可以跟進代碼繼續去排查程式中的問題,優化代碼之後就可以確定死鎖不再發生。

再來看看jconsole的方式,首先按win+r調出運作視窗,然後輸入JConsole指令,緊接着會得到一個如下界面:
深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

然後緊接着可以輕按兩下本地程序中PID為2892的Java程式,進入之後選擇導航欄中的線程選項,如下:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

最後再點選底部的“檢測死鎖”的選項即可,最終就能非常友善快捷的檢測到程式中的死鎖情況,如下:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

通過JConsole這個工具能夠更加友善的檢測死鎖問題,并且還帶有可視化的圖形界面,相對比之前的jps+jstack方式來說,更加友好。

再來看看jvisualvm工具的方式,同樣的在開一個指令行視窗,然後在其内輸入:jvisualvm,如下:
D:\> jvisualvm
複制代碼           

然後同樣的可以得到一個可視化的圖像界面:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

然後可以在左側本地的DeadLock程序上右鍵→選擇“打開”,最終可以得到如下界面:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

從界面中的提示可以明确看出:目前Java程序中檢測到了死鎖,發生死鎖的線程為T1、T2,然後點選右側的“線程Dump”按鈕,同樣可以檢視具體跟蹤日志,如下:

深入并發線程、程式、纖程、協程、管程與死鎖、活鎖、鎖饑餓詳解

從線程Dump日志中可以清晰看見定位到的死鎖相關資訊,以及死鎖發生的位置等。

3.4.4、解除死鎖

當排查到死鎖的具體發生原因和發生位置之後,就應立即釆取對應的措施解除死鎖,避免長時間的資源占用導緻最終拖垮程式或系統。

而一般作業系統處理程序級别的死鎖問題主要用三種方式:

  • ①資源剝奪法。挂起某些死鎖程序,并剝奪它的資源,将這些資源配置設定給其他的死鎖程序。但應當合理處置被挂起的程序,防止程序長時間挂起而得不到資源,一直處于資源匮乏的狀态。
  • ②撤銷程序法。強制撤銷部分、甚至全部死鎖程序并剝奪這些程序的資源。撤銷的原則可以按程序優先級、程序重要性和撤銷程序代價的高低進行。
  • ③程序回退法。讓一個或多個程序回退到足以避免死鎖發生的位置,程序回退時自己釋放資源而不是被剝奪。要求系統保持程序的曆史資訊,設定還原點。

當然,這些對于非底層開發程式員而言不必太過關注,重點我們還是放線上程級别的死鎖問題解決上面,比如經過上一個階段之後,我們已經成功定位死鎖發生位置又該如何處理死鎖問題呢?一般而言在Java程式中隻能修改代碼後重新上線程式,因為大部分的死鎖都是由于代碼編寫不當導緻的,是以将代碼改善後重新部署即可。

其實在資料庫中是這樣處理死鎖問題的,資料庫系統中考慮了檢測死鎖和從死鎖中恢複。當DB檢測到死鎖時,将會選擇一個線程(用戶端那邊的連接配接對象)犧牲者并放棄這個事務,作為犧牲者的事務會放棄它占用的所有資源,進而使其他事務繼續執行,最終當其他死鎖線程執行完畢後,再重新執行被強制終止的事務。

而你的項目如果在短時間内也不能重新開機,那麼隻能寫一個與DB類似的死鎖檢測器+處理器,然後通過自定義一個類加載器将該類動态加載到JVM中(需提前設計),然後在運作時通過你編寫的死鎖處理機制,強制性的掐斷死鎖問題。

但對于這種方式我并不太建議使用,因為強制掐斷線程執行,可能會導緻業務出現問題,是以對于Java程式的死鎖問題解決,更多的還是需要從根源:代碼上着手解決,因為隻有當代碼正确了才能根治死鎖問題。

四、總結

本篇重點是對于之前篇章中未提及的一些概念和問題做個補充,主要叙述了一些如今出現的新概念,以及對于一些并發執行時會出現的其他問題進行了分析

繼續閱讀