- Phân biệt tiến trình và luồng; hiểu vùng nhớ nào được chia sẻ.
- Tạo luồng bằng
ThreadvàRunnable; nắm vòng đời luồng. - Hiểu race condition — và tự tay tái hiện được nó.
- Dùng
synchronizedđúng cách; hiểu khoá nội tại (intrinsic lock). - Hiểu
volatilegiải quyết vấn đề hiển thị, không phải nguyên tử. - Nhận diện và phòng tránh deadlock.
- Dùng
ExecutorServicethay vì tự quản lýThread. - Dùng
FuturevàCompletableFutuređể kết hợp tác vụ bất đồng bộ. - Chọn đúng collection an toàn luồng và lớp
Atomic. - Hiểu virtual thread (Java 21) và vì sao nó thay đổi cuộc chơi.
1. Tiến trình và luồng
Metaspace — metadata class
→ nguồn của mọi vấn đề đồng thời
Program counter
→ luôn an toàn
| Tiến trình (process) | Luồng (thread) | |
|---|---|---|
| Không gian bộ nhớ | Riêng biệt hoàn toàn | Dùng chung heap |
| Chi phí tạo | Nặng (mili giây) | Nhẹ hơn (micro giây) |
| Giao tiếp | Qua IPC, socket, tệp | Qua biến chung — nhanh nhưng nguy hiểm |
| Một cái sập | Không ảnh hưởng cái khác | Có thể kéo cả JVM sập |
| Bộ nhớ mỗi đơn vị | MB | ~1 MB stack (luồng nền tảng) |
Đồng thời (concurrency) ≠ song song (parallelism)
Đồng thời là cấu trúc chương trình: nhiều tác vụ đang trong tiến trình xử lý cùng lúc, dù có thể chỉ một nhân CPU luân phiên chạy chúng. Mục tiêu: đáp ứng tốt — giao diện không đơ khi đang tải dữ liệu.
Song song là thực thi thật sự đồng thời trên nhiều nhân CPU. Mục tiêu: nhanh hơn.
Câu nói kinh điển của Rob Pike: "Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once." Java hỗ trợ cả hai, nhưng phần lớn code đa luồng thực tế là về đồng thời — chờ I/O, chứ không phải tính toán nặng.
2. Tạo luồng
// ① Kế thừa Thread — ❌ ít dùng, tốn "suất extends" duy nhất
class LuongCuaToi extends Thread {
@Override
public void run() { System.out.println("Chạy trong " + getName()); }
}
new LuongCuaToi().start();
// ② Cài đặt Runnable — ✅ tốt hơn, tách tác vụ khỏi cơ chế chạy
Runnable tacVu = () -> System.out.println("Chạy trong "
+ Thread.currentThread().getName());
new Thread(tacVu, "luong-1").start();
// ⚠️ start() vs run() — lỗi kinh điển của người mới
Thread t = new Thread(tacVu);
t.run(); // ❌ chạy TRONG LUỒNG HIỆN TẠI — chỉ là lời gọi method thường!
t.start(); // ✅ tạo luồng MỚI rồi gọi run() trong đó
// ⚠️ Gọi start() hai lần → IllegalThreadStateException
// --- Các thao tác cơ bản ---
t.setName("worker-1"); // ⭐ đặt tên — rất quan trọng khi gỡ lỗi
t.setDaemon(true); // luồng nền: JVM không đợi nó khi thoát
t.setPriority(Thread.MAX_PRIORITY); // ⚠️ gợi ý thôi, hệ điều hành có thể bỏ qua
t.join(); // CHỜ luồng t kết thúc
t.join(5000); // chờ tối đa 5 giây
t.interrupt(); // YÊU CẦU dừng (không ép buộc)
Thread.sleep(1000); // tạm dừng luồng HIỆN TẠI
// ❌ ĐÃ BỊ LOẠI BỎ — không bao giờ dùng
// t.stop(); — dừng đột ngột, để lại trạng thái hỏng
// t.suspend(); — dễ gây deadlock
// t.resume();
| Trạng thái | Nghĩa là | Chuyển sang |
|---|---|---|
NEW | Đã tạo, chưa start() | → RUNNABLE |
RUNNABLE | Đang chạy hoặc sẵn sàng chạy | → mọi trạng thái khác |
BLOCKED | Đang chờ khoá synchronized | → RUNNABLE |
WAITING | wait(), join() không hạn | → RUNNABLE |
TIMED_WAITING | sleep(n), wait(n) | → RUNNABLE |
TERMINATED | Đã kết thúc | — (không quay lại được) |
3. Race condition — vấn đề gốc rễ
class BoDem {
private int dem = 0;
public void tang() { dem++; } // ⚠️ trông như MỘT thao tác...
public int get() { return dem; }
}
BoDem bd = new BoDem();
List<Thread> luong = new ArrayList<>();
for (int i = 0; i < 10; i++) {
Thread t = new Thread(() -> {
for (int j = 0; j < 10_000; j++) bd.tang();
});
luong.add(t);
t.start();
}
for (Thread t : luong) t.join();
System.out.println(bd.get());
// Kỳ vọng: 100000
// Thực tế: 87342, 91205, 100000, 76891... MỖI LẦN CHẠY MỘT KHÁC ⚠️
Vì sao dem++ không nguyên tử
Nhìn thì là một dòng, nhưng bytecode cho thấy nó là ba thao tác riêng biệt:
getfield dem // ① ĐỌC giá trị hiện tại vào ngăn xếp
iconst_1
iadd // ② CỘNG 1
putfield dem // ③ GHI ngược lại vào trường
Hai luồng có thể xen kẽ nhau ở bất kỳ đâu giữa ba bước:
Luồng A: đọc dem = 5
Luồng B: đọc dem = 5 // cùng đọc giá trị cũ!
Luồng A: cộng → 6
Luồng B: cộng → 6
Luồng A: ghi dem = 6
Luồng B: ghi dem = 6 // 💥 hai lần tăng nhưng chỉ tăng được 1
Hiện tượng này gọi là lost update. Nó xảy ra ngẫu nhiên — có thể chạy đúng hàng nghìn lần rồi sai một lần, và thường chỉ sai dưới tải cao trên production.
4. synchronized
// ① Method đồng bộ — khoá trên chính object (this)
class BoDem {
private int dem = 0;
public synchronized void tang() { dem++; } // ✅ an toàn
public synchronized int get() { return dem; } // ⚠️ CŨNG cần synchronized!
}
// ② Method static đồng bộ — khoá trên Class object
public static synchronized void f() { }
// tương đương: synchronized (BoDem.class) { }
// ③ Khối đồng bộ — ⭐ ưu tiên: phạm vi khoá HẸP hơn
class BoDem2 {
private int dem = 0;
private final Object khoa = new Object(); // khoá RIÊNG
public void tang() {
chuanBiTonThoiGian(); // ✅ không khoá phần này
synchronized (khoa) {
dem++; // chỉ khoá đúng phần cần
}
donDep(); // ✅ không khoá
}
}
Nhiều người chỉ đồng bộ method ghi và quên method đọc. Nhưng synchronized giải quyết
hai vấn đề, và getter cần vấn đề thứ hai:
- Loại trừ lẫn nhau — chỉ một luồng vào khối cùng lúc.
- Hiển thị (visibility) — đảm bảo thay đổi của luồng này được luồng khác nhìn thấy.
Không có điểm thứ hai, luồng đọc có thể thấy giá trị cũ mãi mãi — vì JIT được phép cache biến
vào thanh ghi CPU. Xem mục volatile.
// ❌ Khoá trên `this` — code bên ngoài cũng khoá được, gây deadlock ngoài ý muốn
public synchronized void f() { }
// Người dùng viết: synchronized (myObject) { ... } → chặn cả method của bạn
// ❌ Khoá trên String — String pool khiến hai chỗ khác nhau dùng CHUNG khoá!
private final String khoa = "KHOA";
synchronized (khoa) { }
// ❌ Khoá trên wrapper — Integer cache khiến hai giá trị nhỏ dùng chung khoá
private Integer khoa2 = 1;
// ❌ Khoá trên object THAY ĐỔI được — gán lại là mất khoá
private Object khoa3 = new Object(); // thiếu final
// ✅ ĐÚNG: object riêng, private, final
private final Object khoa4 = new Object();
5. volatile — vấn đề hiển thị
// ❌ Không có volatile — vòng lặp có thể chạy MÃI MÃI
class Worker implements Runnable {
private boolean chay = true;
public void run() {
while (chay) { } // JIT có thể tối ưu thành while(true)!
System.out.println("Dừng");
}
public void dung() { chay = false; }
}
// Luồng chính gọi dung(), nhưng luồng worker có thể KHÔNG BAO GIỜ thấy —
// vì nó đã cache `chay` vào thanh ghi CPU.
// ✅ volatile: buộc đọc/ghi thẳng từ bộ nhớ chính
private volatile boolean chay = true;
volatile | synchronized | Atomic* | |
|---|---|---|---|
| Đảm bảo hiển thị | ✅ | ✅ | ✅ |
| Đảm bảo nguyên tử | ❌ Không | ✅ | ✅ (cho thao tác đơn) |
| Chặn luồng | Không | Có | Không (dùng CAS) |
| Chi phí | Thấp | Cao hơn | Thấp |
| Dùng khi | Cờ boolean, tham chiếu chỉ đọc-ghi đơn | Nhiều thao tác cần nguyên tử cùng nhau | Bộ đếm, cập nhật một biến |
volatile KHÔNG làm i++ an toànprivate volatile int dem = 0;
public void tang() { dem++; } // ❌ VẪN race condition!
// volatile chỉ đảm bảo mỗi lần ĐỌC và mỗi lần GHI đều thấy giá trị mới nhất.
// Nhưng dem++ vẫn là ĐỌC → CỘNG → GHI, và hai luồng vẫn xen kẽ được.
// ✅ Ba cách sửa
private final AtomicInteger dem1 = new AtomicInteger();
dem1.incrementAndGet(); // ⭐ nhanh nhất, dùng CAS
private int dem2 = 0;
public synchronized void tang2() { dem2++; }
private final LongAdder dem3 = new LongAdder();
dem3.increment(); // nhanh nhất khi RẤT nhiều luồng ghi
Cách nhớ: volatile = "luôn thấy giá trị mới nhất", không phải "thao tác không bị
cắt ngang".
6. Deadlock
final Object khoaA = new Object();
final Object khoaB = new Object();
new Thread(() -> {
synchronized (khoaA) {
Thread.sleep(100);
synchronized (khoaB) { ... } // chờ B
}
}).start();
new Thread(() -> {
synchronized (khoaB) { // ⚠️ thứ tự NGƯỢC LẠI
Thread.sleep(100);
synchronized (khoaA) { ... } // chờ A
}
}).start();
// 💥 DEADLOCK: luồng 1 giữ A chờ B, luồng 2 giữ B chờ A → treo vĩnh viễn
| Bốn điều kiện Coffman (deadlock cần ĐỦ cả bốn) | Cách phá vỡ |
|---|---|
| Loại trừ lẫn nhau — tài nguyên chỉ một luồng dùng | Dùng cấu trúc bất biến hoặc không khoá |
| Giữ và chờ — giữ khoá này, chờ khoá kia | Lấy tất cả khoá một lần, hoặc nhả hết rồi thử lại |
| Không cưỡng đoạt — không cướp khoá của luồng khác | tryLock(timeout) để tự bỏ cuộc |
| Chờ vòng tròn — A chờ B, B chờ A | ⭐ Luôn lấy khoá theo cùng một thứ tự |
// ✅ CÁCH 1: thứ tự khoá NHẤT QUÁN — dùng một tiêu chí ổn định
void chuyenTien(TaiKhoan tu, TaiKhoan den, long tien) {
TaiKhoan truoc = tu.getId() < den.getId() ? tu : den;
TaiKhoan sau = tu.getId() < den.getId() ? den : tu;
synchronized (truoc) { // LUÔN khoá id nhỏ trước
synchronized (sau) {
tu.rut(tien);
den.nap(tien);
}
}
}
// ✅ CÁCH 2: tryLock có timeout — tự bỏ cuộc thay vì treo
ReentrantLock lockA = new ReentrantLock();
ReentrantLock lockB = new ReentrantLock();
if (lockA.tryLock(1, TimeUnit.SECONDS)) {
try {
if (lockB.tryLock(1, TimeUnit.SECONDS)) {
try { ... } finally { lockB.unlock(); }
}
} finally { lockA.unlock(); } // ⚠️ unlock PHẢI trong finally
}
// ✅ CÁCH 3 (tốt nhất): TRÁNH KHOÁ HOÀN TOÀN
// Dùng collection an toàn luồng, Atomic, hoặc object bất biến.
// --- Chẩn đoán deadlock đã xảy ra ---
// $ jps → tìm PID
// $ jstack <pid> → tìm mục "Found one Java-level deadlock"
// Hoặc dùng jconsole / VisualVM (nút "Detect Deadlock")
7. ExecutorService — đừng tự quản lý luồng
import java.util.concurrent.*;
// ❌ Tự tạo luồng cho mỗi tác vụ — tốn kém và không kiểm soát được
for (int i = 0; i < 10_000; i++) {
new Thread(() -> xuLy()).start(); // 10.000 luồng × 1 MB = 10 GB!
}
// ✅ Dùng pool: tái sử dụng luồng, giới hạn số lượng
try (ExecutorService pool = Executors.newFixedThreadPool(4)) { // Java 19+: AutoCloseable
for (int i = 0; i < 10_000; i++) {
pool.submit(() -> xuLy());
}
} // tự động shutdown và chờ xong
// Cách cũ (trước Java 19)
ExecutorService pool = Executors.newFixedThreadPool(4);
try {
pool.submit(() -> xuLy());
} finally {
pool.shutdown(); // không nhận tác vụ mới
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow(); // ngắt các tác vụ còn chạy
}
}
| Factory | Số luồng | Dùng khi | Cảnh báo |
|---|---|---|---|
newFixedThreadPool(n) | Cố định n | Tác vụ nặng CPU (n ≈ số nhân) | Hàng đợi không giới hạn → có thể hết bộ nhớ |
newCachedThreadPool() | Tự co giãn | Nhiều tác vụ ngắn | ⚠️ Không giới hạn — có thể tạo hàng nghìn luồng |
newSingleThreadExecutor() | 1 | Cần thứ tự tuần tự | |
newScheduledThreadPool(n) | Cố định n | Chạy định kỳ hoặc có độ trễ | |
newWorkStealingPool() | = số nhân | Tác vụ chia nhỏ đệ quy | Dựa trên ForkJoinPool |
newVirtualThreadPerTaskExecutor() ⭐ | Không giới hạn | Tác vụ chờ I/O (Java 21) | Không dùng cho tác vụ nặng CPU |
// ⭐ Sản xuất thật nên tự cấu hình, không dùng Executors.* trần
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, // số luồng lõi
10, // số luồng tối đa
60L, TimeUnit.SECONDS, // luồng thừa sống bao lâu
new ArrayBlockingQueue<>(100), // ⭐ hàng đợi CÓ GIỚI HẠN
new ThreadPoolExecutor.CallerRunsPolicy() // đầy thì luồng gọi tự chạy
);
// Hàng đợi có giới hạn + chính sách từ chối = áp lực ngược (backpressure),
// bảo vệ hệ thống khỏi sập vì quá tải.
8. Callable, Future và CompletableFuture
// Runnable: không trả gì, không ném checked exception
Runnable r = () -> System.out.println("xong");
// Callable<V>: TRẢ VỀ giá trị và NÉM ĐƯỢC checked exception
Callable<Integer> c = () -> {
Thread.sleep(1000);
return 42;
};
try (ExecutorService pool = Executors.newFixedThreadPool(2)) {
Future<Integer> f = pool.submit(c);
lamViecKhac(); // chạy song song
int kq = f.get(); // ⚠️ CHẶN cho tới khi xong
int kq2 = f.get(5, TimeUnit.SECONDS); // có timeout
f.isDone();
f.cancel(true); // true = ngắt luồng nếu đang chạy
// Chạy nhiều tác vụ
List<Future<Integer>> tatCa = pool.invokeAll(List.of(c, c, c)); // chờ TẤT CẢ
Integer dauTien = pool.invokeAny(List.of(c, c, c)); // lấy cái xong ĐẦU TIÊN
}
// ⚠️ Hạn chế lớn của Future: get() CHẶN. Không kết hợp được nhiều Future
// mà không chặn. Đó là lý do CompletableFuture ra đời.
// --- Tạo ---
CompletableFuture<String> f1 = CompletableFuture
.supplyAsync(() -> goiApi("/nguoi-dung")); // có kết quả
CompletableFuture<Void> f2 = CompletableFuture
.runAsync(() -> ghiLog()); // không kết quả
// --- ⭐ Nối chuỗi KHÔNG CHẶN ---
CompletableFuture<String> kq = CompletableFuture
.supplyAsync(() -> layNguoiDung(id)) // bước 1
.thenApply(NguoiDung::getEmail) // biến đổi (đồng bộ)
.thenApplyAsync(String::toLowerCase) // biến đổi (luồng khác)
.thenCompose(e -> kiemTraEmailAsync(e)) // nối CF khác (như flatMap)
.exceptionally(ex -> { // xử lý lỗi
log.error("Thất bại", ex);
return "mac-dinh@example.com";
});
// --- Kết hợp nhiều Future ---
CompletableFuture<String> a = CompletableFuture.supplyAsync(() -> layTen());
CompletableFuture<Integer> b = CompletableFuture.supplyAsync(() -> layTuoi());
// Chờ CẢ HAI rồi gộp
a.thenCombine(b, (ten, tuoi) -> ten + " (" + tuoi + ")");
// Chờ TẤT CẢ
CompletableFuture.allOf(a, b).join();
// Lấy cái nào xong TRƯỚC
CompletableFuture.anyOf(a, b).join();
// --- Gọi 100 API song song rồi gom kết quả ---
List<CompletableFuture<String>> ds = urls.stream()
.map(u -> CompletableFuture.supplyAsync(() -> tai(u)))
.toList();
CompletableFuture.allOf(ds.toArray(CompletableFuture[]::new))
.thenApply(v -> ds.stream().map(CompletableFuture::join).toList())
.thenAccept(System.out::println);
9. Collection an toàn luồng và Atomic
| Không an toàn | An toàn luồng ⭐ | Cơ chế |
|---|---|---|
HashMap | ConcurrentHashMap | Khoá theo từng bucket + CAS |
ArrayList | CopyOnWriteArrayList | Chép mảng khi ghi — hợp khi đọc nhiều ghi ít |
HashSet | ConcurrentHashMap.newKeySet() | Như ConcurrentHashMap |
ArrayDeque | ConcurrentLinkedDeque | Không khoá (lock-free) |
| Hàng đợi | LinkedBlockingQueue, ArrayBlockingQueue | Chặn khi rỗng/đầy — nền của mẫu producer-consumer |
TreeMap | ConcurrentSkipListMap | Skip list |
int, long | AtomicInteger, AtomicLong | CAS |
| Bộ đếm tải cao | LongAdder | Cộng phân tán — nhanh hơn AtomicLong khi nhiều luồng |
// --- ConcurrentHashMap ---
ConcurrentHashMap<String, Integer> m = new ConcurrentHashMap<>();
// ⚠️ Từng thao tác an toàn, nhưng GHÉP hai thao tác thì KHÔNG
if (!m.containsKey(k)) m.put(k, 1); // ❌ hai luồng có thể cùng vào
// ✅ Dùng thao tác nguyên tử có sẵn
m.putIfAbsent(k, 1);
m.merge(k, 1, Integer::sum); // đếm an toàn
m.computeIfAbsent(k, x -> tinhToanTonKem(x)); // chỉ một luồng tính
// --- Atomic: CAS, không chặn luồng ---
AtomicInteger dem = new AtomicInteger();
dem.incrementAndGet();
dem.addAndGet(5);
dem.compareAndSet(5, 10); // nếu đang là 5 thì đặt thành 10
dem.updateAndGet(x -> x * 2);
AtomicReference<String> ref = new AtomicReference<>("a");
ref.compareAndSet("a", "b");
// LongAdder: nhanh hơn AtomicLong khi RẤT nhiều luồng cùng ghi
LongAdder tong = new LongAdder();
tong.increment();
tong.sum();
// --- BlockingQueue: mẫu producer-consumer ---
BlockingQueue<String> q = new LinkedBlockingQueue<>(100);
new Thread(() -> { // producer
q.put("việc"); // CHẶN nếu hàng đợi đầy → áp lực ngược
}).start();
new Thread(() -> { // consumer
while (true) {
String v = q.take(); // CHẶN nếu rỗng — không cần vòng lặp bận
xuLy(v);
}
}).start();
CAS — vì sao Atomic nhanh hơn synchronized
Compare-And-Swap là một lệnh CPU nguyên tử: "nếu giá trị ở địa chỉ này đang là X thì
đặt thành Y, ngược lại thì báo thất bại". AtomicInteger.incrementAndGet() chạy vòng lặp:
đọc giá trị, tính giá trị mới, thử CAS — thất bại thì thử lại.
Ưu điểm: không chặn luồng. Với synchronized, luồng thua cuộc bị hệ điều
hành đưa vào trạng thái ngủ và phải đánh thức lại — tốn hàng nghìn chu kỳ CPU. Với CAS, nó chỉ thử lại
ngay.
Nhược điểm: khi rất nhiều luồng tranh chấp, số lần thử lại tăng vọt. Lúc đó
LongAdder tốt hơn — nó chia bộ đếm thành nhiều ô riêng để giảm tranh chấp, chỉ cộng lại khi
gọi sum().
10. Virtual thread — thay đổi lớn nhất của Java 21
| Luồng nền tảng (platform thread) | Virtual thread ⭐ | |
|---|---|---|
| Ánh xạ | 1:1 với luồng hệ điều hành | N:1 — nhiều virtual trên một luồng OS |
| Bộ nhớ | ~1 MB stack | Vài trăm byte, co giãn theo nhu cầu |
| Số lượng khả thi | Vài nghìn | Hàng triệu |
| Chi phí tạo | ~1 ms | ~1 µs |
| Chuyển ngữ cảnh | Do hệ điều hành (đắt) | Do JVM (rẻ) |
| Khi chặn I/O | Luồng OS bị chiếm | JVM tháo khỏi luồng OS ⭐ |
| Hợp với | Tính toán nặng CPU | Chờ I/O — mạng, database, tệp |
// Tạo một virtual thread
Thread vt = Thread.ofVirtual().name("vt-1").start(() -> {
System.out.println(Thread.currentThread());
});
vt.join();
// ⭐ Executor: MỖI TÁC VỤ MỘT VIRTUAL THREAD
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) { // MỘT TRIỆU luồng!
pool.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // giả lập chờ I/O
return null;
});
}
}
// Chạy tốt trên máy thường. Với luồng nền tảng thì OutOfMemoryError ngay.
// --- Vì sao quan trọng: mô hình lập trình đơn giản trở lại ---
// ❌ Trước Java 21: muốn xử lý nhiều kết nối phải viết bất đồng bộ
CompletableFuture.supplyAsync(() -> layNguoiDung(id))
.thenCompose(u -> layDonHangAsync(u))
.thenApply(this::tinhTong)
.exceptionally(e -> 0L);
// Khó đọc, khó gỡ lỗi, stack trace vô nghĩa
// ✅ Với virtual thread: viết ĐỒNG BỘ nhưng vẫn mở rộng được
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
pool.submit(() -> {
var u = layNguoiDung(id); // chặn — nhưng chỉ chặn virtual thread
var dh = layDonHang(u); // luồng OS được giải phóng để làm việc khác
return tinhTong(dh);
});
}
// Dễ đọc, dễ gỡ lỗi, stack trace đầy đủ — mà vẫn xử lý được hàng triệu kết nối
// --- StructuredTaskScope (Java 21 xem trước): quản lý nhóm tác vụ ---
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var nd = scope.fork(() -> layNguoiDung(id));
var don = scope.fork(() -> layDonHang(id));
scope.join(); // chờ cả hai
scope.throwIfFailed(); // một cái lỗi → huỷ cái kia, ném lỗi ra
return new KetQua(nd.get(), don.get());
}
-
Không dùng pool. Virtual thread rẻ tới mức tạo mới luôn rẻ hơn tái sử dụng. Dùng
newVirtualThreadPerTaskExecutor(), đừng dùngnewFixedThreadPool. - Không hợp với tác vụ nặng CPU. Chúng chỉ có lợi khi luồng chờ. Tính toán thuần thì số luồng hữu ích vẫn bằng số nhân CPU.
-
Cẩn thận với
synchronized. Ở Java 21, khốisynchronizedcó thể ghim (pin) virtual thread vào luồng OS, triệt tiêu lợi ích. DùngReentrantLockthay thế trong code chạy trên virtual thread. (Java 24 đã khắc phục phần lớn vấn đề này.)
11. Cheat sheet chương 12
| Tình huống | Dùng |
|---|---|
| Chạy tác vụ nền | ExecutorService — không tự new Thread() |
| Nhiều tác vụ chờ I/O | Executors.newVirtualThreadPerTaskExecutor() (Java 21) |
| Tác vụ nặng CPU | newFixedThreadPool(số nhân) |
| Cần kết quả trả về | Callable + Future |
| Kết hợp nhiều tác vụ bất đồng bộ | CompletableFuture |
| Bộ đếm | AtomicInteger; tải rất cao thì LongAdder |
| Cờ dừng luồng | volatile boolean |
| Map dùng chung | ConcurrentHashMap |
| List đọc nhiều ghi ít | CopyOnWriteArrayList |
| Producer-consumer | BlockingQueue có giới hạn |
| Nhiều thao tác cần nguyên tử cùng nhau | synchronized trên object private final |
| Cần timeout khi lấy khoá | ReentrantLock.tryLock(t, unit) + unlock trong finally |
| Tránh deadlock | Luôn lấy khoá theo cùng một thứ tự |
| Chẩn đoán treo | jstack <pid> |
Bắt InterruptedException | Luôn Thread.currentThread().interrupt() |
| An toàn nhất | Bất biến — record, List.of(), không trạng thái chung |
Atomic,
ConcurrentHashMap); (4) chỉ khi hết cách mới tự khoá bằng
synchronized/Lock.
Bài tập
Tái hiện và sửa race condition
Viết chương trình 10 luồng, mỗi luồng tăng bộ đếm 100.000 lần. Chạy với năm cài đặt và ghi lại kết quả cùng thời gian:
int demthường.volatile int dem.synchronizedmethod.AtomicInteger.LongAdder.
Trả lời: cách nào cho kết quả sai? Vì sao (2) vẫn sai dù có volatile? Xếp hạng bốn cách đúng theo tốc độ và giải thích thứ hạng.
Gợi ý
(1) và (2) đều sai. volatile chỉ đảm bảo hiển thị (luôn thấy giá trị mới
nhất), không đảm bảo nguyên tử — dem++ vẫn là ba thao tác đọc-cộng-ghi.
Thứ hạng tốc độ thường là: LongAdder > AtomicInteger >
synchronized. LongAdder thắng khi tranh chấp cao vì nó chia bộ đếm
thành nhiều ô riêng; AtomicInteger dùng CAS không chặn luồng;
synchronized chậm nhất vì luồng thua cuộc bị hệ điều hành ru ngủ.
Tạo và gỡ deadlock
Mô phỏng hệ thống chuyển tiền ngân hàng:
- Viết
chuyenTien(TaiKhoan tu, TaiKhoan den, long tien)khoá lần lượtturồiden. - Chạy 100 luồng chuyển tiền ngẫu nhiên giữa 10 tài khoản. Quan sát chương trình treo.
- Dùng
jstackchụp lại phần báo cáo deadlock, chép vào bài làm. - Sửa bằng thứ tự khoá nhất quán (theo id tài khoản). Chạy lại 1.000 lần để xác nhận.
- Sửa bằng
ReentrantLock.tryLockcó timeout — thất bại thì thử lại sau một khoảng ngẫu nhiên. - So sánh hai cách: cách nào nhanh hơn? cách nào dễ hiểu hơn? cách nào an toàn hơn khi hệ thống phức tạp lên?
Bổ sung: ở mọi phiên bản, khẳng định tổng tiền toàn hệ thống không đổi sau khi chạy.
Gợi ý
Bẫy tinh vi ở bước 4: nếu hai tài khoản có cùng id (chuyển cho chính mình), khoá hai
lần trên cùng object là được (synchronized có tính vào lại — reentrant), nhưng
logic nghiệp vụ nên chặn từ đầu.
Bước 6: tryLock tránh được deadlock nhưng có thể gây livelock — hai luồng
liên tục nhường nhau mà không ai tiến. Thêm khoảng chờ ngẫu nhiên để phá vỡ.
Trình tải xuống song song
Viết công cụ tải nhiều URL song song với đầy đủ tính năng sản xuất:
- Giới hạn số kết nối đồng thời (dùng
Semaphorehoặc pool cố định). - Timeout cho mỗi lượt tải; thử lại tối đa 3 lần với backoff luỹ thừa.
- Báo tiến độ theo thời gian thực: đã xong / đang tải / thất bại — dùng
AtomicInteger. - Gom kết quả vào
ConcurrentHashMap<String, KetQua>. - Hỗ trợ huỷ: bấm Ctrl+C thì dừng êm đẹp (shutdown hook +
shutdownNow). - Viết hai phiên bản: một dùng
CompletableFuture, một dùng virtual thread. So sánh số dòng code và độ dễ đọc.
Gợi ý
Phiên bản virtual thread sẽ ngắn hơn đáng kể vì bạn viết code đồng bộ tuần tự — đó
chính là điểm bán hàng chính của tính năng này.
Shutdown hook: Runtime.getRuntime().addShutdownHook(new Thread(pool::shutdownNow)).
Đừng quên khôi phục cờ ngắt khi bắt InterruptedException trong vòng lặp thử lại —
nếu không, shutdownNow sẽ không dừng được luồng.
Producer-Consumer với BlockingQueue
Mô phỏng dây chuyền xử lý đơn hàng ba tầng:
3 Producer → [hàng đợi 1] → 5 Xử lý → [hàng đợi 2] → 2 Ghi
(sinh đơn) sức chứa 100 (tính toán) sức chứa 50 (lưu DB)
- Dùng
LinkedBlockingQueuecó giới hạn để tạo áp lực ngược. - Dùng "viên thuốc độc" (poison pill) để báo hiệu kết thúc cho mọi tầng.
- Thống kê: thông lượng (đơn/giây), độ dài hàng đợi theo thời gian, thời gian chờ trung bình.
- Thử nghiệm: nếu tầng "Ghi" chậm hơn hẳn, chuyện gì xảy ra với hàng đợi và với Producer?
- Thử với hàng đợi không giới hạn — quan sát bộ nhớ. Kết luận gì?
Gợi ý
Áp lực ngược là bài học chính: hàng đợi có giới hạn khiến Producer tự chậm lại khi hạ
nguồn không theo kịp. Với hàng đợi không giới hạn, Producer chạy hết tốc lực và hàng đợi phình
tới khi hết heap — hệ thống sập thay vì chạy chậm. Đây là lý do newFixedThreadPool
của JDK (dùng hàng đợi không giới hạn) bị coi là nguy hiểm cho production.
Poison pill: một object sentinel đặc biệt; mỗi consumer nhận được thì đưa lại vào hàng đợi cho
consumer khác rồi tự thoát.
Virtual thread vs luồng nền tảng
Benchmark hai loại luồng với tác vụ Thread.sleep(100) (giả lập chờ I/O):
- 1.000 tác vụ — luồng nền tảng vs virtual.
- 10.000 tác vụ.
- 100.000 tác vụ.
- 1.000.000 tác vụ.
Với mỗi mức, ghi lại: thời gian hoàn thành, bộ nhớ đỉnh, và có chạy nổi không. Sau đó lặp lại với tác vụ nặng CPU (tính số nguyên tố) thay vì sleep.
- Ở mức nào luồng nền tảng bắt đầu thất bại?
- Với tác vụ nặng CPU, virtual thread có nhanh hơn không? Vì sao?
- Thử đặt một khối
synchronizedtrong tác vụ virtual thread — hiệu năng đổi thế nào? (hiện tượng "pinning")
Gợi ý
Luồng nền tảng thường sập ở khoảng vài nghìn tới vài chục nghìn (tuỳ RAM) với
OutOfMemoryError: unable to create native thread. Virtual thread chạy được một
triệu trên máy tính xách tay bình thường.
Với tác vụ nặng CPU, virtual thread không nhanh hơn — số luồng hữu ích vẫn bằng số
nhân. Đây là hiểu lầm phổ biến nhất về tính năng này.
Với JVM 21, chạy kèm -Djdk.tracePinnedThreads=full để thấy chính xác chỗ nào bị
ghim.
Quiz
t.start() và t.run() khác nhau thế nào?
Xem đáp án
start() yêu cầu JVM tạo một luồng hệ điều hành mới, rồi gọi
run() trong luồng đó. Code chạy song song với luồng gọi.
run() chỉ là một lời gọi method thông thường — code chạy
trong luồng hiện tại, tuần tự. Không có luồng mới nào được tạo.
Runnable r = () -> System.out.println(Thread.currentThread().getName());
new Thread(r).run(); // "main" ❌
new Thread(r).start(); // "Thread-0" ✅
Đây là lỗi kinh điển của người mới, và nó im lặng — chương trình vẫn chạy, chỉ là không hề
đa luồng.
Lưu ý thêm: gọi start() hai lần trên cùng một Thread ném
IllegalThreadStateException — luồng đã kết thúc không khởi động lại được.
Vì sao dem++ không an toàn với đa luồng?
Xem đáp án
Vì nó không phải một thao tác mà là ba — kiểm chứng bằng javap -c:
getfield dem // ① ĐỌC
iconst_1
iadd // ② CỘNG
putfield dem // ③ GHI
Hai luồng có thể xen kẽ ở bất kỳ đâu giữa ba bước:
A đọc 5 · B đọc 5 · A cộng→6 · B cộng→6 · A ghi 6 · B ghi 6
// Hai lần tăng nhưng kết quả chỉ tăng 1 — gọi là "lost update"
Ba cách sửa: AtomicInteger.incrementAndGet() (nhanh nhất, dùng CAS),
synchronized, hoặc LongAdder khi tranh chấp rất cao.
Lưu ý: volatile không sửa được vấn đề này — nó chỉ đảm bảo
hiển thị, không đảm bảo nguyên tử.
volatile và synchronized khác nhau thế nào?
Xem đáp án
Có hai vấn đề trong lập trình đồng thời, và hai từ khoá này giải quyết khác nhau:
- Hiển thị (visibility) — thay đổi của luồng A có được luồng B nhìn thấy không? CPU cache và JIT có thể khiến B thấy giá trị cũ mãi mãi.
- Nguyên tử (atomicity) — chuỗi thao tác có bị luồng khác cắt ngang không?
volatile | synchronized | |
|---|---|---|
| Hiển thị | ✅ | ✅ |
| Nguyên tử | ❌ | ✅ |
| Chặn luồng | Không | Có |
| Chi phí | Thấp | Cao hơn |
Dùng volatile cho cờ boolean hoặc tham chiếu chỉ đọc-ghi đơn giản:
volatile boolean chay = true;.
Dùng synchronized khi nhiều thao tác phải nguyên tử cùng nhau, hoặc
khi cần i++.
Điểm hay quên: synchronized phải đặt trên cả getter lẫn setter. Chỉ đồng bộ
setter thì getter vẫn có thể đọc giá trị cũ.
Deadlock là gì? Cách phòng tránh đơn giản nhất?
Xem đáp án
Deadlock là tình trạng hai (hoặc nhiều) luồng chờ nhau vĩnh viễn: A giữ khoá 1 và chờ khoá 2, trong khi B giữ khoá 2 và chờ khoá 1. Không ai nhả, không ai tiến.
Nó cần đủ bốn điều kiện Coffman: loại trừ lẫn nhau, giữ-và-chờ, không cưỡng đoạt, và chờ vòng tròn. Phá vỡ bất kỳ điều kiện nào là hết deadlock.
Cách đơn giản nhất — phá "chờ vòng tròn": luôn lấy khoá theo cùng một thứ tự, dựa trên một tiêu chí ổn định:
TaiKhoan truoc = tu.getId() < den.getId() ? tu : den;
TaiKhoan sau = tu.getId() < den.getId() ? den : tu;
synchronized (truoc) { synchronized (sau) { ... } }
Cách khác: ReentrantLock.tryLock(timeout) để tự bỏ cuộc thay vì treo. Hoặc tốt nhất —
tránh khoá hoàn toàn bằng cấu trúc an toàn luồng và dữ liệu bất biến.
Chẩn đoán khi đã treo: jstack <pid>, tìm mục
"Found one Java-level deadlock".
Vì sao dùng ExecutorService thay vì tự tạo Thread?
Xem đáp án
Năm lý do:
- Tái sử dụng luồng — tạo luồng nền tảng tốn khoảng 1 ms và 1 MB bộ nhớ. Pool tạo một lần rồi dùng lại.
- Giới hạn số lượng —
new Thread()cho 10.000 tác vụ nghĩa là 10.000 luồng × 1 MB = 10 GB, vàOutOfMemoryError. - Có hàng đợi — tác vụ vượt quá được xếp hàng thay vì làm sập hệ thống.
- Vòng đời rõ ràng —
shutdown(),awaitTermination(),shutdownNow(). - Có kết quả và xử lý lỗi —
Future/Callabletrả về giá trị và truyền được exception ra ngoài. VớiThreadtrần, exception trong luồng con biến mất lặng lẽ.
Lưu ý cho production: đừng dùng Executors.newFixedThreadPool() trần —
nó dùng hàng đợi không giới hạn, nên khi quá tải hàng đợi phình tới hết heap. Tự cấu hình
ThreadPoolExecutor với ArrayBlockingQueue có giới hạn và một chính sách
từ chối.
Từ Java 21, với tác vụ chờ I/O thì newVirtualThreadPerTaskExecutor() là lựa chọn tốt
nhất.
ConcurrentHashMap hơn Hashtable và synchronizedMap ở đâu?
Xem đáp án
Ở độ mịn của khoá. Hashtable và
Collections.synchronizedMap() khoá toàn bộ map cho mỗi thao tác — hai luồng
đọc hai khoá hoàn toàn khác nhau vẫn phải xếp hàng chờ nhau.
ConcurrentHashMap khoá theo từng bucket (và từ Java 8 chủ yếu dùng CAS không
khoá cho trường hợp không tranh chấp). Nhiều luồng thao tác trên các khoá khác nhau chạy
song song thật sự — nhanh hơn hàng chục lần khi tải cao.
Ngoài ra: đọc không bao giờ bị chặn, và iterator không ném
ConcurrentModificationException (nó phản ánh trạng thái tại một thời điểm nào đó, gọi
là "weakly consistent").
// ⚠️ Từng thao tác nguyên tử, nhưng GHÉP hai thao tác thì KHÔNG
if (!m.containsKey(k)) m.put(k, v); // ❌ race condition
// ✅ Dùng thao tác nguyên tử có sẵn
m.putIfAbsent(k, v);
m.merge(k, 1, Integer::sum);
m.computeIfAbsent(k, this::tinh);
Virtual thread khác luồng nền tảng thế nào? Khi nào nên dùng?
Xem đáp án
Luồng nền tảng ánh xạ 1:1 với luồng hệ điều hành: ~1 MB stack, tạo mất ~1 ms, chỉ chạy được vài nghìn cái.
Virtual thread do JVM quản lý, ánh xạ N:1 lên một nhóm nhỏ luồng OS. Chỉ vài trăm byte, tạo mất ~1 µs, chạy được hàng triệu. Điểm mấu chốt: khi virtual thread chặn vì I/O, JVM tháo nó khỏi luồng OS để luồng đó phục vụ virtual thread khác.
Ý nghĩa lớn nhất: bạn viết code đồng bộ, tuần tự, dễ đọc nhưng vẫn mở rộng
được tới hàng triệu kết nối — không cần chuỗi CompletableFuture khó gỡ lỗi nữa.
Dùng khi: tác vụ chờ I/O — gọi API, truy vấn database, đọc tệp.
Không dùng khi:
- Tác vụ nặng CPU — số luồng hữu ích vẫn bằng số nhân, virtual thread không giúp gì.
- Không đặt chúng vào pool — tạo mới rẻ hơn tái sử dụng.
- Cẩn thận với
synchronizedở Java 21: nó có thể ghim virtual thread vào luồng OS. DùngReentrantLockthay thế.
Chiến lược an toàn nhất khi thiết kế code đa luồng là gì?
Xem đáp án
Theo thứ tự ưu tiên — càng lên trên càng an toàn:
- Không chia sẻ trạng thái nào cả. Biến cục bộ nằm trên stack riêng của mỗi luồng nên luôn an toàn. Nếu mỗi luồng chỉ làm việc với dữ liệu riêng, không có vấn đề gì để giải quyết.
-
Chia sẻ dữ liệu bất biến. Object không thay đổi được thì không có race condition
— nhiều luồng đọc chung thoải mái. Dùng
record,List.of(),String, mọi trườngfinal. -
Dùng cấu trúc an toàn luồng có sẵn.
ConcurrentHashMap,AtomicInteger,BlockingQueue— chúng được viết bởi chuyên gia và đã được kiểm nghiệm kỹ hơn code bạn viết nhiều lần. -
Cuối cùng mới tự khoá.
synchronizedtrên objectprivate final, hoặcReentrantLockvớiunlocktrongfinally. Giữ phạm vi khoá hẹp nhất có thể, và luôn lấy khoá theo cùng thứ tự.
Lý do của thứ tự này: bug đa luồng không tái hiện được, biến mất khi bạn thêm dòng in để gỡ lỗi, và chỉ xuất hiện trên production dưới tải cao. Phòng ngừa bằng thiết kế rẻ hơn gỡ lỗi rất nhiều lần.
Tổng kết
Sau chương 12, bạn đã nắm:
- Heap dùng chung, stack riêng — mọi vấn đề đồng thời bắt nguồn từ trạng thái chia sẻ.
start()vsrun(); vòng đời luồng sáu trạng thái.- Race condition:
dem++là ba thao tác bytecode; hiện tượng lost update. synchronized: loại trừ lẫn nhau + hiển thị; khoá trênprivate final Object.volatilechỉ giải quyết hiển thị, không giải quyết nguyên tử.- Deadlock: bốn điều kiện Coffman; phá bằng thứ tự khoá nhất quán.
ExecutorServicethay việc tự quản lý luồng; hàng đợi có giới hạn cho áp lực ngược.FuturevàCompletableFuture— kết hợp tác vụ bất đồng bộ không chặn.- Cấu trúc an toàn luồng:
ConcurrentHashMap,Atomic*,LongAdder,BlockingQueue; cơ chế CAS. - Virtual thread (Java 21) — viết đồng bộ, mở rộng như bất đồng bộ.
- Thứ tự ưu tiên thiết kế: không chia sẻ → bất biến → cấu trúc có sẵn → tự khoá.
Kết nối
- Chương 2 (Kiểu dữ liệu) — bất biến của
Stringkhiến nó an toàn luồng miễn phí. - Chương 5 (OOP nâng cao) —
recordbất biến là chiến lược đồng thời tốt nhất;enumlà singleton an toàn. - Chương 6 (Ngoại lệ) —
InterruptedExceptionvà tầm quan trọng của việc khôi phục cờ ngắt. - Chương 7-8 (Collections) — vì sao
HashMapkhông an toàn;ConcurrentHashMapkhoá theo bucket. - Chương 10 (Stream) — parallel stream chạy trên
ForkJoinPool.commonPool()dùng chung. - Chương 11 (Tệp tin) — I/O là thao tác chặn; virtual thread thay đổi hoàn toàn cách xử lý.
- Chương 13 (JDBC) — connection pool là một dạng resource pool; giao dịch và mức cô lập.
- Trụ cột OS — tiến trình, luồng, lập lịch, khoá, mô hình bộ nhớ.
- Python Chương 11 — đối chiếu: Python có GIL nên đa luồng không song song hoá được tính toán;
asynciotương ứng với virtual thread nhưng cần cú phápasync/await.