Java線程-死鎖(十)

一、死鎖概述

關(guān)于死鎖,我們可以從哲學(xué)家用餐問(wèn)題說(shuō)起(該例子來(lái)自《Java并發(fā)編程實(shí)戰(zhàn)》)。

??話說(shuō)5個(gè)哲學(xué)家去用餐,坐在一張圓桌旁,他們總共有5根筷子(而不是5雙),并且每?jī)蓚€(gè)人中間放一支筷子,哲學(xué)家時(shí)而思考,時(shí)而進(jìn)餐,每個(gè)人都需要一雙筷子才能吃到東西,并且吃完后將筷子放回原處繼續(xù)思考。

??有些筷子管理算法能使每個(gè)人都能相對(duì)及時(shí)的吃到東西,但有些算法卻可能導(dǎo)致一些或者所有哲學(xué)家都餓死的情況,比如每個(gè)人都立即抓住自己左邊 筷子,然后等待自己右邊的筷子空出來(lái),但同時(shí)又不放下已經(jīng)拿到的筷子,這種情況下就會(huì)出現(xiàn)所謂的死鎖。

??也就是說(shuō)每個(gè)人都擁有其他人需要的資源,同時(shí)又等待其他人已經(jīng)擁有的資源,并且每個(gè)人在獲得所有需要的資源之前都不會(huì)放棄已經(jīng)擁有的資源。

當(dāng)線程 A 持有鎖 L 并想獲得鎖 M 的同時(shí),線程 B 持有鎖 M 并嘗試獲得鎖 L,那么這兩個(gè)線程將永遠(yuǎn)的等待下去,這種就是最簡(jiǎn)單的死鎖情況。與該例子相似的其實(shí)還有銀行家算法,有興趣的可以看一下。

可以看到,如果把每個(gè)線程都想象為有向圖中的一個(gè)節(jié)點(diǎn),途中每條邊表示的關(guān)系是:線程A等待線程B鎖占有的資源,如果在途中形成了一個(gè)環(huán),那么這種情況就是死鎖了。

??線程發(fā)生死鎖時(shí),線程之間相互等待,但又不釋放自身的資源,導(dǎo)致陷入一種死循環(huán)的等待過(guò)程中,但一般產(chǎn)生死鎖的情況不會(huì)立即展示出來(lái),也就是說(shuō),如果一個(gè)類可能發(fā)生死鎖,那么并不意味著每次都會(huì)發(fā)生死鎖,而只是表示有可能發(fā)生。

二、避免死鎖

針對(duì)死鎖發(fā)生的情況,我們可以從以下幾個(gè)方面進(jìn)行著手,避免死鎖的發(fā)生。

1. 加鎖的順序

在多線程操作進(jìn)行加鎖的時(shí)候,加鎖的順序是一個(gè)很重要的點(diǎn),如果是按照不同的順序進(jìn)行加鎖,那么死鎖就很容易發(fā)生。我們拿《并發(fā)編程實(shí)戰(zhàn)》中的一個(gè)例子來(lái)說(shuō)明:

線程1執(zhí)行l(wèi)eftRight方法:
  lock left,嘗試lock right,然后永久等待

線程2執(zhí)行rightLeft方法:
  lock right,嘗試lock left,然后永久等待

在這個(gè)簡(jiǎn)單的例子中,兩個(gè)線程試圖以不同的順序來(lái)獲得相同的鎖,如果這兩個(gè)線程的操作是交錯(cuò)執(zhí)行,那么就會(huì)發(fā)生死鎖。而如果按照相同的順序來(lái)請(qǐng)求鎖(比如都先鎖left,再鎖right),那么就不會(huì)出現(xiàn)循環(huán)的加鎖依賴,因此也就不會(huì)產(chǎn)生死鎖了。

如果所有線程以固定的順序來(lái)獲得鎖,那么在程序中就不會(huì)出現(xiàn)鎖順序的死鎖問(wèn)題。

書中還提供了一個(gè)簡(jiǎn)單的代碼示范:

public class DeadLockTest {
    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRight() {
        synchronized (left) {
            synchronized (right) {
                doSomething();
            }
        }
    }

    public void rightLeft() {
        synchronized (right) {
            synchronized (left) {
                doSomethingElse();
            }
        }
    }
}

按照順序加鎖是一種有效的死鎖預(yù)防機(jī)制,但是,這種方式需要你事先知道所有可能會(huì)用到的鎖,但總有些時(shí)候鎖的順序是無(wú)法預(yù)知的。

2. 使用定時(shí)鎖

??也就是說(shuō)使用鎖的時(shí)候,使用支持超時(shí)時(shí)間的鎖來(lái)代替內(nèi)置鎖,比如Lock類中的tryLock功能。在之前,在使用內(nèi)置鎖的時(shí)候,只要沒有獲取到鎖,那么就會(huì)永遠(yuǎn)等待下去,而顯式鎖可以指定一個(gè)超時(shí)時(shí)間(Timeout),在過(guò)了超時(shí)時(shí)間之后,返回相應(yīng)的錯(cuò)誤信息。然后我們可以進(jìn)行后續(xù)操作,比如過(guò)一段時(shí)間再次嘗試,或者輪詢tryLock等,這樣就避免了死鎖發(fā)生的可能性。

當(dāng)有非常多的線程同一時(shí)間去競(jìng)爭(zhēng)資源時(shí),即便有超時(shí)機(jī)制,但有可能會(huì)導(dǎo)致其他的問(wèn)題,比如這些線程重復(fù)地嘗試但卻始終得不到鎖,因?yàn)楫?dāng)線程很多時(shí),其中兩個(gè)或多個(gè)線程的超時(shí)時(shí)間一樣或者接近的可能性就會(huì)很大,因此就算出現(xiàn)競(jìng)爭(zhēng)而導(dǎo)致超時(shí)后,由于超時(shí)時(shí)間一樣,它們又會(huì)同時(shí)開始重試,導(dǎo)致新一輪的競(jìng)爭(zhēng)。

所以說(shuō)使用定時(shí)鎖的時(shí)候需要考慮一下并發(fā)的線程數(shù)量會(huì)不會(huì)很多。

3. 死鎖檢測(cè)

??雖然我們可以在程序中通過(guò)定義順序鎖或者定時(shí)鎖這種方式來(lái)避免死鎖的發(fā)生,但總有一些我們控制不了的加鎖順序,或者鎖超時(shí)處理不了的場(chǎng)景,這種情況下,JVM提是提供了線程轉(zhuǎn)儲(chǔ)(Thread Dump)來(lái)幫助識(shí)別死鎖的發(fā)生。

  1. Thread Dump包括了各個(gè)運(yùn)行中的線程的棧追蹤信息,這類似于發(fā)生異常時(shí)的棧追蹤信息,還包含了加鎖信息,比如說(shuō)每個(gè)線程持有了哪些鎖。
  2. 查看Thread Dump的方式有很多種,比如通過(guò)ps然后結(jié)合kill命令來(lái)查看,也可以通過(guò)JDK自帶的工具,比如jps配合jstack,使用JConsole工具,使用JVisualVM工具等。有關(guān)獲取Thread Dump這里就不多說(shuō)了,等后續(xù)有時(shí)間,專門測(cè)試一下。

三、線程饑餓

有的時(shí)候,我們還能看到一個(gè)概念:饑餓。線程饑餓和線程死鎖稍微有些不同,如果一個(gè)線程一直訪問(wèn)不到它所需要的資源就會(huì)發(fā)生饑餓(Starvation),發(fā)生饑餓最常見的有下面幾種情況:

  1. 線程的優(yōu)先級(jí)使用不當(dāng),如果線程高的優(yōu)先級(jí)一直占用優(yōu)先級(jí)低的線程的資源,那么就會(huì)導(dǎo)致低優(yōu)先級(jí)的線程一直獲取不到資源,這時(shí)候就會(huì)發(fā)生饑餓;比如低優(yōu)先級(jí)線程A,高優(yōu)先級(jí)線程B,C,D,線程B先獲取CPU時(shí)間占用資源,然后釋放后,線程C接著占用資源,周而往復(fù),低優(yōu)先級(jí)線程A就一直獲取不到資源導(dǎo)致饑餓;
  2. 一個(gè)線程一直占用某個(gè)資源不釋放,那么其他線程就會(huì)一直等待得不到執(zhí)行,這時(shí)候也會(huì)發(fā)生饑餓;

??Java中的讀寫鎖ReentranctReadWriteLock就有可能發(fā)生饑餓,比如說(shuō)某一個(gè)資源讀操作優(yōu)先級(jí)較高,那么等待寫操作的線程就會(huì)一直阻塞,也就是發(fā)生了饑餓。但與死鎖不太同的是,饑餓的線程可以在一段時(shí)間之后接著執(zhí)行,比如說(shuō)占用資源的線程釋放了資源等。

了解了什么時(shí)候會(huì)發(fā)生饑餓的情況,我們就可以來(lái)簡(jiǎn)單說(shuō)下怎么盡量避免饑餓的發(fā)生:

  1. 我們要盡量避免修改線程的優(yōu)先級(jí),線程優(yōu)先級(jí)只是作為線程調(diào)度的參考,Thread中定義了10個(gè)優(yōu)先級(jí),JVM根據(jù)需要將他們映射到操作系統(tǒng)的調(diào)度優(yōu)先級(jí),這種映射是與特定操作系統(tǒng)平臺(tái)相關(guān)的,因此在某個(gè)操作系統(tǒng)中兩個(gè)不同的Java優(yōu)先級(jí)可能被映射到同一個(gè)優(yōu)先級(jí),而在另一個(gè)操作系統(tǒng)中有可能相反。所以我們盡量不要修改線程的優(yōu)先級(jí),因?yàn)橹灰淖兞司€程的優(yōu)先級(jí),程序的性為就將與平臺(tái)有關(guān),并且會(huì)導(dǎo)致發(fā)生饑餓的風(fēng)險(xiǎn);
  2. 線程執(zhí)行完之后,記得釋放資源,特別是Lock,正常來(lái)說(shuō),我們都是將unlock操作放到finally塊中執(zhí)行,這樣就不至于出現(xiàn)在異常的時(shí)候資源無(wú)法正常釋放的情況;
  3. 這一點(diǎn)就比較明了了,不要在鎖中出現(xiàn)死循環(huán)的情況,當(dāng)前,無(wú)論是否有鎖都不應(yīng)該有無(wú)限循環(huán)的情況出現(xiàn)。

四、活鎖

??還有一種情況被稱為活鎖(Livelock),活鎖不會(huì)阻塞線程,但也不能繼續(xù)執(zhí)行,因?yàn)榫€程將不斷重復(fù)執(zhí)行相同的操作,而且總會(huì)失敗?;铈i其實(shí)和死鎖類似:

  1. 活鎖通常發(fā)生在處理事務(wù)消息的應(yīng)用程序中,比如說(shuō)消息處理機(jī)制中,異常情況下回滾整個(gè)事務(wù),并將消息重新放到隊(duì)列的開頭,然后監(jiān)聽器監(jiān)聽到該消息,然后再執(zhí)行,再失敗,再回滾。雖然處理消息的線程并沒有阻塞,但也無(wú)法執(zhí)行下去,這種就是所謂的活鎖,這種形式的活鎖通常是由于過(guò)度的錯(cuò)誤恢復(fù)代碼導(dǎo)致的,因?yàn)樗e(cuò)誤的將不可修復(fù)的錯(cuò)誤作為可以修復(fù)的錯(cuò)誤。
  2. 另一種形式的活鎖是多個(gè)線程對(duì)彼此進(jìn)行互相謙讓,都主動(dòng)將資源讓給別的線程使用,這樣該資源在多個(gè)線程之間來(lái)回跳轉(zhuǎn)而又得不到執(zhí)行,就發(fā)生了活鎖,這就像兩個(gè)有禮貌的人在半路上面對(duì)面的遇到,然后他們彼此都給對(duì)方讓路,一直反復(fù)地避讓下去。在JDK中表示的話有點(diǎn)類似于線程A中調(diào)用線程B的join方法,線程B中調(diào)用線程A的join方法;

而要避免活鎖問(wèn)題,可以從以下幾個(gè)方法入手:

  1. 設(shè)置重試次數(shù)的最大值,這樣的話如果一直發(fā)送失敗,我們就可以停止發(fā)送消息,然后記錄下來(lái);
  2. 在重試機(jī)制中引入隨機(jī)性,就說(shuō)上面那兩個(gè)相互讓路的人,我們可以修改兩個(gè)讓路人的時(shí)序來(lái)解除活鎖,比如說(shuō)你讓1秒,我讓2秒;
  3. 從事務(wù)中的回滾操作入手,比如說(shuō)不回滾,如果失敗和上面類似,記錄異常信息,然后使用定時(shí)任務(wù)或異步方式對(duì)表中記錄進(jìn)行處理;
  4. 最后一點(diǎn)也比較簡(jiǎn)單,也就是程序中盡量不要出現(xiàn)兩個(gè)線程間相互謙讓的情況,不過(guò)這種情況也不多見。

五、總結(jié)

線程死鎖,活鎖,饑餓都被稱為線程的活躍性問(wèn)題。線程的活躍性和線程的安全性有些不同:

  • 線程安全性是說(shuō)線程運(yùn)行出的結(jié)果和預(yù)期的結(jié)果不一致,發(fā)生了一些糟糕的事情,要保證線程的安全性,就是要保證線程同步,我們可以使用加鎖機(jī)制來(lái)實(shí)現(xiàn);
  • 而線程的活躍性是指在線程執(zhí)行過(guò)程中,某個(gè)操作無(wú)法繼續(xù)執(zhí)行,這時(shí)候就發(fā)生了活躍性問(wèn)題?;蛘哒f(shuō)并發(fā)應(yīng)用程序能及時(shí)執(zhí)行的能力稱為活躍性(A concurrent application’s ability to execute in a timely manner is known as its liveness.)

而針對(duì)活躍性的這幾種常見情況,我們可以簡(jiǎn)單總結(jié)下:

  1. 死鎖是說(shuō)多個(gè)線程同時(shí)請(qǐng)求對(duì)方占用的資源,但都不釋放自身的資源的情況;
  2. 活鎖是線程不會(huì)發(fā)生阻塞,但也執(zhí)行不了的情況;
  3. 饑餓是線程一直等待其他線程占有的資源,但一直訪問(wèn)不到的情況。

本文主要參考自:
《Java并發(fā)編程實(shí)戰(zhàn)》及并發(fā)編程網(wǎng)的部分文章
Chinaunix.net-C/C++-有關(guān)活鎖的疑問(wèn)
還有Oracle的官方文檔:docs.oracle.com/concurrency/deadlock.html

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容