1. ExecutorService:像專業人士一樣管理執行緒
為什麼不該只是用 new Thread 直接建立執行緒
剛開始學多執行緒時,一切看起來都很簡單:
Thread t = new Thread(() -> {
// 做點事
});
t.start();
這樣的方式可行,但當任務變多時很快就成為負擔。每次呼叫 new Thread() 都會建立新的執行緒,而數十或數百個執行緒會讓系統過載。而且管理起來不方便:要追蹤它們何時結束、發生錯誤該怎麼辦、如何停止並重複利用,等等。
這時就輪到 ExecutorService — 智慧的執行緒調度器登場。你只需把任務交給它,它會自行決定由哪個執行緒、何時來執行。結果通常更快、更穩定,也更省心。
ExecutorService 的運作方式
ExecutorService 的原理簡單但有效。
- 內部有一個執行緒池 — 預先建立的一組工作執行緒(固定或動態)。
- 任務會進入佇列,並由空閒的執行緒取走執行。
- 服務會管理生命週期:你可以等待完成、正常地關閉執行緒池並釋放資源。
建立 ExecutorService
最常見的方式 — 使用 Executors 類別的工廠方法:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
ExecutorService executor = Executors.newFixedThreadPool(4); // 4 個執行緒
- newFixedThreadPool(N) — 含 N 個執行緒的池(適用於多數情境)。
- newCachedThreadPool() — 動態池,按需建立執行緒(小心:任務暴增時可能耗盡記憶體)。
- newSingleThreadExecutor() — 單一執行緒(順序執行)。
範例:透過 Runnable 在 ExecutorService 中執行
executor.submit(() -> {
System.out.println("來自執行緒池的問候!");
});
當你完成對 ExecutorService 的使用後,必須正確地關閉它:
executor.shutdown(); // 禁止新增任務,並等待目前的任務完成
重要:如果不呼叫 shutdown(),程式可能不會結束 — 池中的執行緒會等待新任務。
2. Runnable vs Callable:任務有不同的型態
在 Java 5 之前,若你想在執行緒中做點事,會撰寫 Runnable 的實作。這種任務不會回傳任何結果,也不會拋出受檢例外。
Runnable task = () -> {
System.out.println("只是執行,不會回傳任何東西!");
};
executor.submit(task);
Callable:有結果的任務(並可拋出例外)
有時我們希望任務不只「做點事」,還能回傳結果——例如數字總和、計算結果,或從伺服器取得的資料。這就是介面 Callable<T> 的用途。
import java.util.concurrent.Callable;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
- call() 方法會回傳型別為 T 的結果。
- call() 方法可以拋出受檢例外。
類比:Runnable — 「去把碗洗好」(結果不重要),Callable — 「去端杯茶回來,並說說它的溫度」(結果很重要)。
啟動 Callable:若要取得結果,請使用 executor.submit(...)。它會回傳 Future<T> 物件。
3. Future:對結果的承諾
Future 就是「對未來會回傳結果的承諾」。當你把任務交給 ExecutorService 時,你會得到一個 Future,之後可以從中取得結果、查詢任務是否完成,或取消任務。
Future 的主要方法
- T get() — 取得結果(會等待任務完成)。
- boolean isDone() — 任務是否已完成。
- boolean cancel(boolean mayInterruptIfRunning) — 嘗試取消任務。
- boolean isCancelled() — 任務是否已被取消。
範例:啟動 Callable 並取得結果
import java.util.concurrent.*;
public class ParallelSumApp {
public static void main(String[] args) throws Exception {
ExecutorService executor = Executors.newFixedThreadPool(2);
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int i = 1; i <= 100; i++) sum += i;
return sum;
};
Future<Integer> future = executor.submit(sumTask);
System.out.println("任務已啟動,可以先去做其他事情...");
// 取得結果(如果任務尚未完成,該方法會阻塞執行緒)
Integer result = future.get();
System.out.println("計算結果:" + result);
executor.shutdown();
}
}
- 任務被送入執行緒池。
- 在任務執行期間,主執行緒可以先去做其他事。
- 當需要結果時,呼叫 future.get() —— 若任務尚在執行,呼叫端會等待。
- 任務一完成,就能取得結果。
4. 實作:多個任務與等待完成
我們常需要一次啟動多個任務並等待全部完成。例如處理一個資料陣列,把它切分成多個部分,分別在各自的任務中計算每一部分的總和。
範例:分塊計算陣列元素的總和
import java.util.*;
import java.util.concurrent.*;
public class ParallelArraySum {
public static void main(String[] args) throws Exception {
int[] array = new int[1000];
Arrays.setAll(array, i -> i + 1); // 填入 1 到 1000 的數字
ExecutorService executor = Executors.newFixedThreadPool(4);
int chunkSize = array.length / 4;
List<Future<Integer>> futures = new ArrayList<>();
for (int i = 0; i < 4; i++) {
int from = i * chunkSize;
int to = (i == 3) ? array.length : (i + 1) * chunkSize;
Callable<Integer> sumTask = () -> {
int sum = 0;
for (int j = from; j < to; j++) sum += array[j];
System.out.println("從 " + from + " 到 " + (to - 1) + " 的總和 = " + sum);
return sum;
};
futures.add(executor.submit(sumTask));
}
int totalSum = 0;
for (Future<Integer> f : futures) {
totalSum += f.get(); // 逐一等待每個任務
}
System.out.println("總計:" + totalSum);
executor.shutdown();
}
}
這裡把陣列切成 4 個部分。對每一部分建立任務(Callable)來計算其總和。所有任務送交 ExecutorService,並取得 Future。最後收集每個任務的結果並相加。
在實務情境中,使用 invokeAll 一次等待所有任務完成會更方便。
5. 使用 Future 時的錯誤處理
當你呼叫 future.get() 時,若任務以例外結束,例外會被包裝成 ExecutionException。這點很重要:如果任務出了問題,你往往會在呼叫 get() 時才得知。
範例:處理例外
Callable<Integer> errorTask = () -> {
throw new IllegalArgumentException("出了點問題!");
};
Future<Integer> badFuture = executor.submit(errorTask);
try {
badFuture.get();
} catch (ExecutionException e) {
System.out.println("任務以錯誤結束:" + e.getCause());
}
- 在任務內拋出例外。
- 呼叫 get() 時,例外會被「包裝」在 ExecutionException 中。
- 可透過 getCause() 取得真正的原因。
6. 實用細節
如何取消任務
Future<?> f = executor.submit(() -> {
while (true) {
// 無窮迴圈工作
if (Thread.currentThread().isInterrupted()) {
System.out.println("有人請我結束了!");
break;
}
}
});
Thread.sleep(100); // 稍微等一下
f.cancel(true); // 試著取消任務
- cancel(true) 會嘗試中斷尚未完成的任務。
- 在任務內建議檢查 Thread.currentThread().isInterrupted() 並妥善結束。
shutdown vs shutdownNow
shutdown() — 溫和停止:禁止加入新任務,讓目前的任務自行完成。最常用。
shutdownNow() — 強制停止:嘗試中斷正在執行的執行緒,並回傳尚未開始的任務清單。請謹慎使用。
invokeAll 與 invokeAny
invokeAll(Collection<Callable<T>> tasks) 會啟動所有傳入的任務並等待全部完成。回傳 Future 的清單。
invokeAny(Collection<Callable<T>> tasks) 只會等待第一個成功完成的任務,回傳其結果並取消其餘任務。當你只在意第一個成功回應時相當實用。
7. 使用 ExecutorService、Callable 與 Future 的常見錯誤
錯誤 1:沒有關閉 ExecutorService。 如果忘了呼叫 shutdown(),程式可能會在 main 結束後仍然「掛著」,因為執行緒池在等待新任務。
錯誤 2:在送出任務後立刻等待結果。 如果在 submit() 之後立即呼叫 get(),就無法享受非同步的好處——呼叫端仍會被阻塞。請在等待結果前,並行地做其他有用的工作,只在真正需要時才取得結果。
錯誤 3:忽略任務中的例外。 如果在呼叫 get() 時不處理 ExecutionException,可能會錯過任務中發生的重要錯誤。
錯誤 4:在多個任務間未同步地共享可變狀態。 若多個任務操作同一份資料——就需要同步機制或使用具備執行緒安全的集合。
錯誤 5:建立過多的執行緒。 不要讓執行緒池的大小遠大於處理器核心數——這反而可能讓執行變慢。
錯誤 6:忘了取消不再需要的任務。 如果任務已經不需要,請透過 cancel() 取消它,以免浪費資源。
GO TO FULL VERSION