在開發多執行緒應用程式時,經常會出現一個困境:應用程式的可靠性和效能哪個更重要。例如,我們使用同步來確保執行緒安全,如果同步順序不正確,就會導致死鎖。我們也使用執行緒池和信號量來限制資源消耗,而這種設計中的錯誤可能會因資源不足而導致死鎖。在本文中,我們將討論如何避免死鎖,以及應用程式效能中的其他問題。我們還將研究如何編寫應用程式以便能夠在死鎖情況下恢復。 死鎖是指兩個或多個佔用某些資源的進程試圖取得其他進程佔用的其他資源,但沒有一個進程能夠佔用它們所需的資源,從而釋放所佔用的資源的情況。這個定義太籠統,因此很難理解;為了更好地理解,我們將透過範例來了解死鎖的類型。
同步順序互鎖
考慮以下任務:您需要編寫一個方法來執行交易,將一定數量的資金從一個帳戶轉移到另一個帳戶。解決方案可能如下所示:public void transferMoney(Account fromAccount, Account toAccount, Amount amount) throws InsufficientFundsException {
synchronized (fromAccount) {
synchronized (toAccount) {
if (fromAccount.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAccount.debit(amount);
toAccount.credit(amount);
}
}
}
}
乍一看,這段程式碼同步得很正常;我們有一個檢查和更改來源帳戶狀態以及更改目標帳戶的原子操作。然而,採用這種同步策略,可能會出現死鎖情況。讓我們看一個例子來說明這是如何發生的。需要進行兩筆交易:從A帳戶轉x個錢到B帳戶,從B帳戶轉y個錢到A帳戶。通常這種情況不會導致死鎖,但是,在不幸的情況下,事務 1 將佔用帳戶監視器 A,事務 2 將佔用帳戶監視器 B。結果是死鎖:事務 1 等待交易 2 釋放帳戶監視器B,但事務2必須存取監視器A,該監視器被事務1佔用。死鎖的一大問題是在測試中不容易發現。即使在範例中描述的情況下,執行緒也可能不會阻塞,也就是說,這種情況不會不斷重現,這會使診斷變得非常複雜。一般來說,所描述的非確定性問題是多執行緒的典型問題(儘管這並沒有使它變得更容易)。因此,程式碼審查在提高多執行緒應用程式的品質方面發揮著重要作用,因為它允許您識別在測試期間難以重現的錯誤。當然,這並不意味著應用程式不需要測試;我們只是不應該忘記程式碼審查。我該怎麼做才能防止這段程式碼導致死鎖?此封鎖是由於帳戶同步可能以不同的順序發生而引起的。因此,如果您在帳戶上引入某種順序(這是一些允許您說帳戶 A 小於帳戶 B 的規則),那麼問題將被消除。怎麼做?首先,如果帳戶具有某種唯一標識符(例如帳號)數字、小寫或其他具有自然順序概念的東西(字串可以按字典順序進行比較,那麼我們可以認為自己很幸運,我們會我們總是可以先佔用較小帳戶的監視器,然後再佔用較大帳戶的監視器(反之亦然)。
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
int fromId= fromAcct.getId();
int toId = fromAcct.getId();
if (fromId < toId) {
synchronized (fromAcct) {
synchronized (toAcct) {
doTransfer(fromAcct, toAcct, amount)}
}
}
} else {
synchronized (toAcct) {
synchronized (fromAcct) {
doTransfer(fromAcct, toAcct, amount)}
}
}
}
}
第二個選擇,如果我們沒有這樣的標識符,我們就必須自己想出它。我們可以初步近似地透過雜湊碼來比較物件。他們很可能會有所不同。但如果結果相同怎麼辦?然後您將必須添加另一個物件以進行同步。它可能看起來有點複雜,但你能做什麼呢?此外,第三個物件很少被使用。結果將如下所示:
private static final Object tieLock = new Object();
private void doTransfer(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
if (fromAcct.getBalance().compareTo(amount) < 0)
throw new InsufficientFundsException();
else {
fromAcct.debit(amount);
toAcct.credit(amount);
}
}
public void transferMoney(final Account fromAcct, final Account toAcct, final DollarAmount amount) throws InsufficientFundsException {
int fromHash = System.identityHashCode(fromAcct);
int toHash = System.identityHashCode(toAcct);
if (fromHash < toHash) {
synchronized (fromAcct) {
synchronized (toAcct) {
doTransfer(fromAcct, toAcct, amount);
}
}
} else if (fromHash > toHash) {
synchronized (toAcct) {
synchronized (fromAcct) {
doTransfer(fromAcct, toAcct, amount);
}
}
} else {
synchronized (tieLock) {
synchronized (fromAcct) {
synchronized (toAcct) {
doTransfer(fromAcct, toAcct, amount)
}
}
}
}
}
對象之間的死鎖
所描述的阻塞條件代表了最容易診斷的死鎖情況。通常在多執行緒應用程式中,不同的物件嘗試存取相同的同步區塊。這可能會導致死鎖。考慮以下範例:航班調度員應用程式。飛機到達目的地時會通知管制員並要求降落。管制員儲存有關朝其方向飛行的飛機的所有信息,並可以在地圖上繪製它們的位置。class Plane {
private Point location, destination;
private final Dispatcher dispatcher;
public Plane(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) {
this.location = location;
if (location.equals(destination))
dispatcher.requestLanding(this);
}
}
class Dispatcher {
private final Set<Plane> planes;
private final Set<Plane> planesPendingLanding;
public Dispatcher() {
planes = new HashSet<Plane>();
planesPendingLanding = new HashSet<Plane>();
}
public synchronized void requestLanding(Plane plane) {
planesPendingLanding.add(plane);
}
public synchronized Image getMap() {
Image image = new Image();
for (Plane plane : planes)
image.drawMarker(plane.getLocation());
return image;
}
}
了解此程式碼中存在可能導致死鎖的錯誤比前一個程式碼更困難。乍一看,它沒有重新同步,但事實並非如此。您可能已經注意到,setLocation
類別Plane
和getMap
類別方法Dispatcher
是同步的,並且在它們內部呼叫其他類別的同步方法。這通常是不好的做法。如何糾正這個問題將在下一節中討論。因此,如果飛機到達該地點,當有人決定拿卡時,就會出現僵局。即,將呼叫方法getMap
和setLocation
,這將分別佔用實例監視器Dispatcher
和Plane
。然後,該方法將getMap
呼叫plane.getLocation
(特別是在目前繁忙的實例上Plane
),該方法將等待每個實例的監視器變為空閒Plane
。同時,該方法setLocation
將被調用dispatcher.requestLanding
,而實例監視器Dispatcher
仍忙於繪製地圖。結果陷入僵局。
公開徵集
為了避免上一節中所述的情況,建議使用公共呼叫其他物件的方法。即呼叫synchronized區塊以外的其他物件的方法。如果使用開放呼叫的原則來重寫方法setLocation
,getMap
則將消除死鎖的可能性。例如,它看起來像這樣:
public void setLocation(Point location) {
boolean reachedDestination;
synchronized(this){
this.location = location;
reachedDestination = location.equals(destination);
}
if (reachedDestination)
dispatcher.requestLanding(this);
}
………………………………………………………………………………
public Image getMap() {
Set<Plane> copy;
synchronized(this){
copy = new HashSet<Plane>( planes);
}
Image image = new Image();
for (Plane plane : copy)
image.drawMarker(plane.getLocation());
return image;
}
資源死鎖
當嘗試存取某些一次只有一個執行緒可以使用的資源時,也可能會發生死鎖。一個例子是資料庫連接池。如果某些執行緒需要同時存取兩個連接,並且它們以不同的順序存取它們,這可能會導致死鎖。從根本上講,這種鎖定與同步順序鎖定沒有什麼不同,只不過它不是在嘗試執行某些程式碼時發生,而是在嘗試存取資源時發生。如何避免死鎖?
當然,如果程式碼編寫沒有任何錯誤(我們在前面幾節中看到的範例),那麼其中就不會有死鎖。但誰能保證他的程式碼寫得沒有錯誤呢?當然,測試有助於識別大部分錯誤,但正如我們之前所看到的,多執行緒程式碼中的錯誤並不容易診斷,即使在測試之後,您也無法確定是否存在死鎖情況。我們能以某種方式保護自己免受阻塞嗎?答案是肯定的。類似的技術也用在資料庫引擎中,它們經常需要從死鎖中恢復(與資料庫中的事務機制相關)。Lock
套件中可用的介面及其實java.util.concurrent.locks
作允許您嘗試使用該方法佔用與此類實例關聯的監視器tryLock
(如果可以佔用監視器,則傳回 true)。假設我們有一對實作介面的對象Lock
,並且我們需要以避免相互阻塞的方式佔用它們的監視器。你可以這樣實現:
public void twoLocks(Lock A, Lock B){
while(true){
if(A.tryLock()){
if(B.tryLock())
{
try{
//do something
} finally{
B.unlock();
A.unlock();
}
} else{
A.unlock();
}
}
}
}
正如您在該程式中看到的,我們佔用了兩個監視器,同時消除了相互阻塞的可能性。請注意,該區塊try- finally
是必要的,因為套件中的類別java.util.concurrent.locks
不會自動釋放監視器,並且如果在執行任務期間發生某些異常,監視器將陷入鎖定狀態。如何診斷死鎖?JVM 可讓您透過在執行緒轉儲中顯示死鎖來診斷死鎖。此類轉儲包含有關線程所處狀態的資訊。如果它被阻塞,則轉儲包含有關執行緒正在等待釋放的監視器的資訊。在轉儲線程之前,JVM 會查看等待(繁忙)監視器的圖表,如果發現循環,則會添加死鎖訊息,指示參與的監視器和線程。死鎖線程的轉儲如下圖所示:
Found one Java-level deadlock:
=============================
"ApplicationServerThread":
waiting to lock monitor 0x0f0d80cc (a MyDBConnection),
which is held by "ApplicationServerThread"
"ApplicationServerThread":
waiting to lock monitor 0x0f0d8fed (a MyDBCallableStatement),
which is held by "ApplicationServerThread"
Java stack information for the threads listed above:
"ApplicationServerThread":
at MyDBConnection.remove_statement
- waiting to lock <0x6f50f730> (a MyDBConnection)
at MyDBStatement.close
- locked <0x604ffbb0> (a MyDBCallableStatement)
...
"ApplicationServerThread":
at MyDBCallableStatement.sendBatch
- waiting to lock <0x604ffbb0> (a MyDBCallableStatement)
at MyDBConnection.commit
- locked <0x6f50f730> (a MyDBConnection)
上面的轉儲清楚地表明處理資料庫的兩個線程已相互阻塞。為了使用此 JVM 功能診斷死鎖,需要在程式中的各個位置呼叫執行緒轉儲操作並測試應用程式。接下來,您應該分析產生的日誌。如果它們表示發生了死鎖,則轉儲中的資訊將有助於檢測死鎖發生的條件。一般來說,您應該避免死鎖範例中的情況。在這種情況下,應用程式很可能會穩定運行。但不要忘記測試和程式碼審查。如果問題確實發生,這將有助於識別問題。如果您正在開發一個死鎖欄位的復原至關重要的系統,您可以使用「如何避免死鎖?」一節中所述的方法。在這種情況下,來自 的lockInterruptibly
介面方法。它允許您使用此方法中斷已佔用監視器的執行緒(從而釋放監視器)。 Lock
java.util.concurrent.locks
GO TO FULL VERSION