詳細分析Java中斷機制

InfoQ - 丁一發表於2015-01-21

1. 引言

當我們點選某個防毒軟體的取消按鈕來停止查殺病毒時,當我們在控制檯敲入quit命令以結束某個後臺服務時……都需要通過一個執行緒去取消另一個執行緒正在執行的任務。Java沒有提供一種安全直接的方法來停止某個執行緒,但是Java提供了中斷機制。

如果對Java中斷沒有一個全面的瞭解,可能會誤以為被中斷的執行緒將立馬退出執行,但事實並非如此。中斷機制是如何工作的?捕獲或檢測到中斷後,是丟擲InterruptedException還是重設中斷狀態以及在方法中吞掉中斷狀態會有什麼後果?Thread.stop與中斷相比又有哪些異同?什麼情況下需要使用中斷?本文將從以上幾個方面進行描述。

2. 中斷的原理

Java中斷機制是一種協作機制,也就是說通過中斷並不能直接終止另一個執行緒,而需要被中斷的執行緒自己處理中斷。這好比是家裡的父母叮囑在外的子女要注意身體,但子女是否注意身體,怎麼注意身體則完全取決於自己。

Java中斷模型也是這麼簡單,每個執行緒物件裡都有一個boolean型別的標識(不一定就要是Thread類的欄位,實際上也的確不是,這幾個方法最終都是通過native方法來完成的),代表著是否有中斷請求(該請求可以來自所有執行緒,包括被中斷的執行緒本身)。例如,當執行緒t1想中斷執行緒t2,只需要線上程t1中將執行緒t2物件的中斷標識置為true,然後執行緒2可以選擇在合適的時候處理該中斷請求,甚至可以不理會該請求,就像這個執行緒沒有被中斷一樣。

java.lang.Thread類提供了幾個方法來操作這個中斷狀態,這些方法包括:

public static booleaninterrupted
測試當前執行緒是否已經中斷。執行緒的中斷狀態 由該方法清除。換句話說,如果連續兩次呼叫該方法,則第二次呼叫將返回 false(在第一次呼叫已清除了其中斷狀態之後,且第二次呼叫檢驗完中斷狀態前,當前執行緒再次中斷的情況除外)。
public booleanisInterrupted() 測試執行緒是否已經中斷。執行緒的中斷狀態不受該方法的影響。
public voidinterrupt() 中斷執行緒。

其中,interrupt方法是唯一能將中斷狀態設定為true的方法。靜態方法interrupted會將當前執行緒的中斷狀態清除,但這個方法的命名極不直觀,很容易造成誤解,需要特別注意。

上面的例子中,執行緒t1通過呼叫interrupt方法將執行緒t2的中斷狀態置為true,t2可以在合適的時候呼叫interrupted或isInterrupted來檢測狀態並做相應的處理。

此外,類庫中的有些類的方法也可能會呼叫中斷,如FutureTask中的cancel方法,如果傳入的引數為true,它將會在正在執行非同步任務的執行緒上呼叫interrupt方法,如果正在執行的非同步任務中的程式碼沒有對中斷做出響應,那麼cancel方法中的引數將不會起到什麼效果;又如ThreadPoolExecutor中的shutdownNow方法會遍歷執行緒池中的工作執行緒並呼叫執行緒的interrupt方法來中斷執行緒,所以如果工作執行緒中正在執行的任務沒有對中斷做出響應,任務將一直執行直到正常結束。

3. 中斷的處理

既然Java中斷機制只是設定被中斷執行緒的中斷狀態,那麼被中斷執行緒該做些什麼?

處理時機

顯然,作為一種協作機制,不會強求被中斷執行緒一定要在某個點進行處理。實際上,被中斷執行緒只需在合適的時候處理即可,如果沒有合適的時間點,甚至可以不處理,這時候在任務處理層面,就跟沒有呼叫中斷方法一樣。“合適的時候”與執行緒正在處理的業務邏輯緊密相關,例如,每次迭代的時候,進入一個可能阻塞且無法中斷的方法之前等,但多半不會出現在某個臨界區更新另一個物件狀態的時候,因為這可能會導致物件處於不一致狀態。

處理時機決定著程式的效率與中斷響應的靈敏性。頻繁的檢查中斷狀態可能會使程式執行效率下降,相反,檢查的較少可能使中斷請求得不到及時響應。如果發出中斷請求之後,被中斷的執行緒繼續執行一段時間不會給系統帶來災難,那麼就可以將中斷處理放到方便檢查中斷,同時又能從一定程度上保證響應靈敏度的地方。當程式的效能指標比較關鍵時,可能需要建立一個測試模型來分析最佳的中斷檢測點,以平衡效能和響應靈敏性。

處理方式

1、 中斷狀態的管理

一般說來,當可能阻塞的方法宣告中有丟擲InterruptedException則暗示該方法是可中斷的,如BlockingQueue#put、BlockingQueue#take、Object#wait、Thread#sleep等,如果程式捕獲到這些可中斷的阻塞方法丟擲的InterruptedException或檢測到中斷後,這些中斷資訊該如何處理?一般有以下兩個通用原則:

  • 如果遇到的是可中斷的阻塞方法丟擲InterruptedException,可以繼續向方法呼叫棧的上層丟擲該異常,如果是檢測到中斷,則可清除中斷狀態並丟擲InterruptedException,使當前方法也成為一個可中斷的方法。
  • 若有時候不太方便在方法上丟擲InterruptedException,比如要實現的某個介面中的方法簽名上沒有throws InterruptedException,這時就可以捕獲可中斷方法的InterruptedException並通過Thread.currentThread.interrupt()來重新設定中斷狀態。如果是檢測並清除了中斷狀態,亦是如此。

一般的程式碼中,尤其是作為一個基礎類庫時,絕不應當吞掉中斷,即捕獲到InterruptedException後在catch裡什麼也不做,清除中斷狀態後又不重設中斷狀態也不丟擲InterruptedException等。因為吞掉中斷狀態會導致方法呼叫棧的上層得不到這些資訊。

當然,凡事總有例外的時候,當你完全清楚自己的方法會被誰呼叫,而呼叫者也不會因為中斷被吞掉了而遇到麻煩,就可以這麼做。

總得來說,就是要讓方法呼叫棧的上層獲知中斷的發生。假設你寫了一個類庫,類庫裡有個方法amethod,在amethod中檢測並清除了中斷狀態,而沒有丟擲InterruptedException,作為amethod的使用者來說,他並不知道里面的細節,如果使用者在呼叫amethod後也要使用中斷來做些事情,那麼在呼叫amethod之後他將永遠也檢測不到中斷了,因為中斷資訊已經被amethod清除掉了。如果作為使用者,遇到這樣有問題的類庫,又不能修改程式碼,那該怎麼處理?只好在自己的類裡設定一個自己的中斷狀態,在呼叫interrupt方法的時候,同時設定該狀態,這實在是無路可走時才使用的方法。

2、 中斷的響應

程式裡發現中斷後該怎麼響應?這就得視實際情況而定了。有些程式可能一檢測到中斷就立馬將執行緒終止,有些可能是退出當前執行的任務,繼續執行下一個任務……作為一種協作機制,這要與中斷方協商好,當呼叫interrupt會發生些什麼都是事先知道的,如做一些事務回滾操作,一些清理工作,一些補償操作等。若不確定呼叫某個執行緒的interrupt後該執行緒會做出什麼樣的響應,那就不應當中斷該執行緒。

4. Thread.interrupt VS Thread.stop

Thread.stop方法已經不推薦使用了。而在某些方面Thread.stop與中斷機制有著相似之處。如當執行緒在等待內建鎖或IO時,stop跟interrupt一樣,不會中止這些操作;當catch住stop導致的異常時,程式也可以繼續執行,雖然stop本意是要停止執行緒,這麼做會讓程式行為變得更加混亂。

那麼它們的區別在哪裡?最重要的就是中斷需要程式自己去檢測然後做相應的處理,而Thread.stop會直接在程式碼執行過程中丟擲ThreadDeath錯誤,這是一個java.lang.Error的子類。

在繼續之前,先來看個小例子:

package com.ticmy.interrupt;
import java.util.Arrays;
import java.util.Random;
import java.util.concurrent.TimeUnit;
public class TestStop {
	private static final int[] array = new int[80000];
	private static final Thread t = new Thread() {
		public void run() {
			try {
				System.out.println(sort(array));
			} catch (Error err) {
				err.printStackTrace();
			}
			System.out.println("in thread t");
		}
	};

	static {
		Random random = new Random();
		for(int i = 0; i < array.length; i++) {
			array[i] = random.nextInt(i + 1);
		}
	}

	private static int sort(int[] array) {
		for (int i = 0; i < array.length-1; i++){
			for(int j = 0 ;j < array.length - i - 1; j++){
				if(array[j] < array[j + 1]){
					int temp = array[j];
					array[j] = array[j + 1];
					array[j + 1] = temp;
				}
			}
		}
		return array[0];
	}

	public static void main(String[] args) throws Exception {
		t.start();
		TimeUnit.SECONDS.sleep(1);
		System.out.println("go to stop thread t");
		t.stop();
		System.out.println("finish main");
	}
}

這個例子很簡單,執行緒t裡面做了一個非常耗時的排序操作,排序方法中,只有簡單的加、減、賦值、比較等操作,一個可能的執行結果如下:

 
go to stop thread t
java.lang.ThreadDeath
	at java.lang.Thread.stop(Thread.java:758)
	at com.ticmy.interrupt.TestStop.main(TestStop.java:44)
finish main
in thread t

這裡sort方法是個非常耗時的操作,也就是說主執行緒休眠一秒鐘後呼叫stop的時候,執行緒t還在執行sort方法。就是這樣一個簡單的方法,也會丟擲錯誤!換一句話說,呼叫stop後,大部分Java位元組碼都有可能丟擲錯誤,哪怕是簡單的加法!

如果執行緒當前正持有鎖,stop之後則會釋放該鎖。由於此錯誤可能出現在很多地方,那麼這就讓程式設計人員防不勝防,極易造成物件狀態的不一致。例如,物件obj中存放著一個範圍值:最小值low,最大值high,且low不得大於high,這種關係由鎖lock保護,以避免併發時產生競態條件而導致該關係失效。假設當前low值是5,high值是10,當執行緒t獲取lock後,將low值更新為了15,此時被stop了,真是糟糕,如果沒有捕獲住stop導致的Error,low的值就為15,high還是10,這導致它們之間的小於關係得不到保證,也就是物件狀態被破壞了!如果在給low賦值的時候catch住stop導致的Error則可能使後面high變數的賦值繼續,但是誰也不知道Error會在哪條語句丟擲,如果物件狀態之間的關係更復雜呢?這種方式幾乎是無法維護的,太複雜了!如果是中斷操作,它決計不會在執行low賦值的時候丟擲錯誤,這樣程式對於物件狀態一致性就是可控的。

正是因為可能導致物件狀態不一致,stop才被禁用。

5. 中斷的使用

通常,中斷的使用場景有以下幾個:

  • 點選某個桌面應用中的取消按鈕時;
  • 某個操作超過了一定的執行時間限制需要中止時;
  • 多個執行緒做相同的事情,只要一個執行緒成功其它執行緒都可以取消時;
  • 一組執行緒中的一個或多個出現錯誤導致整組都無法繼續時;
  • 當一個應用或服務需要停止時。

下面來看一個具體的例子。這個例子裡,本打算採用GUI形式,但考慮到GUI程式碼會使程式複雜化,就使用控制檯來模擬下核心的邏輯。這裡新建了一個磁碟檔案掃描的任務,掃描某個目錄下的所有檔案並將檔案路徑列印到控制檯,掃描的過程可能會很長。若需要中止該任務,只需在控制檯鍵入quit並回車即可。

package com.ticmy.interrupt;
import java.io.BufferedReader;
import java.io.File;
import java.io.InputStreamReader;

public class FileScanner {
	private static void listFile(File f) throws InterruptedException {
		if(f == null) {
			throw new IllegalArgumentException();
		}
		if(f.isFile()) {
			System.out.println(f);
			return;
		}
		File[] allFiles = f.listFiles();
		if(Thread.interrupted()) {
			throw new InterruptedException("檔案掃描任務被中斷");
		}
		for(File file : allFiles) {
			//還可以將中斷檢測放到這裡
			listFile(file);
		}
	}

	public static String readFromConsole() {
		BufferedReader reader = new BufferedReader(new InputStreamReader(System.in));
		try {
			return reader.readLine();
		} catch (Exception e) {
			e.printStackTrace();
			return "";
		}
	}

	public static void main(String[] args) throws Exception {
		final Thread fileIteratorThread = new Thread() {
			public void run() {
				try {
					listFile(new File("c:\\"));
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
			}
		};
		new Thread() {
			public void run() {
				while(true) {
					if("quit".equalsIgnoreCase(readFromConsole())) {
						if(fileIteratorThread.isAlive()) {
							fileIteratorThread.interrupt();
							return;
						}
					} else {
						System.out.println("輸入quit退出檔案掃描");
					}
				}
			}
		}.start();
		fileIteratorThread.start();
	}
}

在掃描檔案的過程中,對於中斷的檢測這裡採用的策略是,如果碰到的是檔案就不檢測中斷,是目錄才檢測中斷,因為檔案可能是非常多的,每次遇到檔案都檢測一次會降低程式執行效率。此外,在fileIteratorThread執行緒中,僅是捕獲了InterruptedException,沒有重設中斷狀態也沒有繼續丟擲異常,因為我非常清楚它的使用環境,run方法的呼叫棧上層已經沒有可能需要檢測中斷狀態的方法了。

在這個程式中,輸入quit完全可以執行System.exit(0)操作來退出程式,但正如前面提到的,這是個GUI程式核心邏輯的模擬,在GUI中,執行System.exit(0)會使得整個程式退出。

相關文章