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

  • start() tạo OS thread mới → JVM invoke run(); gọi thẳng run() = method call thường trên thread hiện tại. Mỗi Thread start() đúng một lần — lần hai ném IllegalThreadStateException.
  • 6 trạng thái: RUNNABLE gộp "chạy" + "chờ CPU"; BLOCKED = chờ monitor lock; WAITING/TIMED_WAITING = chờ sự kiện. Trong thread dump: BLOCKED nhiều → nghi lock contention, WAITING nhiều → nghi đợi signal.
  • join() cài bằng wait() trên monitor của Thread object — Javadoc khuyến cáo không wait()/notify() trực tiếp trên Thread; dùng join(timeout) trong production.
  • Daemon thread bị JVM bỏ rơi ngay khi thread non-daemon cuối cùng kết thúc — không chạy finally, không cleanup; setDaemon(true) phải trước start().

8. Tự kiểm tra

Tự kiểm tra
0/5 câu đã trả lời
  1. 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?
  2. Q2
    sleep() có nhả lock không? Hệ quả là gì khi sleep bên trong vùng synchronized?
  3. 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?
  4. 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.
  5. 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"?

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