執行緒上下文切換與死鎖
執行緒上下文切換與死鎖
1. 前言
本節內容主要是對死鎖進行深入的講解,具體內容點如下:
- 理解執行緒的上下文切換,這是本節的輔助基礎內容,從概念層面進行理解即可;
- 瞭解什麼是執行緒死鎖,在併發程式設計中,執行緒死鎖是一個致命的錯誤,死鎖的概念是本節的重點之一;
- 瞭解執行緒死鎖的必備 4 要素,這是避免死鎖的前提,瞭解死鎖的必備要素,才能找到避免死鎖的方式;
- 掌握死鎖的實現,通過程式碼例項,進行死鎖的實現,深入體會什麼是死鎖,這是本節的重難點之一;
- 掌握如何避免執行緒死鎖,我們能夠實現死鎖,也可以避免死鎖,這是本節內容的核心。
2. 理解執行緒的上下文切換
概述: 在多執行緒程式設計中,執行緒個數一般都大於 CPU 個數,而每個 CPU 同一時刻只能被一個執行緒使用,為了讓使用者感覺多個執行緒是在同時執行的, CPU 資源的分配採用了時間片輪轉的策略,也就是給每個執行緒分配一個時間片,執行緒在時間片內佔用 CPU 執行任務。
定義: 當前執行緒使用完時間片後,就會處於就緒狀態並讓出 CPU,讓其他執行緒佔用,這就是上下文切換,從當前執行緒的上下文切換到了其他執行緒。
問題點解析: 那麼就有一個問題,讓出 CPU 的執行緒等下次輪到自己佔有 CPU 時如何知道自己之前執行到哪裡了?所以在切換執行緒上下文時需要儲存當前執行緒的執行現場, 當再次執行時根據儲存的執行現場資訊恢復執行現場。
3. 什麼是執行緒死鎖
定義: 死鎖就是指兩個或者兩個以上的執行緒在執行過程中,因爭奪資源而造成的互相等待的現象,在無外力作用的情況下,這些執行緒會一直相互等待而無法繼續執行下去
如上圖所示死鎖狀態,執行緒 A 己經持有了資源 1,它同時還想申請資源 2,可是此時執行緒 B 已經持有了資源 2 ,執行緒 A 只能等待。
反觀執行緒 B 持有了資源 2 ,它同時還想申請資源 1,但是資源 1 已經被執行緒 A 持有,執行緒 B 只能等待。所以執行緒 A 和執行緒 B 就因為相互等待對方已經持有的資源,而進入了死鎖狀態。
4. 執行緒死鎖的必備要素
- 互斥條件 :程序要求對所分配的資源進行排他性控制,即在一段時間內某資源僅為一個程序所佔有。此時若有其他程序請求該資源,則請求程序只能等待;
- 不可剝奪條件程序所獲得的資源在未使用完畢之前,不能被其他程序強行奪走,即只能由獲得該資源的程序自己來釋放(只能是主動釋放,如 yield 釋放 CPU 執行權);
- 請求與保持條件程序已經保持了至少一個資源,但又提出了新的資源請求,而該資源已被其他程序佔有,此時請求程序被阻塞,但對自己已獲得的資源保持不放;
- 迴圈等待條件指在發生死鎖時,必然存在一個執行緒請求資源的環形鏈,即執行緒集合 {T0,T1,T2,…Tn}中的 T0 正在等待一個 T1 佔用的資源,T1 正在等待 T2 佔用的資源,以此類推,Tn 正在等待己被 T0 佔用的資源。如下圖所示:
5. 死鎖的實現
為了更好的瞭解死鎖是如何產生的,我們首先來設計一個死鎖爭奪資源的場景。
場景設計: - 建立 2 個執行緒,執行緒名分別為 threadA 和 threadB;
- 建立兩個資源, 使用 new Object () 建立即可,分別命名為 resourceA 和 resourceB;
- threadA 持有 resourceA 並申請資源 resourceB;
- threadB 持有 resourceB 並申請資源 resourceA ;
- 為了確保發生死鎖現象,請使用 sleep 方法創造該場景;
- 執行程式碼,看是否會發生死鎖。
期望結果: 發生死鎖,執行緒 threadA 和 threadB 互相等待。
public class DemoTest{
private static Object resourceA = new Object();//建立資源 resourceA
private static Object resourceB = new Object();//建立資源 resourceB
public static void main(String[] args) throws InterruptedException {
//建立執行緒 threadA
Thread threadA = new Thread(new Runnable() {
@Override
public void run() {
synchronized (resourceA) {
System.out.println(Thread.currentThread().getName() + "獲取 resourceA。");
try {
Thread.sleep(1000); // sleep 1000 毫秒,確保此時 resourceB 已經進入run 方法的同步模組
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "開始申請 resourceB。");
synchronized (resourceB) {
System.out.println (Thread.currentThread().getName() + "獲取 resourceB。");
}
}
}
});
threadA.setName("threadA");
//建立執行緒 threadB
Thread threadB = new Thread(new Runnable() { //建立執行緒 1
@Override
public void run() {
synchronized (resourceB) {
System.out.println(Thread.currentThread().getName() + "獲取 resourceB。");
try {
Thread.sleep(1000); // sleep 1000 毫秒,確保此時 resourceA 已經進入run 方法的同步模組
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "開始申請 resourceA。");
synchronized (resourceA) {
System.out.println (Thread.currentThread().getName() + "獲取 resourceA。");
}
}
}
});
threadB.setName("threadB");
threadA. start();
threadB. start();
}
}
程式碼講解:
- 從程式碼中來看,我們首先建立了兩個資源 resourceA 和 resourceB;
- 然後建立了兩條執行緒 threadA 和 threadB。threadA 首先獲取了 resourceA ,獲取的方式是程式碼 synchronized (resourceA) ,然後沉睡 1000 毫秒;
- 在 threadA 沉睡過程中, threadB 獲取了 resourceB,然後使自己沉睡 1000 毫秒;
- 當兩個執行緒都甦醒時,此時可以確定 threadA 獲取了 resourceA,threadB 獲取了 resourceB,這就達到了我們做的第一步,執行緒分別持有自己的資源;
- 那麼第二步就是開始申請資源,threadA 申請資源 resourceB,threadB 申請資源 resourceA 無奈 resourceA 和 resourceB 都被各自執行緒持有,兩個執行緒均無法申請成功,最終達成死鎖狀態。
threadA 獲取 resourceA。
threadB 獲取 resourceB。
threadA 開始申請 resourceB。
threadB 開始申請 resourceA。
看下驗證結果,發現已經出現死鎖,threadA 申請 resourceB,threadB 申請 resourceA,但均無法申請成功,死鎖得以實驗成功。
6. 如何避免執行緒死鎖
要想避免死鎖,只需要破壞掉至少一個構造死鎖的必要條件即可,學過作業系統的讀者應該都知道,目前只有請求並持有和環路等待條件是可以被破壞的。
造成死鎖的原因其實和申請資源的順序有很大關係,使用資源申請的有序性原則就可避免死鎖。
我們依然以第 5 個知識點進行講解,那麼實驗的需求和場景不變,我們僅僅對之前的 threadB 的程式碼做如下修改,以避免死鎖。
程式碼修改
Thread threadB = new Thread(new Runnable() { //建立執行緒 1
@Override
public void run() {
synchronized (resourceA) { //修改點 1
System.out.println(Thread.currentThread().getName() + "獲取 resourceB。");//修改點 3
try {
Thread.sleep(1000); // sleep 1000 毫秒,確保此時 resourceA 已經進入run 方法的同步模組
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "開始申請 resourceA。");//修改點 4
synchronized (resourceB) { //修改點 2
System.out.println (Thread.currentThread().getName() + "獲取 resourceA。"); //修改點 5
}
}
}
});
請看如上程式碼示例,有 5 個修改點:
將第二段的resourceA和B都進行了調換。
修改後程式碼講解:
-
從程式碼中來看,我們首先建立了兩個資源 resourceA 和 resourceB;
-
然後建立了兩條執行緒 threadA 和 threadB。threadA 首先獲取了 resourceA ,獲取的方式是程式碼 synchronized (resourceA) ,然後沉睡 1000 毫秒;
-
在 threadA 沉睡過程中, threadB 想要獲取 resourceA ,但是 resourceA 目前正被沉睡的 threadA 持有,所以 threadB 等待 threadA 釋放 resourceA;
-
1000 毫秒後,threadA 甦醒了,釋放了 resourceA ,此時等待的 threadB 獲取到了 resourceA,然後 threadB 使自己沉睡 1000 毫秒;
-
threadB 沉睡過程中,threadA 申請 resourceB 成功,繼續執行成功後,釋放 resourceB;
-
1000 毫秒後,threadB 甦醒了,繼續執行獲取 resourceB ,執行成功。
執行結果驗證:
threadA 獲取 resourceA。
threadA 開始申請 resourceB。
threadA 獲取 resourceB。
threadB 獲取 resourceA。
threadB 開始申請 resourceB。
threadB 獲取 resourceB。
我們發現 threadA 和 threadB 按照相同的順序對 resourceA 和 resourceB 依次進行訪問,避免了互相交叉持有等待的狀態,避免了死鎖的發生。
7. 小結
死鎖是併發程式設計中最致命的問題,如何避免死鎖,是併發程式設計中恆久不變的問題。
掌握死鎖的實現以及如果避免死鎖的發生,是後續學習的重中之重。