Java并發編程:如何正確停止線程
1. 什么時候需要停止線程?
通常情況下,線程在創建并啟動后,會自然運行到結束。但在某些情況下,我們可能需要在運行過程中停止線程,比如:
- 用戶主動取消執行;
- 線程在運行時發生錯誤或超時,需要停止;
- 服務需要立即關閉。
這些情況都需要我們主動停止線程。然而,安全且可靠地停止線程并不容易。Java 語言并沒有提供一種機制來確保線程能夠立即且正確地停止,但它提供了interrupt方法,這是一種協作機制。
2. 如何正確停止線程?
你可以使用interrupt方法來通知線程應該中斷執行,而被中斷的線程擁有決定權,即它不僅可以決定何時響應中斷并停止,還可以選擇忽略中斷。
換句話說,如果被停止的線程不想被中斷,那么我們除了讓它繼續運行或強制關閉進程外,別無他法。
3. 為什么不強制停止?而是通知、協作
事實上,大多數時候我們想要停止線程時,至少會讓它運行到結束。比如,即使我們在關閉電腦時,也會進行很多收尾工作,結束一些進程并保存一些狀態。
線程也是如此。我們想要中斷的線程可能并不是由我們啟動的,我們對其執行的業務邏輯并不熟悉。如果我們希望它停止,實際上是希望它在停止前完成一系列的保存和交接工作,而不是立即停止。
舉個生活中的例子:
某天下午你得知公司要裁員,覺得自己很可能在名單內,便開始找新工作。幾周后,成功拿到另一家公司 offer。你準備搬到新公司附近,可家里東西多,只能分批處理。搬到一半時,發現公司裁員結束,自己不在名單中。
你十分高興,因為喜歡這家公司,決定留下。但一半物品已搬到新家,還得搬回來。
試想,若此時你決定立刻停止搬家、什么都不做,已搬走的物品就會丟失,這無疑是場災難!
生活中還有很多類似的例子,比如從電腦剪切文件到 U 盤。如果剪切到一半時停止,需要恢復到原來的狀態,不能一半文件在 U 盤,一半在電腦上。
4. 代碼實踐
4.1. 錯誤的線程停止方式
使用stop()方法終止線程執行會導致線程立即停止,這可能會引發意外問題。
public class StopThread implements Runnable {
@Override
public void run() {
System.out.println("Start moving...");
for (int i = 1; i <= 5; i++) {
// 模擬搬家所需時間
int j = 50000;
while (j > 0) {
j--;
}
System.out.println(i + " batches have been moved");
}
System.out.println("End of moving");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new StopThread());
thread.start();
// 稍后嘗試停止
Thread.sleep(2);
thread.stop();
}
}
輸出結果(結果可能因計算機性能不同而有所差異,你可以調整時間以獲得相同的輸出):
Start moving...
1 batches have been moved
2 batches have been moved
3 batches have been moved
可以看到,stop強制線程結束,導致只搬了三批物品,結束后也沒有搬回來!
出于安全考慮,stop方法已被官方棄用。你可以在源碼中看到它被標記為過時。
@Deprecated
public final void stop() {
SecurityManager security = System.getSecurityManager();
if (security != null) {
checkAccess();
if (this != Thread.currentThread()) {
security.checkPermission(SecurityConstants.STOP_THREAD_PERMISSION);
}
}
}
4.2. 直接使用interrupt方法,線程并未停止
在主線程中使用interrupt方法中斷目標線程,但目標線程并未感知到中斷標志,即它不打算處理中斷信號。
public class InterruptThreadWithoutFlag implements Runnable {
@Override
public void run() {
System.out.println("Start moving...");
for (int i = 1; i <= 5; i++) {
// 模擬搬家所需時間
int j = 50000;
while (j > 0) {
j--;
}
System.out.println(i + " batches have been moved");
}
System.out.println("End of moving");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new StopThread());
thread.start();
// 稍后
Thread.sleep(2);
thread.interrupt();
}
}
輸出:
Start moving...
1 batches have been moved
2 batches have been moved
3 batches have been moved
4 batches have been moved
5 batches have been moved
End of moving
你會發現沒有任何效果。我們使用interrupt中斷了這個線程,但它似乎完全忽略了我們的中斷信號。就像前面提到的,線程是否停止取決于它自己,因此我們需要修改線程的邏輯,使其能夠響應中斷,從而停止線程。
4.3. 使用interrupt時,線程識別中斷標志
當指定線程被中斷時,在線程內部調用Thread.currentThread().isInterrupted()會返回true,可以根據此進行中斷后的處理邏輯。
public class InterruptThread implements Runnable {
@Override
public void run() {
System.out.println("Start moving...");
for (int i = 1; i <= 5; i++) {
if (Thread.currentThread().isInterrupted()) {
// 做一些收尾工作
break;
}
// 模擬搬家所需時間
int j = 50000;
while (j > 0) {
j--;
}
System.out.println(i + " batches have been moved");
}
System.out.println("End of moving");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new InterruptThread());
thread.start();
Thread.sleep(2);
thread.interrupt();
}
}
輸出(結果可能不一致):
Start moving...
1 batches have been moved
End of moving
從輸出結果來看,它與使用stop方法的結果類似,顯然線程在執行完之前被停止了,interrupt()方法的中斷是有效的,這是一種標準的處理方式。
4.4. 中斷某個線程時,線程正在睡眠
如果線程處理中使用了sleep方法,在sleep期間的中斷也可以響應,而無需檢查中斷標志。
例如,使用Thread.sleep(1)模擬每次搬家所需的時間。在主線程中,等待 3ms 后中斷,因此預計在搬完 2 到 3 批物品后會被中斷。代碼如下:
public class InterruptWithSleep implements Runnable {
@Override
public void run() {
System.out.println("Start moving...");
for (int i = 1; i <= 5; i++) {
// 模擬搬家所需時間
try {
Thread.sleep(1);
System.out.println(i + " batches have been moved");
} catch (InterruptedException e) {
System.out.println(e.getMessage());
break;
}
}
System.out.println("End of moving");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new InterruptWithSleep());
thread.start();
// 稍后
Thread.sleep(3);
thread.interrupt();
}
}
輸出:
Start moving...
1 batches have been moved
2 batches have been moved
sleep interrupted
End of moving
發現了嗎?額外輸出了sleep interrupted。這是因為發生了中斷異常,我們在catch到InterruptedException后輸出了e.getMessage()。
為什么會拋出異常?
這是因為當線程處于sleep狀態時,如果接收到中斷信號,線程會響應這個中斷,而響應中斷的方式非常特殊,就是拋出java.lang.InterruptedException: sleep interrupted異常。
因此,當我們的程序中有sleep方法的邏輯,或者可以阻塞線程的方法(如wait、join等),并且可能會被中斷時,我們需要注意處理InterruptedException異常。我們可以將其放在catch中,這樣當線程進入阻塞過程時,仍然可以響應中斷并進行處理。
4.5. 當sleep方法與isInterrupted結合使用時會發生什么?
你注意到在示例 3 的代碼中,我們在捕獲異常后使用了break來主動結束循環嗎?那么,我們是否可以在catch中不使用break,而是在循環入口處判斷isInterrupted是否為true呢?
讓我們試試:
public class SleepWithIsInterrupted implements Runnable {
@Override
public void run() {
System.out.println("Start moving...");
for (int i = 1; i <= 5; i++) {
if (Thread.currentThread().isInterrupted()) {
// 做一些收尾工作
break;
}
// 模擬搬家所需時間
try {
Thread.sleep(1);
System.out.println(i + " batches have been moved");
} catch (InterruptedException e) {
System.out.println(e.getMessage());
}
}
System.out.println("End of moving");
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new SleepWithIsInterrupted());
thread.start();
// 稍后
Thread.sleep(3);
thread.interrupt();
}
}
輸出(你可能需要調整主線程執行Thread.sleep的時間以獲得相同的輸出):
Start moving...
1 batches have been moved
2 batches have been moved
sleep interrupted
4 batches have been moved
5 batches have been moved
End of moving
為什么在輸出sleep interrupted后,它繼續搬了第四和第五批物品?
原因是,一旦sleep()響應了中斷,它會重置isInterrupted()方法中的標志,因此在上面的代碼中,循環條件檢查時,Thread.currentThread().isInterrupted()的結果始終為false,導致程序無法退出。
一般來說,在實際的業務代碼中,主邏輯更為復雜,因此不建議在這里直接使用try-catch處理中斷異常,而是直接將異常向上拋出,由調用方處理。
可以將當前邏輯封裝到一個單獨的方法中,并將中斷后的收尾處理也封裝到另一個方法中,如下所示:
public class SleepSplitCase implements Runnable {
@Override
public void run() {
try {
move();
} catch (InterruptedException e) {
System.out.println(e.getMessage());
goBack();
}
}
private void move() throws InterruptedException {
System.out.println("Start moving...");
for (int i = 1; i <= 5; i++) {
// 模擬搬家所需時間
Thread.sleep(1);
System.out.println(i + " batches have been moved");
}
System.out.println("End of moving");
}
private void goBack() {
// 做一些收尾工作
}
public static void main(String[] args) throws InterruptedException {
Thread thread = new Thread(new SleepSplitCase());
thread.start();
// 稍后
Thread.sleep(3);
thread.interrupt();
}
}
4.6. 重新中斷
有沒有辦法在catch之外處理goBack方法?
如前所述,當中斷發生并拋出InterruptedException時,isInterrupted的結果會被重置為false。但是,支持再次調用interrupt,這會使isInterrupted的結果變為true。
基于這個前提,我們可以在示例 5 的實現中將run方法改為以下形式:
@Override
public void run() {
try {
move();
} catch (InterruptedException e) {
System.out.println(e.getMessage());
Thread.currentThread().interrupt();
}
if (Thread.currentThread().isInterrupted()) {
goBack();
}
}
這樣可以避免在catch代碼塊中處理業務邏輯!
4.7 判斷中斷是否發生的方法
- boolean isInterrupted(): 判斷當前線程是否被中斷;
- static boolean interrupted(): 判斷當前線程是否被中斷,但在調用后會將中斷標志直接設置為false,即清除中斷標志。
注意,interrupted()方法的目標是當前線程,無論該方法是從哪個實例對象調用的,從源碼中可以很容易看出:
public class CheckInterrupt {
public static void main(String[] args) throws InterruptedException {
Thread subThread = new Thread(() -> {
// 無限循環
for (; ; ) {
}
});
subThread.start();
subThread.interrupt();
// 獲取中斷標志
System.out.println("isInterrupted: " + subThread.isInterrupted());
// 獲取中斷標志并重置
// (盡管 interrupted() 是由 subThread 線程調用的,但實際執行的是當前線程。)
System.out.println("isInterrupted: " + subThread.interrupted());
// 中斷當前線程
Thread.currentThread().interrupt();
System.out.println("isInterrupted: " + subThread.interrupted());
// Thread.interrupted() 與 subThread.interrupted() 效果相同
System.out.println("isInterrupted: " + Thread.interrupted());
}
}
輸出:
isInterrupted: true
isInterrupted: false
isInterrupted: true
isInterrupted: false
interrupted()會重置中斷標志,因此最后的輸出結果變為false。
5. JDK 內置的可以響應中斷的方法
主要有以下方法可以響應中斷并拋出InterruptedException:
- Object.wait()/wait(long)/wait(long, int)
- Thread.sleep(long)/sleep(long, int)
- Thread.join()/join(long)/join(long, int)
- java.util.concurrent.BlockingQueue.take()/put(E)
- java.util.concurrent.locks.Lock.lockInterruptibly()
- java.util.concurrent.CountDownLatch.await
- java.util.concurrent.CyclicBarrier.await
- java.util.concurrent.Exchanger.exchange(V)
- java.nio.channels.InterruptibleChannel的相關方法
- java.nio.channels.Selector的相關方法