以前說鎖和被保護的資源是1:N的關係,這些被保護的資源有可能是彼此沒關聯的,也有可能彼此關聯。那麼有什麼不同呢?死鎖到底是怎麼產生的?死鎖該如何規避?
保護沒有關聯關係的多個資源
由於這些資源彼此沒關係,我們可以把他們全都塞進this這把鎖裏一了百了,我們也可以給不同的資源加不同的鎖分而治之。總的來說並行分而治之的性能肯定要比串行的同一把鎖好的多。分而治之的這種鎖也叫細粒度鎖
保護有關聯關係的多個資源
有關聯的資源也可以像沒關聯的資源那樣加鎖嗎?
下面我們舉個栗子:
1.有A、B、C三個銀行賬戶,賬戶餘額都爲200
2.A給B轉賬100,B給C轉賬100
如果我們不加鎖:
class Account {
private int balance;
// 轉賬
void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
這樣的話就不存在互斥,不存在原子性操作,會有併發問題產生。
那麼我們就加鎖,首先將所有資源都塞到一個鎖裏面去試試:
class Account {
private int balance;
// 轉賬
synchronized void transfer(
Account target, int amt){
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
兩個相關聯的資源都在this這把鎖的臨界區,但是我們發現this這把鎖只能保護自己的this.balance 無法保護別人的target.balance。我們分析一下:
我們的初衷是要完成上述要求2的效果,轉賬後A、B、C的餘額分別是100 200 300,我們期望是這樣。
但事實上假設線程1執行A轉賬B的操作,線程2執行B轉賬C的操作,恰好這兩個線程都在不同的CPU上執行,那麼這兩個操作就不是互斥的。也就會導致1,2兩個線程同時進入臨界區,他們讀到的B的餘額都爲200,這樣線程1操作的結果是B的餘額是300,線程2操作的結果會是B的賬戶餘額是100,就看這兩個哪個先寫入內存,100 200 300這三種結果都會有。
既然這樣不行我們就想辦法讓兩個賬戶的操作都是具有唯一性,最好的方法是:
class Account {
private int balance;
// 轉賬
void transfer(Account target, int amt){
synchronized(Account.class) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
類鎖的好處就是臨界區的操作絕對是唯一的。這樣就解決了問題。但是我們之前提到細粒度的鎖性能會更好一點,想象一下如果有上億個賬戶都這樣傻瓜式的順序執行,那該有多慢。事實賬戶A轉賬賬戶B,賬戶C轉賬賬戶D是不存在互斥的,是可以同時進行的。如何實現並行操作呢?
class Account {
private int balance;
// 轉賬
void transfer(Account target, int amt){
// 鎖定轉出賬戶
synchronized(this) {
// 鎖定轉入賬戶
synchronized(target) {
if (this.balance > amt) {
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
這樣的話對象this的鎖保護了this.balance ,target對象的鎖保護了target.balance細粒度化了兩個操作實現了可並行操作。但是看似美好的事物背後都藏着陷阱,佔小便宜喫大虧,這是經常會發生的事情,編程也一樣。
就拿上面這個例子來看,假如A要給B轉賬,B恰好也要給A轉賬,沒毛病,可以並行。但是問題就出在,他們都把彼此的資源鎖了。A等B給它餘額信息,同時B也在等A給他餘額信息。然後就是互不讓步,就是瞎等。這樣就形成了一個無解的閉環,很壞的關係:死鎖。
如何防止死鎖呢?
最簡單粗暴的解決方法就是重啓,但這樣治標不治本,遇到類似情況一樣白瞎。所以說最好的辦法是規避它,在設計之初就考慮到它的存在。
如何解決死鎖問題,我們站在巨人的肩頭,所以巨人們已有定論。有個叫 Coffman 的牛人早就總結過了死鎖發生的條件:
1.互斥,共享資源 X 和 Y 只能被一個線程佔用;
2.佔有且等待,線程 T1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X;
3.不可搶佔,其他線程不能強行搶佔線程 T1 佔有的資源;
4.循環等待,線程 T1 等待線程 T2 佔有的資源,線程 T2 等待線程 T1 佔有的資源,就是循環等待。
如何解決,總結一句話就是破而後立,僵持中總有人要讓步,破壞形成死鎖的條件。
之前說過,要保持原子性操作,就要互斥。所以條件1,我們是沒辦法破壞的。那麼剩下的解決辦法就有:
1. 破壞佔用且等待條件
一個線程一次性申請所有的資源。上面的例子,如果我們一次性申請到A B兩個賬戶的餘額信息,交給一個管理員T管理申請,如果A和B的賬戶餘額信息缺一個都表示申請失敗。那麼A轉賬B 和B轉賬A就互斥了,也就不存在死鎖了;
分佈式事務貌似就可以這樣實現
2. 破壞不可搶佔條件
從字面上看就知道,如果搶佔不到共享資源,讓它釋放線程佔用的資源。然而synchronized是做不到這一點的,它搶佔不到資源就會進入阻塞狀態,不會釋放線程佔用資源的。
當然它做不到,不等於別人做不到,豈不小看了天下英雄。至少java.util.concurrent 這個包下面提供的 Lock 是可以輕鬆解決這個問題的。
我們經常會遇到說synchronized和Lock的區別,這就是其中一個。
3. 破壞循環等待條件
要破壞循環等待條件,就是那個無解的閉環,線程1佔有A的資源,線程2佔有B的資源。不妨給A和B的賬戶資源排個序。再讓線程去申請資源。這樣如果資源A排在前頭,線程1 申請到了A的資源,加了鎖後線程2就無法進入資源A鎖的臨界區,也就不可能去佔用B的資源了。如下面代碼:
class Account {
private int id;
private int balance;
// 轉賬
void transfer(Account target, int amt){
Account left = this ①
Account right = target; ②
if (this.id > target.id) { ③
left = target; ④
right = this; ⑤
} ⑥
// 鎖定序號小的賬戶
synchronized(left){
// 鎖定序號大的賬戶
synchronized(right){
if (this.balance > amt){
this.balance -= amt;
target.balance += amt;
}
}
}
}
}
總結:
細顆粒的鎖存在死鎖的風險,要根據情況去規避。
破壞死鎖的幾個條件,酌情應用。
原子性的本質並非不可分割,而是要保持操作的中間狀態的不可見性。例如賬戶轉賬的例子:
A轉賬B ,A減少100的時候B還沒有變,這個就是中間狀態。還沒有操作完成的變量,要保持對外的不可見性。