當開發者在應用中使用了併發來提升性能的同時,開發者也需要注意線程之間有可能會相互阻塞。當整個應用執行的速度比預期要慢的時候,也就是應用沒有按照預期的執行時間執行完畢。在本章中,我們來需要仔細分析可能會影響應用多線程的活性問題。
死鎖
死鎖的概念在軟件開發者中已經廣爲熟知了,甚至普通的計算機用戶也會經常使用這個概念,儘管不是在正確的狀況下使用。嚴格來說,死鎖意味着兩個或者更多線程在等待另一個線程釋放其鎖定的資源,而請求資源的線程本身也鎖定了對方線程所請求的資源。如下:
Thread 1: locks resource A, waits for resource B
Thread 2: locks resource B, waits for resource A
爲了更好的理解問題,參考一下如下的代碼:
public class Deadlock implements Runnable {
private static final Object resource1 = new Object();
private static final Object resource2 = new Object();
private final Random random = new Random(System.currentTimeMillis());
public static void main(String[] args) {
Thread myThread1 = new Thread(new Deadlock(), "thread-1");
Thread myThread2 = new Thread(new Deadlock(), "thread-2");
myThread1.start();
myThread2.start();
}
public void run() {
for (int i = 0; i < 10000; i++) {
boolean b = random.nextBoolean();
if (b) {
System.out.println("[" + Thread.currentThread().getName() +
"] Trying to lock resource 1.");
synchronized (resource1) {
System.out.println("[" + Thread.currentThread().
getName() + "] Locked resource 1.");
System.out.println("[" + Thread.currentThread().
getName() + "] Trying to lock resource 2.");
synchronized (resource2) {
System.out.println("[" + Thread.
currentThread().getName() + "] Locked
resource 2.");
}
}
} else {
System.out.println("[" + Thread.currentThread().getName() +
"] Trying to lock resource 2.");
synchronized (resource2) {
System.out.println("[" + Thread.currentThread().
getName() + "] Locked resource 2.");
System.out.println("[" + Thread.currentThread().
getName() + "] Trying to lock resource 1.");
synchronized (resource1) {
System.out.println("[" + Thread.
currentThread().getName() + "] Locked
resource 1.");
}
}
}
}
}
}
從上面的代碼中可以看出,兩個線程分別啓動,並且嘗試鎖定2個靜態的資源。但對於死鎖,我們需要兩個線程的以不同順序鎖定資源,因此我們利用隨機實例選擇線程要首先鎖定的資源。
如果布爾變量b
爲true
,resource1
會鎖定,然後嘗試去獲得resource2
的鎖。如果b
是false
,線程會優先鎖定resource2
,然而嘗試鎖定resource1
。程序不用一會兒就會碰到死鎖問題,然後就會一直掛住,直到我們結束了JVM纔會結束:
[thread-1] Trying to lock resource 1.
[thread-1] Locked resource 1.
[thread-1] Trying to lock resource 2.
[thread-1] Locked resource 2.
[thread-2] Trying to lock resource 1.
[thread-2] Locked resource 1.
[thread-1] Trying to lock resource 2.
[thread-1] Locked resource 2.
[thread-2] Trying to lock resource 2.
[thread-1] Trying to lock resource 1.
在上面的執行中,thread-1
持有了resource2
的鎖,等待resource1
的鎖,而線程thread-2
持有了resource1
的鎖,等待resource2
的鎖。
如果我們將b
的值配置true
或者false
的話,是不會碰到死鎖的,因爲執行的順序始終是一致的,那麼thread-1
和thread-2
請求鎖的順序始終是一致的。兩個線程都會以同樣的順序請求鎖,那麼最多會暫時阻塞一個線程,最終都能夠順序執行。
大概來說,造成死鎖需要如下的一些條件:
- 互斥:必須存在一個資源在某個時刻,僅能由一個線程訪問。
- 資源持有:當鎖定了一個資源的時候,線程仍然需要去獲得另外一個資源的鎖。
- 沒有搶佔策略:當某個線程已經持有了資源一段時間的時候,沒有能夠強佔線程鎖定資源的機制。
- 循環等待:在運行時必須存在兩個或者更多的線程,相互請求對方鎖定的資源。
儘管產生死鎖的條件看起來較多,但是在多線程應用中存在死鎖還是比較常見的。開發者可以通過打破死鎖構成的必要條件來避免死鎖的產生,參考如下:
- 互斥: 這個需求通常來說是不可避免的,資源很多時候確實只能互斥訪問的。但是並不是總是這樣的。當使用DBMS系統的時候,可能使用類似樂觀鎖的方式來代替原來的悲觀鎖的機制(在更新數據的時候鎖定表中的一行)。
- 還有一種可行的方案,就是對資源持有進行處理,當獲取了某一資源的鎖之後,立刻獲取其他所必須資源的鎖,如果獲取鎖失敗了,則釋放掉之前所有的互斥資源。當然,這種方式並不是總是可以的,有可能鎖定的資源之前是無法知道的,或者是廢棄了的資源。
- 如果鎖不能立刻獲取,防止出現死鎖的一種方式就是給鎖的獲取配置上一個超時時間。在SDK類中的
ReentrantLock
就提供了類似超時的方法。 - 從上面的代碼中,我們可以發現,如果每個線程的鎖定資源的順序是相同的,是不會產生死鎖的。而這個過程可以通過將所有請求鎖的代碼都抽象到一個方法,然後由線程調用來實現。這就可以有效的避免死鎖。
在一個更高級的應用中,開發者或許需要考慮實現一個檢測死鎖的系統。在這個系統中,來實現一些基於線程的監控,當前程獲取一個鎖,並且嘗試請求別的鎖的時候,都記錄日誌。如果以線程和鎖構成有向圖,開發者是能夠檢測到2不同的線程持有資源並且同時請求另外的阻塞的資源的。如果開發者可以檢測,並能夠強制阻塞的線程釋放掉已經獲取的資源,就能夠自動檢測到死鎖並且自動修復死鎖問題。
飢餓
線程調度器會決定哪一個處於RUNNABLE
狀態的線程會的執行順序。決定一般是基於線程的優先級的;因此,低優先級的線程會獲得較少的CPU時間,而高優先級的線程會獲得較多的CPU時間。當然,這種調度聽起來較爲合理,但是有的時候也會引起問題。如果總是執行高優先級的線程,那麼低優先級的線程就會無法獲得足夠的時間來執行,處於一種飢餓狀態。因此,建議開發者只在真的十分必要的時候纔去配置線程的優先級。
一個很複雜的線程飢餓的例子就是finalize()
方法。Java語言中的這一特性可以用來進行垃圾回收,但是當開發者查看一下finalizer
線程的優先級,就會發現其運行的優先級不是最高的。因此,很有可能finalize()
方法跟其他方法比起來會執行更久。
另一個執行時間的問題是,線程以何種順序通過同步代碼塊是沒有定義的。當很多並行線程需要通過封裝的同步代碼塊時,會有的線程等待的時間要比其它線程的時間更久才能進入同步代碼快。理論上,他們可能永遠無法進入代碼塊。這個問題可以使用公平鎖的方案來解決。公平鎖在選擇下個線程的時候會考慮到線程的等待時間。其中一個公平鎖的實現就是java.util.concurrent.locks.ReentrantLock
:
如果使用ReentrantLock
的如下構造函數:
/**
* Creates an instance of {@code ReentrantLock} with the
* given fairness policy.
*
* @param fair {@code true} if this lock should use a fair ordering policy
*/
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
傳入true
,那麼ReentrantLock
是一個公平鎖,是會允許線程按掛起順序來依次獲取鎖執行的。這樣可以削減線程的飢餓,但是,並不能完全解決飢餓的問題,畢竟線程的調度是由操作系統調度的。所以,ReentrantLock
類只考慮等待鎖的線程,調度上是無法起作用的。舉個例子,儘管使用了公平鎖,但是操作系統會給低優先級的線程很短的執行時間。