Command Palette

Search for a command to run...

[Advanced Java] Thread trong Java: Thread, Runnable và virtual thread

Mọi chương trình trong khoá này cho tới giờ đều chỉ có đúng một đường thực thi. Câu lệnh này chạy xong thì câu lệnh sau mới bắt đầu, và chạy lại cùng một chương trình thì bạn nhận lại đúng cùng một output. Bài này mở đầu Phần 4 và chấm dứt cả hai điều đó.

Thread là một đường thực thi độc lập bên trong một process. Start thêm một thread nghĩa là có hai chuỗi lời gọi method cùng chạy một lúc, trên cùng những object đó, theo một thứ tự mà không ai hứa hẹn gì với bạn. Đó là nguồn gốc của mọi thứ hữu ích lẫn mọi thứ khó nhằn của concurrency, nên việc đầu tiên phải hiểu cho đúng là: thread thực sự là gì, JVM cấp riêng cho mỗi thread cái gì, chúng dùng chung cái gì, và ba cách tạo thread thực sự phải trả giá bao nhiêu.

Ba lane thực thi song song có tên main, worker-A và worker-B, mỗi lane có điểm tiến độ riêng

Mọi chương trình bên dưới đều được compile và chạy thật trên OpenJDK 21.0.6 (arm64). Output của code đa thread về bản chất là không lặp lại được, nên ở chỗ nào kết quả đổi giữa các lần chạy, bài này trích nhiều lần chạy và nói rõ điều đó; hãy xem những block đó là một mẫu của cái có thể xảy ra, không bao giờ là output được bảo đảm.

Thread thực sự là gì

Một chương trình Java không bắt đầu với không thread nào. Nó bắt đầu với một. main không phải bộ máy đặc biệt gì — nó là một thread mà JVM đã tạo ra trước khi gọi method main của bạn, và Thread.currentThread() bên trong nó trả về một object Thread bình thường, có tên, có id, có priority.

Thread thứ hai là một đường đi thứ hai xuyên qua cùng chương trình đó. Ranh giới rất rõ ràng: mỗi thread có stack riêng — các frame, các parameter, các local variable của nó — còn heap thì mọi thread dùng chung, tức là mọi object mà chương trình đã cấp phát. Hai thread chạy cùng một method sẽ có hai bản độc lập của các local variable trong method đó, và đúng một bản của bất kỳ object nào cả hai cùng nhận được.

Java
class Shared {
    final String label = "one object on the heap";
}
 
public class ThreadMemory {
 
    static void work(Shared shared) {
        int local = 0;                      // one copy per thread
        for (int i = 0; i < 3; i++) {
            local++;
            System.out.println(Thread.currentThread().getName()
                    + "  local=" + local
                    + "  shared=@" + Integer.toHexString(System.identityHashCode(shared))
                    + "  frames=" + Thread.currentThread().getStackTrace().length);
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        Shared shared = new Shared();
        Thread a = new Thread(() -> work(shared), "worker-A");
        Thread b = new Thread(() -> work(shared), "worker-B");
        a.start();
        b.start();
        a.join();
        b.join();
        work(shared);
    }
}

Hai lần chạy đầy đủ của chương trình đó:

Text
worker-A  local=1  shared=@29bb2e0d  frames=4
worker-B  local=1  shared=@29bb2e0d  frames=4
worker-A  local=2  shared=@29bb2e0d  frames=4
worker-B  local=2  shared=@29bb2e0d  frames=4
worker-A  local=3  shared=@29bb2e0d  frames=4
worker-B  local=3  shared=@29bb2e0d  frames=4
main  local=1  shared=@29bb2e0d  frames=3
main  local=2  shared=@29bb2e0d  frames=3
main  local=3  shared=@29bb2e0d  frames=3
Text
worker-B  local=1  shared=@66f775b8  frames=4
worker-A  local=1  shared=@66f775b8  frames=4
worker-B  local=2  shared=@66f775b8  frames=4
worker-A  local=2  shared=@66f775b8  frames=4
worker-B  local=3  shared=@66f775b8  frames=4
worker-A  local=3  shared=@66f775b8  frames=4
main  local=1  shared=@66f775b8  frames=3
main  local=2  shared=@66f775b8  frames=3
main  local=3  shared=@66f775b8  frames=3

Có ba thứ đáng đọc kỹ ở đây. local đếm 1, 2, 3 riêng trong từng thread, vì mỗi thread có frame riêng giữ bản copy riêng của nó — ba thread chạy cùng một method và không thread nào nhìn thấy local của thread khác. Identity hash giống hệt nhau ở mọi dòng trong cùng một lần chạy, vì chỉ có một object Shared và cả ba thread đều giữ reference tới đúng object đó; nó khác nhau giữa hai lần chạy vì identity hash được sinh ra theo từng lần chạy JVM, không phải theo chương trình. Còn frames là 4 ở các worker so với 3 ở main, vì stack của worker bắt đầu từ Thread.run còn stack của main bắt đầu từ main — stack khác nhau thì độ sâu khác nhau.

Thread nào in trước cũng đã đổi giữa hai lần chạy. Đó là nội dung của một phần sau; bây giờ chỉ cần thấy rằng không có gì trong chương trình quyết định chuyện đó.

Nếu "stack riêng" nghe vẫn còn trừu tượng thì hãy chỉnh kích thước một cái stack rồi đâm thẳng xuống đáy nó. Thread có một constructor bốn tham số mà tham số cuối là kích thước stack tính bằng byte — một gợi ý mà JVM được phép bỏ qua, nhưng ở đây HotSpot có tôn trọng:

Java
public class StackProbe {
 
    static void recurse(int[] depth) {
        depth[0]++;
        recurse(depth);
    }
 
    static Thread probe(String name, long stackBytes) {
        return new Thread(null, () -> {
            int[] depth = new int[1];
            try {
                recurse(depth);
            } catch (StackOverflowError e) {
                System.out.println("  " + Thread.currentThread().getName()
                        + " reached depth " + depth[0]);
            }
        }, name, stackBytes);
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread small = probe("small-stack", 256 * 1024);
        Thread big = probe("big-stack", 8 * 1024 * 1024);
        small.start();
        big.start();
        small.join();
        big.join();
    }
}

Bốn lần chạy liên tiếp, nối lại với nhau — mỗi lần chạy in ra hai dòng:

Text
  small-stack reached depth 1262
  big-stack reached depth 240492
  small-stack reached depth 1555
  big-stack reached depth 145159
  small-stack reached depth 1262
  big-stack reached depth 145871
  small-stack reached depth 1488
  big-stack reached depth 146123

Hai thread, một cái đệ quy sâu 1262 frame, cái kia 240492 frame, trong cùng một JVM và cùng một lúc. Chúng không thể đang dùng chung một stack. Con số độ sâu chính xác đổi theo từng lần chạy — bạn sẽ không tái tạo lại đúng những số này — vì stack còn chứa cả những gì JIT và runtime cần tại thời điểm đó; phần mang tính cấu trúc là khoảng cách hai bậc độ lớn giữa chúng.

Ba cách tạo một thread

Có ba cách để viết đoạn code mà một thread sẽ chạy, và chỉ một trong ba là lựa chọn mặc định tốt. Cả ba đều dẫn tới cùng một đích: một object Thread có method run, được khởi động bằng start.

extends Thread tiêu mất slot kế thừa duy nhất; implements Runnable giữ slot đó lại và biến task thành một giá trị

Kế thừa Thread

Extends Thread rồi override run. Object bạn tạo ra vừa là task vừa là worker:

Java
class Downloader extends Thread {
    @Override
    public void run() {
        System.out.println("subclass    running on " + Thread.currentThread().getName());
    }
}

Cách này chạy được, và nó lấy mất của bạn thứ mà một class chỉ có đúng một lần. Một class chỉ được extends đúng một class, và Downloader vừa tiêu cái slot đó vào Thread. Đến ngày nó cần extends thêm một base class trong code của bạn hoặc của framework, không còn chữ extends thứ hai nào để viết, và compiler nói điều đó trước khi nói bất cứ điều gì khác:

Java
class ReportJob { }
 
class NightlyReport extends ReportJob extends Thread {
    @Override public void run() { }
}
Text
Bad.java:3: error: '{' expected
class NightlyReport extends ReportJob extends Thread {
                                     ^
1 error

Parser dừng lại ngay ở chữ extends thứ hai — không có cú pháp nào cho thứ bạn đang yêu cầu. Ngoài chuyện cú pháp, đây còn là một lỗi mô hình hoá: một Downloader không phải là một loại thread. Nó là một phần việc mà thread nào đó nên chạy. Và vì object chính là thread, nó chỉ dùng được một lần: một Downloader chỉ start được đúng một lần, vĩnh viễn.

Implement Runnable

Runnable là một interface có đúng một method, void run(). Class implement nó, giữ nguyên slot extends của mình, rồi đưa một instance cho Thread:

Java
class Uploader implements Runnable {
    @Override
    public void run() {
        System.out.println("Runnable    running on " + Thread.currentThread().getName());
    }
}
Java
Thread t = new Thread(new Uploader());
t.start();

Đây là lựa chọn mặc định đúng, và lý do không nằm ở thẩm mỹ. Task bây giờ là một giá trị: một object bạn có thể lưu vào field, truyền vào method, bỏ vào list, đưa cho hai thread khác nhau, hoặc giao cho một thread pool ở phần sau của khoá này. Thread là bộ máy chạy nó; Runnable là phần việc. Tách hai thứ đó ra chính là điều cho phép bạn đổi cái này mà không đụng cái kia.

Lambda, cũng chính là Runnable đó

Runnable có đúng một abstract method nên nó là functional interface, và một lambda chính là một Runnable:

Java
Thread t = new Thread(() -> System.out.println("lambda      running on "
        + Thread.currentThread().getName()));
t.start();

Ở đây không có cơ chế thứ ba nào cả. Lambda sinh ra một object implement Runnable, constructor nhận nó, Thread.run gọi nó. Dùng lambda cho những body ngắn, dùng một class có tên implement Runnable khi task có state, có một cái tên đáng đặt, hoặc cần test riêng.

Bỏ cả ba vào một chương trình rồi start chúng:

Java
public class Creation {
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Downloader();
        Thread t2 = new Thread(new Uploader());
        Thread t3 = new Thread(() -> System.out.println("lambda      running on "
                + Thread.currentThread().getName()));
 
        t1.start();
        t2.start();
        t3.start();
 
        t1.join();
        t2.join();
        t3.join();
        System.out.println("main        finished on " + Thread.currentThread().getName());
    }
}
Text
Runnable    running on Thread-1
lambda      running on Thread-2
subclass    running on Thread-0
main        finished on main

Một lần chạy khác của đúng binary đó lại xếp các dòng theo thứ tự khác:

Text
Runnable    running on Thread-1
subclass    running on Thread-0
lambda      running on Thread-2
main        finished on main

Thread-0, Thread-1, Thread-2 là tên mặc định, đánh số theo thứ tự tạo chứ không theo thứ tự start — và đó là lý do các con số vẫn dính chặt vào đúng task của nó trong khi các dòng thì nhảy chỗ.

start() so với run()

startrun đều public, đều không nhận tham số, và chỉ một trong hai tạo ra thread. Gọi thẳng run chỉ là một lời gọi method bình thường: body chạy trên chính thread đang gọi, start không hề tham gia, và không thread nào được tạo ra. In tên thread hiện tại trong cả hai trường hợp thì khác biệt hiện ra ngay.

Gọi run đẩy frame lên stack của chính caller; gọi start tạo ra stack thứ hai rồi return ngay

Java
public class StartVsRun {
    public static void main(String[] args) {
        Runnable task = () -> System.out.println("  task executed on: "
                + Thread.currentThread().getName());
 
        Thread t = new Thread(task, "worker-1");
 
        System.out.println("t.run()   ->");
        t.run();
 
        System.out.println("t.start() ->");
        t.start();
    }
}
Text
t.run()   ->
  task executed on: main
t.start() ->
  task executed on: worker-1

Output này ổn định qua mọi lần chạy, vì nửa đầu chẳng có gì là concurrent cả: t.run() chạy xong hẳn trên main rồi dòng tiếp theo mới được thực thi. Object Thread tên t vẫn được tạo ra trong cả hai trường hợp — nó chỉ nằm im trong khi main làm việc thay cho nó.

Cái start làm thì khác hẳn về bản chất. Nó yêu cầu JVM cấp một đường thực thi mới với stack riêng, sắp xếp để thread mới đó gọi run, rồi return về cho caller ngay lập tức mà không chờ đợi gì. Bây giờ tồn tại hai stack. main chạy tiếp trong khi worker-1 chạy task.

Lỗi này rất dễ mắc và rất im lặng khi mắc: một chương trình gọi run thay vì start vẫn cho ra output trông đúng, theo đúng thứ tự, dùng một thread, và hành xử y như thể bạn chưa từng viết dòng code threading nào. Cái tên trong output là dấu vết duy nhất.

Gọi start() hai lần

Một object Thread chỉ dùng được một lần. Start lại một thread đã chạy xong không làm nó chạy lại:

Java
public class StartTwice {
    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> System.out.println("ran once"), "worker-1");
        t.start();
        t.join();
        t.start();          // the same Thread object, a second time
    }
}
Text
ran once
Exception in thread "main" java.lang.IllegalThreadStateException
	at java.base/java.lang.Thread.start(Thread.java:1525)
	at StartTwice.main(StartTwice.java:6)

IllegalThreadStateException là unchecked nên không có gì ép bạn xử lý nó, và nó không mang message nào cả — tên class chính là toàn bộ chẩn đoán. Chú ý rằng nó được ném trên thread đang gọi, tức main, chứ không phải trên thread bạn định start. Muốn chạy lại cùng phần việc thì tạo một Thread thứ hai bọc quanh cùng cái Runnable; task dùng lại được, thread thì không.

Vì sao thứ tự output đổi giữa các lần chạy

Hai thread mỗi thread in năm dòng thì ra mười dòng. Không có gì trong ngôn ngữ hay trong JVM nói bạn sẽ nhận được thứ tự nào trong số các thứ tự mười dòng đó. Đây là chương trình nhỏ nhất cho thấy điều đó:

Java
public class Interleave {
    public static void main(String[] args) {
        Runnable count = () -> {
            String name = Thread.currentThread().getName();
            for (int i = 1; i <= 5; i++) {
                System.out.println(name + " " + i);
            }
        };
        new Thread(count, "A").start();
        new Thread(count, "B").start();
    }
}

Sáu lần chạy liên tiếp của binary đó, không sửa gì, cho ra năm thứ tự khác nhau. Lần 1 và lần 6 là cặp duy nhất trùng nhau, và cả hai đều chạy tuần tự hoàn toàn:

Text
A 1
A 2
A 3
A 4
A 5
B 1
B 2
B 3
B 4
B 5

Lần 3 bắt đầu bằng B, và hai thread đổi chỗ cho nhau ba lần trên đường đi:

Text
B 1
B 2
B 3
B 4
A 1
A 2
B 5
A 3
A 4
A 5

Lần 5 lại khác nữa:

Text
A 1
A 2
A 3
B 1
A 4
A 5
B 2
B 3
B 4
B 5

Máy của bạn sẽ cho ra thứ khác. Lý do là start chỉ làm cho thread sẵn sàng chạy — scheduler của hệ điều hành mới là bên quyết định thread sẵn sàng nào được cấp core và trong bao lâu, mà quyết định đó phụ thuộc vào số core, tải hiện tại, những gì JIT đã compile được tới lúc đó, và những khoảng thời gian bạn không kiểm soát được. Chạy đúng binary đó trên một máy đang bận hơn thì mấy con số này lại đổi.

Có hai quy tắc sống sót qua đám nhiễu đó, và đó là hai thứ duy nhất bạn được cho không. Bên trong một thread, các câu lệnh chạy đúng thứ tự bạn viết: A 3 luôn đứng trước A 4. Giữa các thread, không có gì được sắp thứ tự trừ khi chính bạn sắp. Mọi thứ in ra ở trên đều nhất quán với cả hai quy tắc đó.

Quy tắc thứ hai là chỗ concurrency thôi làm một tiện ích và bắt đầu làm một kỷ luật. Output xen kẽ nhau thì vô hại. Cập nhật lên cùng một field xen kẽ nhau thì không, và bài kế tiếp nói đúng về chuyện đó.

join(): chờ một thread chạy xong

start return ngay lập tức, nên theo mặc định thread gọi cứ thế chạy tiếp và kết quả chưa sẵn sàng vào lúc nó đi tìm. join là cách sửa: nó block thread đang gọi cho tới khi thread đích chạy xong.

Java
public class JoinDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread loader = new Thread(() -> {
            try {
                Thread.sleep(200);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                return;
            }
            System.out.println("loader: data ready");
        }, "loader");
 
        loader.start();
        System.out.println("main: started the loader");
        loader.join();                       // block until loader finishes
        System.out.println("main: loader is alive? " + loader.isAlive());
        System.out.println("main: continuing with the data");
    }
}
Text
main: started the loader
loader: data ready
main: loader is alive? false
main: continuing with the data

Thứ tự đó giữ nguyên qua mọi lần chạy, và đó chính là điểm mấu chốt: join là một trong số ít thứ tự bạn được phép tin. Sau khi loader.join() return bình thường thì loader đã xong, nên isAlive()false và bất cứ thứ gì nó tạo ra đều đã sẵn sàng. Xoá dòng join đi thì cũng chương trình đó in dòng của loader ở cuối cùng:

Text
main: started the loader
main: continuing with the data
loader: data ready

Có ba chi tiết đáng biết. join được khai báo throws InterruptedException, vì một thread đang chờ thread khác thì bản thân nó cũng có thể bị interrupt — phần cuối bài giải thích phải làm gì với chuyện đó. join(long millis) chờ tối đa từng ấy rồi return bất kể thread đã xong hay chưa, nên bạn phải kiểm tra isAlive() để biết trường hợp nào đã xảy ra. Và join một thread đã kết thúc thì return ngay, nên gọi join phòng thủ là an toàn.

Lệnh Thread.sleep(200) bên trong loader là một phần của chương trình, đứng thay cho một công việc tốn thời gian. Nó không đo đạc cái gì cả.

Daemon thread và lúc JVM thoát

JVM không thoát khi main return. Nó thoát khi thread non-daemon cuối cùng chạy xong. Mọi thread bạn tạo đều là non-daemon theo mặc định và thừa hưởng điều đó từ thread tạo ra nó, nên một vòng lặp nền mà bạn quên mất sẽ giữ cả process sống mãi.

Đánh dấu một thread là daemon nghĩa là nói rằng nó là hạ tầng chứ không phải phần việc: JVM sẽ không chờ nó, và sẽ giết nó ngay tại chỗ nó đang chạy khi thread non-daemon cuối cùng kết thúc.

Java
public class DaemonDemo {
    public static void main(String[] args) {
        Thread ticker = new Thread(() -> {
            int n = 0;
            while (true) {
                System.out.println("  tick " + (++n));
                try { Thread.sleep(100); } catch (InterruptedException e) { return; }
            }
        }, "ticker");
 
        ticker.setDaemon(true);              // must be set BEFORE start()
        System.out.println("ticker daemon? " + ticker.isDaemon());
        ticker.start();
 
        try { Thread.sleep(350); } catch (InterruptedException e) { }
        System.out.println("main: returning");
    }
}
Text
ticker daemon? true
  tick 1
  tick 2
  tick 3
  tick 4
main: returning

Ticker lặp vô hạn, mà chương trình vẫn kết thúc. Khi main return, thread duy nhất còn lại là một daemon, nên JVM đóng máy và kéo theo ticker đang giữa vòng lặp. Số lần tick là hàm của hai lệnh sleep và sẽ nhảy khi máy bận — ba lần chạy ở đây đều ra bốn tick, và một máy bận hơn có thể ra ba hoặc năm.

Đổi đúng một chữ thì hành vi đảo ngược. Với một thread non-daemon — tức mặc định — JVM chờ:

Java
public class UserThreadDemo {
    public static void main(String[] args) {
        Thread ticker = new Thread(() -> {
            for (int n = 1; n <= 6; n++) {
                System.out.println("  tick " + n);
                try { Thread.sleep(100); } catch (InterruptedException e) { return; }
            }
        }, "ticker");
 
        ticker.setDaemon(false);             // the default
        ticker.start();
 
        try { Thread.sleep(350); } catch (InterruptedException e) { }
        System.out.println("main: returning");
    }
}
Text
  tick 1
  tick 2
  tick 3
  tick 4
main: returning
  tick 5
  tick 6

main return ở giữa chừng còn process thì sống tới tận tick 6. Đó chính là hình dạng của một chương trình "treo" sau khi đã làm xong việc: vẫn còn thứ gì đó non-daemon đang chạy.

setDaemon phải được gọi trước start, và JVM ép điều đó bằng đúng cái exception bạn đã gặp ở trên:

Java
Thread t = new Thread(() -> { });
t.start();
t.setDaemon(true);
Text
Exception in thread "main" java.lang.IllegalThreadStateException
	at java.base/java.lang.Thread.setDaemon(Thread.java:2240)
	at DaemonLate.main(DaemonLate.java:5)

Sự đánh đổi rất đơn giản. Daemon cho heartbeat, cho bộ đẩy metrics, cho cái làm mới cache — bất cứ thứ gì mà chết đột ngột giữa chừng vẫn chấp nhận được. Non-daemon cho bất cứ thứ gì bắt buộc phải hoàn tất, vì một daemon thread sẽ không có cơ hội ghi nốt file hay flush nốt buffer.

Tên, id và priority của thread

Mỗi thread mang một cái tên, một id và một priority, và một trong ba thứ đó đáng giá kém hơn hẳn hai thứ kia.

Java
public class Identity {
    public static void main(String[] args) throws InterruptedException {
        Thread main = Thread.currentThread();
        System.out.println("toString: " + main);
        System.out.println("name=" + main.getName()
                + " id=" + main.threadId()
                + " priority=" + main.getPriority()
                + " daemon=" + main.isDaemon());
 
        Thread anonymous = new Thread(() -> { });
        Thread named = new Thread(() -> { }, "report-writer");
        System.out.println("names: " + anonymous.getName() + " | " + named.getName());
        System.out.println("MIN=" + Thread.MIN_PRIORITY
                + " NORM=" + Thread.NORM_PRIORITY
                + " MAX=" + Thread.MAX_PRIORITY);
 
        named.setPriority(Thread.MAX_PRIORITY);
        named.start();
        named.join();
        System.out.println("named: id=" + named.threadId()
                + " priority=" + named.getPriority());
 
        try {
            named.setPriority(11);
        } catch (IllegalArgumentException e) {
            System.out.println("setPriority(11) -> " + e);
        }
    }
}
Text
toString: Thread[#1,main,5,main]
name=main id=1 priority=5 daemon=false
names: Thread-0 | report-writer
MIN=1 NORM=5 MAX=10
named: id=21 priority=10
setPriority(11) -> java.lang.IllegalArgumentException
Thuộc tínhNó là gìGhi chú
getName() / setName(String)Một nhãn, ghi được bất cứ lúc nàoMặc định là Thread-0, Thread-1, … theo thứ tự tạo
threadId()Một long dương, duy nhất trong vòng đời JVMChỉ đọc; từ Java 19 nó thay cho getId() đã deprecated
getPriority() / setPriority(int)Một int từ 1 đến 10, mặc định 5Chỉ là gợi ý cho scheduler; có thể bị bỏ qua hoàn toàn
isDaemon() / setDaemon(boolean)JVM có chờ nó hay khôngPhải set trước start()
isAlive()Đã start và chưa kết thúcChỉ đọc

toString cho bạn tất cả trong một dòng — Thread[#1,main,5,main] là id, tên, priority, thread group. Thread main có id 1; id của các thread bạn tạo phụ thuộc vào việc JVM đã tự tạo bao nhiêu thread trước đó, nên con số 21 ở trên không phải thứ để dựa vào.

Cái tên mới là phần đáng công. Mọi stack trace, mọi thread dump, mọi khung nhìn của profiler đều hiển thị tên, và pool-1-thread-3 chẳng nói với bạn điều gì trong khi invoice-export-3 nói tất cả. Hãy đặt tên cho thread của bạn.

Priority là thứ cần nói thật lòng. setPriority được đặc tả là một gợi ý, việc ánh xạ sang priority của nền tảng phụ thuộc vào từng nền tảng, và một số nền tảng vứt bỏ nó. Hai thread quay vòng, một ở MIN_PRIORITY và một ở MAX_PRIORITY, mỗi cái cộng dồn tới 300 triệu:

Java
public class Priorities {
    static Thread spin(String name, int priority) {
        Thread t = new Thread(() -> {
            long sum = 0;
            for (long i = 0; i < 300_000_000L; i++) sum += i;
            System.out.println("  finished: " + Thread.currentThread().getName()
                    + " (checksum " + (sum & 0xFF) + ")");
        }, name);
        t.setPriority(priority);
        return t;
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread low  = spin("low-priority",  Thread.MIN_PRIORITY);
        Thread high = spin("high-priority", Thread.MAX_PRIORITY);
        low.start();
        high.start();
        low.join();
        high.join();
    }
}

Qua sáu lần chạy liên tiếp, thread MAX_PRIORITY về đích trước ba lần và thread MIN_PRIORITY về đích trước ba lần. Hai trong số các lần chạy đó:

Text
  finished: high-priority (checksum 128)
  finished: low-priority (checksum 128)
Text
  finished: low-priority (checksum 128)
  finished: high-priority (checksum 128)

Đó là thứ tự về đích, không phải phép đo tốc độ — và bấy nhiêu là đủ để nói lên vấn đề. Trên máy này, trên OS này, chênh lệch priority mười ăn một đã không quyết định nổi một cách đáng tin xem vòng lặp nào trong hai vòng lặp giống hệt nhau sẽ xong trước. Hãy viết code mà tính đúng đắn của nó không phụ thuộc vào priority, vì trên một nền tảng nào đó nó sẽ bị bỏ qua.

Virtual thread trong Java 21

Mọi thứ ở trên mô tả một platform thread: một object Thread mỏng bọc quanh một thread do hệ điều hành tạo ra và lên lịch. Đó là một tài nguyên thật. JVM này giữ chỗ 2 MB stack cho mỗi cái, và nó nói thẳng với bạn con số đó:

Text
$ java -XX:+PrintFlagsFinal -version | grep -w ThreadStackSize
     intx ThreadStackSize                          = 2048                                   {pd product} {default}

Đơn vị là kilobyte. Nhân con số đó với số thread mà một server cần khi đang phục vụ mười nghìn request đồng thời, mà phần lớn thời gian mỗi request chỉ ngồi chờ database, thì phép tính không còn khả thi. Đó chính là bài toán mà virtual thread giải, và chúng được hoàn thiện trong Java 21 qua JEP 444 sau hai bản preview.

Virtual thread vẫn là một Thread — cùng class, cùng start, cùng join, cùng Runnable. Cái thay đổi là ai lên lịch cho nó. Nó là một object của JVM với một stack nhỏ có thể lớn dần nằm trên heap, và để chạy nó thì JVM mount nó lên một platform thread lấy từ một pool nội bộ nhỏ. Khi nó block, JVM unmount nó ra, gửi stack của nó lại trên heap, và trao carrier cho một virtual thread khác.

Một platform thread ánh xạ một-một sang OS thread với 2 MB stack giữ chỗ; rất nhiều virtual thread mount lên một pool carrier nhỏ

Java
public class VirtualProps {
    public static void main(String[] args) throws InterruptedException {
        Thread platform = Thread.ofPlatform().name("platform-1").unstarted(
                () -> System.out.println("  " + Thread.currentThread()));
        Thread virtual = Thread.ofVirtual().name("importer-1").unstarted(
                () -> System.out.println("  " + Thread.currentThread()));
 
        platform.start();
        platform.join();
        virtual.start();
        virtual.join();
 
        System.out.println("platform.isVirtual() = " + platform.isVirtual());
        System.out.println("virtual.isVirtual()  = " + virtual.isVirtual());
        System.out.println("virtual: daemon=" + virtual.isDaemon()
                + " priority=" + virtual.getPriority());
 
        Thread another = Thread.ofVirtual().unstarted(() -> { });
        try {
            another.setDaemon(false);
        } catch (IllegalArgumentException e) {
            System.out.println("setDaemon(false) -> " + e);
        }
        another.setPriority(Thread.MAX_PRIORITY);
        System.out.println("after setPriority(MAX): " + another.getPriority());
 
        Thread direct = Thread.startVirtualThread(
                () -> System.out.println("  " + Thread.currentThread()));
        direct.join();
        System.out.println("default name is empty: " + direct.getName().isEmpty());
    }
}
Text
  Thread[#20,platform-1,5,main]
  VirtualThread[#21,importer-1]/runnable@ForkJoinPool-1-worker-1
platform.isVirtual() = false
virtual.isVirtual()  = true
virtual: daemon=true priority=5
setDaemon(false) -> java.lang.IllegalArgumentException: 'false' not legal for virtual threads
after setPriority(MAX): 5
  VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1
default name is empty: true

Cái toString kể trọn câu chuyện trong một dòng. Platform thread in ra Thread[#20,platform-1,5,main]. Virtual thread in ra VirtualThread[#21,importer-1]/runnable@ForkJoinPool-1-worker-1: id và tên của chính nó, trạng thái của nó, và — sau dấu @ — cái carrier mà nó đang được mount lên, một worker trong pool scheduler của JVM. Một cái không đặt tên in ra VirtualThread[#25]/runnable@ForkJoinPool-1-worker-1, vì virtual thread không hề có tên mặc định; getName() trả về chuỗi rỗng, và đó là thêm một lý do để đặt tên cho chúng.

Có ba thuộc tính khác với platform thread, và output ở trên cho thấy cả ba. Virtual thread luôn luôn là daemon — setDaemon(false) ném IllegalArgumentException: 'false' not legal for virtual threads, nên virtual thread không bao giờ giữ JVM sống và bạn buộc phải join chúng, nếu không JVM có thể thoát trước khi chúng xong. Priority của nó cố định ở 5 và setPriority bị bỏ qua không một lời nào. Và nó không có tên mặc định.

Có ba cách để start một cái: Thread.ofVirtual().start(runnable) từ builder, Thread.startVirtualThread(runnable) như một lối viết tắt một dòng, và Executors.newVirtualThreadPerTaskExecutor(), thứ cấp một virtual thread cho mỗi task được submit:

Java
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
 
public class VExecutor {
    public static void main(String[] args) {
        try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
            for (int i = 1; i <= 3; i++) {
                int id = i;
                exec.submit(() -> {
                    try { Thread.sleep(50); } catch (InterruptedException e) { }
                    System.out.println("task " + id + " on " + Thread.currentThread());
                });
            }
        }   // close() waits for every submitted task to finish
        System.out.println("all tasks done");
    }
}
Text
task 3 on VirtualThread[#23]/runnable@ForkJoinPool-1-worker-3
task 2 on VirtualThread[#22]/runnable@ForkJoinPool-1-worker-2
task 1 on VirtualThread[#20]/runnable@ForkJoinPool-1-worker-1
all tasks done

Một lần chạy khác in ra task 2, rồi task 3, rồi task 1 — ba task cùng sleep 50 ms thì kết thúc theo thứ tự nào là do scheduler. ExecutorService và thread pool là nội dung của bài sau nữa; đây chỉ là một dòng vay trước, vì đó là cách phần lớn code thật tạo ra virtual thread.

Một triệu virtual thread

Khẳng định mang tính cấu trúc về virtual thread là chúng rẻ đủ để tạo theo từng task thay vì theo từng connection. Đó không phải một phép benchmark, đó là một phép đếm — nên hãy đếm.

Java
public class Scale {
    public static void main(String[] args) throws InterruptedException {
        int n = 1_000_000;
        Thread[] all = new Thread[n];
 
        for (int i = 0; i < n; i++) {
            all[i] = Thread.startVirtualThread(() -> {
                try {
                    Thread.sleep(100);          // pretend this is a network call
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }
        for (Thread t : all) {
            t.join();
        }
        System.out.println(n + " virtual threads started and joined");
        System.out.println("carrier pool parallelism = " + Runtime.getRuntime().availableProcessors());
    }
}
Text
1000000 virtual threads started and joined
carrier pool parallelism = 10

Một triệu thread, mỗi cái đang giữ một lệnh sleep block thật, trong một JVM khởi động với option mặc định. Cũng chương trình đó viết bằng platform thread sẽ đi xin hệ điều hành một triệu OS thread với một triệu lần giữ chỗ 2 MB stack; bài này không thử làm vậy, vì trên một cái máy đang dùng chung thì kỳ vọng trung thực là OutOfMemoryError: unable to create native thread từ rất lâu trước khi vòng lặp kết thúc.

Chúng chạy ở đâu? Đọc carrier ra từ toString:

Java
import java.util.Set;
import java.util.concurrent.ConcurrentSkipListSet;
 
public class Carriers {
    static String carrierOf(Thread t) {
        String s = t.toString();     // VirtualThread[#21]/runnable@ForkJoinPool-1-worker-3
        int at = s.lastIndexOf('@');
        return at < 0 ? "(none)" : s.substring(at + 1);
    }
 
    public static void main(String[] args) throws InterruptedException {
        Set<String> carriers = new ConcurrentSkipListSet<>();
        Thread[] all = new Thread[2000];
        for (int i = 0; i < all.length; i++) {
            all[i] = Thread.startVirtualThread(() -> {
                carriers.add(carrierOf(Thread.currentThread()));
                try { Thread.sleep(20); } catch (InterruptedException e) { }
                carriers.add(carrierOf(Thread.currentThread()));
            });
        }
        for (Thread t : all) t.join();
        System.out.println("2000 virtual threads ran on " + carriers.size() + " carrier(s):");
        System.out.println(carriers);
    }
}
Text
2000 virtual threads ran on 10 carrier(s):
[ForkJoinPool-1-worker-1, ForkJoinPool-1-worker-10, ForkJoinPool-1-worker-2, ForkJoinPool-1-worker-3, ForkJoinPool-1-worker-4, ForkJoinPool-1-worker-5, ForkJoinPool-1-worker-6, ForkJoinPool-1-worker-7, ForkJoinPool-1-worker-8, ForkJoinPool-1-worker-9]

Hai nghìn thread, mười carrier — đúng một cái cho mỗi processor sẵn có trên máy này, và đó là parallelism mặc định của scheduler. Một máy nhàn hơn hoặc nhỏ hơn có thể cho ra ít carrier hơn; con số đáng nhớ là nó bị chặn bởi số core, chứ không phải bởi số virtual thread.

Virtual thread không làm được gì

Chúng không làm code chạy nhanh hơn. Một virtual thread không bao giờ block sẽ giữ carrier của nó suốt thời gian nó chạy, nên một nghìn virtual thread CPU-bound trên mười carrier sẽ chạy mười cái một lúc — đúng bằng những gì một pool mười thread làm được, cộng thêm phần sổ sách. Chỗ được lợi là khả năng phục vụ khi có blocking I/O: một thread park lại trên một lệnh đọc socket chỉ tốn một object trên heap thay vì một OS thread, nên bạn có thể để rất nhiều cái cùng nằm chờ.

Chúng cũng không xoá đi bất kỳ cạm bẫy nào của phần còn lại trong Phần 4 này. Hai virtual thread cùng cập nhật một field thì race y hệt hai platform thread. Mọi thứ bài sau nói về shared mutable state đều áp dụng nguyên vẹn.

Quy tắc thực dụng cho Java 21: virtual thread cho các task block trên I/O, platform thread cho công việc CPU-bound chạy dài, và tuyệt đối không pool virtual thread — tạo một cái cho mỗi task rồi để nó kết thúc.

Dừng một thread: cái gì đã bị bỏ, và cái gì thay thế

Java từng có các method để stop, suspend và resume một thread từ bên ngoài. Chúng bị deprecated từ Java 1.2 vì chúng không an toàn ngay từ trong thiết kế: giết một thread tại một lệnh bất kỳ sẽ để lại thứ nó đang cập nhật ở trạng thái cập nhật dở, còn suspend nó trong lúc nó đang giữ lock sẽ làm deadlock tất cả những ai đang chờ cái lock đó.

Chúng vẫn còn được khai báo trên Thread trong Java 21, nên câu hỏi thú vị là gọi chúng thì chuyện gì xảy ra. Compile thì ra cảnh báo removal, còn chạy thì ra thế này:

Java
public class Removed {
    public static void main(String[] args) throws Exception {
        Thread t = new Thread(() -> {
            while (true) {
                try { Thread.sleep(50); } catch (InterruptedException e) { return; }
            }
        }, "victim");
        t.setDaemon(true);
        t.start();
        Thread.sleep(100);
 
        try { t.stop(); }    catch (Throwable e) { System.out.println("stop()    -> " + e); }
        try { t.suspend(); } catch (Throwable e) { System.out.println("suspend() -> " + e); }
        try { t.resume(); }  catch (Throwable e) { System.out.println("resume()  -> " + e); }
    }
}
Text
Removed.java:12: warning: [removal] stop() in Thread has been deprecated and marked for removal
        try { t.stop(); }    catch (Throwable e) { System.out.println("stop()    -> " + e); }
               ^
Removed.java:13: warning: [removal] suspend() in Thread has been deprecated and marked for removal
        try { t.suspend(); } catch (Throwable e) { System.out.println("suspend() -> " + e); }
               ^
Removed.java:14: warning: [removal] resume() in Thread has been deprecated and marked for removal
        try { t.resume(); }  catch (Throwable e) { System.out.println("resume()  -> " + e); }
               ^
3 warnings
Text
stop()    -> java.lang.UnsupportedOperationException
suspend() -> java.lang.UnsupportedOperationException
resume()  -> java.lang.UnsupportedOperationException

Cả ba đều vô hiệu trên OpenJDK 21.0.6. Chúng compile được, và chúng ném UnsupportedOperationException lúc runtime mà không kèm message nào. Nếu không bắt, t.stop() hỏng tại java.base/java.lang.Thread.stop(Thread.java:1667). Chính source của JDK ghi @Deprecated(since="1.2", forRemoval=true) trên cả ba, và đó là ý nghĩa của chữ [removal] trong cảnh báo: chúng đang trên đường biến mất hẳn khỏi class. Không có thứ gì để chuyển sang mà hành xử y như vậy, bởi vì không có cách an toàn nào làm được điều đó.

Thứ Java có thay vào đó là interrupt hợp tác. interrupt() không dừng cái gì cả. Nó bật một flag trên thread đích, và nếu thread đó đang block trong sleep, wait hay join thì nó còn đánh thức thread đó bằng một InterruptedException. Còn thread có dừng hay không là quyết định của chính thread đó.

Java
public class Interrupt {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            try {
                System.out.println("worker: sleeping for 10 seconds");
                Thread.sleep(10_000);
                System.out.println("worker: woke up normally");
            } catch (InterruptedException e) {
                System.out.println("worker: caught " + e);
                System.out.println("worker: interrupted flag is now "
                        + Thread.currentThread().isInterrupted());
                Thread.currentThread().interrupt();      // restore it
                System.out.println("worker: flag restored to "
                        + Thread.currentThread().isInterrupted());
            }
            System.out.println("worker: returning");
        }, "worker");
 
        worker.start();
        Thread.sleep(200);
        System.out.println("main: calling worker.interrupt()");
        worker.interrupt();
        worker.join();
        System.out.println("main: worker finished");
    }
}
Text
worker: sleeping for 10 seconds
main: calling worker.interrupt()
worker: caught java.lang.InterruptedException: sleep interrupted
worker: interrupted flag is now false
worker: flag restored to true
worker: returning

Một lệnh sleep mười giây kết thúc sau 200 ms, và message của exception là sleep interrupted — JVM cho bạn biết lời gọi block nào đã bị cắt ngang. Dòng đáng nhìn kỹ là dòng ngay sau đó: ném InterruptedException sẽ xoá luôn cái flag interrupted. Thread đã được yêu cầu dừng lại, và tới lúc bạn nhìn vào thì bằng chứng đã biến mất. Đó là lý do khối catch gọi Thread.currentThread().interrupt() để đặt nó lại, để code ở phía trên stack vẫn còn nhìn thấy.

Nuốt InterruptedException là bug kinh điển

Bắt nó, không làm gì, lặp tiếp, và bạn vừa viết ra một thread không thể huỷ được:

Java
public class Swallow {
    public static void main(String[] args) throws InterruptedException {
        Thread bad = new Thread(() -> {
            int n = 0;
            while (true) {
                System.out.println("  bad worker: pass " + (++n));
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    // the classic bug: the request is caught and thrown away
                }
                if (n == 6) {
                    System.out.println("  bad worker: gave up on its own after 6 passes");
                    return;
                }
            }
        }, "bad");
        bad.setDaemon(true);
        bad.start();
        Thread.sleep(250);
        System.out.println("main: interrupting");
        bad.interrupt();
        bad.join();
        System.out.println("main: done");
    }
}
Text
  bad worker: pass 1
  bad worker: pass 2
  bad worker: pass 3
main: interrupting
  bad worker: pass 4
  bad worker: pass 5
  bad worker: pass 6
  bad worker: gave up on its own after 6 passes
main: done

Interrupt đã tới nơi và chẳng thay đổi được gì. Ba lần chạy đều in ba pass trước khi interrupt và ba pass sau đó; chỗ cắt phụ thuộc vào các lệnh sleep và sẽ xê dịch, nhưng kết cục thì không — vòng lặp chạy tới cái kết của riêng nó. Một catch (InterruptedException e) rỗng không phải là xử lý lỗi, nó là xoá một yêu cầu huỷ, và đó là một trong những bug phổ biến nhất của code Java đa thread.

Hình dạng đúng là lấy chính cái flag interrupt làm điều kiện vòng lặp, và đặt lại flag mỗi khi một lời gọi block xoá nó đi:

Java
public class Cooperative {
    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(() -> {
            int n = 0;
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("  worker: pass " + (++n));
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();   // put the flag back
                }
            }
            System.out.println("  worker: interrupt seen, cleaning up and returning");
        }, "worker");
 
        worker.start();
        Thread.sleep(250);
        System.out.println("main: interrupting");
        worker.interrupt();
        worker.join();
        System.out.println("main: worker alive? " + worker.isAlive());
    }
}
Text
  worker: pass 1
  worker: pass 2
  worker: pass 3
main: interrupting
  worker: interrupt seen, cleaning up and returning
main: worker alive? false

Số pass trước lúc interrupt thay đổi theo các lệnh sleep; cách phản ứng với nó thì không. Thread đã nhận ra, in dòng dọn dẹp của nó, rồi return — và đó chính là ý nghĩa của "dừng một thread trong Java". Có đúng hai cách phản ứng chấp nhận được với InterruptedException và không có cách thứ ba: return khỏi task sau khi dọn dẹp, hoặc ném lại nó. Nếu method của bạn không thể khai báo throws InterruptedException thì hãy đặt lại flag trước khi return.

FAQ

Thread khác process ở chỗ nào?

Process có không gian địa chỉ riêng, thread thì không. Hai process Java không hề nhìn thấy object của nhau, và trao đổi dữ liệu giữa chúng nghĩa là socket, file hoặc pipe. Hai thread trong cùng một JVM dùng chung toàn bộ heap, nên truyền dữ liệu giữa chúng chỉ là truyền một reference — và đó là lý do thread rẻ để phối hợp mà cũng nguy hiểm khi làm sai. Bên trong một process, mỗi thread vẫn có stack riêng, program counter riêng và thread-local state riêng, đúng như output của ThreadMemory ở đầu bài cho thấy: các giá trị local độc lập, một object dùng chung.

Nên extends Thread hay implements Runnable?

Hãy implement Runnable, hoặc viết lambda, trong gần như mọi trường hợp. Một class chỉ được extends đúng một class, và extends Thread tiêu cái ngân sách đó vào một superclass mà bạn không hề muốn — compiler không cho bạn viết extends ReportJob extends Thread, và nó từ chối ngay tại chữ extends thứ hai với error: '{' expected. Đó cũng là mô hình sai: class của bạn là một phần việc, không phải một loại thread. Runnable giữ task tách khỏi bộ máy chạy nó, nên cùng một object có thể đưa cho một Thread thường, cho một executor, hay cho một virtual thread mà không phải sửa gì.

Vì sao gọi run() lại không tạo ra thread?

run là một method bình thường. Thread implement Runnable, nên t.run() là một lời gọi virtual thông thường: nó chạy body trên bất cứ thread nào đã gọi và return khi xong. start mới là method có bộ máy native đứng sau: nó xin JVM một thread mới với stack riêng, sắp xếp để thread đó gọi run, rồi return ngay. Trong ví dụ ở trên, t.run() in ra task executed on: main còn t.start() in ra task executed on: worker-1 — cùng object, cùng task, một thread so với hai.

Gọi start() hai lần trên cùng một thread thì sao?

Bạn nhận java.lang.IllegalThreadStateException từ java.base/java.lang.Thread.start, được ném trên thread đang gọi và không mang message nào. Object Thread chỉ dùng được một lần: một khi nó đã chạy và kết thúc thì không restart lại được, và cũng đúng exception đó được ném nếu bạn gọi start lần hai trước khi lần đầu kịp xong. Muốn chạy lại cùng phần việc thì tạo một Thread mới bọc quanh cùng cái Runnable. Task dùng lại được; thread thì không.

Daemon thread dùng để làm gì?

Dùng cho bộ máy chạy nền mà chết đột ngột vẫn chấp nhận được, vì JVM thoát ngay khi thread non-daemon cuối cùng kết thúc và giết mọi daemon ngay tại chỗ chúng đang chạy. Heartbeat, bộ đẩy metrics, bộ làm mới cache, bộ thu hồi connection nhàn rỗi là những ví dụ quen thuộc. Bất cứ thứ gì bắt buộc phải xong — ghi một file, flush một buffer, hoàn tất một transaction — thì không được là daemon, vì nó sẽ không có cơ hội. setDaemon(true) phải được gọi trước start(); gọi sau thì ném IllegalThreadStateException. Virtual thread luôn là daemon, và đó là lý do bạn buộc phải join chúng.

Priority của thread có thực sự có tác dụng không?

Đôi khi, và không bao giờ đủ để dựa vào. setPriority nhận từ 1 tới 10 với mặc định là 5, ném IllegalArgumentException nếu ra ngoài khoảng đó, và được đặc tả là một gợi ý: JVM ánh xạ nó sang priority của nền tảng theo cách tuỳ nền tảng, và một số nền tảng bỏ qua nó. Hai vòng lặp quay vòng giống hệt nhau, một ở MIN_PRIORITY và một ở MAX_PRIORITY, đã về đích theo thứ tự khác nhau qua sáu lần chạy trên máy này — ba lần mỗi bên. Hãy coi priority nhiều nhất là một cái hích; nếu tính đúng đắn phụ thuộc vào thread nào chạy trước thì hãy sắp thứ tự đó một cách tường minh.

Khi nào nên dùng virtual thread thay cho platform thread?

Dùng chúng cho các task dành phần lớn thời gian block trên I/O — một request handler đang chờ database, một HTTP client đang chờ response — chỗ bạn muốn hàng nghìn hoặc hàng triệu task đồng thời. Một triệu cái đã được start và join trên máy này trong một JVM mặc định. Dùng platform thread cho công việc CPU-bound chạy dài, vì một virtual thread không bao giờ block sẽ chiếm carrier suốt thời gian đó, và mười carrier trên một máy mười core cho bạn đúng mười đường song song dù chọn cách nào. Đừng bao giờ pool virtual thread: tạo một cái cho mỗi task rồi để nó kết thúc.

Dừng một thread trong Java 21 bằng cách nào?

Bạn yêu cầu nó dừng và nó đồng ý. t.stop(), t.suspend()t.resume() vẫn compile được trên Java 21 kèm cảnh báo removal, nhưng cả ba đều ném UnsupportedOperationException lúc runtime và đều được đánh dấu forRemoval=true trong source của JDK. Cơ chế thật là t.interrupt(), thứ bật một flag và đánh thức thread khỏi sleep, wait hay join bằng một InterruptedException mà message nêu tên lời gọi đó — sleep interrupted. Thread phải hợp tác: lặp theo while (!Thread.currentThread().isInterrupted()), và trong khối catch thì hoặc return sau khi dọn dẹp, hoặc đặt lại flag bằng Thread.currentThread().interrupt(), vì việc ném exception đã xoá nó. Một khối catch rỗng chính là cái bug làm thread không thể huỷ.

Kết luận

Thread là một đường thực thi độc lập với stack riêng trên một heap dùng chung, và mọi thứ trong bài này đều rút ra từ câu đó. Hai thread đệ quy sâu 1262 và 240492 frame trong cùng một JVM vì stack là của riêng; ba thread in ra cùng một identity hash vì heap thì không. extends Thread chạy được và tiêu cái slot kế thừa duy nhất của bạn vào một superclass sai, nên hãy implement Runnable — hoặc viết lambda, cũng chính là Runnable đó với ít ký tự hơn. start() tạo thread rồi return; run() là một lời gọi method không tạo ra gì cả, và tên thread in ra là cách bạn phân biệt. join là thứ tự bạn được phép tin, trạng thái daemon quyết định JVM có chờ bạn hay không, tên thì đáng đặt còn priority thì không đáng tin. Virtual thread làm cho một thread rẻ đủ để tạo theo từng task — một triệu cái đã start và join ở đây trên mười carrier — miễn là bạn nhớ rằng chỗ được lợi là blocking I/O và rằng không có gì về tính đúng đắn thay đổi cả.

Vế cuối đó mới là cái bẫy. Sáu lần chạy một chương trình mười dòng cho ra năm thứ tự khác nhau, và lý do duy nhất không có gì hỏng là vì mấy thread đó chỉ in ra màn hình. Đúng khoảnh khắc hai thread cùng ghi lên một field, sự xen kẽ thôi làm một chuyện lạ và trở thành một lost update mà bạn không tái tạo lại được. Bài kế tiếp nói đúng về chuyện đó: vòng đời của thread và các state của nó, race condition là gì và dựng một cái ra sao cho có chủ đích, và synchronized — công cụ đầu tiên để bắt một thread chờ một thread khác.

Bài viết liên quan

[Advanced Java] Set trong Java: HashSet, LinkedHashSet và TreeSet

HashSet, LinkedHashSet và TreeSet trên OpenJDK 21: Set thực sự đảm bảo điều gì, HashMap ẩn bên trong HashSet, các lookup của NavigableSet, hai quy tắc khác nhau để quyết định duplicate, compareTo mâu thuẫn với equals, ClassCastException và null trong TreeSet, EnumSet, và vì sao removeAll có thể thành quadratic.

[Advanced Java] Map nâng cao trong Java: TreeMap, LinkedHashMap, Hashtable và ConcurrentHashMap

Các Map implementation ngoài HashMap trên OpenJDK 21 - TreeMap cùng các method NavigableMap floorEntry, ceilingKey, headMap và subMap, access order của LinkedHashMap và LRU cache năm dòng với removeEldestEntry, vì sao Hashtable là legacy, và ConcurrentHashMap thực sự cam kết gì về lock, weakly consistent iterator và các compound operation atomic.

[Advanced Java] JDBC trong Java: Kết nối và thao tác Database

JDBC trong Java trên OpenJDK 21 với một database SQLite thật: kiến trúc driver phía sau DriverManager, try-with-resources cho Connection, Statement và ResultSet, một màn SQL injection chạy thật mà PreparedStatement chặn được, ResultSet như một cursor, transaction với commit, rollback và Savepoint, batch update, getGeneratedKeys và cách đọc một SQLException.

[Advanced Java] Generics trong Java: type parameter, bounded type, wildcard và type erasure

Generics trong Java trên OpenJDK 21: container Object thời chưa có generics và ClassCastException mà nó sinh ra, cách viết generic class và generic method, bounded type parameter và nhiều bound, wildcard cùng PECS với đúng message lỗi của javac, type erasure chứng minh bằng javap, Signature attribute, bridge method, và lời hứa thật sự của SuppressWarnings unchecked.