Learning Objectives
- 分辨程序與執行緒,說明為什麼需要多執行緒
- 用繼承 Thread、實作 Runnable、Lambda 三種寫法建立執行緒,分辨 start() 與 run()
- 用 join 等待執行緒結束,說明多個執行緒的執行順序不確定
- 說明 count++ 不是不可分割的操作,會造成競爭條件,並用 synchronized 保護共用資料
- 說明 JVM 會等所有非 daemon 執行緒結束才離開,並認識 AtomicInteger 與 ExecutorService
程序與執行緒
你執行一個 Java 程式,作業系統就建立一個程序(process):它有自己的一塊記憶體,和其他程式互不干擾。 一個程序裡可以有好幾條執行緒(thread):每條執行緒各自從某個地方開始、一行一行往下執行,有自己的呼叫堆疊(stack)與區域變數, 但共用同一個程序的堆積(heap),也就是同一批物件與 static 欄位。
到目前為止你寫的程式都只有一條執行緒:JVM 啟動時建立的 main 執行緒,從 main 方法開始執行。為什麼需要更多條?
- 不要卡住:視窗程式在下載檔案時,如果只有一條執行緒,整個畫面會凍結;把下載交給另一條執行緒,畫面仍能回應
- 同時等待:伺服器同時服務很多位使用者,每個連線大部分時間在等網路,各用一條執行緒等待就不會互相耽誤
- 用上多核心:現在的 CPU 有好幾個核心,把大量計算切成幾份交給不同執行緒,可以同時算
代價是:多條執行緒同時讀寫同一份資料時,結果可能出錯,而且錯誤不一定每次都出現。這一課的重點就是看懂這個問題,以及怎麼避免。
本站的教學直譯器不執行真正的執行緒(寫
new Thread會出現說明訊息)。這一課的程式碼區塊標明「請在自己電腦的 JDK 執行」的, 請複製到自己電腦執行;網頁上的互動練習則用逐步動畫與「多執行緒模擬器」完成。
建立執行緒的三種寫法
執行緒要做的事寫在 run() 方法裡,呼叫 start() 讓它開始。(這段請在自己電腦的 JDK 執行。)
// 寫法一:繼承 Thread,覆寫 run()
class Worker extends Thread {
@Override
public void run() {
for (int i = 1; i <= 3; i++) {
System.out.println("Worker:" + i);
}
}
}
// 寫法二:實作 Runnable 介面,把工作交給 Thread
class Task implements Runnable {
@Override
public void run() {
System.out.println("Task 工作中");
}
}
public class Main {
public static void main(String[] args) {
new Worker().start();
Thread t = new Thread(new Task());
t.start();
// 寫法三:Runnable 只有一個抽象方法 run(),可以直接寫成 Lambda
Thread t2 = new Thread(() -> System.out.println("Lambda 工作中"));
t2.start();
System.out.println("main 繼續做自己的事");
}
}
三種寫法的比較:
| 寫法 | 說明 |
|---|---|
繼承 Thread |
寫起來直接,但 Java 只能單一繼承,這個類別就不能再繼承別的類別 |
實作 Runnable |
「要做的工作」和「執行它的執行緒」分開,同一個工作可以交給不同的執行緒或執行緒池;一般建議用這種 |
| Lambda | 實作 Runnable 的簡寫(Lambda 是 Java 8 加入的語法,第 17 課詳細介紹),工作很短時最方便 |
start() 和 run() 不一樣:
t.start():建立一條新的執行緒,由那條新執行緒去呼叫run();start()本身馬上返回,main 繼續往下執行,兩邊同時進行t.run():只是一般的方法呼叫,在目前的執行緒裡把run()執行完才往下走,沒有產生任何新執行緒- 同一個
Thread物件只能start()一次,第二次會拋出IllegalThreadStateException;執行緒結束後也不能重新啟動
直接呼叫 run():只是一般的方法呼叫
這支程式沒有建立任何 Thread,只用 Runnable 物件直接呼叫 run()。看左邊的框架:run() 的框架疊在 main 上面,執行完才回到 main,所以輸出永遠是 A、A、B、B、C 的固定順序。換成 new Thread(a).start(),main 不會等 run() 做完,順序就不再固定。
join、sleep 與不確定的執行順序
啟動執行緒之後,什麼時候輪到哪一條執行緒、每次執行多久,由作業系統的排程器(scheduler)決定,你沒辦法控制。 下面這段程式每次執行,輸出的順序都可能不同。(這段請在自己電腦的 JDK 執行。)
public class Main {
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
System.out.println("A" + i);
}
});
Thread b = new Thread(() -> {
for (int i = 1; i <= 3; i++) {
System.out.println("B" + i);
}
});
a.start();
b.start();
a.join(); // 等 a 結束
b.join(); // 等 b 結束
System.out.println("兩個都做完了");
}
}
可能的輸出之一是 A1 B1 B2 A2 A3 B3,下一次可能是 A1 A2 A3 B1 B2 B3。每一條執行緒自己內部的順序是固定的(A1 一定在 A2 前面),
但兩條執行緒之間怎麼穿插,每次都不一樣。唯一確定的是最後一行:join() 會讓呼叫它的執行緒停下來,等那條執行緒結束才繼續,
所以「兩個都做完了」一定最後印出。
Thread.sleep(500):讓目前的執行緒暫停大約 500 毫秒,期間把 CPU 讓給別的執行緒。常用來模擬耗時的工作join()和sleep()都會拋出InterruptedException(別的執行緒可以呼叫interrupt()打斷等待),它是檢查型例外,所以main要宣告throws InterruptedException或用 try-catch 處理- 用
sleep來「等別的執行緒做完」是錯誤的做法:你不知道要等多久。要等就用join()
想一想:start、run 與 join
下面的程式會印出什麼?(程式裡沒有建立任何執行緒)
class Hello implements Runnable {
private String msg;
Hello(String msg) {
this.msg = msg;
}
public void run() {
System.out.print(msg + " ");
}
}
public class Main {
public static void main(String[] args) {
Runnable a = new Hello("A");
Runnable b = () -> System.out.print("B ");
b.run();
a.run();
System.out.println("main");
}
}
Thread t = new Thread(task); 之後,寫成 t.run(); 而不是 t.start();,會發生什麼事?
同一個 Thread 物件 t,連續呼叫兩次 t.start();,會怎樣?
競爭條件:count++ 不是一個動作
兩條執行緒各把共用的 count 加 100,000 次,最後應該是 200,000。(這段請在自己電腦的 JDK 執行。)
public class Main {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println(count); // 常常小於 200000,而且每次不同
}
}
在多核心的電腦上執行幾次,常常會印出比 200,000 小的數字,而且每次都不一樣。原因是 count++ 看起來是一行,JVM 執行時其實分成三個動作:
- 讀取
count目前的值 - 把讀到的值加 1
- 把結果寫回
count
如果 A 讀到 5、還沒寫回,B 也讀到 5;兩邊各加 1 得到 6,先後寫回 6。加了兩次,count 卻只多 1:一次更新遺失了。
這種「結果取決於執行緒剛好以什麼順序穿插」的錯誤叫競爭條件(race condition)。它很難除錯:大部分時候結果正確,偶爾才出錯,加上 println 觀察又可能改變時間點,讓錯誤消失。
下面的動畫不用真正的執行緒,而是用 regA、regB 兩個變數代表兩條執行緒各自的暫存值,把三個動作一個一個寫出來。
一步一步看更新怎麼遺失
左邊「靜態欄位」框裡的 count(static int count)就是兩條執行緒共用的資料。前半段照順序執行,結果是 2;後半段(第 17 行起)讓 A、B 先後讀到同一個舊值 0,最後兩次寫回都是 1,count 只有 1。
synchronized:同一時間只讓一個執行緒進來
解決競爭條件的方法,是讓「讀取、加 1、寫回」三個動作不會被別的執行緒插隊。Java 的每個物件都可以當成一把鎖(lock),
synchronized 區塊進入時要先拿到鎖,離開時釋放;同一時間只有一條執行緒能拿到同一把鎖,其他執行緒只能等待:
static int count = 0;
static final Object lock = new Object();
// 寫法一:synchronized 區塊,指定用哪個物件當鎖
synchronized (lock) {
count++;
}
// 寫法二:synchronized 方法;static 方法用的鎖是這個類別本身,一般方法用的是 this
static synchronized void increment() {
count++;
}
把前面的範例改成在迴圈裡呼叫 increment(),每次執行都會印出 200000。要注意:
- 所有讀寫這份資料的地方都要用同一把鎖保護;只保護一半等於沒保護
- 鎖住的範圍越小越好:等待鎖的時間,執行緒什麼事也不能做,整個程式都用 synchronized 包起來就失去多執行緒的意義
- 兩條執行緒各拿著一把鎖、又在等對方的鎖,就會永遠卡住,這叫死結(deadlock)
兩個常用工具(簡介):
java.util.concurrent.atomic.AtomicInteger 提供不可分割的操作,像計數器這種簡單的情況不必自己寫 synchronized:
static AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // 讀取、加 1、寫回一次完成,不會被插隊
System.out.println(count.get());
ExecutorService(執行緒池)先建立固定數量的執行緒重複使用,你只要把工作交給它,不必自己 new Thread。實際的程式大多用這種方式:
ExecutorService pool = Executors.newFixedThreadPool(4); // 4 條執行緒
for (int i = 1; i <= 10; i++) {
int id = i;
pool.submit(() -> System.out.println("工作 " + id)); // 10 個工作輪流交給 4 條執行緒
}
pool.shutdown(); // 不再接受新工作;已交出的工作做完後執行緒結束
(以上兩段請在自己電腦的 JDK 執行,需要 import java.util.concurrent.*; 與 import java.util.concurrent.atomic.AtomicInteger;。)
main 結束了,程式還沒結束
模擬器的第 4 個任務裡,main 印完 count 就結束了,但 A、B 還在執行。Java 的規則是:JVM 會等所有非 daemon 的執行緒都結束,才結束程式。
你用 new Thread 建立的執行緒預設都是非 daemon 的,所以就算 main 先結束,它們也會做完。
- 沒有
join的問題不是「執行緒被中斷」,而是 main 在它們做完之前就讀了count,讀到還沒加完的值 - daemon 執行緒(背景執行緒)不會讓 JVM 等它:在
start()之前呼叫t.setDaemon(true),當所有非 daemon 執行緒都結束時,JVM 就直接結束,daemon 執行緒做到哪裡就停在哪裡。適合「程式還在就順便做」的背景工作 - 和 C++ 不同:C++ 的
std::thread如果在銷毀前沒有join或detach,程式會呼叫std::terminate中止;main結束時整個程序也就結束了
想一想:競爭條件與 synchronized
在多核心電腦的 JDK 執行下面的程式,印出的值會是?
public class Main {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
};
Thread a = new Thread(task);
Thread b = new Thread(task);
a.start();
b.start();
a.join();
b.join();
System.out.println(count);
}
}
下面的程式沒有呼叫 join。在 JDK 執行時,下面哪個描述正確?
public class Main {
static int count = 0;
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
count++;
}
};
new Thread(task).start();
new Thread(task).start();
System.out.println(count);
}
}
把 count++; 改成 synchronized (lock) { count++; }(兩條執行緒用同一個 lock 物件),保證了什麼?
動手寫:把加總分成兩個工作
多執行緒常見的用法是把一件大工作切成幾份。完成 SumTask 的 run():把 from 到 to(含)的整數加起來存進 result;再在 main 裡執行兩個工作。網頁上的直譯器不能跑真正的執行緒,所以這裡直接呼叫 run();在自己電腦的 JDK,可以改成 Thread t1 = new Thread(low); t1.start();,兩個都 start 之後再分別 join(),就能讓兩份同時計算。
重點回顧
- 一個程序裡可以有多條執行緒,各有自己的堆疊與區域變數,共用同一個堆積(物件與 static 欄位)
- 建立執行緒:繼承
Thread、實作Runnable(建議)、或用 Lambda 寫Runnable;工作寫在run()裡 start()建立新執行緒去執行run();直接呼叫run()只是一般的方法呼叫;同一個 Thread 只能 start 一次- 執行緒之間的執行順序由排程器決定、每次可能不同;
join()等某條執行緒結束;sleep()暫停目前的執行緒 count++是讀取、加 1、寫回三個動作,多條執行緒同時做會遺失更新(競爭條件)synchronized讓同一時間只有一條執行緒能執行受保護的程式;所有存取共用資料的地方都要用同一把鎖AtomicInteger提供不可分割的計數;ExecutorService用執行緒池管理工作- JVM 會等所有非 daemon 執行緒結束才離開;daemon 執行緒不會讓 JVM 等它