Những bài trước trong series này đã mô tả Vector, Hashtable và Collections.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.
![]()
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ì:
System.out.println(Arrays.toString(Thread.State.values()));[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.
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:
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 TERMINATEDBa 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.

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:
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();availableProcessors = 10
threads started = 20
reported RUNNABLE = 20Hai 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ể.
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ì:
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ỳ.

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.
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:
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 = 295759Bả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:
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 2000Sá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:
public class SyncCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int get() {
return count;
}
}synchronized : 2000000
AtomicInteger : 2000000
synchronized : 2000000
AtomicInteger : 2000000
synchronized : 2000000
AtomicInteger : 2000000
synchronized : 2000000
AtomicInteger : 2000000
synchronized : 2000000
AtomicInteger : 2000000Khô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.

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:
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:
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 anyMộ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:
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: returnBytecode 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:
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_SYNCHRONIZEDJVM 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:
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);
}
}-- 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_WAITINGTrê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, t4 là TIMED_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.
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");
}
}outer() holds the monitor
inner() acquired the same monitor again
outer() still holds itNế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ả:
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:
main set running = false
worker still alive? true
worker state : RUNNABLEBa 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á:
private static volatile boolean running = true;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? falseBa 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:
private static synchronized boolean isRunning() { return running; }
private static synchronized void stop() { running = false; }worker saw the flag after 391776802 spins
worker still alive? falseThứ 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:
private static volatile int count = 0;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 1330949Nă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, iadd và putfield 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úc | loại trừ lẫn nhau | visibility |
|---|---|---|
synchronized | có | có |
volatile | không | có |
AtomicInteger | theo từng thao tác | có |
| field thường | không | khô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, AtomicBoolean và AtomicReference, 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.
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.
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:
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 = -3Nă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:
balance.updateAndGet(v -> v >= 1 ? v - 1 : v);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 = 0Nă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() và 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:
Object lock = new Object();
lock.wait();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:
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.
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;
}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 waitingBa 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.
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:
tryLock() : false
tryLock(300ms) : false
isLocked / heldByCurrent : true / false
after the holder finished : truetryLock() 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:
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");
}
}
}Integer.valueOf(1) == Integer.valueOf(1) : true
Cart holds the monitor
report thread state: BLOCKED
Cart releases
Report holds the monitorHai 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ờ:
BoxLock.java:5: warning: [synchronization] attempt to synchronize on an instance of a value-based class
synchronized (lock) {
^
1 warningString 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:
synchronized ("config") { ... }"config" == "config".intern() : true
library A holds the monitor on "config"
library B state: BLOCKED
library B finally got inQuy 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ó:
Thread outsider = new Thread(() -> {
synchronized (registry) { // hợp lệ: reference là public
System.out.println(" outside code grabbed the Registry monitor");
Thread.sleep(1200);
}
}); outside code grabbed the Registry monitor
user state: BLOCKED
Registry.add(a) ranClass 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.
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:
slow call inside the lock : 5 of 6 threads BLOCKED
slow call outside the lock: 0 of 6 threads BLOCKEDNă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() và 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.