Atomic và CAS — Compare-And-Swap bên dưới AtomicInteger
Cơ chế CAS (Compare-And-Swap) hardware, AtomicInteger/Reference API, ABA problem, AtomicStampedReference, LongAdder vs AtomicLong cho high-contention counter, và khi nào atomic vượt lock.
TL;DR: AtomicInteger và gia đình java.util.concurrent.atomic không dùng lock OS — chúng dùng CAS (Compare-And-Swap), một hardware instruction đơn lẻ nguyên tử (x86 CMPXCHG, ARM LL/SC). CAS: so sánh giá trị tại địa chỉ với expected, nếu match thì swap với new value, return kết quả — tất cả trong 1 cycle không bị interrupt. Khi contention thấp, atomic nhanh hơn synchronized vì không cần syscall. Nhưng CAS có ABA problem (fix bằng AtomicStampedReference), và khi contention cao thì LongAdder thắng AtomicLong. Bài này giải thích tận CPU instruction, đủ để bạn lý luận chính xác khi nào atomic đủ và khi nào phải lock.
Bài 02 giới thiệu AtomicInteger như "giải pháp cho counter++ race". Bài này đi sâu vào cơ chế bên dưới — CAS instruction, lock-free semantics, và những case atomic không replace được lock.
1. Scenario — counter shared, synchronized là bottleneck
API gateway đếm số request mỗi endpoint để rate limiting. 100 thread xử lý request song song, mỗi thread increment counter sau khi phục vụ xong.
Phương án synchronized:
class RequestCounter {
private long count = 0;
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
public long get() {
synchronized (lock) {
return count;
}
}
}
Vấn đề: với 100 thread, 99 thread phải queue bên ngoài lock mỗi lần increment. Syscall futex (Linux) để manage lock → context switch → overhead. Benchmark JMH trên máy 8 core cho thấy AtomicLong throughput cao hơn synchronized counter 5-10 lần khi contention trung bình.
Tại sao? Vì AtomicLong dùng CAS — không cần OS, không cần syscall, không context switch.
2. CAS — Compare-And-Swap, hardware instruction nguyên tử
CAS (Compare-And-Swap) là CPU instruction nguyên tử: "So sánh giá trị tại địa chỉ memory X với giá trị expected. Nếu bằng nhau, ghi newValue vào X và trả true. Nếu khác, không làm gì và trả false." Toàn bộ 3 bước xảy ra như 1 đơn vị không thể bị interrupt.
Trên x86/x86-64: instruction CMPXCHG (Compare and Exchange):
; Pseudocode: atomically compare [mem] with EAX, if equal swap with EDX
CMPXCHG [mem], EDX ; if [mem]==EAX then [mem]=EDX, ZF=1 else EAX=[mem], ZF=0
Trên ARM: cặp LDREX/STREX (Load-link/Store-conditional — LL/SC):
LDREX r0, [addr]: load value và mark address "exclusive" trong hardware monitor.STREX r1, r2, [addr]: cố ghi r2 vào addr. Nếu không có thread/CPU khác modify addr kể từLDREX, ghi thành công (r1=0). Nếu có, fail (r1=1). Caller retry.
Cả 2 đều là single-instruction atomic — CPU hardware đảm bảo không có thread nào khác thực hiện operation giữa compare và swap.
sequenceDiagram
participant T1 as Thread 1
participant T2 as Thread 2
participant M as Memory [value=5]
T1->>M: CAS(expected=5, new=6)
Note over M: Atomic compare-and-swap
M-->>T1: success (value now 6)
T2->>M: CAS(expected=5, new=6)
M-->>T2: FAIL (value is 6, not 5)
T2->>M: CAS(expected=6, new=7)
M-->>T2: success (value now 7)3. Lock-free, wait-free, blocking — định nghĩa
3 đặc tính quan trọng khi nói về concurrent data structure:
Blocking: thread có thể bị block vô hạn bởi thread khác (mutex, semaphore). Nếu thread giữ lock bị kill, thread khác block mãi mãi.
Lock-free: ít nhất 1 thread tiến triển trong mọi khoảng thời gian hữu hạn — system không đứng yên, dù có thể một số thread retry nhiều lần. Không cần OS lock nhưng không guarantee mỗi thread cá nhân tiến triển.
Wait-free: tất cả thread đều tiến triển trong N bước bounded, không phụ thuộc hành động của thread khác. Mạnh nhất nhưng khó implement.
AtomicInteger với CAS loop là lock-free: nếu 1 thread CAS fail, ít nhất 1 thread khác CAS success → system tiến. Nhưng 1 thread cụ thể có thể retry nhiều lần trong môi trường contention cao — không wait-free.
LongAdder (striped cells) tiến gần hơn wait-free nhưng không guarantee.
4. AtomicInteger API và CAS loop pattern
import java.util.concurrent.atomic.AtomicInteger;
AtomicInteger counter = new AtomicInteger(0);
counter.get(); // read current value
counter.set(10); // set value
counter.getAndSet(20); // set, return old value
counter.incrementAndGet(); // atomic ++, return new value
counter.getAndIncrement(); // atomic ++, return old value
counter.addAndGet(5); // atomic +=5, return new value
counter.getAndAdd(5); // atomic +=5, return old value
// CAS truc tiep
boolean ok = counter.compareAndSet(10, 20);
// If current==10: set to 20, return true
// If current!=10: no change, return false
// Higher-level update operations (Java 8+)
counter.updateAndGet(x -> x * 2); // atomic: x = x*2, return new
counter.accumulateAndGet(3, (x, y) -> x + y); // atomic: x = x+y, return new
CAS loop pattern — idiomatic khi cần custom update:
AtomicInteger counter = new AtomicInteger(0);
// Increment chi khi value < 100 (bounded counter)
public boolean tryIncrement() {
int current, next;
do {
current = counter.get();
if (current >= 100) return false; // boundary check
next = current + 1;
} while (!counter.compareAndSet(current, next));
return true;
}
Cơ chế: đọc current, tính next. CAS thử swap. Nếu fail (thread khác đã modify), đọc lại current và thử lại. Mỗi fail có nghĩa là ít nhất 1 thread khác thành công — system tiến triển.
Vì sao không race condition: compareAndSet là 1 hardware instruction. Không thread nào chen vào giữa compare và set. Nếu 2 thread cùng đọc current=5, cùng tính next=6, chỉ 1 CAS thành công. Thread kia fail và retry với current=6, tính next=7. Không mất update.
5. Vì sao CAS nhanh hơn synchronized khi contention thấp
synchronized cần:
- Check lock state (atomic instruction).
- Nếu locked: syscall
futex_wait(Linux) → kernel mode, thread bị suspend, context switch. - Khi release:
futex_wake→ kernel mode, wake waiting thread.
Mỗi lock/unlock cần 2 syscall khi có contention. Kernel mode transition tốn ~1000ns.
AtomicInteger.incrementAndGet() khi không contention:
LOCK XADD(x86) — 1 instruction, no syscall.- Tốn ~10-50ns.
Khi contention thấp (vài thread), CAS retry hiếm khi xảy ra → throughput cao.
Khi contention rất cao (100 thread cùng update 1 counter): CAS fail nhiều → CPU spin retry → CPU cycles lãng phí mà không tiến triển. Đây là lúc LongAdder thắng.
6. AtomicReference — CAS trên object reference
AtomicReference<V> áp CAS lên object reference (pointer), không phải primitive.
import java.util.concurrent.atomic.AtomicReference;
record Config(int timeout, int maxConn) {}
AtomicReference<Config> configRef = new AtomicReference<>(new Config(30, 100));
// Hot-swap config atomic -- no lock needed
Config oldConfig = configRef.get();
Config newConfig = new Config(60, 200);
boolean swapped = configRef.compareAndSet(oldConfig, newConfig);
Lưu ý quan trọng: CAS của AtomicReference so sánh reference equality (==), không phải equals(). Hai object cùng nội dung nhưng khác instance sẽ CAS fail.
String a = new String("hello");
String b = new String("hello");
AtomicReference<String> ref = new AtomicReference<>(a);
// CAS voi b -- FAIL vi a != b (khac reference du cung content)
boolean ok = ref.compareAndSet(b, "world");
System.out.println(ok); // false
Pattern đúng với AtomicReference: giữ tham chiếu đến object cũ, dùng tham chiếu đó để CAS:
Config currentConfig = configRef.get(); // keep reference
// ... decide newConfig based on currentConfig ...
boolean ok = configRef.compareAndSet(currentConfig, newConfig); // reference compare
7. ABA problem — khi CAS không đủ
ABA problem là lỗi tinh tế nhất với CAS. Scenario:
- Thread 1 đọc value = A.
- Thread 2 đổi A → B.
- Thread 2 đổi B → A lại.
- Thread 1 CAS: "value vẫn là A, swap!" → thành công. Nhưng intermediate B đã xảy ra và có thể đã thay đổi state liên quan.
Trong nhiều trường hợp ABA vô hại (counter đơn giản). Nhưng trong lock-free linked list hay stack, ABA nguy hiểm:
Stack ban dau: [A] -> [B] -> [C]
Thread 1 doc head=A, chuan bi pop A
Thread 2 pop A, pop B (stack gio: [C])
Thread 2 push A lai (stack gio: [A] -> [C])
Thread 1 CAS head: A -> B? Thanh cong (head van la A)
Nhung B da bi pop boi Thread 2, node B co the da bi gc hoac reuse!
Stack gio bi corrupt: [B] -> [C] trong khi B da invalid
Fix: AtomicStampedReference<V>
AtomicStampedReference (Java 5) ghép value với một integer stamp (version counter). CAS phải match cả value VÀ stamp:
import java.util.concurrent.atomic.AtomicStampedReference;
AtomicStampedReference<String> ref = new AtomicStampedReference<>("A", 0);
// Doc value va stamp cung luc
int[] stampHolder = new int[1];
String value = ref.get(stampHolder); // value="A", stampHolder[0]=0
// CAS: chi swap neu ca value va stamp match
boolean ok = ref.compareAndSet(
"A", "B", // expectedRef, newRef
0, 1 // expectedStamp, newStamp
);
// Thread khac doi A->B->A nhung stamp tang: 0->1->2
// Khi thread 1 thu CAS voi stamp=0, fail vi stamp hien tai=2
Mỗi lần update tăng stamp. Thread không thể phân biệt A lần đầu với A sau khi qua B, nhưng stamp khác nhau — CAS fail. ABA bị detect.
Trade-off: AtomicStampedReference tốn thêm memory (giữ cả value lẫn stamp) và đọc phức tạp hơn (phải truyền int[] để nhận stamp).
Có thể dùng AtomicMarkableReference nếu chỉ cần 1 bit mark (boolean) thay vì full stamp.
8. LongAdder vs AtomicLong — chọn loại nào cho counter
AtomicLong dùng 1 cell memory. Tất cả thread update cùng 1 location → cache line bouncing: mỗi lần 1 thread write, CPU phải invalidate cache của tất cả core khác, các core re-read từ memory. Với 100 thread, đây là bottleneck.
LongAdder (Java 8, cùng package với AtomicLong) dùng striped counter — mảng cell, mỗi thread update 1 cell riêng (hash by thread ID). Khi gọi sum(), cộng tất cả cell lại.
// AtomicLong -- 1 cell, good for low contention
AtomicLong atomicCounter = new AtomicLong(0);
atomicCounter.incrementAndGet();
long val = atomicCounter.get(); // exact atomic snapshot
// LongAdder -- striped, good for high contention
LongAdder adder = new LongAdder();
adder.increment();
adder.add(5);
long sum = adder.sum(); // NOT atomic snapshot -- cells may change
// between reads; OK for stats, NOT for CAS
Benchmark trên máy 8 core, 32 thread cùng increment:
AtomicLong: ~200M ops/sec.LongAdder: ~900M ops/sec (4.5x speedup).
Trade-off của LongAdder:
sum()đọc tất cả cell, không atomic — giữa khi đọc cell 1 và cell 8, thread khác có thể đã update cell 5. Không dùng được cho CAS logic.- Tốt cho metrics, request counter, throughput tracking — không cần exact snapshot.
- Kém hơn
AtomicLongkhi contention thấp (overhead cell management).
// Dung dung use case:
LongAdder requestCount = new LongAdder(); // metrics, ok voi approximate sum
AtomicLong userId = new AtomicLong(); // ID generation, phai exact, sequence
AtomicReference<Config> cfg = new AtomicReference<>(); // hot-swap config
Tương tự: LongAccumulator (tổng quát hơn, cho phép custom reduction function), DoubleAdder, DoubleAccumulator.
9. Atomic vs lock — khi nào dùng cái nào
Atomic phù hợp khi:
- Update 1 biến đơn theo operation không phụ thuộc biến khác.
- Logic đủ đơn giản để biểu diễn bằng CAS loop (increment, max, min, CAS-and-swap).
- Contention thấp đến trung bình.
Lock phù hợp khi:
- Cần update nhiều biến mà phải giữ invariant (ví dụ: cập nhật cả
xvàysao chox + y == constant). - Logic phức tạp hơn 1 CAS — cần nhiều bước atomic cùng nhau.
- Cần
Condition(waiting set) — không có với AtomicXxx.
Ví dụ không thể thay lock bằng atomic:
// SAI -- dung 2 atomic rieng biet de giu invariant
AtomicLong balance = new AtomicLong(1000);
AtomicLong reserved = new AtomicLong(0);
// Invariant: balance + reserved == 1000
void reserve(long amount) {
balance.addAndGet(-amount); // step 1
// <-- Thread khac co the doc giua step 1 va step 2
reserved.addAndGet(amount); // step 2
}
// Thread khac thay balance=900, reserved=0 (invariant bi vi pham thoang qua)
Fix bằng lock:
void reserve(long amount) {
synchronized (lock) {
balance -= amount; // step 1
reserved += amount; // step 2
}
// Invariant luon dung trong lock
}
Lập trình viên thấy atomic "không cần lock" nên dùng nhiều atomic riêng cho các field liên quan. Nhưng không có cách atomic update 2 field đồng thời (trừ khi pack vào 1 long). Dùng atomic riêng cho state phụ thuộc là race condition ngầm — không exception, không crash, chỉ sai logic đôi khi.
10. So sánh tổng hợp
| Cơ chế | Throughput | Latency | ABA | Multi-var invariant | Khi dùng |
|---|---|---|---|---|---|
synchronized | Thấp | Cao khi contention | N/A | Có | Multi-variable, complex logic |
AtomicLong | Cao | Thấp–trung bình | Không | Không | Single variable, low–medium contention |
LongAdder | Rất cao | Thấp | Không | Không | High-contention counter, metrics |
AtomicStampedReference | Trung bình | Trung bình | Có | Không | Lock-free structure với ABA risk |
ReentrantLock | Trung bình | Trung bình | N/A | Có | Timeout, fairness, Condition |
11. Deep Dive
java.util.concurrent.atomicpackage Javadoc — docs.oracle.com/.../atomic/package-summary.html — mô tả chính thức tất cả atomic class, memory effects (volatile semantics), và design rationale. Đọc class-level Javadoc củaAtomicIntegerđể hiểuupdateAndGetvàaccumulateAndGet.- JLS Chapter 17 — Threads and Locks — docs.oracle.com/.../jls-17.html — §17.7 ("Non-atomic Treatment of double and long") và §17.4 (memory model + volatile semantics) liên quan trực tiếp đến tại sao atomic operations cần hardware support.
- "The Art of Multiprocessor Programming" — Herlihy, Shavit: định nghĩa chính thức lock-free và wait-free (Chapter 3), lock-free stack/queue/linked list (Chapter 10-11). Đây là textbook học thuật tiêu chuẩn về concurrent data structure.
- Intel x86 Manual, Volume 2: CMPXCHG — software.intel.com/en-us/articles/intel-sdm — spec instruction level cho
CMPXCHGvàLOCK CMPXCHG. Giải thích vì sao cần prefixLOCKtrên multiprocessor system. - ARM Architecture Reference Manual — LDREX/STREX — ARM LL/SC alternative cho CAS. Quan trọng khi deploy Java trên ARM (AWS Graviton, Apple Silicon) — behavior tương đương nhưng cơ chế hardware khác.
- JEP 155 (Java 8) —
LongAddervàDoubleAdder: Doug Lea thiết kếLongAdderdựa trên Striped64 — xem source JDKjava.util.concurrent.atomic.Striped64để hiểu cell array và hash-by-thread mechanism.
12. Self-check
Q1Vì sao counter++ không thread-safe dù dùng volatile, nhưng AtomicInteger.incrementAndGet() thì thread-safe?▸
counter++ không thread-safe dù dùng volatile, nhưng AtomicInteger.incrementAndGet() thì thread-safe?volatile counter++ là 3 bytecode riêng biệt: getfield (read), iadd (+1), putfield (write). volatile đảm bảo mỗi read/write đi thẳng memory (visibility), nhưng không làm 3 bước thành 1 đơn vị atomic. Thread A đọc 5, Thread B đọc 5 (cũng thấy 5 nhờ volatile), cả hai ghi 6 — mất 1 update.
AtomicInteger.incrementAndGet() dùng LOCK XADD (x86) hoặc CAS loop: "đọc current, tính current+1, CAS(current, current+1)". Nếu CAS fail (thread khác đã modify), retry với current mới. Mỗi lần chỉ 1 thread CAS thành công — không mất update.
Điểm cốt lõi: volatile = visibility guarantee. CAS = atomicity guarantee. Cần cả hai cho concurrent counter. AtomicInteger cung cấp cả hai thông qua hardware instruction.
Thêm: Java Memory Model đảm bảo AtomicInteger operations có volatile semantics — read/write không cache trong register, không reorder với surrounding ops. Vừa atomic vừa visible.
Q2Giải thích ABA problem với ví dụ cụ thể và cách AtomicStampedReference fix.▸
ABA xảy ra khi thread A đọc value V1, thread B đổi V1→V2→V1 trong khi A đang xử lý, rồi A CAS V1→newValue thành công — nhưng A bỏ qua intermediate state V2 có thể đã thay đổi logic.
Ví dụ lock-free stack: top=[Node A→Node B→Node C]. Thread 1 đọc head=A, chuẩn bị pop A (new head = B). Thread 2 pop A, pop B (stack=[C]), push A lại (stack=[A→C], A.next=C). Thread 1 CAS head: A→B? Thành công vì head vẫn là A. Nhưng B đã bị Thread 2 remove — B.next trỏ tới C đã invalid. Stack bị corrupt.
AtomicStampedReference fix bằng cách ghép stamp (version counter) vào mỗi update:
- Mỗi CAS thành công tăng stamp.
- Thread 1 đọc (A, stamp=0), Thread 2 thực hiện A→B (stamp=1) → B→A (stamp=2).
- Thread 1 CAS với (A, stamp=0) — fail vì stamp hiện tại là 2, không phải 0.
- Thread 1 retry, đọc lại (A, stamp=2), nhận ra stack đã thay đổi.
ABA detect vì stamp không bao giờ bằng nhau sau khi đã thay đổi, dù value trở về giá trị cũ.
Trade-off: phải truyền int[] để nhận stamp khi đọc (ref.get(stampHolder)), CAS cần 4 tham số thay vì 2. Code phức tạp hơn.
Q3Khi nào LongAdder tốt hơn AtomicLong, và khi nào không nên dùng LongAdder?▸
LongAdder tốt hơn AtomicLong, và khi nào không nên dùng LongAdder?LongAdder tốt hơn khi: contention cao — nhiều thread (vượt 4-8) đồng thời cập nhật cùng counter. LongAdder dùng mảng cell (Striped64), mỗi thread update cell riêng theo hash thread ID. Không có CAS retry bottleneck. Benchmark 32 thread: LongAdder vượt AtomicLong 4-5 lần throughput.
Không dùng LongAdder khi:
- Cần exact atomic snapshot:
sum()đọc từng cell tuần tự, không atomic. Giữa đọc cell 1 và cell 8, thread khác có thể đã update cell 5 →sum()không phản ánh trạng thái nhất quán tại 1 thời điểm. - Cần CAS conditional: không có
compareAndSettrên LongAdder — không dùng được cho "increment chỉ khi value thỏa điều kiện". - Contention thấp: overhead cell management của Striped64 lớn hơn lợi ích — AtomicLong nhanh hơn.
- Cần sequence ID generation: ID phải exact, sequential — dùng AtomicLong.
Use case lý tưởng cho LongAdder: metrics counter (request count, error count, bytes transferred), statistics (sum để tính mean), rate limiting approximate.
Q4Đoạn code sau có race condition không? Nếu có, fix thế nào?
AtomicInteger x = new AtomicInteger(0); AtomicInteger y = new AtomicInteger(0); // invariant: x + y == 100
void transfer(int amount) { x.addAndGet(-amount); y.addAndGet(amount); }▸
AtomicInteger x = new AtomicInteger(0); AtomicInteger y = new AtomicInteger(0); // invariant: x + y == 100
void transfer(int amount) { x.addAndGet(-amount); y.addAndGet(amount); }Có race condition. Dù mỗi addAndGet là atomic, 2 operation là 2 bước riêng biệt. Giữa x.addAndGet(-amount) và y.addAndGet(amount), thread khác có thể đọc cả x và y — thấy x đã giảm nhưng y chưa tăng. Invariant x + y == 100 bị vi phạm ở window nhỏ đó.
Ví dụ cụ thể: x=60, y=40. Thread 1 transfer 10: x.addAndGet(-10) → x=50. Thread 2 đọc x=50, y=40 → x+y=90 (sai). Thread 1 tiếp tục: y.addAndGet(10) → y=50. Invariant restore. Nhưng Thread 2 đã thấy trạng thái trung gian sai.
Fix bằng lock bao toàn bộ operation:
private final Object lock = new Object();
private int x = 60, y = 40; // khong can atomic nu dung lock
void transfer(int amount) {
synchronized (lock) {
x -= amount; // step 1
y += amount; // step 2
}
// invariant luon dung trong lock
}Hoặc pack x và y vào 1 long (x ở 32 bit cao, y ở 32 bit thấp) và dùng 1 AtomicLong — CAS update cả 2 cùng lúc. Phức tạp hơn nhưng lock-free nếu cần.
Nguyên tắc: atomic riêng lẻ chỉ đủ cho invariant 1 biến. Multi-variable invariant cần lock bao toàn bộ update.
Q5Vì sao CAS nhanh hơn synchronized khi contention thấp, nhưng lại tệ hơn khi contention rất cao?▸
Contention thấp — CAS thắng: CAS thành công ngay lần đầu → 1 hardware instruction (LOCK XADD hoặc CMPXCHG), không syscall, không context switch. Tốn ~10-50ns. synchronized phải acquire monitor (atomic op) + nếu locked: syscall futex_wait → kernel mode → thread suspend → context switch → ~1000ns+.
Contention rất cao — CAS tệ hơn: khi 100 thread cùng CAS 1 biến, chỉ 1 thành công mỗi lần. 99 thread fail → retry loop → đọc lại → CAS lại → nhiều khả năng fail lại. CPU cores liên tục invalidate cache của nhau (cache line bouncing) — mỗi CAS success buộc tất cả core khác invalidate cache line chứa biến đó. CPU cycles bị lãng phí cho coordination thay vì computation.
Với synchronized high contention: threads queue và sleep, không burn CPU khi chờ. Khi lock release, 1 thread được wake — ít CPU waste hơn spin CAS retry.
Ngưỡng: thực nghiệm khoảng 4-8 thread là điểm giao nhau. Dưới ngưỡng: atomic thắng. Trên ngưỡng: lock hoặc LongAdder (striped) thắng.
Đây là lý do LongAdder tồn tại: loại bỏ single hot-location CAS bằng cách stripe ra nhiều cell — không thread nào cạnh tranh cùng cache line thường xuyên.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên