1. Vì sao không phải lúc nào cũng đủ ReadWriteLock
Vấn đề cạnh tranh ghi cao
ReadWriteLock (thường là ReentrantReadWriteLock) hoạt động tốt khi đa số luồng chỉ đọc dữ liệu và số luồng ghi ít. Khi đó, nhiều luồng có thể đọc đồng thời, còn việc ghi chỉ chặn truy cập trong một khoảng thời gian ngắn.
Vấn đề nảy sinh khi số luồng ghi nhiều hơn dự kiến hoặc chúng thường xuyên chuyển đổi giữa đọc và ghi. Nếu các thao tác ghi mất nhiều thời gian, các luồng phải chờ lâu hơn để khóa được giải phóng. Hệ quả là mức độ cạnh tranh truy cập dữ liệu tăng lên, cả đọc lẫn ghi đều chậm lại, và hiệu quả của ReadWriteLock giảm sút.
Chuyển đổi khóa tốn kém
Khi một luồng chuyển từ chế độ đọc sang chế độ ghi (hoặc ngược lại), bên dưới sẽ có nhiều kiểm tra phức tạp: cần bảo đảm không có ai đang ghi, rằng tất cả độc giả đã thoát, rồi mới cho phép ghi.
Nếu các chuyển đổi như vậy diễn ra thường xuyên, chúng gây ra độ trễ. Các luồng bắt đầu xếp hàng, và hiệu năng giảm — đặc biệt khi có nhiều thao tác đọc và ghi cùng lúc.
ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
Lock readLock = lock.readLock();
Lock writeLock = lock.writeLock();
// Đọc
readLock.lock();
try {
// đọc dữ liệu
} finally {
readLock.unlock();
}
// Ghi
writeLock.lock();
try {
// thay đổi dữ liệu
} finally {
writeLock.unlock();
}
Mỗi lần luồng chuyển giữa readLock và writeLock, hệ thống thực hiện mọi kiểm tra để tránh xung đột. Nếu có nhiều lần chuyển như vậy — chi phí rất cao.
2. StampedLock: cách tiếp cận hiện đại cho đồng bộ hóa
StampedLock là cơ chế đồng bộ hiện đại xuất hiện trong Java 8. Nó kết hợp các ý tưởng của ReadWriteLock, nhưng bổ sung chế độ mới — đọc lạc quan, đồng thời không làm việc với “khóa” mà với “tem” (stamps) — các token đặc biệt cần được giải phóng một cách tường minh.
Đặc điểm chính:
- Ba chế độ: write lock (ghi độc quyền), read lock (đọc chia sẻ), optimistic read (đọc lạc quan không khóa).
- Không có reentrancy: không thể vào khóa lần nữa từ cùng một luồng.
- Hiệu năng cao khi số lần đọc lớn và việc ghi hiếm.
- Yêu cầu quản lý tem (stamp) một cách tường minh.
Đọc lạc quan: tryOptimisticRead + validate
Đọc lạc quan là chế độ trong đó luồng đọc dữ liệu mà không khóa, hy vọng rằng không có ai ghi vào lúc đó. Sau khi đọc, luồng phải kiểm tra xem có xảy ra ghi trong lúc đọc hay không bằng phương thức validate(stamp).
import java.util.concurrent.locks.StampedLock;
public class Point {
private double x, y;
private final StampedLock lock = new StampedLock();
public double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead();
double currentX = x;
double currentY = y;
// Kiểm tra xem có ghi trong khi đọc hay không
if (!lock.validate(stamp)) {
// Nếu có ghi — lấy read lock thông thường
stamp = lock.readLock();
try {
currentX = x;
currentY = y;
} finally {
lock.unlockRead(stamp);
}
}
return Math.hypot(currentX, currentY);
}
}
Cách thức hoạt động:
- tryOptimisticRead() trả về một tem (long) “hợp lệ” chừng nào không ai ghi.
- Đọc các giá trị x và y.
- validate(stamp) kiểm tra xem có ghi giữa lúc bắt đầu và kết thúc quá trình đọc hay không.
- Nếu mọi thứ ổn — dùng các giá trị đã đọc; nếu có ghi — lấy readLock thông thường và đọc lại.
Điều này có lợi khi việc ghi rất hiếm còn đọc thì nhiều. Phần lớn trường hợp validate sẽ trả về true, và việc đọc gần như miễn phí.
Kịch bản “đọc nhiều — ghi ít”
- Dữ liệu hiếm khi thay đổi nhưng được đọc thường xuyên (ví dụ: cache, tọa độ, metadata).
- Cần tối thiểu hóa độ trễ khi đọc.
- Có thể “đọc lại” dữ liệu nếu lỡ có ghi xảy ra.
Rơi về read lock
Nếu đọc lạc quan không thành công (validate trả về false), luồng “rơi về” readLock thông thường. Điều này bảo đảm tính đúng đắn của dữ liệu, nhưng xảy ra hiếm.
3. Những cạm bẫy của StampedLock
Không có reentrancy
Khác với ReentrantReadWriteLock, StampedLock không hỗ trợ tái nhập. Nếu luồng đã giữ khóa mà cố lấy nó lần nữa — sẽ xảy ra deadlock.
long stamp1 = lock.writeLock();
long stamp2 = lock.writeLock(); // DEADLOCK! Luồng đang chờ chính nó
Nhạy cảm với việc ngắt (interrupt)
StampedLock không phản ứng với ngắt luồng giống như các khóa cổ điển. Nếu luồng bị ngắt trong khi chờ khóa, nó không phải lúc nào cũng “tỉnh” ngay. Với các bài toán cần phản ứng nhanh với interrupt, hãy dùng cơ chế khác.
Giải phóng tem đúng cách
Mỗi lần gọi readLock(), writeLock() hoặc tryOptimisticRead() trả về một tem duy nhất (long). Bắt buộc phải truyền nó vào phương thức unlock tương ứng:
- unlockRead(stamp)
- unlockWrite(stamp)
Lỗi: Nếu nhầm tem hoặc quên gọi unlock — bạn sẽ bị rò rỉ khóa và chương trình treo.
4. So sánh với ReentrantReadWriteLock
| Đặc tính | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| Reentrancy (tái nhập) | Có | Không |
| Đọc lạc quan | Không | Có |
| Hiệu năng khi cạnh tranh cao | Trung bình | Cao (khi ghi ít) |
| Quản lý khóa tường minh | Không (tự động) | Có (tem) |
| Phản ứng với interrupt | Có | Không phải lúc nào cũng có |
| Tính công bằng (fairness) | Có (có thể bật) | Không |
Chế độ công bằng và ảnh hưởng đến starvation
- Trong ReentrantReadWriteLock có thể bật chế độ “công bằng” (fair) để các luồng được phục vụ theo thứ tự hàng đợi. Điều này ngăn ngừa starvation (đói tài nguyên).
- Trong StampedLock không có tính công bằng: các luồng có thể chờ lâu hơn nếu các luồng khác liên tục “giành” khóa. Có thể xảy ra starvation (dù hiếm).
5. Bộ đếm: LongAdder/LongAccumulator vs AtomicLong
Vấn đề với AtomicLong khi cạnh tranh cao
AtomicLong là biến nguyên tử bảo đảm tăng an toàn luồng. Nhưng khi có nhiều luồng đồng thời gọi incrementAndGet(), tất cả “tranh giành” cùng một biến, dẫn đến hiệu năng giảm.
LongAdder: bộ đếm dạng dải (striped)
LongAdder giải quyết theo cách khác: nó chia bộ đếm thành nhiều “dải” (stripes), mỗi dải phục vụ một nhóm luồng. Mỗi luồng tăng trên một dải, và giá trị cuối cùng là tổng của tất cả các dải.
Ưu điểm:
- Khi cạnh tranh cao, các luồng hầu như không cản trở nhau.
- Hiệu năng cao hơn nhiều lần so với AtomicLong.
import java.util.concurrent.atomic.LongAdder;
LongAdder adder = new LongAdder();
Runnable task = () -> {
for (int i = 0; i < 100_000; i++) {
adder.increment();
}
};
Thread[] threads = new Thread[8];
for (int i = 0; i < threads.length; i++) {
threads[i] = new Thread(task);
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("Giá trị cuối cùng: " + adder.sum());
LongAccumulator
LongAccumulator — phiên bản tổng quát của LongAdder, nơi bạn có thể chỉ định hàm tích lũy tùy ý (ví dụ: tối đa, tối thiểu, v.v.).
import java.util.concurrent.atomic.LongAccumulator;
LongAccumulator max = new LongAccumulator(Long::max, Long.MIN_VALUE);
max.accumulate(10);
max.accumulate(42);
max.accumulate(7);
System.out.println("Tối đa: " + max.get()); // 42
Khóa dạng dải (striped) và giảm cạnh tranh
Kỹ thuật striped locks chia tài nguyên chung thành nhiều phần độc lập (dải), mỗi phần được bảo vệ bằng khóa hoặc biến riêng. Các luồng được phân bố đều lên các dải, giúp giảm cạnh tranh và tăng hiệu năng. Chính cách tiếp cận này được LongAdder và LongAccumulator sử dụng.
6. Thực hành: cache với đọc chiếm ưu thế
Nhiệm vụ: chúng ta có một map (Map) với dữ liệu và metadata (ví dụ, bộ đếm lượt truy cập). Việc đọc diễn ra thường xuyên, ghi — hiếm.
Triển khai với StampedLock:
import java.util.*;
import java.util.concurrent.locks.StampedLock;
import java.util.concurrent.atomic.LongAdder;
public class MetadataCache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final StampedLock lock = new StampedLock();
private final LongAdder hits = new LongAdder();
public V get(K key) {
long stamp = lock.tryOptimisticRead();
V value = map.get(key);
if (!lock.validate(stamp)) {
stamp = lock.readLock();
try {
value = map.get(key);
} finally {
lock.unlockRead(stamp);
}
}
if (value != null) hits.increment();
return value;
}
public void put(K key, V value) {
long stamp = lock.writeLock();
try {
map.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
public long getHits() {
return hits.sum();
}
}
- Đối với đọc dùng chế độ lạc quan: nếu không ai ghi — đọc gần như miễn phí.
- Đối với ghi — write lock.
- Để đếm lượt truy cập — LongAdder: ngay cả khi cạnh tranh cao, các phép tăng không cản trở nhau.
7. Đo hiệu năng LongAdder vs AtomicLong dưới tải
Bài test: 8 luồng, mỗi luồng 1 triệu lần tăng
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;
public class CounterBenchmark {
public static void main(String[] args) throws InterruptedException {
int threads = 8;
int increments = 1_000_000;
// AtomicLong
AtomicLong atomic = new AtomicLong();
long start = System.nanoTime();
Thread[] t1 = new Thread[threads];
for (int i = 0; i < threads; i++) {
t1[i] = new Thread(() -> {
for (int j = 0; j < increments; j++) atomic.incrementAndGet();
});
t1[i].start();
}
for (Thread t : t1) t.join();
long timeAtomic = System.nanoTime() - start;
// LongAdder
LongAdder adder = new LongAdder();
start = System.nanoTime();
Thread[] t2 = new Thread[threads];
for (int i = 0; i < threads; i++) {
t2[i] = new Thread(() -> {
for (int j = 0; j < increments; j++) adder.increment();
});
t2[i].start();
}
for (Thread t : t2) t.join();
long timeAdder = System.nanoTime() - start;
System.out.printf("AtomicLong: %d ms, LongAdder: %d ms%n", timeAtomic / 1_000_000, timeAdder / 1_000_000);
}
}
Kết quả điển hình:
AtomicLong: 2500 ms, LongAdder: 200 ms
Kết luận: Khi cạnh tranh cao, LongAdder nhanh hơn nhiều lần so với AtomicLong.
8. Những lỗi thường gặp khi sử dụng StampedLock và LongAdder
Lỗi số 1: quên gọi unlockRead/unlockWrite. Nếu không giải phóng tem, các luồng khác sẽ chờ mãi. Luôn dùng try/finally!
Lỗi số 2: cố gắng reentrancy. StampedLock không hỗ trợ tái nhập. Đừng lấy khóa hai lần từ cùng một luồng.
Lỗi số 3: dùng validate sai cách. Nếu không kiểm tra validate sau tryOptimisticRead, bạn có thể nhận dữ liệu không nhất quán.
Lỗi số 4: dùng AtomicLong khi cạnh tranh cao. AtomicLong tốt cho 1–2 luồng, nhưng với 8+ luồng nó trở thành “nút thắt cổ chai”. Hãy dùng LongAdder.
Lỗi số 5: quên khóa dạng dải. Nếu bạn tự triển khai striped-lock, hãy bảo đảm các luồng được phân bổ đều lên các dải; nếu không một số dải sẽ quá tải, còn dải khác thì nhàn rỗi.
Lỗi số 6: kỳ vọng tính công bằng từ StampedLock. StampedLock không bảo đảm thứ tự phục vụ các luồng. Hiếm khi nhưng có thể xảy ra starvation.
GO TO FULL VERSION