Java Internals & Concurrency/Thread API và vòng đời — start, join, sleep, daemon
4/75
Bài 4 / 75~13 phútConcurrency cơ bảnMiễn phí lượt xem

Thread API và vòng đời — start, join, sleep, daemon

Tạo và chạy thread đúng: start() khác run() ra sao, sáu trạng thái Thread.State, join() chờ thread khác, sleep() không nhả lock, và daemon thread.

TL;DR: start() mới tạo thread thật — gọi thẳng run() chỉ là method call thường trên thread hiện tại, không sinh thread nào. Một thread đi qua sáu trạng thái: NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED; RUNNABLE gộp cả "đang chạy" lẫn "chờ CPU". join() chờ thread khác kết thúc (vào WAITING); sleep() ngủ có hạn nhưng không nhả lock đang giữ. Daemon thread bị JVM bỏ rơi ngay khi thread thường cuối cùng kết thúc, nên chỉ dùng cho việc cắt ngang vô hại. Mỗi Thread object chỉ start() được một lần — vòng đời một chiều, muốn chạy lại thì tạo Thread mới hoặc dùng thread pool.

1. Tạo thread: start() khác run() ở đâu?

Bài trước dừng ở mức khái niệm: process, thread, và lý do thread tồn tại. Bài này cầm tay vào API.

Service đặt vé TicketFlow lúc khởi động nạp ba nguồn dữ liệu: danh sách sự kiện, bảng giá vé, và sơ đồ ghế. Mỗi thao tác mất khoảng 2 giây vì đọc đĩa và parse. Chạy tuần tự:

public class TicketFlowBootstrap {
    public static void main(String[] args) throws Exception {
        loadEvents();      // ~2s doc file + parse
        loadPricing();     // ~2s
        loadSeatMaps();    // ~2s
        System.out.println("Ready");   // sau ~6s
    }
}

Ba thao tác hoàn toàn độc lập, nhưng vẫn xếp hàng ăn trọn 6 giây trong khi đĩa và CPU phần lớn thời gian ngồi chờ nhau. Mỗi thao tác một thread riêng thì cả ba tiến triển song song, tổng thời gian xấp xỉ thao tác chậm nhất: ~2 giây. Để giữ lời hứa đó mà không tự bắn vào chân, bạn cần trả lời ba câu hỏi trong bài này — tạo thread thế nào, chờ nó xong thế nào, nó đang ở trạng thái gì — và một câu thứ tư khó hơn, để dành cho bài 02b: bảo nó dừng giữa chừng thế nào.

Cách phổ biến nhất để tạo thread là đưa một Runnable — mẩu công việc không nhận tham số, không trả kết quả — vào constructor của Thread:

Thread eventsLoader = new Thread(() -> loadEvents(), "events-loader");
eventsLoader.start();   // tu day, loadEvents() chay tren thread moi

Tham số thứ hai là tên thread — luôn nên đặt, vì khi đọc thread dump hay log lỗi, "events-loader" nói lên nhiều điều hơn "Thread-0".

Bẫy lớn nhất nằm ở chỗ Thread có cả hai method start()run(), và gọi nhầm cái sau thì chương trình vẫn chạy, không báo lỗi gì. Cơ chế bên dưới của chúng khác nhau một trời một vực:

  • start() gọi xuống method native start0(), yêu cầu hệ điều hành tạo một OS thread mới (với platform thread, mô hình one-to-one như bài trước). Khi OS thread đó được scheduler cấp CPU, JVM mới invoke run() trên thread mới đó. start() trả về gần như ngay lập tức — không chờ công việc chạy xong, thậm chí không chờ nó bắt đầu.
  • run() gọi trực tiếp thì chỉ là một method call bình thường: code trong Runnable thực thi tuần tự trên chính thread đang gọi. Không có thread nào được tạo ra cả.
Thread t = new Thread(() -> {
    System.out.println("Run on: " + Thread.currentThread().getName());
}, "worker");

t.run();     // in "Run on: main"   -- chi la method call thuong
t.start();   // in "Run on: worker" -- thread moi thuc su chay

Bug này nguy hiểm vì nó âm thầm: ba thao tác load vẫn hoàn thành đủ, kết quả đúng, chỉ có điều chúng chạy tuần tự trên main thread — vẫn mất 6 giây, và bạn không hiểu vì sao "đã dùng thread" mà không nhanh lên.

Một pitfall API nữa: mỗi Thread object chỉ start() được đúng một lần, gọi lần hai ném IllegalThreadStateException. Lý do nằm ở thiết kế vòng đời: một Thread object đại diện cho một lần thực thi, đi một chiều từ lúc tạo đến lúc kết thúc, không quay vòng. Muốn chạy lại công việc, tạo Thread mới — hoặc tốt hơn, dùng thread pool tái sử dụng thread (bài 17 — Executor và thread pool).

2. Vòng đời: 6 trạng thái của một thread

Vòng đời một chiều đó được Java mô hình hóa bằng enum Thread.State — gọi t.getState() bất kỳ lúc nào để xem thread đang ở đâu. Có đúng 6 trạng thái:

Vòng đời sáu trạng thái của một thread JavaMột thread đi từ NEW sang RUNNABLE rồi có thể rơi vào BLOCKED, WAITING hoặc TIMED_WAITING và quay lại RUNNABLE, cuối cùng kết thúc ở TERMINATED. Mọi chuyển đổi đều đi qua trạng thái trung tâm RUNNABLE.NEWRUNNABLEBLOCKEDWAITINGTIMED_WAITINGTERMINATED
bước 0/9
Sáu trạng thái của một thread (enum Thread.State). RUNNABLE là trung tâm — mọi chuyển đổi đều đi qua nó. Bấm “Chạy” (hoặc “Bước”) để đi theo một hành trình điển hình.
Trạng tháiVào khi nàoRa khi nàoMethod liên quan
NEWVừa new Thread(...), chưa startGọi start()constructor
RUNNABLEĐã start — đang chạy hoặc chờ được cấp CPUKết thúc, hoặc rơi vào một trạng thái chờstart()
BLOCKEDChờ lấy monitor lock đang bị thread khác giữLấy được locksynchronized (bài 08)
WAITINGChờ vô hạn một sự kiện từ thread khácSự kiện xảy ra: thread đích xong, được notify, được unparkjoin(), wait(), LockSupport.park()
TIMED_WAITINGChờ có thời hạnHết timeout, sự kiện xảy ra, hoặc bị interruptsleep(ms), join(ms), wait(ms)
TERMINATEDrun() kết thúc — trả về bình thường hoặc ném exceptionKhông bao giờ ra — trạng thái cuối

Ba điều đáng dừng lại:

RUNNABLE không có nghĩa là "đang chạy". Nó gộp cả hai tình huống: thread đang thực thi trên một core, và thread sẵn sàng chạy nhưng đang xếp hàng chờ scheduler cấp CPU. JVM không phân biệt vì việc cấp CPU là chuyện của OS, thay đổi từng mili-giây.

BLOCKEDWAITING khác nhau về bản chất. BLOCKED chỉ dành cho việc chờ monitor lock — thread muốn vào vùng synchronized mà lock đang trong tay thread khác. WAITING là chờ một sự kiện: thread khác kết thúc (join), một thông báo (notify), một tín hiệu unpark. Khi debug thread dump, phân biệt này nói cho bạn biết hệ thống kẹt vì tranh lock hay vì chờ việc — hai bệnh khác nhau, hai cách chữa khác nhau.

TERMINATED là một chiều. Khớp với pitfall start-hai-lần ở trên: không có cạnh nào từ TERMINATED quay về NEW hay RUNNABLE.

3. join() và sleep() — hai kiểu chờ

3.1 join(): chờ thread khác xong

Quay lại TicketFlow. Ba loader chạy song song, nhưng main thread phải biết khi nào cả ba xong mới được in "Ready". Công cụ cho việc đó là join() — thread gọi t.join() dừng lại (vào WAITING) cho đến khi thread t kết thúc:

public static void main(String[] args) throws InterruptedException {
    Thread events  = new Thread(TicketFlowBootstrap::loadEvents,   "events-loader");
    Thread pricing = new Thread(TicketFlowBootstrap::loadPricing,  "pricing-loader");
    Thread seats   = new Thread(TicketFlowBootstrap::loadSeatMaps, "seats-loader");

    events.start();
    pricing.start();
    seats.start();      // ca ba chay song song tu day

    events.join();      // main cho tung loader xong
    pricing.join();
    seats.join();

    System.out.println("Ready");   // sau ~2s thay vi ~6s
}

Thứ tự ba lệnh join() không quan trọng — main chỉ cần cả ba xong, và tổng thời gian chờ vẫn bằng loader chậm nhất.

Cơ chế bên dưới không phải magic: bản cài đặt truyền thống trong JDK cho thread gọi join ngủ bằng wait() trên monitor của chính Thread object t; khi t kết thúc, JVM gọi notifyAll() trên t để đánh thức mọi thread đang join (bài 08 — volatile & synchronized mổ wait/notify). Hệ quả: Javadoc khuyên đừng bao giờ wait()/notify() trên Thread object — sẽ giẫm chân lên cơ chế nội bộ của join.

join() còn có bản giới hạn thời gian join(millis) — chờ tối đa chừng đó rồi bỏ cuộc (vào TIMED_WAITING thay vì WAITING). Luôn cân nhắc bản này cho production: chờ vô hạn một thread có thể không bao giờ xong là một cách treo hệ thống.

3.2 sleep(): ngủ có hạn, và không nhả gì cả

Thread.sleep(millis) đưa thread hiện tại vào TIMED_WAITING trong ít nhất millis mili-giây — "ít nhất" vì hết giờ thread chỉ trở lại RUNNABLE, còn bao giờ được cấp CPU lại là việc của scheduler.

Điều quan trọng nhất về sleep() lại là điều nó không làm: nó không nhả bất kỳ lock nào thread đang giữ. Thread ngủ trong vùng synchronized thì ôm lock ngủ luôn — mọi thread khác cần lock đó đứng BLOCKED chờ đủ giấc của nó. Đây là khác biệt then chốt với wait(), thứ nhả lock trước khi ngủ (chi tiết ở bài 09), và là lý do "sleep để chờ điều kiện" trong vùng có lock gần như luôn là bug. Cần chờ điều kiện thì dùng wait() hoặc condition (bài 11).

4. Daemon thread: người dọn dẹp bị bỏ lại

Mặc định, JVM chỉ exit khi mọi thread thường (non-daemon) đã kết thúcmain xong mà còn một worker đang chạy thì JVM vẫn sống chờ worker. Daemon thread là ngoại lệ: chúng là thread "phục vụ nền", và JVM không chờ. Khi thread thường cuối cùng kết thúc, JVM exit ngay lập tức, bỏ rơi mọi daemon thread giữa chừng — không exception, không finally, dừng tại chỗ.

Thread cacheRefresher = new Thread(() -> {
    while (true) {
        refreshSeatMapCache();
        try { Thread.sleep(60_000); } catch (InterruptedException e) { return; }
    }
}, "cache-refresher");
cacheRefresher.setDaemon(true);   // PHAI set truoc khi start()
cacheRefresher.start();

Ví dụ daemon nổi tiếng nhất chính là các thread của garbage collector: chúng tồn tại để phục vụ chương trình, và khi chương trình xong thì không còn lý do gì giữ JVM sống chỉ để... dọn rác.

Tiêu chí chọn: việc bị cắt ngang không gây hậu quả — refresh cache, gửi metrics, dọn dẹp định kỳ — thì daemon phù hợp. Ngược lại là pitfall kinh điển: daemon thread đang ghi dở một file lúc JVM exit sẽ bị kill ngay giữa thao tác I/O — file cụt, buffer chưa flush, dữ liệu mất — và không một dòng log nào cho bạn biết. Bất kỳ việc gì phải hoàn thành trọn vẹn (ghi file, commit transaction, gửi response) đều phải chạy trên thread thường.

Lưu ý nhỏ: setDaemon(true) phải gọi trước start() — gọi sau ném IllegalThreadStateException, vì tính daemon được quyết định lúc OS thread được tạo.

5. Liên hệ các bài khác

  • Bài 01 — Process và Thread: nền khái niệm của bài này — vì sao platform thread đắt (one-to-one với OS thread) giải thích vì sao start() phải gọi xuống native.
  • Bài 02b — Cooperative cancellation: phần tiếp trực tiếp — sau khi tạo được thread chạy song song, làm sao bảo nó dừng giữa chừng một cách an toàn bằng interrupt().
  • Bài 03 — Thread safety: bài này tạo được nhiều thread chạy song song; bài 03 trả lời câu hỏi tiếp theo — chuyện gì xảy ra khi chúng cùng chạm vào một mảnh dữ liệu.
  • Bài 08 — volatile & synchronized: mổ cơ chế monitor, wait()/notify() — thứ mà join() được xây bên trên, và lý do BLOCKED tồn tại.
  • Bài 17 — Executor và thread pool: lời giải cho "chạy lại công việc" — tách Runnable khỏi thread, để pool tái sử dụng thread.

6. 📚 Deep Dive

📚 Deep Dive Oracle

Spec / reference chính thức:

Ghi chú: Javadoc Thread.State là nguồn chính xác nhất để phân biệt BLOCKED (chờ monitor lock) với WAITING (chờ sự kiện) — hai trạng thái này là chìa khóa đọc thread dump.

7. Tóm tắt

  • new Thread(Runnable) + start() tạo OS thread mới rồi JVM invoke run() trên thread đó; gọi thẳng run() chỉ là method call thường trên thread hiện tại.
  • Mỗi Thread object start() được đúng một lần — lần hai ném IllegalThreadStateException.
  • 6 trạng thái: NEWRUNNABLE → (BLOCKED | WAITING | TIMED_WAITING) → TERMINATED; RUNNABLE gộp cả "đang chạy" và "chờ CPU".
  • BLOCKED = chờ monitor lock; WAITING/TIMED_WAITING = chờ sự kiện (vô hạn / có hạn) — phân biệt này quan trọng khi đọc thread dump.
  • join() chờ thread khác kết thúc, cài đặt bằng wait trên monitor của Thread object; ưu tiên bản có timeout trong production.
  • sleep() đưa thread vào TIMED_WAITING nhưng không nhả lock đang giữ.
  • Daemon thread bị JVM bỏ rơi ngay khi thread thường cuối cùng kết thúc — chỉ dùng cho việc cắt ngang vô hại; setDaemon(true) phải trước start().

8. Tự kiểm tra

Tự kiểm tra
Q1
Đoạn code tạo Thread t in tên thread hiện tại, rồi gọi t.run() từ main. Chương trình in ra gì, và vì sao không có lỗi nào báo cho bạn biết mình dùng sai?

In ra mainrun() gọi trực tiếp chỉ là method call thường, chạy tuần tự trên thread đang gọi, không tạo OS thread nào. Không có lỗi vì code hợp lệ về ngôn ngữ: run() là public method bình thường và Runnable vẫn cho kết quả đúng; cái sai chỉ là sai concurrency (tuần tự thay vì song song) mà compiler không cách nào biết.

Chỉ start() mới tạo OS thread mới rồi invoke run() trên đó. Kiểm nhanh khi nghi ngờ: in Thread.currentThread().getName() bên trong công việc.

Q2
sleep() có nhả lock không? Hệ quả là gì khi sleep bên trong vùng synchronized?

Không. sleep() chỉ đưa thread vào TIMED_WAITING, không đụng tới lock nào — mọi monitor lock vẫn nằm trong tay thread đang ngủ. Nên thread khác cần cùng lock đứng BLOCKED suốt giấc ngủ: một sleep(5000) trong vùng có lock là 5 giây cả hệ thống xếp hàng.

Muốn chờ điều kiện thì dùng wait() — nó nhả lock trước khi ngủ rồi lấy lại khi tỉnh (bài 09). Nhớ nhanh: sleep ôm lock ngủ, wait trả lock rồi mới ngủ.

Q3
Thread dump cho thấy 40 thread BLOCKED và 40 thread WAITING. Hai nhóm này đang gặp chuyện gì khác nhau?

BLOCKED chỉ có một nghĩa: thread muốn vào vùng synchronized nhưng lock đang trong tay thread khác — 40 thread BLOCKED là dấu hiệu tranh lock, nên đi tìm thread đang giữ lock đó làm gì. WAITING là chờ sự kiện (join, notify, unpark) — 40 thread WAITING thường là worker chờ việc, có thể bình thường hoặc do upstream không đẩy việc xuống.

Cùng "đứng yên" nhưng BLOCKED là bệnh contention (chữa bằng thu hẹp vùng lock), WAITING là chuyện luồng công việc — hai cách chữa khác nhau.

Q4
Vì sao JVM exit khi chỉ còn daemon thread? Nêu một tình huống daemon thread gây mất dữ liệu.

Daemon là thread phục vụ (như GC thread); khi mọi thread thường đã xong thì chương trình coi như kết thúc, giữ JVM sống chỉ để phục vụ không ai nữa là vô nghĩa — nên JVM exit ngay, không chờ. Giá phải trả: daemon bị dừng tại chỗ, không finally, không dọn dẹp.

Mất dữ liệu kinh điển: daemon đang flush buffer xuống file lúc JVM exit → file cụt, phần chưa flush mất, không dấu vết. Quy tắc: việc phải hoàn thành trọn vẹn (ghi I/O, commit) bắt buộc thread thường.

Q5
Gọi start() lần thứ hai trên một Thread đã chạy xong thì điều gì xảy ra? Vì sao Java thiết kế như vậy thay vì cho thread "chạy lại"?

Ném IllegalThreadStateException ngay, kể cả khi thread đã TERMINATED. Vì một Thread object đại diện một lần thực thi, vòng đời một chiều NEW → ... → TERMINATED không có cạnh quay ngược; cho restart sẽ phải định nghĩa lại mọi trạng thái cũ (interrupt status, ai đang join, stack cũ) — mơ hồ và dễ bug.

Nhu cầu "chạy lại công việc" có lời giải đúng hơn: tách Runnable khỏi thread, để thread pool tái sử dụng thread cho nhiều công việc (bài 17).

Bài tiếp theo: Cooperative cancellation — interrupt và InterruptedException

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên

Bài tiếp theo

Cooperative cancellation — interrupt và InterruptedException