Thread và vòng đời

Xem thử

Tình huống mở đầu

Quán Cà Phê Chú Tư có màn hình bếp nhận đơn, và việc pha một ly Latte mất 2 giây. Bạn muốn bếp trưởng (chương trình chính) vẫn nhận đơn mới trong lúc một đầu bếp riêng pha ly đó. Bạn tạo một Thread cho việc pha, rồi viết barista.run() cho nhanh. Mong đợi: màn hình nhận đơn tiếp ngay lập tức. Thực tế: màn hình đứng im đúng 2 giây, và dòng log in ra tên luồng là main, chứ không phải tên đầu bếp bạn đặt.

Code không có dòng nào đỏ, cũng không báo lỗi. Nhưng bạn đã tạo ra một đầu bếp rồi không cho họ làm việc, và chính bếp trưởng phải tự pha. Vì sao tên luồng lại là main, và máy đã làm gì với đầu bếp mà bạn vừa tạo? Hãy giữ câu hỏi này, vì ở phần Ví dụ đầu tiên bạn sẽ nhìn thấy câu trả lời.

Ôn nhanh bài trước: LocalDate.plusDays(1) trả về gì, và ngày gốc có thay đổi không?

Đáp án: nó trả về một LocalDate mới, ngày gốc không đổi vì các kiểu java.time là bất biến.

Mục tiêu bài học

Sau bài này, bạn có thể:

  • Tạo và chạy một luồng bằng Thread với Runnable, và nói được start() khác run() ở đâu.
  • Mô tả được điều xảy ra khi chương trình có hai luồng: mỗi luồng có ngăn xếp riêng nhưng chung đống nhớ, và join() làm luồng gọi đứng chờ nên đọc được kết quả của luồng kia.
  • Đọc được sáu trạng thái của Thread.State và chỉ ra dòng code nào đưa luồng vào trạng thái nào.
  • Yêu cầu một luồng dừng bằng interrupt() và viết được vòng lặp có kiểm tra cờ ngắt.

Bài này đo được bằng năm câu trong phần Tự kiểm tra, mỗi câu ghi rõ nó kiểm tra mục tiêu nào.

Hãy xem video dưới đây để có cái nhìn đầu tiên, sau đó mình đi chậm hơn từng bước ở các phần sau.

Bức tranh lớn (Big Picture)

Trước khi vào chi tiết, hãy xem một chương trình có nhiều luồng được tổ chức thế nào trong bộ nhớ.

Cách đọc hình: bên trên là hai luồng: Main thread ở bên trái và barista-1 ở bên phải, nối với nhau bằng mũi tên start(). barista-1 chạy đoạn mã trong Runnable brew (ô vàng). Bên dưới là Heap (ô lục), nơi chứa các đối tượng và biến static. Cả hai luồng đều có mũi tên tới Heap, nghĩa là cùng đọc và ghi vào một chỗ. Màu chỉ là tín hiệu phụ, mỗi ô đều có nhãn chữ.

Điều đáng nhớ: mỗi luồng có ngăn xếp (stack) riêng để ghi các lời gọi phương thức và biến cục bộ, nhưng mọi đối tượng đều nằm chung trên đống nhớ. Vì chung nên một luồng ghi thì luồng kia đọc được, và cũng vì chung nên bài sau sẽ có chuyện tranh nhau.

Xuyên suốt cả chương này, mình dùng hình ảnh bếp quán Cà Phê Chú Tư: chương trình là một căn bếp, mỗi Thread là một đầu bếp có thớt riêng, còn quầy giữa bếp là đống nhớ chung. main là bếp trưởng. Hình ảnh này chưa đúng ở một chỗ: đầu bếp thật nhìn thấy nhau làm gì, còn hai luồng chỉ biết nhau qua đống nhớ chung và các lệnh như join().

Khái niệm cốt lõi

Bài này có bốn khái niệm. Mỗi khái niệm đi kèm một hình.

Khái niệm 1: Thread, Runnable và start() khác run()

Thread là một đường chạy riêng trong chương trình, như một đầu bếp có thớt riêng. Runnable là phần việc cho đầu bếp làm: một đối tượng có phương thức run(). start() là lệnh bảo JVM tạo một luồng thật rồi cho nó chạy run(). Còn gọi thẳng run() chỉ là gọi một phương thức bình thường trên luồng đang đứng.

Cách đọc hình: hàng trên (màu đỏ nhạt) là barista.run(): việc chạy ngay trên main, nên main bận cho tới khi run() xong. Hàng dưới (màu lục) là barista.start(): start() trả về ngay nên main đi tiếp, và công việc chạy trên luồng barista-1. Mũi tên "new thread" chỉ đường đi tới luồng mới.

Đây là khái niệm quan trọng nhất của bài, vì mọi kết luận còn lại đều dựa vào việc có luồng mới hay không. Tình huống mở đầu là hệ quả trực tiếp: gọi run() thì không có luồng nào được tạo.

Concept Check 1. Bạn viết Thread t = new Thread(task, "w"); t.run();. Tên luồng in ra từ trong task là gì? Hãy chọn trước khi đọc tiếp.

  • A. w, vì tên luồng đã được đặt trong constructor.
  • B. main, vì run() chạy ngay trên luồng đang gọi nó.
  • C. Một tên ngẫu nhiên, vì JVM tự chọn luồng để chạy.
  • D. Không in gì, vì chưa gọi start() nên việc chưa chạy.

Đáp án: B. run() chỉ là một phương thức thường, nên nó chạy trên luồng đang đứng, tức main. A là hiểu lầm hay gặp nhất: tên w chỉ gắn với luồng khi start() tạo ra luồng thật. C sai vì không có gì ngẫu nhiên ở đây, kết quả luôn là main. D sai vì Java không có cơ chế "chưa start thì chưa chạy" cho run(): bạn gọi nó thì nó chạy. [Đã chạy, JDK 17.0.15, xem Breakages.]

Khái niệm 2: main, join() và khi nào đọc được kết quả

Luồng main là luồng JVM tạo sẵn để chạy phương thức main. join() là lệnh bảo luồng đang gọi đứng chờ cho đến khi luồng kia kết thúc. Sau khi join() trả về, mọi thứ luồng kia đã ghi vào bộ nhớ chung đều đảm bảo là main nhìn thấy.

Cách đọc hình: hàng trên là luồng main: start(), rồi join() (ô vàng, trạng thái WAITING), rồi main resumes (ô lục). Hàng dưới là luồng worker: chạy brew ghi cups = 3, rồi kết thúc (TERMINATED). Mũi tên nét đứt "join returns" cho biết main chỉ đi tiếp khi worker đã xong. Chú thích bên dưới nhắc rằng không có join thì main có thể đọc cups trước khi worker ghi.

Từ start() về sau, hai luồng chạy song song: thứ tự dòng in giữa hai luồng không được bảo đảm [Có thể khác nhau]. Cái được bảo đảm là: những gì luồng kia ghi trước khi kết thúc thì main thấy sau join(). Vì thế quy tắc bạn có thể dựa vào là "muốn dùng kết quả của luồng nào, hãy join() luồng đó trước".

Khái niệm 3: vòng đời sáu trạng thái

Trạng thái của luồng là giá trị Thread.State mà getState() trả về. Một luồng chỉ ở đúng một trong sáu trạng thái: NEW (vừa tạo, chưa start), RUNNABLE (đang chạy hoặc sẵn sàng chờ CPU), BLOCKED (chờ khoá synchronized), WAITING (chờ vô hạn, ví dụ join() hay wait()), TIMED_WAITING (chờ có hạn, ví dụ sleep(ms)), và TERMINATED (đã chạy xong).

Cách đọc hình: đi từ trái sang phải. NEW đi vào RUNNABLE nhờ start(). Từ RUNNABLE có ba mũi tên hai chiều đi tới BLOCKED, WAITING, TIMED_WAITING: luồng vào đó khi phải chờ và quay lại RUNNABLE khi hết chờ. Mũi tên một chiều xuống TERMINATED là khi run() kết thúc. TERMINATED là điểm cuối: luồng không bao giờ quay lại.

Muốn thấy tận mắt, States.java đưa một luồng qua cả sáu trạng thái. Nó có một hàm nhỏ để chờ tới khi luồng đạt đúng trạng thái, thay vì đoán thời gian:

java
static void await(Thread t, Thread.State wanted) throws InterruptedException {
    while (t.getState() != wanted) {
        Thread.sleep(1);
    }
}

Kết quả [Đã chạy, JDK 17.0.15; chương trình đợi tới đúng trạng thái nên kết quả ổn định]:

text
[NEW, RUNNABLE, TIMED_WAITING, WAITING, BLOCKED, TERMINATED]
Dòng codeMáy làm gìVì sao viết như vậy
while (t.getState() != wanted) rồi Thread.sleep(1)Hỏi trạng thái liên tục, nghỉ 1 mili giây giữa hai lần hỏiKhông có cách đoán chính xác lúc luồng đổi trạng thái, nên phải chờ đến khi thấy
while (spin.get()) trong luồng tLuồng quay vòng đang làm việcĐây là RUNNABLE: luồng không chờ gì cả
Thread.sleep(60_000) trong luồng tLuồng ngủ có hạnĐây là TIMED_WAITING vì có giới hạn thời gian
other.join() trong luồng tLuồng t chờ luồng other xongĐây là WAITING vì không đặt giới hạn thời gian
synchronized (lock) { } khi main đang giữ lockLuồng t xếp hàng chờ vào khốiĐây là BLOCKED: chờ để lấy một khoá bị luồng khác giữ
Sau khi run() kết thúcLuồng không còn việcĐây là TERMINATED, điểm cuối của vòng đời

Chú ý rằng RUNNABLE gộp cả "đang chiếm CPU" lẫn "sẵn sàng chờ CPU". Java không tách hai thứ này, vì quyết định đó thuộc về hệ điều hành.

Khái niệm 4: interrupt() là lời nhắn dừng, không phải lệnh dừng

interrupt() đặt một cờ "có người yêu cầu dừng" trên luồng đích, và không làm gì thêm. Cờ ngắt là một giá trị đúng sai gắn với mỗi luồng, đọc bằng isInterrupted(). Nếu luồng đích đang ở sleep, wait hay join, nó bị đánh thức ngay bằng InterruptedException và cờ bị xoá. Ẩn dụ: bạn đặt một tờ giấy "xin dừng" lên thớt của đầu bếp. Đầu bếp phải tự đọc rồi tự dừng, bạn không thể giật dao khỏi tay họ.

Cách đọc hình: hàng trên là luồng chạy vòng lặp: interrupt() đặt cờ, vòng lặp kiểm tra isInterrupted() thấy true nên thoát và kết thúc sạch. Hàng dưới là luồng đang ngủ trong sleep(): nó bị đánh thức ngay bằng InterruptedException, và cờ bị xoá. Dòng chú thích nhắc rằng luồng không bao giờ kiểm tra cờ và không bao giờ chờ thì sẽ phớt lờ interrupt().

Chỗ hình ảnh không còn đúng: với đầu bếp thật, tờ giấy luôn được đọc ngay. Với luồng, tờ giấy chỉ có tác dụng nếu mã trong luồng thật sự nhìn vào cờ hoặc gọi một lệnh chờ. Đoạn dưới đây là vòng lặp đúng cách:

Stop.java

java
public class Stop {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            // Đọc cờ ngắt ở đầu mỗi vòng, đây là chỗ luồng đọc lời nhắn
            while (!Thread.currentThread().isInterrupted()) {
                Thread.onSpinWait();
            }
            System.out.println("saw the flag, stopping");
        }, "worker");
        worker.start();
        Thread.sleep(50);
        // Chỉ bật cờ, worker tự quyết định thoát
        worker.interrupt();
        worker.join();
        System.out.println("alive " + worker.isAlive());
    }
}

Kết quả khi chạy [Đã chạy, JDK 17.0.15]:

text
saw the flag, stopping
alive false
Dòng codeMáy làm gìVì sao viết như vậy
while (!Thread.currentThread().isInterrupted())Kiểm tra cờ ở đầu mỗi vòng, còn cờ chưa bật thì chạy tiếpĐây là chỗ luồng "đọc tờ giấy". Không có dòng này thì interrupt() vô tác dụng
Thread.onSpinWait()Gợi ý cho CPU rằng luồng đang quay vòng chờCho vòng lặp một phần việc để chạy, dòng này không ảnh hưởng tới đúng sai
Thread.sleep(50) trong mainmain ngủ 50 mili giâyCho luồng worker kịp bắt đầu vòng lặp trước khi bị ngắt
worker.interrupt()Bật cờ ngắt của workerChỉ là lời nhắn dừng, worker tự quyết định thoát
worker.join() rồi in alive falsemain chờ worker kết thúc hẳnChỉ sau join mới chắc chắn worker đã xong

Còn khi luồng đang ngủ thì bị đánh thức bằng InterruptedException. Đoạn rút gọn từ Wake.java:

java
try {
    // Đang ngủ nên bị interrupt() đánh thức ngay
    Thread.sleep(60_000);
} catch (InterruptedException e) {
    System.out.println("woke up: " + e.getMessage() + ", flag " + Thread.currentThread().isInterrupted());
}

Khi main ngắt sleeper đang ngủ, kết quả là [Đã chạy, JDK 17.0.15]:

text
woke up: sleep interrupted, flag false
state TERMINATED
Dòng codeMáy làm gìVì sao viết như vậy
Thread.sleep(60_000)Luồng ngủ tối đa 60 giâyỞ trạng thái TIMED_WAITING nên bị interrupt() đánh thức ngay, không phải đợi hết 60 giây
catch (InterruptedException e)Bắt lỗi do bị đánh thứcsleep bắt buộc phải xử lý lỗi này, đây là cách Java báo "có người yêu cầu dừng"
e.getMessage()Lấy thông điệp sleep interruptedCho biết đánh thức đến từ lời nhắn dừng
isInterrupted() trong catchCho falseCờ bị xoá khi ném lỗi. Muốn cho nơi gọi biết thì tự bật lại bằng Thread.currentThread().interrupt()

Ví dụ đầu tiên (Worked Example) và "Xem từng bước"

Đây là chương trình đầu tiên của dự án: một đầu bếp barista-1 pha 3 ly, và bếp trưởng thử cả hai cách run() và start() để thấy khác biệt.

Barista.java

java
public class Barista {
    // Biến chung nằm trên đống nhớ, mọi luồng cùng thấy
    static int cups;

    public static void main(String[] args) throws InterruptedException {
        Runnable brew = () -> {
            System.out.println("brewing on " + Thread.currentThread().getName());
            cups = 3;
        };
        Thread barista = new Thread(brew, "barista-1");
        System.out.println("state " + barista.getState());
        // Gọi thường: chạy ngay trên luồng main, không có luồng mới
        barista.run();
        System.out.println("after run: cups " + cups + ", state " + barista.getState());
        cups = 0;
        // Tạo luồng thật, main đi tiếp ngay
        barista.start();
        // Main chờ barista-1 xong rồi mới đọc cups
        barista.join();
        System.out.println("after join: cups " + cups + ", state " + barista.getState());
    }
}

Predict: hãy dừng lại, lấy giấy nháp và đoán: dòng brewing on ... xuất hiện mấy lần và mỗi lần in tên luồng nào? Ở dòng after run, trạng thái của barista là gì? Đoán xong rồi hãy đọc tiếp.

Kết quả mong đợi [Đã chạy, JDK 17.0.15; thứ tự các dòng ổn định vì main chờ ở join() trước khi in dòng cuối]:

text
state NEW
brewing on main
after run: cups 3, state NEW
brewing on barista-1
after join: cups 3, state TERMINATED

Đây là câu trả lời cho tình huống mở đầu: lần đầu brewing on main, vì run() chạy ngay trên main, và trạng thái vẫn NEW vì luồng chưa hề được start. Lần hai mới là brewing on barista-1. Đọc code từng dòng:

Dòng codeMáy làm gìVì sao viết như vậy
static int cups;Khai báo một biến chung, ban đầu là 0Biến static nằm trên đống nhớ, nên mọi luồng thấy cùng một cups
Runnable brew = () -> { ... };Tạo phần việc: in tên luồng đang chạy rồi đặt cups = 3Tách phần việc khỏi Thread để dùng lại cho cả run() và start()
new Thread(brew, "barista-1")Tạo đối tượng Thread, đặt tên, chưa chạyĐặt tên để nhìn ra luồng nào đang làm gì. Lúc này trạng thái là NEW
barista.run();Gọi brew như phương thức thường trên mainĐây là lỗi cố ý, để thấy tên in ra là main và getState() vẫn là NEW
cups = 0;Đặt lại biến chungĐể lần sau nhìn rõ luồng mới có ghi 3 hay không
barista.start();JVM tạo luồng thật, main đi tiếp ngayĐây là cách đúng để chạy song song
barista.join();main đứng chờ tới khi barista-1 xongChờ để đọc cups an toàn ở dòng sau, và trạng thái lúc đó là TERMINATED

Bây giờ xem từng bước. Mỗi bước nêu dòng code đang chạy, luồng nào thực thi và lý do:

Đang tải Animation…

Bảng trace (bản chữ tương đương của animation, [Suy luận] bằng cách đi từng dòng và khớp với kết quả chạy thật):

Dòng chạyLuồng thực thistate của baristacupsIn ra
new Thread(brew, "barista-1") rồi in statemainNEW0state NEW
barista.run()mainNEW3brewing on main
in after run, rồi cups = 0mainNEW0after run: cups 3, state NEW
barista.start()main rồi barista-1RUNNABLE0chưa in gì
barista.join()main chờ, barista-1 chạyRUNNABLE rồi TERMINATED3brewing on barista-1
in after joinmainTERMINATED3after join: cups 3, state TERMINATED

Bạn vừa làm được: đọc một chương trình hai luồng và nói đúng luồng nào chạy dòng nào, trạng thái của luồng ở từng dòng.

Concept Check xen kẽ

Chọn đáp án của bạn trước, rồi đọc phần đáp án ngay bên dưới mỗi câu.

Concept Check 2. Bạn viết t.start(); rồi ngay dòng sau in cups, và luồng t sẽ đặt cups = 3. Điều nào đúng?

  • A. In 3, vì start() chỉ trả về sau khi run() đã chạy xong.
  • B. Luôn in 0, vì luồng mới luôn chạy chậm hơn main.
  • C. Ném IllegalThreadStateException, vì cups được hai luồng cùng dùng.
  • D. Có thể in 0 hoặc 3, và kết quả có thể khác nhau giữa các lần chạy.

Đáp án: D. start() trả về ngay, hai luồng chạy song song, nên ai ghi hay đọc trước là không bảo đảm [Có thể khác nhau]. A là hiểu lầm cho rằng start() chờ như một lời gọi thường. B tưởng có thể đoán chắc, nhưng "thường là 0" không phải "luôn là 0". C sai vì dùng chung biến không ném lỗi nào, chỉ cho kết quả không chắc. Cách làm đúng là join() trước khi đọc.

Concept Check 3. Luồng w đang chạy Thread.sleep(5000). Đúng lúc đó main gọi w.getState(). Kết quả là gì?

  • A. TIMED_WAITING, vì luồng chờ có giới hạn thời gian.
  • B. WAITING, vì luồng không làm gì trong lúc ngủ.
  • C. BLOCKED, vì luồng không được phép chạy tiếp.
  • D. RUNNABLE, vì luồng vẫn đang chiếm một phần CPU.

Đáp án: A. sleep(ms) là chờ có hạn nên là TIMED_WAITING. B nhầm với chờ không giới hạn như join() hoặc wait(). C sai vì BLOCKED chỉ dành cho chờ khoá synchronized, không phải ngủ. D sai vì luồng đang ngủ không dùng CPU. [Đã chạy, JDK 17.0.15, xem States.java.]

Concept Check 4. Luồng w chạy while (true) { work(); } và không kiểm tra gì thêm. main gọi w.interrupt(). Điều gì xảy ra?

  • A. w dừng ngay lập tức vì interrupt() là lệnh dừng.
  • B. main nhận InterruptedException vì chính main ra lệnh ngắt.
  • C. w tiếp tục chạy vì interrupt() chỉ đặt cờ mà vòng lặp không đọc cờ.
  • D. w chuyển sang TERMINATED vì cờ ngắt bật thì luồng bị loại.

Đáp án: C. interrupt() chỉ đặt cờ. Vòng lặp không đọc cờ và không gọi lệnh chờ nào, nên không có gì phát hiện lời nhắn. A là hiểu lầm kinh điển, vì tên gọi interrupt nghe như "cắt ngang". B sai vì lỗi chỉ được ném trong luồng đích, và chỉ khi luồng đó đang chờ. D sai vì cờ bật không làm đổi trạng thái. Sửa bằng while (!Thread.currentThread().isInterrupted()).

Làm cùng mình (Guided Practice)

Bạn sẽ dựng dần class CafeBoard qua bốn bước nhỏ, mỗi bước thêm vào chương trình của bước trước. Hỗ trợ giảm dần: bước 1 gần như làm sẵn, bước 4 chỉ còn đề. Gõ code vào file CafeBoard.java rồi chạy bằng java CafeBoard.java.

Bước 1: đầu bếp pha Latte.

java
public class CafeBoard {
    static int mochaCups;

    public static void main(String[] args) throws InterruptedException {
        Thread latte = new Thread(() -> System.out.println("brewing latte on " + Thread.currentThread().getName()), "latte-thread");
        // TODO 1: chạy luồng latte rồi chờ nó xong
        System.out.println("step 1: " + latte.getState());
    }
}

Chạy xong bạn sẽ thấy hai dòng: brewing latte on latte-thread rồi step 1: TERMINATED.

Bước 2: đầu bếp Mocha ghi vào biến chung. Ngay dưới dòng in của bước 1, thêm:

java
        Thread mocha = new Thread(() -> mochaCups = 2);
        // TODO 2: chạy luồng mocha, rồi chờ nó xong trước khi đọc mochaCups
        System.out.println("step 2: cups " + mochaCups);

Chạy xong bạn sẽ thấy: step 2: cups 2.

Vẽ ra giấy: vẽ hai cột main và mocha, và một ô mochaCups ở giữa. Điền thứ tự các việc: start, luồng mocha ghi 2, mocha kết thúc, join trả về, main đọc. Đối chiếu: main chỉ đọc sau khi join trả về. Nếu bạn bỏ dòng join, main có thể đọc lúc ô còn là 0, và đó là hình "mô hình sai" ở phần Lỗi thường gặp. Gợi ý: join() đứng sau start() và trước dòng println.

Bước 3: một luồng ngủ, và xem trạng thái.

java
        Thread idle = new Thread(() -> {
            try {
                Thread.sleep(60_000);
            } catch (InterruptedException e) {
                System.out.println("woke up");
            }
        });
        idle.start();
        // TODO 3: chờ tới khi idle ở trạng thái TIMED_WAITING (kiểm tra getState trong vòng lặp)
        System.out.println("step 3: " + idle.getState());

Chạy xong bạn sẽ thấy: step 3: TIMED_WAITING. Gợi ý: vòng while hỏi getState(), mỗi lần nghỉ Thread.sleep(1). Hàm await ở khái niệm 3 là cùng ý.

Bước 4: đánh thức luồng đang ngủ. Bạn tự quyết cách viết.

java
        // TODO 4: ngắt luồng idle, chờ nó xong rồi in trạng thái của nó
        System.out.println("step 4: " + idle.getState());

Chạy xong bạn sẽ thấy woke up rồi step 4: TERMINATED. Gợi ý: hai lệnh là interrupt() và join(), và thứ tự quan trọng. Lời giải đầy đủ nằm ở cuối bài.

Bạn vừa làm được: tự dựng một chương trình có bốn luồng, chờ kết quả bằng join(), đọc trạng thái luồng và đánh thức luồng đang ngủ. Toàn bộ mã nguồn của bài cũng nằm trong thư mục 15-da-luong/01-thread-vong-doi của kho GitHub, nhưng bạn không cần mở nó để học.

Thử phá nó đi (Lỗi thường gặp)

Bốn lỗi dưới đây là những lỗi người mới gặp nhiều nhất với Thread. Mỗi dòng trong bảng đã được chạy thật bằng Breakages.java và một tệp riêng cho lỗi biên dịch [Đã chạy, JDK 17.0.15; chữ trong thông báo lỗi có thể khác ở phiên bản Java khác].

Code lỗiThông báo lỗiNguyên nhân bằng lời thườngCách sửa
named.run(); thay vì named.start();Không báo lỗi, in ran on mainrun() là lời gọi thường nên chạy trên luồng hiện tại, không có luồng mớiGọi named.start();
once.start(); lần thứ hai trên luồng đã chạy xongIllegalThreadStateException, và trạng thái là TERMINATEDMột đối tượng Thread chỉ start được đúng một lần trong đờiTạo new Thread(task) mới cho mỗi lần chạy
Thread.sleep(100); trong main không khai báo throwsunreported exception InterruptedException; must be caught or declared to be thrownsleep có thể bị ngắt nên phải xử lý InterruptedExceptionThêm throws InterruptedException hoặc bọc try, catch
throw new IllegalStateException("boom") trong luồng crasherException in thread "crasher" java.lang.IllegalStateException: boom rồi main vẫn chạy tiếpLỗi chưa bắt chỉ làm chết luồng chứa nó, không làm chết cả chương trìnhBắt lỗi trong run(), hoặc đặt setUncaughtExceptionHandler

Lỗi thứ nhất nguy hiểm nhất vì không có thông báo nào, và nó chính là tình huống ở đầu bài. Hình dưới đây gom mô hình sai mà nhiều người mới tin về start().

Cách đọc hình: hàng trên màu đỏ nhạt là mô hình sai: tưởng start() chỉ trả về khi run() đã xong, nên dòng đọc cups kế tiếp luôn thấy 3. Hàng dưới màu lục là mô hình đúng: start() trả về ngay, luồng mới chạy song song, nên cups có thể vẫn là 0 và khác nhau giữa các lần chạy.

Mô hình đúng giải thích vì sao cần join() trước khi dùng kết quả của luồng.

Bạn vừa làm được: đọc bốn lỗi phổ biến của Thread và nói được nguyên nhân bằng lời thường.

Tự tay làm (Bài tập)

Làm lần lượt từng mức. Mỗi bài có đề, dữ liệu vào, kết quả mong đợi, tiêu chí nghiệm thu và gợi ý. Lời giải nằm riêng ở cuối bài, hãy thử trước rồi mới so.

Mức Cơ bản

Bài 1. Điền bảng trace. Dữ liệu vào là đoạn code sau (đã khai báo throws InterruptedException):

java
Thread t = new Thread(() -> System.out.println("A on " + Thread.currentThread().getName()), "w");
System.out.println("1 " + t.getState());
t.run();
System.out.println("2 " + t.getState());
t.start();
t.join();
System.out.println("3 " + t.getState());

Điền bảng gồm các cột: dòng vừa chạy, luồng thực thi, state của t, dòng in ra. Kết quả mong đợi: năm dòng in cả thảy. Tiêu chí nghiệm thu: bạn chỉ ra được dòng nào in tên main, dòng nào in tên w, và vì sao dòng thứ hai vẫn là NEW. Gợi ý: run() không làm đổi state.

Bài 2. Hai đầu bếp cùng pha. Dữ liệu vào: tên hai luồng latte và mocha, mỗi luồng in tên của nó, rồi main in done. Kết quả mong đợi: ba dòng, trong đó done luôn đứng cuối, còn thứ tự latte và mocha thì không cố định [Có thể khác nhau]. Tiêu chí nghiệm thu: done không bao giờ xuất hiện trước một trong hai tên, và bạn giải thích được vì sao hai tên đổi chỗ được. Gợi ý: start cả hai trước, rồi join cả hai, rồi mới in done.

Bài 3. Sửa lời gọi sai. Dữ liệu vào: đoạn dưới in brewing on main, mà người viết mong tên luồng là barista.

Mã nguồn: Thread t = new Thread(() -> System.out.println("brewing on " + Thread.currentThread().getName()), "barista"); rồi t.run();. Sửa để in brewing on barista ổn định. Tiêu chí nghiệm thu: in đúng một dòng brewing on barista và chương trình thoát sạch. Gợi ý: đổi run() thành start(), và nhớ join() để dòng in ra chắc chắn nằm trước khi chương trình kết thúc.

Mức Vận dụng

Bài 4. Class Station cho hai quầy pha. Viết class Station implements Runnable giữ số đơn orders và số ly đã pha cups, với các thành phần sau:

Thành phầnViệc phải làm
Station(int orders)Lưu số đơn cần pha
void run()Ngủ 100 mili giây (giả lập pha), rồi gán cups = orders. Nếu bị ngắt thì thoát luôn
int cups()Trả về số ly đã pha

Dữ liệu vào là hàm main sau (thêm vào class của bạn):

java
public static void main(String[] args) throws InterruptedException {
    Station a = new Station(3);
    Station b = new Station(4);
    Thread ta = new Thread(a, "station-a");
    Thread tb = new Thread(b, "station-b");
    ta.start();
    tb.start();
    ta.join();
    tb.join();
    System.out.println("states " + ta.getState() + " " + tb.getState());
    System.out.println("total " + (a.cups() + b.cups()));
}

Kết quả mong đợi:

text
states TERMINATED TERMINATED
total 7

Tiêu chí nghiệm thu: hai dòng khớp từng ký tự, mỗi luồng chỉ ghi vào trường cups của riêng nó, và main chỉ đọc cups sau khi đã join cả hai. Gợi ý: Thread.sleep ném InterruptedException, nên run() cần try, catch.

Mức Thử thách (tuỳ chọn)

Bài 5. Đầu bếp báo giờ tự dừng. Viết một luồng ticker cứ 100 mili giây đếm thêm một nhịp, và dừng sạch khi bị interrupt(). main ngủ 350 mili giây rồi ngắt ticker, chờ nó xong, rồi in alive false. Kết quả mong đợi: số nhịp đếm được khoảng 3 [Có thể khác nhau], nên chỉ in một câu chắc chắn: stopped, ticks at least 1: true rồi alive false. Tiêu chí nghiệm thu: luồng dừng dù bị ngắt đúng lúc đang ngủ hay đúng lúc đang đếm, và không còn bị treo. Gợi ý: trong catch (InterruptedException e) hãy bật lại cờ bằng Thread.currentThread().interrupt() rồi break, để điều kiện while cũng thấy lời nhắn.

Tự kiểm tra (Retrieval Practice)

Trả lời bằng trí nhớ, không xem lại bài. Mỗi câu ghi rõ nó kiểm tra mục tiêu nào trong bốn mục tiêu ở đầu bài.

Câu 1 (nhớ lại, mục tiêu 1). start() khác run() ở điểm nào? Nêu một dấu hiệu trong kết quả in ra cho biết ai đang chạy.

Câu 2 (dự đoán kết quả code, mục tiêu 1 và 2). Đoạn sau in ra những dòng nào, theo thứ tự nào?

java
Thread t = new Thread(() -> System.out.println("B"));
System.out.println("A");
t.start();
t.join();
System.out.println("C");

Câu 3 (dự đoán trạng thái sau nhiều bước, mục tiêu 3). Cho luồng t. Viết getState() của nó tại năm thời điểm: (a) ngay sau new Thread(...), (b) đang ở giữa sleep(1000), (c) đang chờ ở other.join(), (d) đang xếp hàng để vào khối synchronized mà luồng khác đang giữ, (e) sau khi run() kết thúc.

Câu 4 (tìm lỗi, mục tiêu 4). Luồng sau không bao giờ dừng dù main đã gọi interrupt(). Hãy chỉ ra lỗi và sửa.

Mã nguồn là new Thread(() -> { while (true) { doWork(); } }).

Câu 5 (giải thích cho bạn chưa học, mục tiêu 2). Bạn của bạn hỏi: "Tôi gọi start() rồi in kết quả ngay thì thấy 0, thêm join() thì thấy 3. Tại sao?" Hãy giải thích bằng hình ảnh bếp trưởng và đầu bếp.

Điều cần nhớ

  • Thread cần start() để chạy song song, còn run() gọi trực tiếp thì chạy trên luồng hiện tại và không tạo luồng nào (mục tiêu 1).
  • Các luồng có ngăn xếp riêng nhưng chung đống nhớ, và muốn dùng kết quả của luồng nào thì join() luồng đó (mục tiêu 2).
  • Luồng ở một trong sáu trạng thái: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED, và TERMINATED là điểm cuối (mục tiêu 3).
  • interrupt() chỉ đặt cờ, luồng phải tự kiểm tra cờ hoặc bị đánh thức khỏi lúc chờ (mục tiêu 4).

Cheat sheet một trang.

Việc cần làmCú phápGhi chú
Tạo luồng có tênnew Thread(task, "name")Trạng thái NEW, chưa chạy
Chạy song songt.start()Chỉ gọi một lần cho mỗi Thread
Chờ luồng xongt.join()Ném InterruptedException, đọc kết quả sau dòng này
Xem trạng tháit.getState()Một trong sáu giá trị
Ngủ có hạnThread.sleep(ms)Trạng thái TIMED_WAITING
Nhắn dừngt.interrupt()Chỉ bật cờ, không dừng ngay
Đọc cờ ngắtThread.currentThread().isInterrupted()Dùng làm điều kiện vòng lặp
Tên luồng đang chạyThread.currentThread().getName()main cho luồng chính
Hình: tóm tắt công cụ luồng nào cho việc nào

Cách đọc hình: mỗi hàng là một câu hỏi bên trái và công cụ nên chọn bên phải. Đọc theo hàng ngang: chạy việc nền thì start(), chờ rồi dùng kết quả thì join(), xin dừng thì interrupt() kèm kiểm tra cờ. Dòng chú thích nhắc rằng thứ tự dòng in giữa các luồng không được bảo đảm.

Câu hỏi để dành cho ngày mai: hai đầu bếp cùng cộng vào một biến cups chung, mỗi người cộng 1000 lần. Kết quả cuối có chắc là 2000 không? Hãy nghĩ trước khi sang bài sau.

Đào sâu

Phần này dành cho bạn tò mò. Bỏ qua cũng không thiếu gì ở trên.

Bên trong một luồng. Với HotSpot trên JDK 17, mỗi Thread được start() ứng với một luồng của hệ điều hành, và hệ điều hành là bên quyết định luồng nào được chạy lúc nào. Vì vậy thứ tự in giữa hai luồng và độ chính xác của sleep đều thuộc loại [Có thể khác nhau]. Đây cũng là lý do RUNNABLE không tách "đang chạy" và "chờ CPU".

Luồng nền (daemon). Daemon.java cho thấy luồng mặc định không phải daemon, setDaemon(true) chỉ gọi được trước start(), và JVM thoát khi mọi luồng không phải daemon đã xong [Đã chạy, JDK 17.0.15]:

text
new thread daemon false
after setDaemon(true) true
setDaemon after start: IllegalThreadStateException

Dòng ba cho biết gọi setDaemon sau start() ném IllegalThreadStateException. Luồng nền hợp với việc phụ như ghi log, vì khi chương trình chính xong thì luồng nền bị bỏ lại mà không được chờ.

Hiểu lầm nâng cao. Nhiều người nghĩ interrupt() luôn làm luồng dừng. Ignored.java ngắt một luồng chỉ chạy vòng lặp bận mà không nhìn cờ, và luồng vẫn chạy hết phần việc rồi mới kết thúc [Đã chạy, JDK 17.0.15]:

text
finished normally, state TERMINATED

Vì vậy mọi vòng lặp dài trong một luồng nên có một chỗ kiểm tra cờ ngắt.

So với lựa chọn khác. Tạo luồng bằng cách kế thừa Thread cũng chạy được, nhưng truyền một Runnable cho new Thread(...) tách việc khỏi người làm, và dùng lại được cho nhiều luồng. Bài Concurrency Utilities ở cuối chương sẽ giới thiệu ExecutorService để khỏi tự tạo từng luồng.

Giới hạn của bài. Bài này chưa nói về hai luồng cùng ghi vào một biến (bài Đồng bộ hóa), về nhóm luồng (bài Concurrency Utilities), và về wait với notify. Luồng ảo (virtual thread) chỉ chính thức từ Java 21, nên nằm ngoài JDK 17 của khoá này.

Đọc thêm (tài liệu chính thống, Java SE 17):

Bài tiếp theo

Bài sau là Đồng bộ hóa: điều gì xảy ra khi hai luồng cùng ghi vào một biến, vì sao tổng bị mất, và cách synchronized chặn điều đó. Bạn sẽ thấy câu hỏi để dành cho ngày mai có đáp án.

Mini-dự án nối tiếp: ở bài sau, hai đầu bếp của quán Cà Phê Chú Tư sẽ cùng cộng vào một bộ đếm số ly bán. Hãy giữ lại class Station bạn vừa viết làm điểm xuất phát.

Lời giải và đáp án

Hãy thử xong rồi mới đọc phần này.

Lời giải Làm cùng mình. Chỉ cần thay các dòng // TODO bằng những dòng dưới đây. Ghép lại chạy ra đúng các dòng ở trên [Đã chạy, JDK 17.0.15]:

java
latte.start();
latte.join();
mocha.start();
// Chờ xong rồi mới đọc mochaCups
mocha.join();
// Chờ tới khi idle thật sự đang ngủ
while (idle.getState() != Thread.State.TIMED_WAITING) {
    Thread.sleep(1);
}
idle.interrupt();
// Chờ idle in woke up rồi mới in step 4
idle.join();
Dòng codeMáy làm gìLý do chọn cách này
latte.start(); rồi latte.join();Tạo luồng thật, rồi main chờ nó xongChờ để dòng step 1 thấy trạng thái TERMINATED
mocha.start(); rồi mocha.join();Chạy luồng ghi mochaCups = 2 rồi chờjoin bảo đảm giá trị 2 đã ghi trước khi main đọc
while (idle.getState() != ...TIMED_WAITING)Hỏi trạng thái cho tới khi luồng thật sự đang ngủKhông đoán thời gian: start() trả về ngay, luồng chưa chắc đã tới sleep
idle.interrupt();Bật cờ, đánh thức luồng đang ngủLuồng đang ở sleep nên nhận InterruptedException ngay
idle.join();Chờ luồng in woke up rồi kết thúcBảo đảm woke up in ra trước dòng step 4

Lời giải Bài 1. [Suy luận, khớp với kết quả chạy thật]

Dòng vừa chạyLuồng thực thistate của tDòng in
new Thread(...) rồi inmainNEW1 NEW
t.run()mainNEWA on main
in sau run()mainNEW2 NEW
t.start() rồi t.join()wTERMINATEDA on w
in cuốimainTERMINATED3 TERMINATED

Dòng A on main do run() gọi trực tiếp, A on w do start() tạo luồng thật. State vẫn NEW sau run() vì chưa có luồng nào được tạo.

Lời giải Bài 2. [Đã chạy, ba dòng với done luôn ở cuối]

Gọi latte.start() và mocha.start() trước, rồi latte.join() và mocha.join(), rồi mới println("done"). Hai luồng chạy song song nên tên nào in trước là không cố định [Có thể khác nhau]. done luôn cuối vì main chỉ đi tới dòng đó sau khi cả hai join trả về.

Lời giải Bài 3. [Đã chạy, in brewing on barista]

java
t.start();
t.join();

start() mới tạo luồng barista. join() bảo đảm main chờ luồng in xong rồi mới kết thúc chương trình.

Lời giải Bài 4. [Đã chạy, hai dòng khớp với kết quả mong đợi]

Hàm main của đề đã đủ, phần còn lại là class Station:

java
@Override
public void run() {
    try {
        // Giả lập thời gian pha
        Thread.sleep(100);
    } catch (InterruptedException e) {
        return;
    }
    // Mỗi luồng chỉ ghi trường riêng của quầy mình
    cups = orders;
}
Dòng codeMáy làm gìLý do chọn cách này
Thread.sleep(100)Ngủ 100 mili giây để giả lập việc phaCả hai quầy ngủ song song nên không phải 200 mili giây liên tiếp
catch (InterruptedException e) { return; }Thoát khỏi run() nếu bị ngắtĐây là cách dừng sạch khi có lời nhắn dừng
cups = orders;Gán số ly vào trường của chính quầy nàyMỗi luồng chỉ ghi trường riêng, nên không có tranh chấp ở bài này

Hai lần join trong main bảo đảm cả hai quầy đã xong trước khi a.cups() + b.cups() được tính, ra 7.

Lời giải Bài 5. [Đã chạy, in stopped, ticks at least 1: true rồi alive false]

java
} catch (InterruptedException e) {
    // Bật lại cờ vì ném lỗi đã xoá nó
    Thread.currentThread().interrupt();
    break;
}

Phần còn lại là vòng while (!Thread.currentThread().isInterrupted()) bọc quanh Thread.sleep(100) rồi ticks++. Điều kiện while bắt cờ khi luồng đang đếm, còn catch bắt lúc luồng đang ngủ. Ném InterruptedException làm cờ bị xoá, nên bật lại cờ bằng interrupt() rồi break để nơi gọi và điều kiện vòng lặp đều biết có lời nhắn dừng. Số nhịp thực tế là [Có thể khác nhau], thường quanh 3.

Đáp án Tự kiểm tra.

  1. start() tạo một luồng mới rồi trả về ngay, luồng đó chạy run(). run() gọi trực tiếp chỉ là lời gọi thường trên luồng hiện tại. Dấu hiệu: tên luồng in ra bằng Thread.currentThread().getName() là main khi dùng run(), và là tên luồng mới khi dùng start(). Kiểm tra mục tiêu 1.
  2. In A, B, C theo thứ tự đó [Đã chạy]. A in trước khi luồng được tạo. join() làm main chờ luồng in B xong rồi mới in C. Kiểm tra mục tiêu 1 và 2.
  3. (a) NEW, (b) TIMED_WAITING, (c) WAITING, (d) BLOCKED, (e) TERMINATED [Đã chạy, xem States.java]. Kiểm tra mục tiêu 3.
  4. Vòng lặp while (true) không bao giờ nhìn cờ ngắt, và không gọi lệnh chờ nào để bị đánh thức. Sửa thành while (!Thread.currentThread().isInterrupted()) [Suy luận]. Kiểm tra mục tiêu 4.
  5. Bếp trưởng gọi đầu bếp (start()) rồi nhìn quầy ngay, lúc đó đầu bếp mới đặt dao xuống nên quầy còn 0 ly. Khi bếp trưởng đứng chờ đầu bếp làm xong (join()) thì quầy đã có 3 ly. Không có join thì ai nhanh hơn là không chắc [Có thể khác nhau]. Kiểm tra mục tiêu 2.