synchronized, volatile và Atomic: chọn cái nào?
Tiếp đi: synchronized, volatile và atomic.
Ở bài 3.1 mình đã đánh dấu một dòng nguy hiểm trong Order Service: orderCount++ trên field của singleton bean. Giờ có Tomcat 200 thread và pool riêng chạy song song, đây là lúc nó gây lỗi thật.
1. Bài toán
- Order Service đếm số đơn đã tạo trong ngày để hiển thị dashboard.
- Có cờ
runningđể dừng job đồng bộ tồn kho chạy nền. - Test: 2 thread cùng gọi
create()10.000 lần mỗi thread, kết quả lại nhỏ hơn 20.000.
2. Vấn đề
count++ trông như một lệnh nhưng thật ra là 3 bước: đọc giá trị, cộng 1, ghi lại.
class Counter {
private int count = 0;
void inc() { count++; } // đọc → cộng → ghi
int get() { return count; }
}
var c = new Counter();
Runnable job = () -> { for (int i = 0; i < 10_000; i++) c.inc(); };
Thread t1 = new Thread(job), t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println(c.get()); // thường < 20000
Nhìn rõ hơn với số dư balance = 100, hai thread cùng balance = balance + 50:
Thread A Thread B
read 100
read 100
100 + 50 = 150 100 + 50 = 150
write 150
write 150
balance = 150 ❌ (đúng ra 200)
Thao tác không atomic, thread khác chen vào giữa được. Kết quả phụ thuộc thứ tự chạy: đó là race condition.
Còn một lỗi khác, không cần race: cờ running set false rồi mà job vẫn chạy mãi. Đó là lỗi visibility: mỗi core có cache riêng, JIT được phép đọc biến một lần rồi dùng mãi, nên thread worker có thể không bao giờ thấy giá trị mới.
class SyncJob implements Runnable {
private boolean running = true; // thiếu volatile
public void run() {
while (running) { syncStock(); } // JIT có thể biến thành while(true)
}
public void stop() { running = false; }
}
3. Giải pháp
Có hai vấn đề cần phân biệt, và ba công cụ:
- Atomicity: nhiều bước chạy liền một mạch, không ai chen.
- Visibility: thread khác thấy giá trị mới nhất.
synchronized: dùng monitor lock, tại một thời điểm chỉ một thread vào đoạn code với cùng một lock. Lo cả hai.volatile: mọi lần đọc đều thấy lần ghi mới nhất, cấm sắp xếp lại lệnh quanh biến đó. Chỉ lo visibility.AtomicInteger/AtomicLong: dùng CAS, atomic cho một biến, không block.
4. Code từng bước
Bước 1: synchronized.
class Counter {
private int count = 0;
synchronized void inc() { count++; } // lock trên this
synchronized int get() { return count; } // get cũng phải synchronized để thấy giá trị mới
}
// hoặc khóa một đoạn nhỏ, với lock object riêng
private final Object lock = new Object();
void inc() {
synchronized (lock) { count++; }
}
Bước 2: volatile cho cờ.
private volatile boolean running = true;
Bước 3: Atomic cho bộ đếm trong Order Service.
@Service
public class OrderService {
private final AtomicLong orderCount = new AtomicLong();
public void create(OrderRequest req) {
// ... lưu đơn
orderCount.incrementAndGet(); // atomic, không lock
}
public long todayCount() { return orderCount.get(); }
}
Bước 4: test. Chạy lại bài 2 thread × 10.000: với synchronized hoặc AtomicInteger luôn ra đúng 20.000. Gọi stop() trên job có volatile thì vòng lặp dừng ngay.
5. Cách hoạt động
synchronized (monitor lock):
Thread A ── lấy lock ──► [ read → +1 → write ] ── nhả lock ──►
Thread B ── chờ lock (BLOCKED) ............... ── lấy lock ──► [ read → +1 → write ]
CAS (AtomicInteger.incrementAndGet):
Thread A: đọc 5 → CAS(5→6) ✅
Thread B: đọc 5 → CAS(5→6) ❌ (giá trị đã là 6) → đọc lại 6 → CAS(6→7) ✅
không ai bị block, chỉ thử lại
CAS (Compare-And-Swap) là lệnh CPU nguyên tử: "nếu giá trị hiện tại vẫn là X thì đổi thành Y, nếu không thì báo thất bại".
// incrementAndGet bên trong tương đương:
int prev, next;
do {
prev = count.get();
next = prev + 1;
} while (!count.compareAndSet(prev, next));
// cập nhật phức tạp hơn vẫn atomic
count.updateAndGet(x -> Math.min(x + 5, 100));
synchronized lock cái gì?
| Cách viết | Lock trên |
|---|---|
synchronized void inc() | this (object hiện tại) |
static synchronized void inc() | Counter.class |
synchronized (lock) { ... } | object lock bạn chọn |
Vì vậy c1.inc() và c2.inc() trên hai object khác nhau không chặn nhau. synchronized không có nghĩa "cả JVM chỉ một thread chạy", mà là các thread tranh cùng một monitor mới phải chờ nhau. Và mỗi JVM có lock riêng: Order Service chạy 3 instance sau load balancer thì synchronized không chặn được request ở instance khác.
6. So sánh
| Visibility | Atomicity (nhiều bước) | Chờ nhau | Hợp cho | |
|---|---|---|---|---|
volatile | Có | Không | Không | Cờ trạng thái, 1 ghi nhiều đọc |
synchronized | Có | Có | Có (block) | Nhiều biến, nhiều bước |
AtomicInteger | Có | Có, cho 1 biến | Không (CAS) | Bộ đếm, ID |
LongAdder | Có | Có, cho cộng dồn | Không | Metric dưới tải rất cao |
7. Khi nào nên dùng / không nên dùng
- volatile: chỉ khi ghi không phụ thuộc giá trị cũ (cờ dừng, config reload).
- Atomic: một biến cần cập nhật atomic, tranh chấp vừa phải.
- synchronized: bảo vệ nhiều biến hoặc nhiều bước cùng lúc. Khóa đoạn nhỏ nhất có thể; đừng khóa cả method gọi API Payment, sẽ làm nghẽn cả hệ thống.
- Không đủ khi app chạy nhiều instance: phải khóa ở DB (optimistic/pessimistic lock) hoặc distributed lock.
volatile cho count thì count++ hết lỗi chưa?volatile int count rồi count++ có an toàn không?
Không. Đây là câu bẫy kinh điển. volatile lo visibility, không lo atomicity. Hai thread vẫn có thể cùng đọc giá trị mới nhất là 5, cùng ghi 6:
volatile int count = 5;
Thread A: read 5 (mới nhất ✅) Thread B: read 5 (mới nhất ✅)
Thread A: write 6 Thread B: write 6
→ count = 6 ❌ (đúng ra 7)
Mỗi lần đọc đều thấy giá trị mới nhất, nhưng giữa đọc và ghi vẫn có khe hở. Muốn bịt khe hở thì dùng AtomicInteger hoặc synchronized.
AtomicInteger hoạt động thế nào mà không cần lock?
Bạn hiểu đúng là nó không lock: nó dựa vào CAS như sơ đồ ở trên, thất bại thì đọc lại và thử tiếp. Không thread nào bị block, nên nhanh khi tranh chấp vừa phải.
Nhược điểm: khi rất nhiều thread cùng tăng một biến (ví dụ bộ đếm request của cả Order Service giờ sale), CAS thất bại liên tục, các thread cứ quay vòng thử lại, tốn CPU.
Lúc đó dùng LongAdder: chia thành nhiều ô đếm riêng, mỗi thread cộng vào ô của mình, khi đọc mới cộng tổng lại.
LongAdder requests = new LongAdder();
requests.increment(); // ghi rất nhanh dưới tải cao
long total = requests.sum(); // đọc: cộng các ô, không phải snapshot chính xác tuyệt đối
Hợp cho metric, đếm request. Cần đọc–so sánh chính xác (ví dụ cấp số thứ tự đơn hàng) thì vẫn dùng AtomicLong.
Happens-before là gì, nói ngắn gọn được không?
Được. Đó là quy tắc của Java Memory Model: nếu A happens-before B thì mọi thứ A ghi đều được B nhìn thấy. Không có quan hệ này thì JVM không hứa gì về việc thread khác thấy dữ liệu của bạn. Vài quy tắc hay dùng:
- Nhả lock (thoát
synchronized) happens-before lần lấy lại cùng lock đó. - Ghi biến
volatilehappens-before lần đọc biến đó sau này. thread.start()happens-before mọi lệnh trong thread mới; mọi lệnh trong thread happens-beforejoin()trả về.- Submit task vào executor happens-before task chạy; task xong happens-before
future.get()trả về.
// Thread ghi // Thread đọc
config = loadConfig(); ─┐ if (ready) { // volatile read
ready = true; ─┴─ hb ──► use(config); // chắc chắn thấy config mới
// (volatile write) }
Quy tắc cuối giải thích vì sao ở bài 3.2 future.get() lấy kết quả từ worker thread an toàn mà không cần volatile.
Phỏng vấn hỏi "synchronized, volatile và AtomicInteger khác nhau thế nào?" thì trả lời sao?
Câu 1: synchronized, volatile, AtomicInteger khác nhau thế nào?
🇻🇳 Tiếng Việt:
Có hai vấn đề: visibility (thread khác có thấy giá trị mới không) và atomicity (nhiều bước có chạy liền không bị chen).
volatilechỉ giải quyết visibility, hợp cho cờ trạng thái nhưrunning.synchronizedgiải quyết cả hai bằng monitor lock, dùng khi bảo vệ nhiều biến hay nhiều bước, đổi lại thread phải chờ nhau.AtomicIntegerdùng CAS, atomic cho một biến mà không block; tranh chấp rất cao thì dùngLongAdder.
🇬🇧 English:
There are two problems: visibility (do other threads see the latest value) and atomicity (does a multi-step update run without interference).
volatileonly gives visibility, good for status flags.synchronizedgives both via a monitor lock, for protecting several fields or steps, at the cost of threads blocking.AtomicIntegeruses CAS for lock-free atomic updates of a single variable; under very high contentionLongAdderis better for counters.
Câu 2: volatile count++ có thread-safe không?
🇻🇳 Tiếng Việt:
Không.
count++là đọc–cộng–ghi;volatilechỉ đảm bảo mỗi lần đọc thấy giá trị mới nhất, nhưng hai thread vẫn có thể đọc cùng một giá trị rồi ghi đè nhau. Phải dùngAtomicIntegerhoặcsynchronized.
🇬🇧 English:
No.
count++is read-modify-write;volatileonly guarantees each read sees the latest value, but two threads can still read the same value and overwrite each other. UseAtomicIntegerorsynchronized.
Câu 3: synchronized có chặn được khi chạy nhiều instance không?
🇻🇳 Tiếng Việt:
Không. Lock của
synchronized, volatile hay Atomic chỉ có tác dụng trong một JVM. Service chạy nhiều instance thì phải khóa ở DB (optimistic/pessimistic lock, update có điều kiện) hoặc dùng distributed lock như Redis. Tất cả các công cụ trong JVM đều dựa trên quy tắc happens-before của Java Memory Model.
🇬🇧 English:
No.
synchronized, volatile and atomics only work inside one JVM. With multiple instances we need database locking (optimistic or pessimistic, conditional updates) or a distributed lock such as Redis. All the in-JVM tools are built on the happens-before rules of the Java Memory Model.
Cách nhớ ngắn gọn
count++= đọc → cộng → ghi, 3 bước → race.- volatile = thấy (visibility). synchronized = thấy + một mình (atomicity). Atomic = thấy + CAS cho 1 biến.
- synchronized khóa trên object: khác object thì không chặn nhau.
- Tranh chấp cao → LongAdder. Nhiều instance → lock ở DB/distributed.
Tự kiểm tra
Vì sao count++ không thread-safe?
Nó gồm đọc, cộng, ghi; thread khác có thể chen giữa các bước, gây mất cập nhật.
Khi nào volatile là đủ?
Khi chỉ có ghi/đọc đơn lẻ không phụ thuộc giá trị cũ, ví dụ cờ dừng thread do một thread ghi, nhiều thread đọc.
CAS là gì?
Lệnh nguyên tử "nếu giá trị vẫn là X thì đổi thành Y". Thất bại thì đọc lại và thử tiếp, không cần lock.
static synchronized lock trên gì?
Trên object Class (ví dụ Counter.class), nên chặn mọi lời gọi static synchronized của class đó.
LongAdder hơn AtomicLong ở đâu?
Dưới tranh chấp cao, nó chia ra nhiều ô đếm nên ít xung đột CAS, ghi nhanh hơn; đổi lại sum() không phải snapshot nguyên tử.