Đâ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()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.

Thử ba mươi giây

Tìm trong dự án của bạ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.

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.