National Taiwan University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

2

Local AI

--

Uedu Code

--

台灣大學 AQI 70 24°C PM2.5 17
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 16

多執行緒

Thread、競爭條件、synchronized 與 join
Interaction實作 25 minute(s)

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

1.

下面的程式會印出什麼?(程式裡沒有建立任何執行緒)

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");
    }
}
2.

Thread t = new Thread(task); 之後,寫成 t.run(); 而不是 t.start();,會發生什麼事?

3.

同一個 Thread 物件 t,連續呼叫兩次 t.start();,會怎樣?

第 1 次作答

競爭條件: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 執行時其實分成三個動作:

  1. 讀取 count 目前的值
  2. 把讀到的值加 1
  3. 把結果寫回 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。

多執行緒模擬器

模擬器裡有三條執行緒:main 依序 a.start()、b.start()、a.join()、b.join(),最後印出 count; 執行緒 A、B 各做 2 次 count++,每次拆成「①讀取、②加 1、③寫回」三步。每條執行緒有自己的「暫存值」,count 在右邊的「共用記憶體」。

按某條執行緒的「執行一步」,就由你決定下一步輪到誰(你扮演排程器);也可以按「隨機排程跑到結束」交給電腦隨機決定。上面兩個勾選框可以切換程式寫法,切換後會重來。 請完成下面四個任務:

  1. 照順序執行:用「執行一步」自己安排順序,一直執行到 main 印出 count。(執行緒還沒 start 之前不能執行;join 要等那條執行緒結束才能往下)
  2. 更新遺失:不勾 synchronized,安排出讓 main 印出的值小於 4 的順序。提示:讓 A 讀取之後,先別讓它寫回,換 B 讀取
  3. synchronized:勾選「用 synchronized (lock) 保護 count++」,按「隨機排程跑到結束」。拿不到鎖的執行緒只能等待,結果一定是 4
  4. 拿掉 join:取消「main 呼叫 join 等待執行緒結束」,讓 main 在執行緒還沒做完時就印出 count。印出之後,main 結束了,但 A、B 還在跑:JVM 會等它們做完才結束程式

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

1.

在多核心電腦的 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);
    }
}
2.

下面的程式沒有呼叫 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);
    }
}
3.

把 count++; 改成 synchronized (lock) { count++; }(兩條執行緒用同一個 lock 物件),保證了什麼?

第 1 次作答

動手寫:把加總分成兩個工作

多執行緒常見的用法是把一件大工作切成幾份。完成 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 等它
這一課完成了!
Next lesson:Lambda 表示式
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。