從零開始學多執行緒之死鎖(八)
死鎖
每個人手裡都有其他人需要的資源,自己又不會放下手上的資源,這麼一直等待下去,就會發生死鎖.
當一個執行緒永遠佔有一個鎖,而其他執行緒嘗試去獲得這個鎖,那麼它們將永遠被阻塞.
當執行緒A佔有鎖L時,想要獲得鎖M,同時執行緒B持有M,並嘗試得到L,兩個執行緒將永遠等待下去,這種情況是死鎖最簡單的形式(或稱致命的擁抱,deadly embrace)
資料庫不會發生死鎖的情況,它會選擇一個犧牲者,強行釋放鎖,讓程式可以繼續執行下去.
JVM不行,只能重啟程式.
死鎖並不會每次都出現
死鎖很少能立即發現.一個類如果有發生死鎖的潛在可能並不意味著每次都將發生,它只發生在該發生的時候.
當死鎖出現的時候,往往是遇到了最不幸的時候--- 在高負載下.
鎖順序死鎖
public class LeftRightDeadLock { private Object leftLock = new Object(); private Object rightLock = new Object(); public void getLeftLock(){ synchronized (this.rightLock){ synchronized (this.leftLock){ //do something } } } public void getRightLock(){ synchronized (this.leftLock){ synchronized (this.rightLock){ //do something. } } } }
兩個執行緒分別進入getRightLock和getLeftLock方法,同時獲得第一個鎖,在等待下一個鎖的時候,就會發生鎖順序死鎖.
發生死鎖的原因: 兩個執行緒試圖通過不同的順序獲得多個相同的鎖.
如果請求的順序相同就不會出現迴圈的鎖依賴現象,就不會產生死鎖了.
如果所有執行緒以通用的固定秩序獲得鎖,程式就不會出現鎖順序死鎖問題了.
動態的鎖順序死鎖
public class DynamicDeadLock { public void transferMoney(Account fromAcount,Account toAccount){ synchronized (fromAcount){ synchronized (toAccount){ //轉賬操作 } } } }
當兩個執行緒同時呼叫transferMoney,一個從X向Y轉賬,另一個從Y向X轉賬,那就會發生死鎖.
transferMoney(myAccount,yourAccount)
transferMoney(yourAccount,myAccount)
之前說了,造成死鎖的原因就是以不同的順序獲得相同的鎖.
那麼要解決這個問題,我就就必須制定鎖的順序.
System.indentityHashCode(傳入物件)方法可以得到物件的雜湊碼.我們通過雜湊碼來決定鎖的順序.
public class DynamicDeadLock {
private Object obj = new Object();
public void transferMoney(Account fromAcount,Account toAccount){
//這個內部類秒啊,可以減少重複程式碼
class Helper {
public void transferMoney(){
//真正的轉賬操作..
//假裝使用 外部的兩個引數 fromAcount和toAccount做一下操作..
}
}
//制定鎖的順序
int fromHash = System.identityHashCode(fromAcount);
int toHash = System.identityHashCode(toAccount);
if(fromHash<toHash){
synchronized (fromAcount){
synchronized (toAccount){
new Helper().transferMoney();
}
}
}else if(fromHash>toHash){
synchronized (toAccount){
synchronized (fromAcount){
new Helper().transferMoney();
}
}
}else{
//使用成員變數的鎖
synchronized (obj){
synchronized (fromAcount){
synchronized (toAccount){
new Helper().transferMoney();
}
}
}
}
}
}
雖然有點麻煩,但是減少了發生死鎖的可能性.
注意上面程式碼的最後一種else的情況,使用了一個額外的obj的鎖,這是因為極少數的情況下會出現hashcode相同的情況,當hashCode相同的時候,使用之前的兩種順序鎖,兩個執行緒同時呼叫兩個方法,引數換位,顛倒順序計算雜湊值,就又有了出現死鎖的可能,所以引入第三種鎖來保證鎖的順序,從而減少死鎖發生的可能性.
如果經常出現hash值衝突,那麼併發性會降低(因為多加了一個鎖),但是因為 System.identityHashCode的雜湊衝突出現頻率很低,所以這個技術以最小的代價,換來了最大的安全性.
如果Account具有一個唯一的,不可變的,並且具有可比性的key,比如賬號,那麼就可以通過賬號來排定物件順序,這樣就能省去obj的鎖了.
協作物件間的死鎖
public class A {
private final B b ;
public A(B b) {
this.b = b;
}
public synchronized void methodA(){
//do something.
//呼叫B的同步的方法
b.methodB();
}
}
public class B {
private final A a;
public B(A a) {
this.a = a;
}
public synchronized void methodB(){
//do something
//呼叫A的同步的方法
a.methodA();
}
}
在持有鎖的時候呼叫外部方法是在挑戰活躍度問題,外部方法可能會獲得其他鎖(產生死鎖的風險),或者遭遇嚴重超時的阻塞,當你持有鎖的時候會延遲其他試圖獲得該鎖的執行緒
開放呼叫
在持有鎖的時候呼叫一個外部方法很難進行分析,因此是危險的.
當呼叫的方法不需要持有鎖時,這被稱為開放呼叫(open call). 依賴於開放呼叫的類更容易與其他的類合作.
使用開放呼叫來避免死鎖類似於使用封裝來提供執行緒安全:對一個有效封裝的類進行執行緒安全分析,要比分析沒有封裝的類容易得多.
類似地,分析一個完全依賴於開放呼叫的程式的程式活躍度,比分析哪些非開放呼叫的程式更簡單.
儘量讓你自己使用開放呼叫,這比獲得多重鎖後識別程式碼路徑更簡單,因為可以確保使用一致的順序獲得鎖.
不使用synchronized修飾方法,減少synchronized包住的程式碼塊,來避免協作物件間的死鎖.
public class A {
private final B b;
public A(B b) {
this.b = b;
}
public void methodA() {
//關鍵在這
synchronized (this) {
//do something.
}
//呼叫B的同步的方法
b.methodB();
}
}
除了能避免死鎖以外,因為同步的程式碼塊變小,所以使得響應速度得到提高.
在程式中儘量使用開放呼叫.依賴於開放呼叫的程式,相比於那些在持有鎖的時候還呼叫外部方法的程式,更容易進行死鎖自由度(deadlock-freedom)的分析.
在同步方法之間互相呼叫的時候,儘量使用開放呼叫來避免死鎖.
避免和診斷死鎖
使用定時的鎖
使用顯示的Lock類中定時tryLock方法來替代synchronized,可以設定超時時間,超時會失敗,這樣避免了死鎖.
其他的活躍度失敗.
除了死鎖,還有一些其他的活躍度危險:
- 飢餓
- 丟失訊號
- 活鎖
飢餓
當執行緒訪問它所需要的資源時卻被永久拒絕,以至於不能再繼續進行,這樣就發生了飢餓(starvation).
引發飢餓的情況:
- 使用執行緒的優先順序不當
- 在鎖中執行無終止的構建(無限迴圈,或者無盡等待資源).
歸根結底是因為執行緒不能再執行.
執行緒優先順序並不是方便的工具,它改變執行緒優先順序的效果往往不明顯;提高一個執行緒的優先順序往往什麼都不能改變,或者總是會引起一個執行緒的排程優先高於其他執行緒,從而導致飢餓.
抵制使用執行緒優先順序的誘惑,因為這會增加平臺依賴性,並且可能引起活躍度問題.大多數併發應用程式可以對所有執行緒使用相同的優先順序.
弱響應性
當計算密集型後臺計算任務影響到響應性時,這種情況下可以使用執行緒優先順序.降低執行後臺任務的執行緒的優先順序,從而提高程式的響應性.
活鎖
活鎖(livelock)是執行緒活躍度失敗的另一種形式,儘管沒有被阻塞,執行緒缺仍然不能繼續,因為他不斷重試相同的操作,缺總是失敗.
例如程式處理一段程式碼出錯了,業務邏輯使它回退重複執行,然後有錯了,再回退重新執行,如此反覆.這就是活鎖.
這種形式的活躍通常來源於過渡的錯誤恢復程式碼,誤將不可修復的錯誤當做是可修復的錯誤.
還有另一個例子: 多個相互協作的執行緒間,他們為了彼此響應而修改了狀態,使得沒有一個執行緒能夠繼續前進,那麼就發生了活鎖.
就好比兩個有禮貌的人在路上相遇,他們給對方讓路,於是在另一條路又遇上了,如此反覆...
在併發程式中,通過隨機等待和撤回來進行重試能夠相當有效地避免活鎖的發生.
總結:
活躍度失敗是非常嚴重的問題,因為除了中止應用程式,沒有任何機制可以恢復這種失敗.
最常見的活躍度失敗是死鎖.應該在設計時就避免鎖順序死鎖:確保多個執行緒在獲得多個鎖時,使用一致的順序.
最好的解決方法是在程式中使用開放呼叫,這會大大減少一個執行緒一次請求多個鎖的情況.
下篇會更新提高響應速度的方式.