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
ThreadvớiRunnable, và nói đượcstart()khácrun()ở đâ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.Statevà 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:
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]:
[NEW, RUNNABLE, TIMED_WAITING, WAITING, BLOCKED, TERMINATED]
| Dòng code | Má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ỏi | Khô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 t | Luồ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 t | Luồng ngủ có hạn | Đây là TIMED_WAITING vì có giới hạn thời gian |
other.join() trong luồng t | Luồ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ữ lock | Luồ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úc | Luồ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
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]:
saw the flag, stopping
alive false
| Dòng code | Má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 main | main ngủ 50 mili giây | Cho 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 worker | Chỉ là lời nhắn dừng, worker tự quyết định thoát |
worker.join() rồi in alive false | main chờ worker kết thúc hẳn | Chỉ 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:
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]:
woke up: sleep interrupted, flag false
state TERMINATED
| Dòng code | Má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ức | sleep 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 interrupted | Cho biết đánh thức đến từ lời nhắn dừng |
isInterrupted() trong catch | Cho false | Cờ 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
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]:
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 code | Má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à 0 | Biế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 = 3 | Tá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 xong | Chờ để đọ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ạy | Luồng thực thi | state của barista | cups | In ra |
|---|---|---|---|---|
new Thread(brew, "barista-1") rồi in state | main | NEW | 0 | state NEW |
barista.run() | main | NEW | 3 | brewing on main |
in after run, rồi cups = 0 | main | NEW | 0 | after run: cups 3, state NEW |
barista.start() | main rồi barista-1 | RUNNABLE | 0 | chưa in gì |
barista.join() | main chờ, barista-1 chạy | RUNNABLE rồi TERMINATED | 3 | brewing on barista-1 |
| in after join | main | TERMINATED | 3 | after 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 khirun()đã chạy xong. - B. Luôn in
0, vì luồng mới luôn chạy chậm hơnmain. - C. Ném
IllegalThreadStateException, vìcupsđược hai luồng cùng dùng. - D. Có thể in
0hoặc3, 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.
wdừng ngay lập tức vìinterrupt()là lệnh dừng. - B.
mainnhậnInterruptedExceptionvì chínhmainra lệnh ngắt. - C.
wtiếp tục chạy vìinterrupt()chỉ đặt cờ mà vòng lặp không đọc cờ. - D.
wchuyển sangTERMINATEDvì 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.
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:
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.
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.
// 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ỗi | Thông báo lỗi | Nguyên nhân bằng lời thường | Cách sửa |
|---|---|---|---|
named.run(); thay vì named.start(); | Không báo lỗi, in ran on main | run() 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ới | Gọi named.start(); |
once.start(); lần thứ hai trên luồng đã chạy xong | IllegalThreadStateException, và trạng thái là TERMINATED | Một đối tượng Thread chỉ start được đúng một lần trong đời | Tạo new Thread(task) mới cho mỗi lần chạy |
Thread.sleep(100); trong main không khai báo throws | unreported exception InterruptedException; must be caught or declared to be thrown | sleep có thể bị ngắt nên phải xử lý InterruptedException | Thêm throws InterruptedException hoặc bọc try, catch |
throw new IllegalStateException("boom") trong luồng crasher | Exception in thread "crasher" java.lang.IllegalStateException: boom rồi main vẫn chạy tiếp | Lỗ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ình | Bắ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):
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ần | Việ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):
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:
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?
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ớ
Threadcầnstart()để chạy song song, cònrun()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àTERMINATEDlà đ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àm | Cú pháp | Ghi chú |
|---|---|---|
| Tạo luồng có tên | new Thread(task, "name") | Trạng thái NEW, chưa chạy |
| Chạy song song | t.start() | Chỉ gọi một lần cho mỗi Thread |
| Chờ luồng xong | t.join() | Ném InterruptedException, đọc kết quả sau dòng này |
| Xem trạng thái | t.getState() | Một trong sáu giá trị |
| Ngủ có hạn | Thread.sleep(ms) | Trạng thái TIMED_WAITING |
| Nhắn dừng | t.interrupt() | Chỉ bật cờ, không dừng ngay |
| Đọc cờ ngắt | Thread.currentThread().isInterrupted() | Dùng làm điều kiện vòng lặp |
| Tên luồng đang chạy | Thread.currentThread().getName() | main cho luồng chính |
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]:
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]:
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]:
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 code | Má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ó xong | Chờ để 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úc | Bả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ạy | Luồng thực thi | state của t | Dòng in |
|---|---|---|---|
new Thread(...) rồi in | main | NEW | 1 NEW |
t.run() | main | NEW | A on main |
in sau run() | main | NEW | 2 NEW |
t.start() rồi t.join() | w | TERMINATED | A on w |
| in cuối | main | TERMINATED | 3 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]
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:
@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 code | Má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 pha | Cả 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ày | Mỗ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]
} 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.
start()tạo một luồng mới rồi trả về ngay, luồng đó chạyrun().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ằngThread.currentThread().getName()làmainkhi dùngrun(), và là tên luồng mới khi dùngstart(). Kiểm tra mục tiêu 1.- In
A,B,Ctheo thứ tự đó [Đã chạy].Ain trước khi luồng được tạo.join()làmmainchờ luồng inBxong rồi mới inC. Kiểm tra mục tiêu 1 và 2. - (a)
NEW, (b)TIMED_WAITING, (c)WAITING, (d)BLOCKED, (e)TERMINATED[Đã chạy, xemStates.java]. Kiểm tra mục tiêu 3. - 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ànhwhile (!Thread.currentThread().isInterrupted())[Suy luận]. Kiểm tra mục tiêu 4. - 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ójointhì ai nhanh hơn là không chắc [Có thể khác nhau]. Kiểm tra mục tiêu 2.