1. Vì sao i++ không hoạt động trong môi trường đa luồng?
Bắt đầu với một bài toán kinh điển: chúng ta có một biến bộ đếm, ví dụ số yêu cầu đã xử lý hoặc số tệp đã tải xuống. Chúng ta muốn nhiều luồng cùng tăng bộ đếm này. Điều gì có thể xảy ra nếu chỉ đơn giản viết i++?
Ví dụ: race condition khi tăng (increment)
public class Counter {
public int count = 0;
public void increment() {
count++; // Không nguyên tử!
}
}
Hãy hình dung hai luồng đồng thời gọi increment(). Cả hai luồng đọc giá trị cũ, cả hai tăng lên 1, và cả hai ghi… cùng một giá trị mới! Kết quả là một lần tăng bị “mất”. Nếu lặp lại điều này nhiều lần, giá trị cuối cùng sẽ nhỏ hơn mong đợi.
Vì sao lại như vậy?
Thao tác i++ thực ra gồm ba bước:
- Đọc giá trị của biến (ví dụ, 5).
- Tăng giá trị đó thêm 1.
- Ghi giá trị mới trở lại bộ nhớ.
Trong môi trường đa luồng, luồng khác có thể kịp thay đổi biến giữa các bước này. Kết quả — “race condition”.
Thao tác nguyên tử là gì?
Thao tác nguyên tử là hành động hoặc được thực hiện trọn vẹn, hoặc không được thực hiện chút nào; không có luồng nào khác có thể “chen vào” giữa thao tác.
Trong Java có một tập các lớp cung cấp những thao tác như vậy cho kiểu nguyên thủy và tham chiếu. Chúng nằm trong gói java.util.concurrent.atomic. Phổ biến nhất:
- AtomicInteger — kiểu số nguyên nguyên tử.
- AtomicLong — long nguyên tử.
- AtomicBoolean — boolean nguyên tử.
- AtomicReference<T> — tham chiếu nguyên tử đến đối tượng bất kỳ.
2. AtomicInteger: bộ đếm an toàn luồng
Khai báo và sử dụng cơ bản
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Tăng nguyên tử
}
public int get() {
return count.get();
}
}
Ở đây incrementAndGet() thực hiện “tăng và trả về giá trị mới” như một thao tác không thể chia nhỏ. Ngay cả khi 100 luồng gọi phương thức này cùng lúc, sẽ không có lần tăng nào bị mất.
Phương thức hữu ích:
| Phương thức | Mô tả |
|---|---|
|
Lấy giá trị hiện tại |
|
Đặt giá trị |
|
Tăng 1 và trả về giá trị mới |
|
Trả về giá trị hiện tại rồi tăng 1 |
|
Tăng thêm delta và trả về giá trị mới |
|
Nếu giá trị hiện tại bằng expect, đặt thành update (CAS) |
Ví dụ: bộ đếm đa luồng
Giả sử chúng ta có một lớp đếm số tin nhắn đã xử lý trong phòng chat.
public class MessageStatistics {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void onMessageReceived() {
int newCount = messageCount.incrementAndGet();
System.out.println("Tổng số tin nhắn: " + newCount);
}
public int getMessageCount() {
return messageCount.get();
}
}
Bên trong: AtomicInteger hoạt động như thế nào?
Bên trong, AtomicInteger sử dụng một lệnh đặc biệt của bộ xử lý — CAS (Compare-And-Swap, “so sánh‑và‑hoán đổi”). Đây là thao tác nguyên tử so sánh giá trị hiện tại của biến với giá trị kỳ vọng, và nếu trùng khớp — ghi giá trị mới. Nếu luồng khác đã kịp thay đổi biến — thao tác không thực hiện, và sẽ thử lại.
Sơ đồ hoạt động:
1. Đọc giá trị hiện tại (ví dụ, 5)
2. So sánh với giá trị kỳ vọng (5)
3. Nếu trùng khớp — ghi giá trị mới (6)
4. Nếu không — lặp lại thử
Tất cả diễn ra rất nhanh và không dùng khóa (lock‑free). Vì vậy các lớp nguyên tử thường nhanh hơn synchronized, đặc biệt khi có nhiều luồng.
3. AtomicReference: tham chiếu nguyên tử đến đối tượng
AtomicReference<T> là một vùng chứa nguyên tử tổng quát cho bất kỳ đối tượng nào. Nó cho phép thay đổi tham chiếu đến đối tượng một cách an toàn từ nhiều luồng.
Ví dụ: cập nhật tham chiếu an toàn luồng
import java.util.concurrent.atomic.AtomicReference;
public class AtomicReferenceExample {
private final AtomicReference<String> latestMessage = new AtomicReference<>("");
public void updateMessage(String message) {
latestMessage.set(message);
}
public String getLatestMessage() {
return latestMessage.get();
}
}
Sử dụng compareAndSet
Phép toán thú vị nhất — compareAndSet(expected, newValue). Nó cho phép cập nhật giá trị chỉ khi giá trị đó không thay đổi kể từ lần đọc gần nhất.
public void safeUpdate(String oldValue, String newValue) {
boolean success = latestMessage.compareAndSet(oldValue, newValue);
if (success) {
System.out.println("Cập nhật thành công!");
} else {
System.out.println("Ai đó đã thay đổi giá trị, hãy thử lại.");
}
}
Đây là nền tảng của các thuật toán không khóa: từ hàng đợi và ngăn xếp đến cache, nơi cần tránh khóa không cần thiết.
4. Ví dụ sử dụng trong ứng dụng
Ví dụ 1: bộ đếm tin nhắn đa luồng
public class ChatRoom {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void receiveMessage(String message) {
// ... xử lý tin nhắn ...
int count = messageCount.incrementAndGet();
System.out.println("Tin nhắn mới: " + message + ". Tổng số tin nhắn: " + count);
}
}
Ví dụ 2: cập nhật an toàn tham chiếu đến tin nhắn cuối cùng
public class ChatRoom {
private final AtomicReference<String> lastMessage = new AtomicReference<>("");
public void receiveMessage(String message) {
lastMessage.set(message);
// ... xử lý ...
}
public String getLastMessage() {
return lastMessage.get();
}
}
Nếu cần cập nhật tham chiếu chỉ khi tin nhắn cuối cùng không thay đổi (để tránh “mất” khi cập nhật đồng thời), hãy dùng compareAndSet.
5. Giới hạn và những cạm bẫy
Khi nào các lớp nguyên tử không phải là giải pháp vạn năng?
Biến nguyên tử rất phù hợp cho các thao tác đơn giản: tăng, đặt giá trị, kiểm tra và thay thế. Nhưng nếu cần cập nhật nhiều biến cùng lúc, tính nguyên tử không còn được đảm bảo. Ví dụ, nếu bạn có hai bộ đếm và muốn tăng cả hai như một thao tác duy nhất — khi đó cần synchronized hoặc cơ chế đồng bộ khác.
Ví dụ sử dụng sai
// Không nguyên tử!
if (ref.get() == null) {
ref.set("Hello");
}
Giữa get() và set(...) một luồng khác có thể thay đổi giá trị, và điều kiện sẽ không còn đúng. Với các trường hợp như vậy, hãy dùng compareAndSet.
Lớp nguyên tử ≠ đối tượng an toàn luồng
Nếu đối tượng mà AtomicReference trỏ tới bản thân nó không an toàn luồng, thì việc thay thế tham chiếu là nguyên tử, nhưng thay đổi các trường của đối tượng thì không. Ví dụ, nếu bạn lưu trong AtomicReference<List<String>> một ArrayList thông thường, thì chính danh sách đó cũng không trở thành an toàn luồng (thread‑safe).
6. Các lớp nguyên tử nâng cao
Trong gói java.util.concurrent.atomic còn có các lớp hữu ích khác:
- AtomicLong, AtomicBoolean — cho long và boolean.
- AtomicIntegerArray, AtomicReferenceArray — thao tác nguyên tử với mảng.
- LongAdder, LongAccumulator — cho các bộ đếm tải cao.
LongAdder và LongAccumulator
Nếu bạn có rất nhiều luồng và AtomicInteger thông thường trở thành “nút cổ chai” (mọi luồng tranh chấp trên một biến), hãy dùng LongAdder. Nó chia bộ đếm thành nhiều ô nội bộ và cộng chúng lại khi lấy giá trị, giúp tăng tốc khi mức độ cạnh tranh cao.
import java.util.concurrent.atomic.LongAdder;
public class FastCounter {
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCount() {
return adder.sum();
}
}
7. Các lỗi điển hình khi làm việc với biến nguyên tử
Lỗi số 1: Kỳ vọng tính nguyên tử cho các thao tác phức tạp.
Nếu cần thực hiện nhiều hành động trên một giá trị, các lớp nguyên tử sẽ không cứu được — giữa các bước, luồng khác có thể thay đổi dữ liệu. Với thao tác tổng hợp, hãy dùng compareAndSet hoặc đồng bộ hóa.
Lỗi số 2: Bỏ qua tính an toàn luồng của đối tượng lồng bên trong.
Nếu trong AtomicReference là một đối tượng thông thường, các phương thức và trường của nó không tự trở nên an toàn luồng. Chỉ có việc thay thế tham chiếu là nguyên tử.
Lỗi số 3: Dùng các lớp nguyên tử khi không cần thiết.
Trong mã một luồng, các kiểu nguyên tử là thừa và hơi chậm hơn biến thông thường do có kiểm tra bổ sung.
Lỗi số 4: Tối ưu hóa sớm.
Đôi khi đơn giản và đáng tin cậy hơn là dùng synchronized, đặc biệt khi logic phức tạp và đụng đến nhiều biến cùng lúc. Không phải lúc nào giải pháp lock‑free cũng đáng giá.
Lỗi số 5: Quên vấn đề ABA.
Trường hợp hiếm nhưng quan trọng: giá trị đổi từ A sang B rồi lại về A — compareAndSet “nghĩ” rằng không có gì thay đổi. Với các kịch bản như vậy, hãy dùng các lớp chuyên dụng như AtomicStampedReference (hoặc AtomicMarkableReference).
GO TO FULL VERSION