Hình dung một phiếu giao việc. Runnable là tờ phiếu ấy — nó ghi việc gì cần làm. Có hai cách xử lý: bạn cầm phiếu rồi tự tay làm luôn (đó là run(), việc chạy trên chính luồng của bạn), hoặc đưa phiếu cho một người làm khác (đó là start(), JVM cấp một luồng riêng). Rất nhiều người tưởng cầm phiếu lên đọc to là đã giao việc — gọi new Thread(viec).run() và đinh ninh mình vừa tạo một luồng. Không: bạn vừa tự làm hết, còn "người làm" mới thuê thì ngồi không. Cả bài hôm nay là về tờ phiếu đó và vòng đời của người làm.

Đây là bài mở đầu chặng đồng thời của sê-ri. Sáu mươi sáu bài trước gần như đều chạy trên một luồng; từ đây trở đi thì không.

Bắt đầu bằng lỗi mà tôi đã thấy đủ nhiều lần để tin rằng ai cũng mắc ít nhất một lần.

run() không tạo ra luồng nào

Runnable viec = () -> System.out.println("chạy trên luồng: " + Thread.currentThread().getName());

new Thread(viec, "luong-A").run();     // <- sai
Thread t = new Thread(viec, "luong-B"); t.start();
  gọi run():
    việc chạy trên luồng: main
  gọi start():
    việc chạy trên luồng: luong-B

run() chỉ là một phương thức bình thường. Gọi nó là gọi một phương thức — thân hàm chạy trên luồng hiện tại, đồng bộ, y như mọi lời gọi khác. Bạn đã tạo một đối tượng Thread và rồi không dùng tới nó.

start() mới là thứ yêu cầu JVM cấp một luồng của hệ điều hành rồi chạy run() trên đó.

Điều làm lỗi này khó chịu: mã vẫn cho kết quả đúng. Không ngoại lệ, không cảnh báo, kết quả tính toán không sai — chỉ là mọi thứ chạy tuần tự và bạn không hiểu vì sao phần "song song" chẳng nhanh hơn chút nào. Trong một vòng lặp gọi run() cho 10 tác vụ, tổng thời gian là tổng của cả 10.

Cách phát hiện nhanh nhất là in Thread.currentThread().getName() ra như trên. Thấy main là biết ngay.

Một chi tiết liên quan: gọi start() hai lần trên cùng đối tượng Thread sẽ ném IllegalThreadStateException. Một Thread chạy được đúng một lần, không tái sử dụng — đó là một trong những lý do người ta dùng ExecutorService thay vì tự quản lý Thread.

Sáu trạng thái

  vừa tạo, chưa start : NEW
  đang sleep(500)     : TIMED_WAITING
  đang wait()         : WAITING
  đang chờ khoá       : BLOCKED
  sau khi xong        : TERMINATED

Cộng RUNNABLE cho luồng đang chạy, là đủ sáu.

Phân biệt ba trạng thái chờ, vì chúng nói lên nguyên nhân khác hẳn nhau:

BLOCKED — đang chờ vào một khối synchronized mà luồng khác đang giữ. Nhiều luồng BLOCKED cùng lúc nghĩa là bạn có tranh chấp khoá.

WAITING — đang chờ vô hạn, do wait(), join() không tham số, hay park(). Phải có luồng khác đánh thức thì mới tiếp tục.

TIMED_WAITING — chờ có hạn, do sleep(n), wait(n), join(n).

RUNNABLE có một điểm dễ hiểu nhầm: nó không phân biệt "đang chạy trên CPU" với "sẵn sàng chạy và đang xếp hàng". Và điều bất ngờ hơn — một luồng đang chờ đọc từ ổ đĩa hay từ mạng vẫn hiện là RUNNABLE, vì JVM không biết hệ điều hành đang chặn nó. Nên thấy một luồng RUNNABLE trong ảnh chụp ngăn xếp không có nghĩa nó đang tốn CPU.

Trạng thái đọc được bằng t.getState(), nhưng trong thực tế bạn sẽ đọc chúng từ ảnh chụp luồng (jstack hoặc jcmd <pid> Thread.print) khi ứng dụng treo. Nhận ra một đám BLOCKED cùng chờ một đối tượng là cách tìm ra deadlock nhanh nhất.

Thread.stop() giờ ném lỗi

  java.lang.UnsupportedOperationException

Và trình biên dịch cũng nói trước:

  warning: [removal] stop() in Thread has been deprecated and marked for removal

stop() bị bỏ dùng từ Java 1.2 và tới Java 20 thì thành ném lỗi luôn — mã cũ gọi nó vẫn biên dịch được nhưng chết lúc chạy. Đây là chi tiết đáng biết khi nâng cấp một hệ thống cũ.

Lý do nó bị bỏ: stop() ném một lỗi vào luồng đích tại một điểm bất kỳ trong mã. Có thể là giữa hai dòng của một khối synchronized, khi cấu trúc dữ liệu mới sửa được một nửa. Khoá được nhả ra, luồng khác vào và thấy dữ liệu hỏng. Không có cách nào viết mã an toàn trước chuyện đó.

Cùng lý do, suspend() và resume() cũng bị bỏ — chúng giữ nguyên khoá trong lúc treo luồng, nên rất dễ dẫn tới deadlock.

Cách dừng đúng: yêu cầu, không ép buộc

Java không cho bạn giết một luồng. Nó chỉ cho bạn giơ cờ, và luồng kia tự quyết định:

while (!Thread.currentThread().isInterrupted()) {
    // làm việc
}
    đã dừng, cờ ngắt = true

interrupt() không dừng gì cả — nó bật một cờ boolean. Luồng đang chạy phải tự kiểm cờ đó ở chỗ hợp lý. Nghe có vẻ yếu, nhưng đó chính là điểm mạnh: luồng tự chọn thời điểm dừng, nên nó dừng khi dữ liệu đang ở trạng thái nhất quán.

Hệ quả cần nhớ: một vòng lặp không bao giờ kiểm cờ thì không bao giờ dừng được. Không có cách nào ép.

Cái bẫy: bắt InterruptedException là xoá cờ

Đây là phần tôi muốn bạn nhớ nhất của bài.

    bắt được InterruptedException
    cờ ngắt ngay sau catch : false     <- đã bị xoá!
    sau khi đặt lại        : true

Khi luồng đang ở sleep(), wait() hay join() mà bị ngắt, nó ném InterruptedException — và xoá cờ ngắt trước khi ném.

Nên đoạn mã cực kỳ phổ biến này phá hỏng cơ chế ngắt của cả ứng dụng:

try { Thread.sleep(100); } catch (InterruptedException e) { }   // <- nuốt mất tín hiệu

Sau khối catch đó, cờ là false. Vòng lặp bao ngoài kiểm isInterrupted() sẽ thấy false và chạy tiếp như chưa có gì xảy ra. Yêu cầu dừng đã bị nuốt mất, và luồng của bạn không chịu chết khi đóng ứng dụng.

Hai cách xử lý đúng, chọn một:

// 1. Khôi phục cờ rồi để tầng trên xử lý
catch (InterruptedException e) { Thread.currentThread().interrupt(); return; }

// 2. Ném tiếp lên, nếu chữ ký cho phép
throws InterruptedException
Đừng bao giờ để khối catch (InterruptedException e) trống. Đó là chỗ tín hiệu dừng biến mất, và triệu chứng là ứng dụng không thoát được khi bạn bấm dừng — một luồng nào đó vẫn sống mà không ai biết nó nằm ở đâu.

Tôi thừa nhận đây là dòng mã tôi từng viết sai nhiều lần, vì IDE tự sinh ra khối catch trống và ta bấm qua nó mà không nghĩ.

Luồng nền và ngoại lệ không bắt

Luồng nền (daemon) không giữ JVM sống. Khi luồng không-daemon cuối cùng kết thúc, JVM thoát và các luồng daemon bị chấm dứt giữa chừng, không chạy finally. Nên đừng để việc cần hoàn tất — ghi tệp, đóng kết nối — trong luồng daemon.

main không phải daemon:

  luồng chính là daemon? false

Ngoại lệ trong luồng con không lan sang luồng cha. Không có cách nào bắt nó bằng try/catch ở nơi gọi start():

  handler bắt được: [no-tung] nổ trong luồng con
  luồng chính vẫn chạy tiếp bình thường

Mặc định nó in dấu vết ngăn xếp ra System.err rồi luồng đó chết lặng lẽ. Trong ứng dụng thật, hãy đặt Thread.setDefaultUncaughtExceptionHandler(...) để đưa nó vào hệ thống log — nếu không, một luồng nền chết vì ngoại lệ sẽ biến mất mà không ai hay, và chức năng nó phụ trách đơn giản là ngừng hoạt động.

Luồng tốn bao nhiêu

Cùng một container 1GB RAM, -Xmx256m, cùng công việc là ngủ:

  luồng nền tảng : 35 000 luồng trong 9,3 giây, rồi ĐỨNG HẲN
                   (tôi giết tiến trình sau 200 giây, nó không tạo thêm được cái nào)
  luồng ảo       : 200 000 luồng trong 0,2 giây

Chú ý cách luồng nền tảng hỏng: không có OutOfMemoryError, không ngoại lệ nào cả. Máy chỉ chậm dần rồi dừng lại. Đó là kiểu hỏng khó chẩn đoán nhất, vì không có gì trong log để mà đọc.

Và chi phí cho từng luồng, với công việc thật:

  10.000 luồng, mỗi luồng ngủ 100ms:
    luồng nền tảng : 1050 ms
    luồng ảo       :  136 ms

  chi phí tạo thuần:
    luồng nền tảng : 97,3 micro giây/luồng
    luồng ảo       : 31,6 micro giây/luồng

Mỗi luồng nền tảng là một luồng của hệ điều hành, với ngăn xếp riêng thường 1MB được đặt chỗ. Luồng ảo nằm trên heap, khởi đầu vài trăm byte và lớn dần theo nhu cầu.

Đó là toàn bộ lý do luồng ảo tồn tại: để bạn được viết mã chặn một cách thoải mái. Mô hình "một luồng cho mỗi request" đơn giản và dễ gỡ lỗi, nhưng trước Java 21 nó không mở rộng được, nên cả ngành phải chuyển sang lập trình bất đồng bộ với CompletableFuture — đổi sự đơn giản lấy khả năng mở rộng. Luồng ảo trả lại sự đơn giản đó.

Một điều không đổi: luồng ảo không làm mã đa luồng an toàn hơn. Tranh chấp dữ liệu, deadlock và điều kiện đua vẫn nguyên vẹn — thậm chí dễ gặp hơn vì bạn chạy được nhiều tác vụ đồng thời hơn nhiều. Đó là chủ đề của những bài tiếp theo.

Khi nào tự tạo Thread

Hiếm. Trong mã ứng dụng, hầu như luôn nên dùng ExecutorService — nó quản lý vòng đời, tái sử dụng luồng, và cho bạn Future để lấy kết quả cùng ngoại lệ.

new Thread(...) còn hợp lý ở vài chỗ: một luồng nền chạy suốt vòng đời ứng dụng, một hook lúc tắt máy, hoặc chính lúc bạn đang học cơ chế như bài này.

Còn với công việc ngắn và nhiều, Java 21 có Executors.newVirtualThreadPerTaskExecutor() — mỗi tác vụ một luồng ảo, không cần pool, không cần chọn kích thước.

Nếu chỉ làm một việc sau bài này, hãy tìm trong dự án mọi khối catch (InterruptedException e): với mỗi khối trống hoặc chỉ có một dòng log, thêm Thread.currentThread().interrupt(); vào. Đó là sửa một dòng, và nó chữa đúng loại lỗi "ứng dụng không chịu tắt" mà rất khó lần ra nguồn gốc.

Mẫu số chung

Luồng ảo của Java 21 không phải phát minh mới — nó là Java cuối cùng cũng có thứ mà nhiều ngôn ngữ đã xây quanh từ lâu: luồng nhẹ do runtime quản lý, rẻ tới mức tạo hàng triệu cái. Và biết nó học từ đâu làm rõ vì sao nó quan trọng.

  • Go là hình mẫu trực tiếp: goroutine rẻ như luồng ảo, chạy trên bộ lập lịch M:N y hệt, và cho phép viết mã chặn kiểu tuần tự thay vì callback — đúng thứ luồng ảo mang lại cho Java. Điểm khác đáng chú ý: Go huỷ bằng context.Context mà goroutine phải tự kiểm ctx.Done() — chính là cơ chế "giơ cờ, không ép buộc" của interrupt(). Không ngôn ngữ nào cho bạn giết một luồng an toàn; tất cả đều chọn huỷ hợp tác.
  • Erlang/Elixir đi xa nhất và sớm nhất: "process" của chúng nhẹ tới mức chạy hàng triệu là bình thường, và cả một triết lý chịu lỗi được xây trên đó ("let it crash").
  • Kotlin dùng coroutine với suspend — cùng mục tiêu "viết tuần tự, chạy không chặn", nhưng đạt bằng biến đổi lúc biên dịch thay vì runtime như Java/Go.
  • Python minh hoạ mặt trái: thread thật bị GIL bó, nên muốn nhiều tác vụ I/O đồng thời phải dùng asyncio với async/await — và cú chuyển sang async chính là cái giá mà luồng ảo giúp Java tránh.

Sợi chỉ chung đáng mang theo có hai vế. Một: "một tác vụ một luồng" là mô hình dễ nghĩ nhất, và luồng nhẹ khiến nó mở rộng được — nên xu hướng chung của cả ngành là quay về lối viết chặn-tuần-tự, bỏ dần callback địa ngục, dù mỗi ngôn ngữ gọi tên khác nhau (goroutine, process, coroutine, virtual thread). Hai: huỷ luôn là hợp tác, không bao giờ là cưỡng bức — dù bạn viết Java, Go hay Python, muốn dừng một tác vụ thì phải có chỗ nó tự kiểm tín hiệu, và nuốt mất tín hiệu đó (khối catch trống, quên kiểm ctx.Done()) là cách nhanh nhất tạo ra một tác vụ không chịu chết.

Ngày mai: synchronized và vùng loại trừ lẫn nhau — vì sao i++ không phải một thao tác nguyên tử, và hai luồng cùng cộng một triệu lần lại ra kết quả nhỏ hơn hai triệu.