synchronized, volatile và Atomic: chọn cái nào?

5 câu hỏi~22 phútTừ khóa: race condition, visibility, atomicity, synchronized, monitor lock, volatile, CAS, AtomicInteger, LongAdder, happens-before

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

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ụ:

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ếtLock 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

VisibilityAtomicity (nhiều bước)Chờ nhauHợp cho
volatileCóKhôngKhôngCờ trạng thái, 1 ghi nhiều đọc
synchronizedCóCóCó (block)Nhiều biến, nhiều bước
AtomicIntegerCóCó, cho 1 biếnKhông (CAS)Bộ đếm, ID
LongAdderCóCó, cho cộng dồnKhôngMetric dưới tải rất cao

7. Khi nào nên dùng / không nên dùng

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:

// 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). volatile chỉ giải quyết visibility, hợp cho cờ trạng thái như running. synchronized giả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. AtomicInteger dùng CAS, atomic cho một biến mà không block; tranh chấp rất cao thì dùng LongAdder.

🇬🇧 English:

There are two problems: visibility (do other threads see the latest value) and atomicity (does a multi-step update run without interference). volatile only gives visibility, good for status flags. synchronized gives both via a monitor lock, for protecting several fields or steps, at the cost of threads blocking. AtomicInteger uses CAS for lock-free atomic updates of a single variable; under very high contention LongAdder is 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; volatile chỉ đả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ùng AtomicInteger hoặc synchronized.

🇬🇧 English:

No. count++ is read-modify-write; volatile only guarantees each read sees the latest value, but two threads can still read the same value and overwrite each other. Use AtomicInteger or synchronized.

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


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ử.