本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結
本節主要討論一個問題,如何在Java中取消或關閉一個執行緒?
取消/關閉的場景
我們知道,通過執行緒的start方法啟動一個執行緒後,執行緒開始執行run方法,run方法執行結束後執行緒退出,那為什麼還需要結束一個執行緒呢?有多種情況,比如說:
- 很多執行緒的執行模式是死迴圈,比如在生產者/消費者模式中,消費者主體就是一個死迴圈,它不停的從佇列中接受任務,執行任務,在停止程式時,我們需要一種"優雅"的方法以關閉該執行緒。
- 在一些圖形使用者介面程式中,執行緒是使用者啟動的,完成一些任務,比如從遠端伺服器上下載一個檔案,在下載過程中,使用者可能會希望取消該任務。
- 在一些場景中,比如從第三方伺服器查詢一個結果,我們希望在限定的時間內得到結果,如果得不到,我們會希望取消該任務。
- 有時,我們會啟動多個執行緒做同一件事,比如類似搶火車票,我們可能會讓多個好友幫忙從多個渠道買火車票,只要有一個渠道買到了,我們會通知取消其他渠道。
取消/關閉的機制
Java的Thread類定義瞭如下方法:
public final void stop()
複製程式碼
這個方法看上去就可以停止執行緒,但這個方法被標記為了過時,簡單的說,我們不應該使用它,可以忽略它。
在Java中,停止一個執行緒的主要機制是中斷,中斷並不是強迫終止一個執行緒,它是一種協作機制,是給執行緒傳遞一個取消訊號,但是由執行緒來決定如何以及何時退出,本節我們主要就是來理解Java的中斷機制。
Thread類定義瞭如下關於中斷的方法:
public boolean isInterrupted()
public void interrupt()
public static boolean interrupted()
複製程式碼
這三個方法名字類似,比較容易混淆,我們解釋一下。isInterrupted()和interrupt()是例項方法,呼叫它們需要通過執行緒物件,interrupted()是靜態方法,實際會呼叫Thread.currentThread()操作當前執行緒。
每個執行緒都有一個標誌位,表示該執行緒是否被中斷了。
- isInterrupted:就是返回對應執行緒的中斷標誌位是否為true。
- interrupted:返回當前執行緒的中斷標誌位是否為true,但它還有一個重要的副作用,就是清空中斷標誌位,也就是說,連續兩次呼叫interrupted(),第一次返回的結果為true,第二次一般就是false (除非同時又發生了一次中斷)。
- interrupt:表示中斷對應的執行緒,中斷具體意味著什麼呢?下面我們進一步來說明。
執行緒對中斷的反應
interrupt()對執行緒的影響與執行緒的狀態和在進行的IO操作有關,我們先主要考慮執行緒的狀態:
- RUNNABLE:執行緒在執行或具備執行條件只是在等待作業系統排程
- WAITING/TIMED_WAITING:執行緒在等待某個條件或超時
- BLOCKED:執行緒在等待鎖,試圖進入同步塊
- NEW/TERMINATED:執行緒還未啟動或已結束
RUNNABLE
如果執行緒在執行中,且沒有執行IO操作,interrupt()只是會設定執行緒的中斷標誌位,沒有任何其它作用。執行緒應該在執行過程中合適的位置檢查中斷標誌位,比如說,如果主體程式碼是一個迴圈,可以在迴圈開始處進行檢查,如下所示:
public class InterruptRunnableDemo extends Thread {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
// ... 單次迴圈程式碼
}
System.out.println("done ");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new InterruptRunnableDemo();
thread.start();
Thread.sleep(1000);
thread.interrupt();
}
}
複製程式碼
WAITING/TIMED_WAITING
執行緒執行如下方法會進入WAITING狀態:
public final void join() throws InterruptedException
public final void wait() throws InterruptedException
複製程式碼
執行如下方法會進入TIMED_WAITING狀態:
public final native void wait(long timeout) throws InterruptedException;
public static native void sleep(long millis) throws InterruptedException;
public final synchronized void join(long millis) throws InterruptedException
複製程式碼
在這些狀態時,對執行緒物件呼叫interrupt()會使得該執行緒丟擲InterruptedException,需要注意的是,丟擲異常後,中斷標誌位會被清空,而不是被設定。比如說,執行如下程式碼:
Thread t = new Thread (){
@Override
public void run() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
System.out.println(isInterrupted());
}
}
};
t.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
t.interrupt();
複製程式碼
程式的輸出為false。
InterruptedException是一個受檢異常,執行緒必須進行處理。我們在異常處理中介紹過,處理異常的基本思路是,如果你知道怎麼處理,就進行處理,如果不知道,就應該向上傳遞,通常情況下,你不應該做的是,捕獲異常然後忽略。
捕獲到InterruptedException,通常表示希望結束該執行緒,執行緒大概有兩種處理方式:
- 向上傳遞該異常,這使得該方法也變成了一個可中斷的方法,需要呼叫者進行處理。
- 有些情況,不能向上傳遞異常,比如Thread的run方法,它的宣告是固定的,不能丟擲任何受檢異常,這時,應該捕獲異常,進行合適的清理操作,清理後,一般應該呼叫Thread的interrupt方法設定中斷標誌位,使得其他程式碼有辦法知道它發生了中斷。
第一種方式的示例程式碼如下:
public void interruptibleMethod() throws InterruptedException{
// ... 包含wait, join 或 sleep 方法
Thread.sleep(1000);
}
複製程式碼
第二種方式的示例程式碼如下:
public class InterruptWaitingDemo extends Thread {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
// 模擬任務程式碼
Thread.sleep(2000);
} catch (InterruptedException e) {
// ... 清理操作
// 重設中斷標誌位
Thread.currentThread().interrupt();
}
}
System.out.println(isInterrupted());
}
public static void main(String[] args) {
InterruptWaitingDemo thread = new InterruptWaitingDemo();
thread.start();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
thread.interrupt();
}
}
複製程式碼
BLOCKED
如果執行緒在等待鎖,對執行緒物件呼叫interrupt()只是會設定執行緒的中斷標誌位,執行緒依然會處於BLOCKED狀態,也就是說,interrupt()並不能使一個在等待鎖的執行緒真正"中斷"。我們看段程式碼:
public class InterruptSynchronizedDemo {
private static Object lock = new Object();
private static class A extends Thread {
@Override
public void run() {
synchronized (lock) {
while (!Thread.currentThread().isInterrupted()) {
}
}
System.out.println("exit");
}
}
public static void test() throws InterruptedException {
synchronized (lock) {
A a = new A();
a.start();
Thread.sleep(1000);
a.interrupt();
a.join();
}
}
public static void main(String[] args) throws InterruptedException {
test();
}
}
複製程式碼
test方法在持有鎖lock的情況下啟動執行緒a,而執行緒a也去嘗試獲得鎖lock,所以會進入鎖等待佇列,隨後test呼叫執行緒a的interrupt方法並等待執行緒執行緒a結束,執行緒a會結束嗎?不會,interrupt方法只會設定執行緒的中斷標誌,而並不會使它從鎖等待佇列中出來。
我們稍微修改下程式碼,去掉test方法中的最後一行a.join,即變為:
public static void test() throws InterruptedException {
synchronized (lock) {
A a = new A();
a.start();
Thread.sleep(1000);
a.interrupt();
}
}
複製程式碼
這時,程式就會退出。為什麼呢?因為主執行緒不再等待執行緒a結束,釋放鎖lock後,執行緒a會獲得鎖,然後檢測到發生了中斷,所以會退出。
在使用synchronized關鍵字獲取鎖的過程中不響應中斷請求,這是synchronized的侷限性。如果這對程式是一個問題,應該使用顯式鎖,後面章節我們會介紹顯式鎖Lock介面,它支援以響應中斷的方式獲取鎖。
NEW/TERMINATE
如果執行緒尚未啟動(NEW),或者已經結束(TERMINATED),則呼叫interrupt()對它沒有任何效果,中斷標誌位也不會被設定。比如說,以下程式碼的輸出都是false。
public class InterruptNotAliveDemo {
private static class A extends Thread {
@Override
public void run() {
}
}
public static void test() throws InterruptedException {
A a = new A();
a.interrupt();
System.out.println(a.isInterrupted());
a.start();
Thread.sleep(100);
a.interrupt();
System.out.println(a.isInterrupted());
}
public static void main(String[] args) throws InterruptedException {
test();
}
}
複製程式碼
IO操作
如果執行緒在等待IO操作,尤其是網路IO,則會有一些特殊的處理,我們沒有介紹過網路,這裡只是簡單介紹下。
- 如果IO通道是可中斷的,即實現了InterruptibleChannel介面,則執行緒的中斷標誌位會被設定,同時,執行緒會收到異常ClosedByInterruptException。
- 如果執行緒阻塞於Selector呼叫,則執行緒的中斷標誌位會被設定,同時,阻塞的呼叫會立即返回。
我們重點介紹另一種情況,InputStream的read呼叫,該操作是不可中斷的,如果流中沒有資料,read會阻塞 (但執行緒狀態依然是RUNNABLE),且不響應interrupt(),與synchronized類似,呼叫interrupt()只會設定執行緒的中斷標誌,而不會真正"中斷"它,我們看段程式碼。
public class InterruptReadDemo {
private static class A extends Thread {
@Override
public void run() {
while(!Thread.currentThread().isInterrupted()){
try {
System.out.println(System.in.read());
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println("exit");
}
}
public static void main(String[] args) throws InterruptedException {
A t = new A();
t.start();
Thread.sleep(100);
t.interrupt();
}
}
複製程式碼
執行緒t啟動後呼叫System.in.read()從標準輸入讀入一個字元,不要輸入任何字元,我們會看到,呼叫interrupt()不會中斷read(),執行緒會一直執行。
不過,有一個辦法可以中斷read()呼叫,那就是呼叫流的close方法,我們將程式碼改為:
public class InterruptReadDemo {
private static class A extends Thread {
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
try {
System.out.println(System.in.read());
} catch (IOException e) {
e.printStackTrace();
}
}
System.out.println("exit");
}
public void cancel() {
try {
System.in.close();
} catch (IOException e) {
}
interrupt();
}
}
public static void main(String[] args) throws InterruptedException {
A t = new A();
t.start();
Thread.sleep(100);
t.cancel();
}
}
複製程式碼
我們給執行緒定義了一個cancel方法,在該方法中,呼叫了流的close方法,同時呼叫了interrupt方法,這次,程式會輸出:
-1
exit
複製程式碼
也就是說,呼叫close方法後,read方法會返回,返回值為-1,表示流結束。
如何正確地取消/關閉執行緒
以上,我們可以看出,interrupt方法不一定會真正"中斷"執行緒,它只是一種協作機制,如果不明白執行緒在做什麼,不應該貿然的呼叫執行緒的interrupt方法,以為這樣就能取消執行緒。
對於以執行緒提供服務的程式模組而言,它應該封裝取消/關閉操作,提供單獨的取消/關閉方法給呼叫者,類似於InterruptReadDemo中演示的cancel方法,外部呼叫者應該呼叫這些方法而不是直接呼叫interrupt。
Java併發庫的一些程式碼就提供了單獨的取消/關閉方法,比如說,Future介面提供瞭如下方法以取消任務:
boolean cancel(boolean mayInterruptIfRunning);
複製程式碼
再比如,ExecutorService提供瞭如下兩個關閉方法:
void shutdown();
List<Runnable> shutdownNow();
複製程式碼
Future和ExecutorService的API文件對這些方法都進行了詳細說明,這是我們應該學習的方式。關於這兩個介面,我們後續章節介紹。
小結
本節主要介紹了在Java中如何取消/關閉執行緒,主要依賴的技術是中斷,但它是一種協作機制,不會強迫終止執行緒,我們介紹了執行緒在不同狀態和IO操作時對中斷的反應,作為執行緒的實現者,應該提供明確的取消/關閉方法,並用文件描述清楚其行為,作為執行緒的呼叫者,應該使用其取消/關閉方法,而不是貿然呼叫interrupt。
從65節到本節,我們介紹的都是關於執行緒的基本內容,在Java中還有一套併發工具包,位於包java.util.concurrent下,裡面包括很多易用且高效能的併發開發工具,從下一節開始,我們就來討論它,先從最基本的原子變數和CAS操作開始。
(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)
未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。