synchronized và volatile — memory model và happens-before
Atomicity vs visibility, synchronized là mutex + visibility, volatile chỉ visibility, khái niệm happens-before. Vì sao counter++ không safe dù thêm volatile, và làm cách nào JVM/CPU cache biến khiến thread A không thấy ghi của thread B.
Đoạn code sau trông vô hại:
class Counter {
int value = 0;
void increment() { value++; }
}
Khởi chạy 2 thread, mỗi thread gọi increment() 1 triệu lần. Sau khi cả hai xong, kỳ vọng value == 2_000_000.
Thực tế: chạy 10 lần, kết quả có thể là 1_234_567, 1_877_001, 1_543_281... khác nhau mỗi lần. Mất update silent. Không exception, không warning, compiler không báo. Chỉ là con số sai.
Điều gì xảy ra? Hai vấn đề đan xen:
-
Atomicity:
value++không phải 1 thao tác — nó là 3 bước (read, +1, write). Thread A đọc 100, chưa kịp write, thread B cũng đọc 100, +1 ghi 101. A tiếp tục ghi 101. Kết quả 101 thay vì 102. Mất 1 update. -
Visibility: kể cả không chen giữa, thread A ghi
value = 100nhưng thread B có thể không thấy — vì CPU cache giá trị trong register, JVM optimize không đọc lại memory.
Bài này giải thích 2 vấn đề trên ở level CPU/JVM, sau đó 2 công cụ Java cung cấp: synchronized (mutex + visibility) và volatile (chỉ visibility). Kết thúc với happens-before — luật chính thức định nghĩa "thread nào thấy update của thread nào" trong JLS §17.
Đây là bài khó nhất module 10 nhưng quan trọng nhất. Hiểu sai memory model → code race condition chạy OK 99% thời gian, hỏng 1% lúc bất ngờ.
1. Analogy — Sổ ghi chép chung trong phòng
Hai nhân viên phải cập nhật một con số trong sổ kế toán chung.
Kịch bản 1 — không khoá, không broadcast: Cả hai đọc sổ thấy số 100, mỗi người ghi vào nháp riêng, cộng 1 thành 101, viết lại vào sổ. Cả hai ghi 101 — mất 1 update. Đây là atomicity: thao tác "đọc-tính-ghi" bị xen kẽ.
Thêm một bẫy: mỗi nhân viên có bản copy số đó trong đầu (CPU cache). A vừa ghi 101 vào sổ, B không nhìn sổ mà dùng số trong đầu (vẫn là 100) → ghi 101. Đây là visibility: update của A không lan tới B kịp.
Kịch bản 2 — cửa phòng có khoá: chỉ 1 người vào phòng tại 1 lúc. A vào, làm xong, đi ra. B vào, thấy số đã 101, cộng 1 thành 102. Đúng. Đây là synchronized — mutex + ép reload từ sổ mỗi lần vào.
Kịch bản 3 — không khoá nhưng ép mỗi lần đọc/ghi đi thẳng sổ: không ai dùng nháp, không ai cache trong đầu. Visibility đảm bảo. Nhưng 2 người vẫn có thể cùng lúc đọc, cùng lúc ghi — vẫn mất update. Đây là volatile — visibility mà không mutex.
Rút ra:
- Cần visibility →
volatileđủ. - Cần atomic (thao tác không bị chia cắt) → phải
synchronizedhoặcAtomicInteger. volatilekhông thay thế đượcsynchronizedcho compound operation.
2. Hai vấn đề chi tiết — atomicity và visibility
Vấn đề 1: Atomicity
value++ compile ra 3 bytecode:
getfield value // doc value tu heap vao stack
iconst_1 // push 1 vao stack
iadd // cong 2 gia tri
putfield value // ghi ket qua ve heap
Giữa getfield và putfield, thread khác có thể chen vào. Minh hoạ 2 thread tăng từ 0:
Thread A: getfield -> 0
Thread B: getfield -> 0 (A chua ghi, van la 0)
Thread B: iadd -> 1
Thread B: putfield 1 (heap = 1)
Thread A: iadd -> 1 (A van nho gia tri cu la 0, +1 = 1)
Thread A: putfield 1 (heap = 1, dang le la 2)
Kết quả: 2 lần increment, value chỉ tăng 1. Lỗi này gọi là lost update.
Tương tự với mọi compound op:
if (x == 0) x = 1(check-then-act).x = x * 2(read-modify-write).if (map.containsKey(k)) return map.get(k); else return null;(check-then-get, với concurrent modification có thể miss).
Vấn đề 2: Visibility
Ngay cả khi atomic (ví dụ chỉ có 1 thread ghi), visibility vẫn là vấn đề riêng.
class BadShutdown {
boolean running = true;
void worker() {
while (running) { // Thread B co the khong bao gio thay running = false
doWork();
}
}
void shutdown() {
running = false; // Thread A ghi
}
}
Thread A (main) gọi shutdown(). Thread B (worker) đang loop trên running. Kỳ vọng: sau shutdown() return, worker thoát loop.
Thực tế: worker có thể loop mãi mãi, không thấy running = false.
Vì sao? Có 3 tầng optimization có thể tham gia:
Tầng 1 — CPU cache: CPU modern có L1 cache per core. Thread A trên core 1 ghi running = false vào L1 của core 1. Thread B trên core 2 có L1 riêng, đã cache running = true từ lần đọc đầu. Không có gì "đẩy" update từ cache core 1 sang core 2 — mỗi core tự quản cache của mình. Cache coherence protocol (MESI) sẽ eventually đẩy, nhưng khi nào không có guarantee.
Tầng 2 — JIT compiler: JIT thấy loop while (running) doWork(); — nhận thấy running không thay đổi trong loop body → optimize thành:
if (running) {
while (true) doWork(); // Loop vo tan, khong doc lai running
}
Hợp pháp! JIT coi code như single-thread, running không đổi trong body → hoisting ra ngoài loop.
Tầng 3 — CPU register: ngay cả không JIT optimize, CPU cũng có thể giữ running trong register thay vì đọc memory mỗi iteration. Compile thành cmp eax, 1 — eax là copy của running load lúc đầu, không reload.
Kết quả: worker chạy forever dù A đã ghi running = false.
Solution overview
JVM cung cấp 2 công cụ fix visibility + atomicity:
synchronized: lock exclusive + memory barrier. Cả atomicity và visibility.volatile: memory barrier + JIT hint. Chỉ visibility.
Thêm java.util.concurrent.atomic: AtomicInteger, AtomicReference — dùng CAS (compare-and-swap) hardware instruction. Atomic mà không cần lock.
3. synchronized — mutex và memory barrier
Ngữ nghĩa
synchronized làm 2 việc:
- Mutex: chỉ 1 thread giữ monitor của 1 object tại 1 thời điểm. Thread khác chờ (BLOCKED state).
- Memory barrier: khi thread release monitor (exit
synchronizedblock), mọi ghi của nó flush về main memory. Khi thread acquire monitor, cache của nó invalidate — đọc lại từ memory.
Hai việc này combine giải quyết cả atomicity và visibility.
3 dạng cú pháp
Dạng 1 — instance method:
class Counter {
private int value = 0;
public synchronized void increment() {
value++;
}
public synchronized int get() {
return value;
}
}
Lock là monitor của this. Hai thread gọi counter.increment() cùng lúc — một thread vào được, thread kia BLOCKED.
Dạng 2 — static method:
class Counter {
private static int value = 0;
public static synchronized void increment() {
value++;
}
}
Lock là Counter.class (monitor class-level). Khác monitor của instance. Instance method và static method trên cùng class không lock nhau (khác object monitor).
Dạng 3 — block:
class Counter {
private final Object lock = new Object();
private int value = 0;
void increment() {
synchronized (lock) {
value++;
}
}
}
Lock trên object cụ thể bạn chọn. Linh hoạt hơn — có thể lock fine-grained (lock khác cho data khác), hoặc lock object private không expose ra ngoài.
Quy tắc: lock object private
So sánh 2 design:
// Design A: synchronized method
class Counter {
private int value;
public synchronized void inc() { value++; }
}
// Design B: private lock
class Counter {
private final Object lock = new Object();
private int value;
public void inc() { synchronized (lock) { value++; } }
}
Design A lock là this — object Counter. Ai bên ngoài có thể:
Counter c = new Counter();
synchronized (c) {
// Giu cung monitor voi method inc() trong Counter
// Can tro inc() cua thread khac, hoac deadlock
}
Design B lock là lock private — không code nào ngoài Counter truy cập được. An toàn.
Đây là nguyên tắc encapsulation áp cho lock — lock là implementation detail, không nên expose. Design B là pattern chuẩn cho production code.
Reentrant
synchronized reentrant — thread đã giữ monitor có thể vào synchronized block khác với cùng monitor:
class Foo {
public synchronized void a() {
b(); // b() cung synchronized this - OK, cung monitor
}
public synchronized void b() {
// ...
}
}
Thread vào a() → lock this. Trong a() gọi b() → b() cũng lock this. Vì cùng thread đang giữ, lock cho vào mà không block.
Không reentrant → deadlock với chính mình (đang giữ lock, lại cố lock → chờ mãi).
synchronized reentrant là lý do nó dễ dùng. ReentrantLock trong java.util.concurrent.locks cùng behavior, nhưng API rõ hơn.
4. volatile — chỉ memory barrier
Ngữ nghĩa
volatile bắt buộc 2 điều:
- Read/write đi thẳng main memory, không cache trong register.
- JIT compiler không reorder với op trước/sau nó — bảo vệ happens-before (mục 6).
Không bảo đảm atomicity cho compound op.
class Shutdown {
private volatile boolean running = true;
void worker() {
while (running) { // Dam bao thay update cua shutdown()
doWork();
}
}
void shutdown() {
running = false;
}
}
Thêm volatile — sau shutdown() return, worker chắc chắn thấy running = false ở iteration kế tiếp.
Use case đúng cho volatile
Chỉ khi thoả cả 2 điều kiện:
- Không compound op: không
volatile x; x++;hayvolatile x; if (x == 0) x = 1;. - Ghi không phụ thuộc giá trị cũ: chỉ set giá trị mới độc lập.
Ví dụ đúng:
// Flag shutdown
volatile boolean running = true;
// Pointer tro den object immutable, hot-swap
volatile Config config;
void reload() {
this.config = loadFromDisk(); // Khong doc config cu
}
// One-time init flag
volatile boolean initialized;
Use case SAI — volatile counter
class BadCounter {
private volatile int value = 0;
public void inc() { value++; } // VAN RACE
}
value++ vẫn 3 bước. Thread A read 100, thread B read 100 (cũng là giá trị mới nhất — volatile đảm bảo visibility), cả hai +1 ghi 101. Mất update.
volatile giải quyết visibility nhưng không atomicity. Với counter → dùng AtomicInteger hoặc synchronized.
volatile cho "biến flag 1 chiều". synchronized hoặc AtomicXxx cho compound operation.
5. AtomicInteger và family — atomic không lock
java.util.concurrent.atomic cung cấp class wrapping primitive với op atomic dùng CAS (Compare-And-Swap) hardware instruction.
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet(); // atomic ++ va tra gia tri moi
counter.getAndIncrement(); // atomic ++ va tra gia tri cu
counter.addAndGet(5); // atomic += 5
counter.set(100); // atomic set
int v = counter.get(); // atomic read
// CAS directly
boolean ok = counter.compareAndSet(10, 20);
// Neu value hien tai = 10, set = 20, tra true
// Neu khac 10, khong lam gi, tra false
CAS bên dưới
CAS là CPU instruction nguyên tử: "so sánh giá trị tại địa chỉ X với expected, nếu bằng thì ghi newValue, trả về kết quả so sánh". x86 có CMPXCHG, ARM có LDREX/STREX.
incrementAndGet loop CAS:
// Tuong duong:
int oldValue;
do {
oldValue = get();
} while (!compareAndSet(oldValue, oldValue + 1));
return oldValue + 1;
Nếu không contention (1 thread), CAS thành công ngay → không lock OS, nhanh hơn synchronized.
Nếu có contention (nhiều thread cùng inc), retry loop — performance giảm dần. Với contention rất cao, LongAdder (Java 8) tốt hơn — chia counter thành nhiều cell, mỗi thread update cell riêng, sum cuối.
Biến thể
AtomicLong,AtomicBoolean,AtomicReference.AtomicIntegerArray,AtomicReferenceArray.LongAdder,LongAccumulatorcho high-contention counter.
Khi nào dùng atomic vs synchronized:
- Single variable, op đơn giản (inc, dec, set) →
AtomicXxxnhanh hơn. - Multiple variable cần cập nhật cùng lúc, hoặc invariant cross-variable →
synchronized. Ví dụ update cảxvàyđể giữx + y == 10.
6. Happens-before — luật chính thức của memory model
Java Memory Model (JMM) định nghĩa quan hệ happens-before giữa action. Nếu A happens-before B, thì B chắc chắn thấy kết quả của A.
9 rule chính (JLS §17.4.5)
- Program order: trong cùng thread, dòng code trước happens-before dòng sau.
- Monitor lock:
unlock(m)happens-beforelock(m)kế tiếp (bất kỳ thread nào). - Volatile: write(v) happens-before read(v) kế tiếp (bất kỳ thread nào).
- Thread start:
thread.start()happens-before mọi action trong thread mới. - Thread join: mọi action trong thread X happens-before
x.join()return ở thread gọi. - Thread interrupt:
thread.interrupt()happens-before target thread detect interrupt. - Constructor finish: end constructor happens-before finalizer (ít dùng).
- Transitivity: A happens-before B, B happens-before C → A happens-before C.
- Default values: write default (0, null, false) happens-before read mọi thread.
Minh hoạ rule 2 — monitor
int data = 0;
final Object lock = new Object();
// Thread A
synchronized (lock) {
data = 42; // Ghi trong lock
} // unlock happens-before lock ke tiep
// Thread B
synchronized (lock) {
System.out.println(data); // Chac chan thay 42
}
Rule 2 + rule 1 (program order) + rule 8 (transitivity):
- Ghi
data = 42trướcunlock(lock)→ ghi happens-before unlock (program order). unlock(lock)happens-beforelock(lock)của thread B (rule 2).lock(lock)happens-before readdatacủa thread B (program order).- Transitivity: ghi
data = 42happens-before readdata→ thread B chắc chắn thấy 42.
Không cần volatile data — synchronized đã set up happens-before cho mọi ghi trong block.
Minh hoạ rule 5 — join
int[] result = new int[1];
Thread t = new Thread(() -> {
result[0] = heavyCompute(); // Ghi trong thread con
});
t.start();
t.join();
System.out.println(result[0]); // Chac chan thay
Rule 5 đảm bảo: action trong thread t happens-before t.join() return. Không cần volatile hay synchronized — thread main sau join thấy mọi update.
Tại sao hiểu happens-before quan trọng
Viết code concurrent không cần suy nghĩ happens-before từng bước — thường pattern có sẵn (synchronized block, volatile flag, AtomicXxx) đã "miễn phí" setup happens-before. Nhưng khi đọc code của người khác, hoặc debug bug concurrency hiếm gặp, happens-before là công cụ lý luận chính xác.
Câu hỏi bạn tự đặt: "thread B đọc biến X — có happens-before chain từ ghi của thread A đến đọc này không?" Nếu có → B chắc chắn thấy update. Nếu không → race condition, có thể thấy hoặc không.
7. Pitfall tổng hợp
❌ Nhầm 1: volatile counter; counter++; nghĩ là atomic.
private volatile int count;
void inc() { count++; } // Race!
✅ AtomicInteger:
private final AtomicInteger count = new AtomicInteger();
void inc() { count.incrementAndGet(); }
❌ Nhầm 2: Lock khác nhau tưởng safe.
synchronized void a() { value++; }
synchronized (new Object()) { value++; }
// Lock khac -> khong protect nhau
✅ Dùng cùng lock cho mọi access đến cùng data.
❌ Nhầm 3: Lock quá nhỏ → miss compound op.
synchronized void set(int x) { a = x; }
synchronized int get() { return a; }
// if (get() == 0) set(1); // Check-then-act khong atomic
✅ Lock toàn compound:
synchronized void setIfZero(int x) {
if (a == 0) a = x;
}
❌ Nhầm 4: synchronized trên String literal.
synchronized ("LOCK") { ... }
// String intern shared global -> class khac co the lock cung string -> deadlock
✅ Private final object:
private final Object lock = new Object();
synchronized (lock) { ... }
❌ Nhầm 5: Double-check locking sai (cổ điển).
class Singleton {
private static Singleton instance; // Khong volatile
public static Singleton get() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) instance = new Singleton();
}
}
return instance;
}
}
✅ Cần volatile:
private static volatile Singleton instance;
Hoặc tốt hơn: dùng enum singleton hoặc holder class pattern.
❌ Nhầm 6: Thay đổi nhiều field mà chỉ mỗi field synchronized getter/setter riêng.
synchronized void setName(String n) { name = n; }
synchronized void setAge(int a) { age = a; }
// Khong bao dam name va age update cung thoi diem
✅ Lock cả operation logic:
synchronized void update(String n, int a) { name = n; age = a; }
8. 📚 Deep Dive Oracle
Spec / reference chính thức:
- JLS §17 Threads and Locks — memory model chính thức.
- JLS §17.4.5 Happens-before Order — 9 rule đầy đủ.
- java.util.concurrent.atomic — tất cả atomic class.
- "Java Concurrency in Practice" - Brian Goetz — bible concurrency Java (sách in, không online). Must-read nếu viết code concurrent production.
- Doug Lea's Concurrency Interest — mailing list + paper của tác giả java.util.concurrent.
Ghi chú: JLS §17 dense nhưng chính xác. Đọc §17.4 trước (memory model overview), sau đó §17.4.5 (happens-before). Hiểu §17.4.5 là đủ viết code safe — phần còn lại là formalism cho người viết JVM. "Java Concurrency in Practice" là sách dạy concurrency tốt nhất viết bằng tiếng Anh — Brian Goetz (JDK spec lead) + Doug Lea (thiết kế j.u.c) làm co-author.
9. Tóm tắt
- 2 vấn đề concurrency độc lập: atomicity (op không bị chia cắt) và visibility (thread thấy update thread khác).
value++không atomic — 3 bytecode (read, +1, write), có thể xen kẽ.- Visibility bị ảnh hưởng bởi CPU cache, JIT hoisting, CPU register — không có đồng bộ, thread có thể không bao giờ thấy update của thread khác.
synchronized= mutex + memory barrier. Giải quyết cả atomic và visibility. Reentrant.synchronizedtrên instance method = lockthis; static method = lockClass; block = lock object chọn.- Quy tắc: lock object private final — tránh expose lock cho code ngoài can thiệp.
volatile= chỉ visibility + ngăn reorder. Không atomic cho compound op.volatile counter++vẫn race — phảiAtomicIntegerhoặcsynchronized.AtomicIntegerdùng CAS hardware — atomic mà không lock OS. Nhanh hơn synchronized khi contention thấp.- happens-before luật: program order, monitor lock (unlock → lock), volatile (write → read), thread start/join, interrupt, transitivity.
- Mỗi primitive concurrency (synchronized, volatile, atomic) đã setup happens-before đúng cho pattern chuẩn. Chỉ khi custom lock mới phải suy nghĩ rule từng bước.
10. Tự kiểm tra
Q1Vì sao counter++ không safe dù biến là volatile?▸
counter++ compile ra 3 bytecode: getfield (read), iadd (+1), putfield (write). Giữa read và write, thread khác có thể chen vào đọc value cũ → cả hai ghi cùng value mới → mất 1 update.
volatile đảm bảo 2 điều:
- Read/write đi thẳng memory, không cache — visibility.
- JVM không reorder với op xung quanh — ordering.
Nhưng volatile không làm 3 bytecode thành 1 op atomic. Thread khác vẫn chen vào giữa được.
Fix 2 cách:
AtomicInteger.incrementAndGet()— dùng CAS hardware, atomic thực sự.synchronizedwrapcounter++— mutex loại trừ thread khác.
Q2Khi nào dùng volatile thay vì synchronized?▸
Dùng volatile khi thoả cả 2 điều kiện:
- Ghi 1 chiều (1 thread ghi, N thread đọc; hoặc nhiều thread ghi cùng giá trị độc lập). Vd flag shutdown, config swap reference.
- Không compound op — ghi mới không phụ thuộc giá trị cũ.
Ví dụ đúng:
volatile boolean running;— flag ngừng loop.volatile Config config;— hot-swap config object immutable.volatile boolean initialized;— one-time init.
Ngược lại, cần atomic op (counter++, compare-and-set, check-then-act) hoặc update nhiều field liên quan → phải dùng synchronized hoặc AtomicXxx.
volatile nhanh hơn synchronized khi áp dụng đúng — read/write primitive, không lock OS.
Q3Đoạn sau có race condition không, nếu cả 2 method được gọi từ nhiều thread? synchronized void a() { x++; } synchronized void b() { y++; }▸
synchronized void a() { x++; } synchronized void b() { y++; }Không race condition tại op đơn. Cả 2 method đều synchronized instance — lock là this. Tại 1 thời điểm chỉ 1 method chạy, x++ và y++ đều safe.
Nhưng cần suy xét:
- Nếu
xvàyđộc lập (không invariant cross-variable): design đang dùng 1 lock cho 2 field độc lập → nguồn contention không cần. Tách lock tăng throughput:private final Object lockX = new Object(); private final Object lockY = new Object(); void a() { synchronized (lockX) { x++; } } void b() { synchronized (lockY) { y++; } } - Nếu
xvàycó invariant (vdx + y = constant): 1 lock là đúng. Nhưng cần thêm method compound:Nếu thiếu method này mà app expect invariant, có thể race với check-then-act ngoài class.synchronized void transferXToY() { x--; y++; }
Đọc code không đủ — phải biết semantic app.
Q4Happens-before nào đảm bảo thread B thấy update của thread A sau t.join(), mà không cần volatile hay synchronized?▸
t.join(), mà không cần volatile hay synchronized?Rule "Thread join" (rule 5 trong JLS §17.4.5): mọi action trong thread X happens-before x.join() trả về ở thread gọi join.
Ví dụ:
int result;
Thread t = new Thread(() -> { result = compute(); });
t.start();
t.join();
System.out.println(result); // Chac chan thay ket quaChain happens-before:
- Ghi
result = compute()happens-before end of threadt(program order trong thread). - End of thread
thappens-beforet.join()return (rule thread join). - Transitivity: ghi
resulthappens-before readresultsaujoin().
Đây là lý do join() + biến local là pattern đơn giản nhất cho "worker compute, main lấy kết quả" — không cần bất kỳ đồng bộ nào khác.
Cảnh báo: chỉ áp dụng cho thread bạn join. Thread khác không join không có guarantee.
Q5Vì sao nên dùng private final Object lock thay cho synchronized (this)?▸
Code bên ngoài có thể synchronized (myInstance) { ... } — chiếm cùng monitor với method synchronized bên trong class. Hậu quả:
- Deadlock: outer lock trong code khác có thể sai thứ tự với internal method, tạo deadlock chain.
- Liveness degradation: outer giữ lock lâu (vd debugging, monitoring) → method internal BLOCKED, throughput giảm bất ngờ.
- Phá vỡ encapsulation: lock là implementation detail của class, không nên expose cho caller thao túng.
Private final Object lock = new Object(); chỉ code trong class thấy — không ai ngoài can thiệp. Pattern chuẩn cho production code concurrent.
Lý do dùng final: đảm bảo lock không bị thay đổi reference — nếu thay lock mid-flight, thread cũ và thread mới sẽ lock 2 object khác nhau, mất đồng bộ.
Lý do dùng Object thuần: không có data ý nghĩa trong lock — chỉ dùng làm monitor. Dùng new Object() tạo instance minimal.
Q6Khi nào AtomicInteger nhanh hơn synchronized, và khi nào chậm hơn?▸
Nhanh hơn khi contention thấp: 1-2 thread đồng thời update. AtomicInteger.incrementAndGet() dùng CAS hardware instruction — không syscall lock OS. Nếu CAS thành công ngay (không có thread khác chen) → gần bằng op primitive.
Chậm hơn khi contention cao: 10+ thread cùng inc 1 counter. CAS fail → retry loop. Mỗi lần fail là 1 chu kỳ CPU, không tiến triển. Với contention cực cao, throughput của AtomicInteger có thể tệ hơn synchronized.
Giải pháp cho high-contention counter: LongAdder (Java 8) — chia counter thành nhiều cell, mỗi thread update cell riêng, sum cuối. Tránh CAS retry hotspot.
Quy tắc chọn:
- Counter thường (app hit counter, metric) →
AtomicLong. - Counter under heavy contention (hơn 10 thread cùng inc) →
LongAdder. - Cần update nhiều field cùng lúc hoặc invariant cross-field →
synchronized.
Bài tiếp theo: ExecutorService và CompletableFuture
Bài này có giúp bạn hiểu bản chất không?