JavaRush /Java Blog /Random-TW /並發基礎:死鎖與物件監視器(第 1、2 節)(文章翻譯)
Snusmum
等級 34
Хабаровск

並發基礎:死鎖與物件監視器(第 1、2 節)(文章翻譯)

在 Random-TW 群組發布
來源文章:http://www.javacodegeeks.com/2015/09/concurrency-fundamentals-deadlocks-and-object-monitors.html 作者:Martin Mois 本文是我們的Java 並發基礎課程 的一部分。 在本課程中,您將深入研究並行性的魔力。您將學習並行性和平行程式碼的基礎知識,並熟悉原子性、同步和執行緒安全性等概念。看看這裡吧

內容

1.活躍性  1.1死鎖  1.2飢餓 2.使用 wait() 和 notify() 進行物件監控  2.1  使用wait() 和 notify() 嵌套同步區塊  2.2同步區塊中的條件 3.多執行緒設計3.1不可變對象  3.2 API 設計  3.3本地線程存儲
1. 活力
在開發使用並行性來實現其目標的應用程式時,您可能會遇到不同執行緒相互阻塞的情況。如果在這種情況下應用程式的運行速度比預期慢,我們會說它沒有按預期運行。在本節中,我們將仔細研究可能威脅多執行緒應用程式生存能力的問題。
1.1 相互阻塞
死鎖這個術語在軟體開發人員中是眾所周知的,甚至大多數普通用戶也會時不時地使用它,儘管並不總是在正確的意義上。嚴格來說,該術語意味著兩個(或多個)線程中的每一個都在等待另一個線程釋放其鎖定的資源,而第一個線程本身已鎖定第二個線程正在等待訪問的資源:為了更好地理解問題,請看 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."); } } } } } } 從上面的程式碼可以看到,兩個執行緒啟動並嘗試鎖定兩個靜態資源。但對於死鎖,我們需要兩個執行緒有不同的順序,因此我們使用 Random 物件的實例來選擇執行緒首先要鎖定的資源。如果布林變數b為true,則資源1先被鎖定,然後執行緒嘗試取得資源2的鎖。如果 b 為 false,則執行緒鎖定資源 2,然後嘗試取得資源 1。這個程式不需要運作很久就能實現第一個死鎖,即 如果我們不中斷它,程式將永遠掛起: [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. 在此運行中,tread-1 已取得資源2 鎖定並正在等待資源1 的鎖,而tread-2 擁有資源1 鎖定並正在等待資源2。如果我們將上面程式碼中的布林變數 b 的值設為 true,我們將無法觀察到任何死鎖,因為執行緒 1 和執行緒 2 請求鎖的順序始終相同。在這種情況下,兩個執行緒中的一個將首先獲得鎖,然後請求第二個鎖,第二個執行緒仍然可用,因為另一個執行緒正在等待第一個鎖。一般來說,我們可以區分出以下幾種發生死鎖的必要條件: - 共享執行:存在一種資源,任何時候只能被一個執行緒存取。- 資源持有:在取得一個資源時,執行緒嘗試取得某個唯一資源上的另一個鎖。- 無搶佔:如果一個執行緒持有鎖一段時間,則沒有釋放資源的機制。- 循環等待:在執行過程中,會發生執行緒集合,其中兩個(或多個)執行緒相互等待以釋放已鎖定的資源。儘管條件清單看起來很長,但對於運行良好的多執行緒應用程式來說,出現死鎖問題的情況並不罕見。但是,如果您可以刪除上述條件之一,則可以阻止它們: - 共用執行:當資源必須僅由一個人使用時,此條件通常無法刪除。但這不一定是原因。 當使用 DBMS 系統時,一種可能的解決方案是使用一種稱為樂觀鎖定的技術,而不是對需要更新的某些表格行使用悲觀鎖定。- 避免在等待另一個獨佔資源時持有一個資源的方法是在演算法開始時鎖定所有必需的資源,如果不可能一次鎖定所有資源,則將它們全部釋放。當然,這並不總是可能的;也許需要鎖定的資源是事先未知的,或者這種方法只會導致資源浪費。- 如果無法立即取得鎖,繞過可能的死鎖的一種方法是引入逾時。例如, ReentrantLock類SDK 提供了設定鎖定到期日的功能。- 正如我們從上面的範例中看到的,如果不同執行緒之間的請求順序沒有差異,則不會發生死鎖。如果您可以將所有阻塞程式碼放入所有執行緒都必須執行的一個方法中,那麼這很容易控制。在更高級的應用程式中,您甚至可以考慮實現死鎖檢測系統。在這裡,您將需要實現某種類似的線程監視,其中每個線程報告它已成功獲取鎖並正在嘗試獲取鎖。如果執行緒和鎖被建模為有向圖,您可以偵測兩個不同的執行緒何時持有資源,同時嘗試存取其他鎖定的資源。如果您可以強制阻塞執行緒釋放所需的資源,則可以自動解決死鎖情況。
1.2 禁食
調度程式決定接下來應該執行哪個 處於 RUNNABLE 狀態的執行緒。該決定基於線程優先權;因此,與優先順序較高的執行緒相比,優先順序較低的執行緒所獲得的 CPU 時間較少。看似合理的解決方案如果被濫用也可能會導致問題。如果高優先權執行緒大部分時間都在執行,那麼低優先權執行緒似乎就會挨餓,因為它們沒有足夠的時間來正常運作。因此,建議僅在有令人信服的理由時才設定執行緒優先權。例如,finalize() 方法給出了一個不明顯的線程飢餓範例。它為 Java 語言提供了一種在物件被垃圾收集之前執行程式碼的方法。但是如果您查看終結線程的優先級,您會發現它並不是以最高優先級運行。因此,當物件的 Finalize() 方法相對於程式碼的其餘部分花費太多時間時,就會發生線程飢餓。執行時間的另一個問題是由於沒有定義執行緒遍歷同步區塊的順序。當許多並行執行緒正在遍歷同步區塊中的某些程式碼時,可能會發生某些執行緒在進入同步區塊之前必須比其他執行緒等待更長的時間。從理論上講,他們可能永遠無法到達那裡。這個問題的解決方案就是所謂的「公平」阻塞。公平鎖在決定接下來要傳遞給誰時會考慮線程等待時間。Java SDK 中提供了公平鎖定的範例實作:java.util.concurrent.locks.ReentrantLock。如果使用建構函式並將布林標誌設為 true,則 ReentrantLock 會授予對等待時間最長的執行緒的存取權限。這保證了不存在飢餓,但同時也導致了忽視優先事項的問題。因此,經常在此屏障等待的優先順序較低的進程可能會更頻繁地運行。最後但並非最不重要的一點是,ReentrantLock 類別只能考慮正在等待鎖的線程,即 經常啟動並到達屏障的執行緒。如果執行緒的優先權太低,那麼這種情況不會經常發生,因此高優先權執行緒仍然會更頻繁地傳遞鎖。
2.物件監聽與wait()和notify()一起使用
在多執行緒運算中,常見的情況是讓一些工作執行緒等待其生產者為它們創建一些工作。但是,正如我們所知,就 CPU 時間而言,在檢查某個值時主動循環等待並不是一個好的選擇。如果我們想在到達後立即開始工作,那麼在這種情況下使用 Thread.sleep() 方法也不是特別合適。為此,Java 程式語言有另一種結構可以用於該方案:wait() 和notify()。wait() 方法由 java.lang.Object 類別的所有物件繼承,可用來掛起目前執行緒並等待,直到另一個執行緒使用 notify() 方法喚醒我們。為了正確工作,呼叫 wait() 方法的執行緒必須持有先前使用 synchronized 關鍵字取得的鎖定。當呼叫 wait() 時,鎖被釋放,並且執行緒等待,直到現在持有鎖的另一個執行緒在同一物件實例上呼叫 notify()。在多執行緒應用程式中,自然可能有多個執行緒等待某個物件的通知。因此,喚醒執行緒有兩種不同的方法:notify()和notifyAll()。第一個方法喚醒其中一個等待線程,而notifyAll() 方法則喚醒所有等待線程。但要注意的是,與synchronized關鍵字一樣,沒有規則決定呼叫notify()時接下來會喚醒哪個執行緒。在一個具有生產者和消費者的簡單範例中,這並不重要,因為我們不關心哪個執行緒被喚醒。以下程式碼顯示如何使用 wait() 和 notification() 機制讓消費者執行緒等待生產者執行緒將新工作加入佇列: package a2; import java.util.Queue; import java.util.concurrent.ConcurrentLinkedQueue; public class ConsumerProducer { private static final Queue queue = new ConcurrentLinkedQueue(); private static final long startMillis = System.currentTimeMillis(); public static class Consumer implements Runnable { public void run() { while (System.currentTimeMillis() < (startMillis + 10000)) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } } if (!queue.isEmpty()) { Integer integer = queue.poll(); System.out.println("[" + Thread.currentThread().getName() + "]: " + integer); } } } } public static class Producer implements Runnable { public void run() { int i = 0; while (System.currentTimeMillis() < (startMillis + 10000)) { queue.add(i++); synchronized (queue) { queue.notify(); } try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { queue.notifyAll(); } } } public static void main(String[] args) throws InterruptedException { Thread[] consumerThreads = new Thread[5]; for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i); consumerThreads[i].start(); } Thread producerThread = new Thread(new Producer(), "producer"); producerThread.start(); for (int i = 0; i < consumerThreads.length; i++) { consumerThreads[i].join(); } producerThread.join(); } } main() 方法啟動 5 個消費者線程和 1 個生產者線程,然後等待它們完成。然後,生產者執行緒將新值新增至佇列中,並通知所有等待執行緒發生了某些情況。消費者獲得隊列鎖(即,一個隨機消費者),然後進入睡眠狀態,稍後當隊列再次滿時被提升。當生產者完成工作後,它會通知所有消費者喚醒他們。如果我們沒有執行最後一步,消費者線程將永遠等待下一個通知,因為我們沒有設定等待逾時。相反,我們可以使用 wait(long timeout) 方法至少在經過一段時間後被喚醒。
2.1 使用wait()和notify()巢狀同步區塊
如上一節所述,在物件的監視器上呼叫 wait() 僅釋放該監視器上的鎖。同一執行緒持有的其他鎖不會被釋放。很容易理解,在日常工作中,可能會出現呼叫 wait() 的執行緒進一步持有鎖的情況。如果其他執行緒也在等待這些鎖,則可能會出現死鎖情況。讓我們看看以下範例中的鎖定: public class SynchronizedAndWait { private static final Queue queue = new ConcurrentLinkedQueue(); public synchronized Integer getNextInt() { Integer retVal = null; while (retVal == null) { synchronized (queue) { try { queue.wait(); } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } } return retVal; } public synchronized void putInt(Integer value) { synchronized (queue) { queue.add(value); queue.notify(); } } public static void main(String[] args) throws InterruptedException { final SynchronizedAndWait queue = new SynchronizedAndWait(); Thread thread1 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { queue.putInt(i); } } }); Thread thread2 = new Thread(new Runnable() { public void run() { for (int i = 0; i < 10; i++) { Integer nextInt = queue.getNextInt(); System.out.println("Next int: " + nextInt); } } }); thread1.start(); thread2.start(); thread1.join(); thread2.join(); } } 正如我們 之前所了解到的,將synchronized 加入方法簽章相當於建立一個synchronized(this){} 區塊。在上面的範例中,我們不小心將synchronized關鍵字新增至方法中,然後將佇列與佇列物件的監視器同步,以使該執行緒在等待佇列中的下一個值時進入睡眠狀態。然後,當前執行緒釋放佇列上的鎖,但不釋放佇列上的鎖。putInt() 方法通知休眠執行緒已新增值。但偶然我們也將synchronized關鍵字加入這個方法。現在第二個執行緒已經進入睡眠狀態,它仍然持有鎖。因此,當第二個執行緒持有鎖時,第一個執行緒無法進入 putInt() 方法。結果,我們陷入了僵局,程序被凍結。如果運行上面的程式碼,它將在程式開始運行後立即發生。在日常生活中,這種情況可能不那麼明顯。執行緒持有的鎖定可能取決於執行時間遇到的參數和條件,並且導致問題的同步區塊在程式碼中可能與我們放置 wait() 呼叫的位置不那麼接近。這使得發現此類問題變得困難,特別是因為它們可能會隨著時間的推移或在高負載下發生。
2.2 同步區塊中的條件
通常,在對同步物件執行任何操作之前,您需要檢查是否滿足某些條件。例如,當您有一個隊列時,您希望等待它填滿。因此,您可以編寫一個方法來檢查佇列是否已滿。如果它仍然是空的,那麼你讓當前執行緒進入睡眠狀態,直到它被喚醒: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } } synchronized (queue) { retVal = queue.poll(); if (retVal == null) { System.err.println("retVal is null"); throw new IllegalStateException(); } } return retVal; } 上面的程式碼在呼叫 wait() 之前與佇列同步,然後在 while 迴圈中等待,直到佇列中至少出現一個元素。第二個同步區塊再次使用佇列作為物件監視器。它呼叫隊列的 poll() 方法來取得值。出於演示目的,當 poll 傳回 null 時,將引發 IllegalStateException。當佇列沒有要取得的元素時會發生這種情況。當您執行此範例時,您將看到經常拋出 IllegalStateException。儘管我們使用佇列監視器正確同步,但還是拋出了異常。原因是我們有兩個不同的同步區塊。想像一下,我們有兩個執行緒已經到達第一個同步區塊。第一個執行緒進入區塊並進入睡眠狀態,因為佇列為空。第二個線程也是如此。現在兩個執行緒都已喚醒(感謝另一個執行緒為監視器呼叫的notifyAll() 呼叫),它們都看到了生產者添加到佇列中的值(項目)。隨後兩人就來到了第二道關卡前。這裡第一個線程進入隊列並從隊列中檢索值。當第二個線程進入時,隊列已經空了。因此,它接收 null 作為從佇列傳回的值並引發異常。為了防止這種情況,您需要在同一個同步區塊中執行所有依賴監視器狀態的操作: public Integer getNextInt() { Integer retVal = null; synchronized (queue) { try { while (queue.isEmpty()) { queue.wait(); } } catch (InterruptedException e) { e.printStackTrace(); } retVal = queue.poll(); } return retVal; } 這裡我們在與 isEmpty() 方法相同的同步區塊中執行 poll() 方法。由於使用了同步區塊,我們可以確定在給定時間只有一個執行緒正在執行該監視器的方法。因此,在呼叫 isEmpty() 和 poll() 之間,沒有其他執行緒可以從佇列中刪除元素。 這裡繼續翻譯。
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION