Command Palette

Search for a command to run...

[Advanced Java] Vòng đời Thread, synchronized và Race Condition

Những bài trước trong series này đã mô tả Vector, HashtableCollections.synchronizedList như những bản hợp đồng có tài liệu, và nói thẳng rằng chưa hề race-test gì cả, vì một race không tái hiện theo yêu cầu được. Nói vậy là trung thực, nhưng nó để lại một khoảng trống. Bài này lấp khoảng trống đó: hai thread, một field int, và một tổng ra sai.

Chủ đề ở đây không hề trừu tượng. count++ là ba lệnh bytecode, và JVM hoàn toàn có quyền cho một thread khác chạy xen vào giữa hai lệnh bất kỳ. Mọi thứ còn lại trong bài — intrinsic lock, volatile, AtomicInteger, wait/notify — tồn tại để siết lại cái quyền tự do đó, và mỗi thứ siết một phần khác nhau.

Thread A và Thread B cùng chạy count++, mong đợi 2.000.000 nhưng thực tế 1.708.088

Mọi đoạn code bên dưới đều được compile và chạy trên OpenJDK 21.0.6 (arm64), trên một máy báo có mười available processor. Chỗ nào con số thay đổi giữa các lần chạy thì đều có nhiều lần chạy được trích ra và mức dao động được nói rõ. Bạn sẽ không tái hiện đúng những con số đó, và đó chính là ý nghĩa của chúng.

Sáu giá trị của Thread.State và một chương trình đi qua đủ cả sáu

Thread.getState() trả về một Thread.State, enum có đúng sáu hằng số. In chúng ra chẳng tốn gì:

Java
System.out.println(Arrays.toString(Thread.State.values()));
Text
[NEW, RUNNABLE, BLOCKED, WAITING, TIMED_WAITING, TERMINATED]

Một sơ đồ trạng thái kiểu sách giáo khoa thì dễ vẽ và cũng dễ nhớ nhầm. Hữu ích hơn nhiều là viết một chương trình cố tình lùa một thread đi qua từng trạng thái, rồi để main thread đứng ngoài quan sát và in ra những gì nó thấy.

Java
public class ThreadStates {
    private static final Object lock = new Object();
    private static volatile boolean spin = true;
 
    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            while (spin) { }
            synchronized (lock) {
                try { lock.wait(); }
                catch (InterruptedException e) { return; }
            }
            try { Thread.sleep(300); }
            catch (InterruptedException e) { }
        }, "worker");
 
        print("before start()", worker);
        worker.start();
        Thread.sleep(100);
        print("in the spin loop", worker);
 
        synchronized (lock) {
            spin = false;
            Thread.sleep(200);
            print("main holds the monitor", worker);
        }
        Thread.sleep(200);
        print("inside lock.wait()", worker);
 
        synchronized (lock) { lock.notify(); }
        Thread.sleep(200);
        print("inside Thread.sleep(300)", worker);
 
        worker.join();
        print("after run() returned", worker);
    }
 
    private static void print(String when, Thread t) {
        System.out.printf("%-24s %s%n", when, t.getState());
    }
}

Main thread giành lock trước rồi mới xoá spin, nên worker vừa thoát vòng lặp là đâm thẳng vào một monitor nó không thể lấy được. Đó là cách duy nhất để quan sát BLOCKED một cách chủ động:

Text
before start()           NEW
in the spin loop         RUNNABLE
main holds the monitor   BLOCKED
inside lock.wait()       WAITING
inside Thread.sleep(300) TIMED_WAITING
after run() returned     TERMINATED

Ba lần chạy liên tiếp đều in ra đúng sáu dòng đó. Các bước chuyển được ép bằng cấu trúc của chương trình chứ không phó mặc cho may rủi, và đó là lý do riêng output này ổn định trong khi gần như mọi thứ khác trong bài thì không.

Sáu giá trị Thread.State kèm lời gọi đưa thread vào và ra khỏi từng trạng thái

Dòng MONITOR LÚC ĐÓ trong hình là chỗ nhiều người hiểu sai nhất. wait() nhả monitor ra trước khi park — chính điều đó mới làm cho việc bàn giao giữa các thread khả thi. Thread.sleep() thì không nhả gì cả: một thread đang ngủ mà đang giữ lock thì giữ nguyên lock suốt giấc ngủ đó.

Vì sao RUNNABLE nói ít hơn bạn tưởng

Javadoc của Thread.State.RUNNABLE nói thẳng luôn: "A thread in the runnable state is executing in the Java virtual machine but it may be waiting for other resources from the operating system such as processor." Đúng một câu đó gộp chung hai tình huống khác nhau — đang thực sự chạy trên một CPU ngay lúc này, và đang nằm trong run queue của hệ điều hành chờ tới lượt — và không có gì trong API phân biệt được chúng, vì JVM không phải là thứ lập lịch cho thread. Hệ điều hành mới là, và JVM cũng không hỏi.

Hai mươi thread quay vòng trên một máy mười core là đủ để thấy:

Java
int n = 20;
Thread[] ts = new Thread[n];
long until = System.nanoTime() + 400_000_000L;
for (int i = 0; i < n; i++) {
    ts[i] = new Thread(() -> { while (System.nanoTime() < until) { } });
    ts[i].start();
}
Thread.sleep(150);
long runnable = Arrays.stream(ts).filter(t -> t.getState() == Thread.State.RUNNABLE).count();
Text
availableProcessors   = 10
threads started       = 20
reported RUNNABLE     = 20

Hai mươi thread RUNNABLE trên mười core nghĩa là ít nhất mười thread trong đó không hề chạy. Ba lần chạy đều báo 20. getState() là một gợi ý để chẩn đoán, không bao giờ là công cụ đồng bộ: tới lúc giá trị đó về tới câu if của bạn thì nó có thể đã cũ rồi.

Vì sao count++ không phải là một bước

Đây là object dùng chung nhỏ nhất có thể.

Java
public class Counter {
    private int count = 0;
 
    public void increment() {
        count++;
    }
 
    public int get() {
        return count;
    }
}

count++ trông có vẻ atomic vì nó là một token duy nhất trong source. Trong file class thì nó không phải một thao tác. javap -c Counter.class cho thấy compiler thực sự sinh ra cái gì:

Text
  public void increment();
    Code:
       0: aload_0
       1: dup
       2: getfield      #7                  // Field count:I
       5: iconst_1
       6: iadd
       7: putfield      #7                  // Field count:I
      10: return

Đọc field, cộng một trên operand stack, ghi field trở lại. Ba bước riêng biệt tác động lên bộ nhớ dùng chung, và scheduler có quyền cắt ngang giữa hai bước bất kỳ.

Hai thread xen kẽ getfield, iadd và putfield khiến một lần tăng bị mất

Race condition đúng là như vậy: kết quả phụ thuộc vào thứ tự các thread chạm tới state dùng chung, mà chương trình lại không ràng buộc thứ tự đó chút nào.

Chạy thử: mỗi lần một con số sai khác nhau

Hai thread, mỗi thread một triệu lần tăng, nên đáp án bắt buộc phải là 2.000.000.

Java
public class Race {
    public static void main(String[] args) throws Exception {
        Counter counter = new Counter();
        int rounds = 1_000_000;
 
        Thread a = new Thread(() -> { for (int i = 0; i < rounds; i++) counter.increment(); });
        Thread b = new Thread(() -> { for (int i = 0; i < rounds; i++) counter.increment(); });
 
        a.start();
        b.start();
        a.join();
        b.join();
 
        int expected = rounds * 2;
        System.out.println("expected = " + expected);
        System.out.println("actual   = " + counter.get());
        System.out.println("lost     = " + (expected - counter.get()));
    }
}

Bảy lần chạy liên tiếp đúng chương trình đó, không đổi gì ở giữa:

Text
run 1   actual = 1708088   lost = 291912
run 2   actual = 1658395   lost = 341605
run 3   actual = 1669182   lost = 330818
run 4   actual = 1628574   lost = 371426
run 5   actual = 1648923   lost = 351077
run 6   actual = 1690288   lost = 309712
run 7   actual = 1704241   lost = 295759

Bảy lần chạy, bảy đáp án khác nhau, không cái nào đúng. Máy của bạn cũng sẽ in ra bảy đáp án khác nhau, và chúng sẽ không phải mấy con số này. Đó là điều biến những con số này thành bằng chứng chứ không phải phép đo: bản thân từng con số chẳng nói lên gì, cái nói lên tất cả là nó không bao giờ bằng 2.000.000. Khoảng 15% đến 19% số lần tăng đã bốc hơi qua các lần chạy trên, nhưng tỷ lệ đó là đặc tính của cái máy này trong buổi chiều này, không phải của Java.

Vì sao một vòng lặp nhỏ không phải là một bài test

Hạ số vòng từ một triệu xuống một nghìn rồi chạy tám lần:

Text
rounds 1000 x2 -> expected 2000, actual 1873
rounds 1000 x2 -> expected 2000, actual 1366
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000
rounds 1000 x2 -> expected 2000, actual 2000

Sáu trong tám lần chạy cho ra đáp án đúng, từ một đoạn code chắc chắn là sai. Thread thứ nhất thường xong trước khi thread thứ hai thực sự bắt đầu, nên không có phần chồng lấn nào để mà tranh nhau. Một unit test viết kiểu này sẽ pass, được commit, và không chứng minh được gì hết — đó là lý do bug concurrency được tìm thấy ở production chứ không phải ở CI.

⚠️ Một lần chạy test xanh trên code concurrent chỉ chứng minh rằng cách xen kẽ lần này không sao, chứ không phải mọi cách xen kẽ đều không sao. Tính đúng đắn ở đây phải đến từ việc lập luận trên code, không phải từ việc quan sát một lần thực thi của nó.

Giờ làm cho thao tác đó atomic và không đổi gì khác. Hai phiên bản, mỗi bên năm lần chạy:

Java
public class SyncCounter {
    private int count = 0;
 
    public synchronized void increment() {
        count++;
    }
 
    public synchronized int get() {
        return count;
    }
}
Text
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000
synchronized  : 2000000
AtomicInteger : 2000000

Không mất gì, mười trên mười. Khác với bản hỏng, output này không phải một mẫu thống kê — nó là thứ memory model bảo đảm, nên một kết quả khác đi sẽ là bug của JVM chứ không phải xui.

synchronized: intrinsic lock

Mọi object Java đều gắn với một monitor — một intrinsic lock, mỗi object một cái, mà tại một thời điểm chỉ đúng một thread được sở hữu. synchronized là cách duy nhất để lấy nó, và JVM luôn trả lại: lock được nhả khi block kết thúc, dù là chạy hết, return, hay có exception bắn ra trên đường đi.

Intrinsic lock với owner, entry set gồm các thread BLOCKED và wait set gồm các thread WAITING

Quanh một monitor có ba thứ: owner (kèm một hold count), entry set gồm các thread đang bị chặn khi cố lấy nó, và wait set gồm các thread đã gọi wait() và tự nguyện trả nó lại. Hai cái set đó khác nhau, và thread dump cũng báo cáo chúng khác nhau.

javap cho thấy gì: monitorenter so với ACC_SYNCHRONIZED

Một synchronized block và một synchronized method nhìn giống nhau trong source nhưng được compile hoàn toàn khác nhau. Class dưới đây có cả hai:

Java
public class Locks {
    private final Object lock = new Object();
    private int n;
 
    public void withBlock() {
        synchronized (lock) {
            n++;
        }
    }
 
    public synchronized void withMethod() {
        n++;
    }
}

javap -c Locks.class với bản dùng block:

Text
  public void withBlock();
    Code:
       0: aload_0
       1: getfield      #7                  // Field lock:Ljava/lang/Object;
       4: dup
       5: astore_1
       6: monitorenter
       7: aload_0
       8: dup
       9: getfield      #13                 // Field n:I
      12: iconst_1
      13: iadd
      14: putfield      #13                 // Field n:I
      17: aload_1
      18: monitorexit
      19: goto          27
      22: astore_2
      23: aload_1
      24: monitorexit
      25: aload_2
      26: athrow
      27: return
    Exception table:
       from    to  target type
           7    19    22   any
          22    25    22   any

Một lệnh monitorenter và hai lệnh monitorexit. Cái exit thứ hai ở vị trí 24 là nhánh exception, được nối vào bằng entry trong Exception table phủ kiểu "any": đây là compiler tự lo cho việc lock luôn được nhả kể cả khi thân block ném exception.

Bản dùng method, cũng từ chính output javap -c đó, không có một lệnh monitor nào:

Text
  public synchronized void withMethod();
    Code:
       0: aload_0
       1: dup
       2: getfield      #13                 // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #13                 // Field n:I
      10: return

Bytecode giống hệt từng byte so với một method không synchronized. Phần khoá nằm ở access flags, và javap -v lôi nó ra:

Text
  public void withBlock();
    flags: (0x0001) ACC_PUBLIC
 
  public synchronized void withMethod();
    flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
 
  public static synchronized void staticMethod();
    flags: (0x0029) ACC_PUBLIC, ACC_STATIC, ACC_SYNCHRONIZED

JVM kiểm tra ACC_SYNCHRONIZED lúc gọi method và tự lấy monitor trước khi lệnh đầu tiên chạy. Hệ quả thực tế: một synchronized method không thể thu hẹp vùng critical section của nó, vì lock được lấy ngay từ lời gọi, và nó cũng không được chọn object để khoá. Một block thì làm được cả hai.

Lock thuộc về object, không thuộc về method

Hai thread gọi cùng một synchronized method chỉ tranh nhau nếu chúng gọi trên cùng một instance. Chương trình dưới đây cho hai thread chạy trên một object, rồi hai thread chạy trên hai object khác nhau:

Java
static class Vault {
    private final String name;
    Vault(String name) { this.name = name; }
 
    synchronized void enter(String who) throws InterruptedException {
        System.out.println(who + " is inside " + name);
        Thread.sleep(600);
    }
}
Text
-- same object --
t1 is inside vault A
t2 state: BLOCKED
t2 is inside vault A
-- different objects --
t3 is inside vault A
t4 is inside vault B
t4 state: TIMED_WAITING

Trên một object, t2 ở trạng thái BLOCKED và phải chờ hết giấc ngủ của t1. Trên hai object, t4TIMED_WAITING, nghĩa là nó đã ở trong enter rồi và đang ngủ — nó chưa từng tranh giành cái gì. Qua ba lần chạy, hai dòng "is inside" của cặp thứ hai in ra theo cả hai thứ tự, vì chúng thực sự chạy cùng lúc; còn các trạng thái thì lần nào cũng như nhau.

Một instance method khoá this. Một static synchronized method khoá Class object — Vault.class — vốn là một monitor hoàn toàn khác. Một thread đang ở trong static synchronized method và một thread đang ở trong instance synchronized method của cùng class không hề loại trừ nhau.

Reentrancy: chính thread đó có thể lấy lại lock

Intrinsic lock đếm số lần, chứ không chỉ đơn giản là đang giữ hay không. Một thread đã sở hữu monitor thì vào lại được; mỗi lần vào làm hold count tăng một, mỗi lần ra giảm một, và lock chỉ được nhả khi nó về 0.

Java
public class Reentrant {
    public synchronized void outer() {
        System.out.println("outer() holds the monitor");
        inner();
        System.out.println("outer() still holds it");
    }
 
    public synchronized void inner() {
        System.out.println("inner() acquired the same monitor again");
    }
}
Text
outer() holds the monitor
inner() acquired the same monitor again
outer() still holds it

Nếu không có reentrancy thì chương trình đó sẽ treo vĩnh viễn trên chính cái lock của mình, và mọi synchronized method gọi sang một synchronized method khác trên cùng object cũng vậy — tức là phần lớn class thật ngoài đời. Lưu ý rằng điều này chỉ bảo vệ một thread khỏi chính nó. Hai thread lấy cùng hai lock theo thứ tự ngược nhau vẫn deadlock như thường; kiểu hỏng đó, cùng với livelock, là chủ đề của một bài sau trong series này.

Visibility là vấn đề khác với atomicity

Atomicity nói về việc một thao tác có chia cắt được hay không. Visibility nói về việc một lần ghi của thread này có bao giờ được thread kia nhìn thấy hay không. Hai thứ độc lập với nhau, và một chương trình có thể hỏng theo cả hai hướng.

Đây là một lỗi visibility thuần tuý — một lần ghi, một lần đọc, không có read-modify-write ở đâu cả:

Java
public class StopFlag {
    private static boolean running = true;
 
    public static void main(String[] args) throws Exception {
        Thread worker = new Thread(() -> {
            long spins = 0;
            while (running) {
                spins++;
            }
            System.out.println("worker saw the flag after " + spins + " spins");
        });
        worker.start();
 
        Thread.sleep(1000);
        running = false;
        System.out.println("main set running = false");
 
        worker.join(3000);
        System.out.println("worker still alive? " + worker.isAlive());
        System.out.println("worker state      : " + worker.getState());
        System.exit(0);
    }
}

Cái này thì tái hiện được trên JVM này, lần nào cũng vậy:

Text
main set running = false
worker still alive? true
worker state      : RUNNABLE

Ba trên ba lần chạy, worker không hề nhận ra gì, và cái System.exit(0) ở cuối là lý do duy nhất khiến chương trình kết thúc được. Worker chưa in ra dòng nào cả. JIT compiler hoàn toàn có quyền kéo một lần đọc field không volatile ra khỏi vòng lặp không ghi vào nó, biến while (running) thành if (running) while (true), và không có luật nào trong memory model bắt nó phải đọc lại field đó.

Đây là trường hợp mà hành vi thực sự phụ thuộc vào JVM và nền tảng. Chạy bằng interpreter, hoặc thêm một synchronized block hay một System.out.println vào thân vòng lặp, thì cũng đoạn code đó có thể kết thúc bình thường — không phải vì nó trở nên đúng, mà vì một hàng rào không liên quan đã làm cho lần đọc cũ trở nên bất khả thi trong lần chạy đó.

volatile sửa visibility và chỉ visibility

Thêm đúng một từ khoá:

Java
private static volatile boolean running = true;
Text
main set running = false
worker saw the flag after 3227396683 spins
worker still alive? false
worker saw the flag after 3245484210 spins
worker still alive? false
worker saw the flag after 3229623815 spins
worker still alive? false

Ba lần chạy, ba lần kết thúc. Số vòng lặp khác nhau mỗi lần và chẳng có ý nghĩa gì — đó chỉ là quãng đường vòng lặp đi được trong một giây.

Một cặp accessor synchronized cũng sửa đúng bug đó, vì việc vào monitor cũng buộc phải đọc lại giá trị mới:

Java
private static synchronized boolean isRunning() { return running; }
private static synchronized void stop() { running = false; }
Text
worker saw the flag after 391776802 spins
worker still alive? false

Thứ volatile không cho bạn là atomicity. Lấy lại cái counter hỏng ban đầu, khai báo field là volatile, không đổi gì khác:

Java
private static volatile int count = 0;
Text
volatile counter: expected 2000000, actual 1712636
volatile counter: expected 2000000, actual 1358314
volatile counter: expected 2000000, actual 1412960
volatile counter: expected 2000000, actual 1454897
volatile counter: expected 2000000, actual 1330949

Năm lần chạy, năm đáp án sai. volatile bảo đảm mọi lần đọc đều thấy lần ghi mới nhất; nó không làm gì được với chuyện hai thread cùng đọc đúng cái giá trị mới nhất đó rồi cùng ghi lại cùng một số. getfield, iaddputfield vẫn là ba lệnh.

Luật happens-before, nói cho gọn

Hai luật là đủ cho gần như mọi thứ trong bài này.

Luật monitor. Một lần unlock trên một monitor happens-before mọi lần lock sau đó trên chính monitor ấy. Nghĩa là mọi thứ một thread đã ghi trước khi rời synchronized block đều nhìn thấy được bởi thread tiếp theo vào một block trên cùng object đó — toàn bộ, chứ không riêng những field mà block có nhắc tới.

Luật volatile. Một lần ghi vào field volatile happens-before mọi lần đọc sau đó trên chính field ấy. Mọi thứ thread ghi đã làm trước lần ghi đó đều nhìn thấy được bởi thread đọc ra nó.

Cả hai luật đều có điều kiện là hai thread phải dùng chung một lock hoặc chung một field. Hai thread synchronize trên hai object khác nhau thì không thiết lập được gì giữa chúng, và đó là lý do câu "method này có synchronized" tự nó chưa phải câu trả lời cho "cái này thread-safe chưa".

cấu trúcloại trừ lẫn nhauvisibility
synchronized
volatilekhông
AtomicIntegertheo từng thao tác
field thườngkhôngkhông

AtomicInteger và compare-and-swap

Với một biến đơn lẻ, một lock là bộ máy nặng hơn mức công việc cần. java.util.concurrent.atomic cung cấp AtomicInteger, AtomicLong, AtomicBooleanAtomicReference, lấy tính atomic từ một lệnh compare-and-swap của phần cứng thay vì từ một monitor.

Java
public class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger();
 
    public void increment() {
        count.incrementAndGet();
    }
 
    public int get() {
        return count.get();
    }
}

Cơ chế ở đây là một vòng lặp thử lại chứ không phải một hàng đợi: đọc giá trị hiện tại, tính giá trị mới, và chỉ ghi đè nếu biến vẫn đang giữ đúng giá trị vừa đọc. Nếu thread khác nhanh tay hơn thì lần swap đó thất bại và toàn bộ được làm lại. Không thread nào bị treo lại để làm việc đó, nên một thread đang thử lại compare-and-swap vẫn ở RUNNABLE — còn một thread đang chờ một synchronized block bị tranh giành thì là BLOCKED, đúng như phần entry set ở trên đã cho thấy. Đó là khác biệt về mặt cấu trúc giữa hai bên, và nó đáng biết hơn bất kỳ con số thời gian nào, vốn phụ thuộc hoàn toàn vào việc có bao nhiêu thread đang giã vào biến đó và trên phần cứng nào.

Hai thao tác atomic không phải một thao tác atomic

Đây là chỗ atomic thôi đóng vai thứ thay thế trực tiếp cho lock. Mọi method của AtomicInteger đều atomic; hai method đi liền nhau thì không.

Java
AtomicInteger balance = new AtomicInteger(1000);
 
Runnable withdraw = () -> {
    for (int i = 0; i < 500; i++) {
        if (balance.get() >= 1) {        // atomic read
            balance.decrementAndGet();   // atomic write
        }
    }
};

Tám thread, mỗi thread thử 500 lần, rút từ số dư 1000. Câu lệnh guard nói rằng số dư không bao giờ được xuống dưới 0:

Text
balance after 4000 withdrawals from 1000 = -2
balance after 4000 withdrawals from 1000 = -7
balance after 4000 withdrawals from 1000 = -7
balance after 4000 withdrawals from 1000 = -5
balance after 4000 withdrawals from 1000 = -3

Năm lần chạy, năm số dư âm, và con số cụ thể khác nhau mỗi lần — mức âm phụ thuộc vào việc có bao nhiêu thread lọt vào khe giữa lần đọc và lần ghi. Không có gì ở đây là bug của AtomicInteger cả; việc kiểm tra và việc hành động là hai bước atomic riêng, và state có thể đổi ở giữa.

Cách sửa là gộp cả quyết định thành một bước atomic duy nhất, và đó chính là việc của updateAndGet — nó thử lại hàm cho tới khi compare-and-swap thành công:

Java
balance.updateAndGet(v -> v >= 1 ? v - 1 : v);
Text
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0
balance after 4000 withdrawals from 1000 = 0

Năm lần chạy, chạm đáy 0 và không bao giờ vượt qua. Hàm truyền vào updateAndGet bắt buộc không được có side effect, vì nó có thể bị gọi nhiều hơn một lần cho mỗi lần cập nhật thành công. Khi invariant trải trên hai biến chứ không phải một, không atomic nào diễn đạt được nó và bạn quay lại với lock.

wait, notify và notifyAll

wait(), notify()notifyAll() là method của Object chứ không phải của Thread, vì chúng thao tác trên monitor của chính object đó. Cả ba đều đòi thread đang gọi phải sở hữu sẵn monitor ấy. Gọi một trong ba mà không giữ lock thì không phải là cảnh báo hay là chạy suông:

Java
Object lock = new Object();
lock.wait();
Text
Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.wait0(Native Method)
	at java.base/java.lang.Object.wait(Object.java:366)
	at java.base/java.lang.Object.wait(Object.java:339)
	at BadWait.main(BadWait.java:4)

notifyAll() cho đúng message đó từ một frame khác:

Text
Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not owner
	at java.base/java.lang.Object.notifyAll(Native Method)
	at BadNotify.main(BadNotify.java:4)

Bên trong một synchronized block, wait() làm ba việc theo thứ tự: nhả monitor, park thread vào wait set của object với trạng thái WAITING, và — sau khi được notify — giành lại monitor trước khi return. Bước cuối cùng đó rất quan trọng và là nơi phần lớn bug trú ngụ.

wait luôn phải nằm trong một vòng lặp

Giữa lúc một thread đang chờ được notify và lúc nó thực sự lấy lại được monitor, các thread khác vẫn chạy. Điều kiện mà nó chờ hoàn toàn có thể lại sai vào lúc đó. Thêm nữa, đặc tả cho phép spurious wakeup: một lần wait() có thể return mà không hề có notify() nào.

Cả hai vấn đề đều có chung một dòng sửa — kiểm tra lại điều kiện trong một vòng lặp — và một chương trình là đủ để thấy vì sao. Hai consumer chờ trên một queue rỗng; một item được thêm vào và notifyAll() đánh thức cả hai.

Java
synchronized String take(String who) throws InterruptedException {
    if (queue.isEmpty()) wait();          // sai
    // while (queue.isEmpty()) wait();    // đúng
    String item = queue.poll();
    System.out.println("  " + who + " took " + item);
    return item;
}
Text
with if:
  consumer-1 took job-1
  consumer-2 took null
  consumer-2 state: TERMINATED
with while:
  consumer-1 took job-1
  consumer-2 state: WAITING
  consumer-2 interrupted, still waiting

Ba lần chạy đều cho output y hệt. Với if, consumer thứ hai tỉnh dậy, không kiểm tra lại, và lôi null ra từ một queue rỗng — trong code thật đó là một NullPointerException cách nguyên nhân vài frame. Với while, nó kiểm tra lại, thấy queue vẫn rỗng và quay về chờ tiếp, và như vậy là đúng.

Consumer nào giành được item thì không cố định; ở đây consumer-1 thắng cả ba lần, nhưng code phải đúng với cả hai khả năng.

notifyAll là lựa chọn mặc định an toàn

notify() đánh thức một thread tuỳ ý trong wait set. Bạn không chọn được thread nào, và JVM cũng không bảo đảm đó là thread có điều kiện đang thoả mãn. Nếu nhiều thread cùng chờ trên một monitor cho những điều kiện khác nhau, notify() có thể đánh thức nhầm thread; thread đó kiểm tra lại, thấy chẳng có việc gì để làm, và ngủ tiếp — thế là lời thông báo mất luôn, vì thread thực sự có thể tiến triển thì lại không được gọi dậy.

notifyAll() đánh thức tất cả. Mỗi thread kiểm tra lại điều kiện của mình trong vòng while, nhiều nhất một thread đi tiếp, số còn lại quay về chờ. Nó tốn công hơn nhưng không làm mất thông báo. Chỉ dùng notify() khi mọi thread đang chờ thực sự thay thế nhau được và chờ đúng một điều kiện giống nhau, mà kể cả khi đó thì phần tiết kiệm hiếm khi bù nổi phần phải suy nghĩ.

ReentrantLock, khi synchronized là chưa đủ

java.util.concurrent.locks.ReentrantLock là phiên bản tường minh của intrinsic lock: cùng ngữ nghĩa reentrant, cùng bảo đảm happens-before, nhưng dưới dạng một object mà bạn gọi method lên. Cái giá là việc nhả lock giờ thuộc trách nhiệm của bạn, nên unlock() phải nằm trong khối finally — với synchronized thì compiler đã viết sẵn nhánh exception đó hộ bạn, đúng như output javap ở trên cho thấy.

Đổi lại, bạn có được những thứ một monitor không diễn đạt nổi: lấy lock kèm timeout, bỏ cuộc ngay lập tức nếu lock đã bị chiếm, có thể bị interrupt trong lúc chờ, và một chính sách fairness tuỳ chọn.

Java
System.out.println("tryLock()                    : " + lock.tryLock());
System.out.println("tryLock(300ms)               : " + lock.tryLock(300, TimeUnit.MILLISECONDS));
System.out.println("isLocked / heldByCurrent     : " + lock.isLocked() + " / " + lock.isHeldByCurrentThread());

Với một thread khác giữ lock trong 1,5 giây:

Text
tryLock()                    : false
tryLock(300ms)               : false
isLocked / heldByCurrent     : true / false
after the holder finished    : true

tryLock() trả về false thay vì chặn lại chính là toàn bộ ý nghĩa của nó — không có cách nào viết được điều đó bằng synchronized, nơi lựa chọn duy nhất là chờ vô hạn hoặc không vào. ReentrantLock còn mang theo Condition, tức là wait/notify được tách ra thành bao nhiêu hàng đợi riêng tuỳ bạn. Hãy dùng nó khi bạn cần một trong những khả năng đó; còn lại thì synchronized ngắn hơn, khó rò rỉ hơn, và cũng đúng y như vậy.

Không được synchronize lên những thứ gì

Một lock chỉ có tác dụng khi tất cả cùng đồng ý nó là object nào. Ba sai lầm dưới đây đều phá vỡ sự đồng thuận đó, và không cái nào trông có vẻ sai khi review.

Một Integer đã box hoặc một String đã intern

Integer.valueOf theo mặc định cache khoảng −128 tới 127 — Integer.valueOf(127) == Integer.valueOf(127) cho true còn cùng phép so sánh đó ở 128 cho false — nên Integer lock = 1; ở hai class không liên quan lại cho ra đúng cùng một object:

Java
static class Cart {
    void checkout() throws InterruptedException {
        Integer lock = 1;                 // Integer.valueOf(1), lấy từ cache
        synchronized (lock) {
            System.out.println("Cart holds the monitor");
            Thread.sleep(1500);
            System.out.println("Cart releases");
        }
    }
}
 
static class Report {
    void render() throws InterruptedException {
        Integer lock = 1;                 // class không liên quan, cùng object
        synchronized (lock) {
            System.out.println("Report holds the monitor");
        }
    }
}
Text
Integer.valueOf(1) == Integer.valueOf(1) : true
Cart holds the monitor
report thread state: BLOCKED
Cart releases
Report holds the monitor

Hai class không chung dòng code nào và không biết gì về nhau giờ lại xếp hàng chờ nhau, chỉ vì chúng chọn cùng một số nguyên. javac báo chuyện này mà không cần ai nhờ:

Text
BoxLock.java:5: warning: [synchronization] attempt to synchronize on an instance of a value-based class
        synchronized (lock) {
        ^
1 warning

String literal còn tệ hơn, vì chúng được JVM intern và dùng chung trên toàn bộ ứng dụng, kể cả mọi library trong đó. Trường hợp này compiler không cảnh báo:

Java
synchronized ("config") { ... }
Text
"config" == "config".intern() : true
library A holds the monitor on "config"
library B state: BLOCKED
library B finally got in

Quy tắc là khoá lên một object private sinh ra không vì mục đích nào khác: private final Object lock = new Object();. Nó không có ngữ nghĩa equals, không cache, không intern, và không gì bên ngoài class của bạn với tới được nó.

this, một khi reference đã lọt ra ngoài

Một synchronized method khoá this, mà this chính là thứ người gọi đang cầm trong tay. Bất kỳ đoạn code nào giữ reference tới object của bạn đều có thể lấy lock của bạn và giữ nó:

Java
Thread outsider = new Thread(() -> {
    synchronized (registry) {          // hợp lệ: reference là public
        System.out.println("  outside code grabbed the Registry monitor");
        Thread.sleep(1200);
    }
});
Text
  outside code grabbed the Registry monitor
  user state: BLOCKED
  Registry.add(a) ran

Class Registry không làm gì sai mà vẫn bị đứng hình 1,2 giây bởi một đoạn code nó chưa từng nghe tên. Tệ hơn, chuyện này vô hình: trong Registry không có gì nhắc tới kẻ bên ngoài, nên sự đứng hình chỉ hiện ra dưới dạng một trạng thái BLOCKED không giải thích được trong thread dump. Khoá lên một private field làm cho vấn đề trở nên bất khả thi chứ không chỉ là ít xảy ra.

Đừng bao giờ giữ lock trong lúc làm I/O

Một lock chỉ nên phủ phần cập nhật state dùng chung và không gì khác. Giữ lock xuyên qua một lời gọi mạng, một lần ghi đĩa hay một vòng đi về database nghĩa là mọi thread khác xếp hàng sau một thao tác mà bạn không kiểm soát được thời lượng.

Java
void slowInsideLock() {
    synchronized (lock) {
        sleep(1000);              // đại diện cho một network call
    }
}
 
void slowOutsideLock() {
    sleep(1000);                  // phần chậm chạy ngoài lock
    synchronized (lock) {
        sleep(1);                 // chỉ phần cập nhật state là được khoá
    }
}

Sáu thread, lấy mẫu ở mốc 400 ms:

Text
slow call inside the lock : 5 of 6 threads BLOCKED
slow call outside the lock: 0 of 6 threads BLOCKED

Năm lần chạy đều cho đúng cặp dòng đó. Cách sắp xếp thứ hai chạy sáu network call song song và chỉ tuần tự hoá phần cập nhật bé xíu ở cuối; cách thứ nhất chạy chúng lần lượt từng cái một. Hình dạng của cách sửa lúc nào cũng như nhau — làm phần chậm ở ngoài lock, lấy lock, áp kết quả vào, nhả lock — và nó thường kéo theo việc bước gộp kết quả phải xử lý được tình huống state đã đổi trong lúc bạn đi vắng.

FAQ

Vì sao race condition của tôi không tái hiện được trong test?

Vì một race cần có phần chồng lấn, mà một bài test ngắn thì hiếm khi tạo ra được. Trong các lần chạy ở trên, hai thread mỗi thread một nghìn lần tăng đã cho ra đáp án đúng sáu trên tám lần, từ đoạn code chắc chắn là sai. Thread cần thời gian để khởi động, nên với khối lượng nhỏ thì thread thứ nhất thường xong trước khi thread thứ hai bắt đầu. Tăng số vòng lặp, thêm thread và chạy trên nhiều core đều làm cửa sổ rộng ra, nhưng không cách nào biến "không thấy hỏng" thành bằng chứng của tính đúng đắn. Hãy lập luận trên code; chỉ dùng lần chạy để xác nhận cái bug bạn đã tìm ra bằng đầu.

Thread.sleep có nhả lock đang giữ không?

Không. Thread.sleep đưa thread vào TIMED_WAITING và không thay đổi gì với những monitor nó đang sở hữu, nên một thread ngủ bên trong synchronized block sẽ chặn mọi thread khác suốt cả khoảng thời gian đó. Object.wait thì ngược lại: nó nhả monitor trước khi park và giành lại trước khi return. Chính sự phân biệt đó là lý do wait là method của Object còn sleep là static method của Thread — một cái thuộc về giao thức khoá, cái kia thì không.

volatile có làm count++ trở nên thread-safe không?

Không, và bài thử ở trên đã cho thấy: với count khai báo volatile, năm lần chạy hai thread mỗi thread một triệu lần tăng trả về 1712636, 1358314, 1412960, 1454897 và 1330949 thay vì 2000000. volatile bảo đảm mọi lần đọc đều thấy lần ghi mới nhất, và điều đó sửa dứt điểm vấn đề flag cũ. Nó không làm gì được với chuyện hai thread cùng đọc đúng giá trị mới đó rồi cùng ghi lại cùng một số, vì count++ vẫn là getfield, iadd, putfield. Hãy dùng synchronized hoặc AtomicInteger cho counter, và volatile cho một flag mà một thread ghi còn các thread khác chỉ đọc.

BLOCKED và WAITING trong thread dump khác nhau chỗ nào?

BLOCKED nghĩa là thread đang cố lấy một monitor mà thread khác sở hữu — nó đang đứng ở monitorenter và không giữ lock nào. WAITING nghĩa là nó đã gọi wait() (hoặc join(), hoặc LockSupport.park()), tức là đã tự nguyện nhả monitor, và nó sẽ không chạy lại cho tới khi có gì đó đánh thức. Cách đọc thực tế cũng khác nhau: một đống thread BLOCKED chỉ về một lock nóng, thường là lock bị giữ xuyên qua công việc chậm; một đống thread WAITING thường chỉ về một thông báo bị thiếu hoặc bị mất. TIMED_WAITING giống WAITING nhưng có hạn chót, và nó sẽ tự tỉnh lại.

Vì sao wait() ném IllegalMonitorStateException?

Vì thread đang gọi không sở hữu monitor của object mà nó gọi wait() lên. Message đúng nguyên văn là current thread is not owner. Nó hay xảy ra nhất khi wait() được gọi ngoài mọi synchronized block, và hay nhì là khi block synchronize trên object này mà wait() lại gọi lên object khác — synchronized (lockA) { lockB.wait(); } compile sạch sẽ và ném lỗi lúc runtime. notify()notifyAll() ném đúng exception đó trong đúng những điều kiện đó.

synchronized ở setter đã đủ chưa, hay getter cũng cần?

Getter cũng cần. Cạnh happens-before được tạo ra giữa một lần unlock và một lần lock sau đó trên cùng một monitor, nên một thread đọc mà không bao giờ lock thì không có cạnh nào cả, và cũng không có luật nào bắt nó phải nhìn thấy giá trị của thread ghi. Một getter không synchronized còn có thể trả về một object mới cập nhật được một nửa, khi setter ghi nhiều field. Đây đúng là sai lầm giống hệt việc quên volatile trên cái stop flag, và nó hỏng theo đúng kiểu đó: âm thầm, và chỉ trên một số JVM cùng một số phần cứng.

AtomicInteger có luôn tốt hơn synchronized không?

Không — chúng trả lời hai câu hỏi khác nhau. AtomicInteger làm cho các cập nhật lên một biến trở nên atomic mà không bao giờ phải treo thread lại, và đó là lý do một thread đang thử lại compare-and-swap vẫn ở RUNNABLE thay vì rơi vào entry set. Nhưng nó không gộp được hai thao tác thành một, như những số dư âm ở trên đã chứng minh, và nó không phủ được một invariant trải trên nhiều field. synchronized bảo vệ được lượng state bất kỳ và chuỗi bước bất kỳ, đổi lại là các thread xếp hàng trong entry set. Chọn atomic cho một counter hay một flag đơn lẻ, và chọn lock khi một nhóm field phải di chuyển cùng nhau.

Kết luận

Race condition không phải chuyện xa lạ. Nó là count++ trên một field dùng chung, ba lệnh bytecode với một scheduler tự do xen kẽ chúng, và bằng chứng là bảy lần chạy y hệt nhau của cùng một chương trình tám dòng đã in ra bảy tổng sai khác nhau. Mỗi công cụ ở đây siết cái tự do đó theo một cách riêng: synchronized cho loại trừ lẫn nhau và visibility thông qua monitor của một object, volatile chỉ cho visibility, AtomicInteger cho atomicity trên một biến và không ghép được với gì, còn wait/notifyAll trong vòng while điều phối những thread cần bàn giao việc cho nhau. Hãy khoá lên một private field, giữ critical section gọn đúng bằng phần cập nhật state, và đừng bao giờ kết luận từ một bài test pass rằng cách xen kẽ bạn không nhìn thấy cũng sẽ không sao.

Bài tiếp theo rời khỏi thread thô để đến với thứ mà code production thực sự dùng: ExecutorService và thread pool — submit task thay vì tạo thread, các loại pool JDK cho sẵn, Future, và một pool tắt đi như thế nào.

Bài viết liên quan

[Advanced Java] Unit Testing trong Java với JUnit 5

Unit testing trong Java với JUnit 5.11.3 trên OpenJDK 21: kiến trúc Platform, Jupiter và Vintage, các callback lifecycle, mỗi test method một instance mới, message báo lỗi thật của từng assertion, assertThrows và assertAll, DisplayName, Nested, Disabled và Tag, parameterized test với đủ các argument source, assumption so với assertion, và những thói quen khiến một test trở nên vô dụng.

[Advanced Java] Java NIO: Path, Files và Channel

Java NIO chuyên sâu trên OpenJDK 21: path algebra với resolve, relativize, normalize và toRealPath, duyệt thư mục bằng walk, find và walkFileTree, file attribute và symlink, WatchService, cùng mô hình FileChannel và ByteBuffer với position, limit, capacity, direct buffer, transferTo và memory-mapped file.

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

Thread trong Java trên OpenJDK 21: thread là gì, stack riêng và heap dùng chung, tạo thread bằng Thread, Runnable và lambda, start khác run ra sao, join, daemon thread, tên và priority, output không xác định giữa các lần chạy, virtual thread với Thread.ofVirtual, và interrupt.

[Advanced Java] Deadlock, Livelock trong Java và cách phòng tránh

Deadlock và livelock trong Java trên OpenJDK 21: bốn điều kiện Coffman, một deadlock tái hiện được mọi lần, cách chẩn đoán bằng thread dump thật từ jstack và ThreadMXBean.findDeadlockedThreads, phòng tránh bằng lock ordering toàn cục và ReentrantLock.tryLock kèm back-off, deadlock trong thread pool, livelock, starvation và cái giá của fair lock.