Chương 12 · Đa luồng

Đa luồng và lập trình đồng thời

Java hỗ trợ đa luồng ngay từ phiên bản đầu tiên — trước cả khi CPU nhiều nhân trở nên phổ biến. Đó là một thế mạnh lớn, và cũng là nguồn của những bug khó nhất trong nghề: chú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. Chương này dạy bạn cách tránh chúng bằng công cụ cấp cao, và kết thúc bằng virtual thread — thay đổi lớn nhất của Java 21.

Độ dài: ~1700 dòng Bài tập: 5 Quiz: 8 Prerequisites: Chương 1-11
🎯 Mục tiêu chương
  • 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 Thread và 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 volatile giả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 ExecutorService thay vì tự quản lý Thread.
  • Dùng Future và 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

Dùng chung (mọi luồng)
Heap — mọi object
Metaspace — metadata class
→ nguồn của mọi vấn đề đồng thời
Riêng từng luồng
Stack — biến cục bộ
Program counter
→ luôn an toàn
Quy tắc vàng: biến cục bộ luôn an toàn (nằm trên stack riêng). Trường của object nằm trên heap dùng chung — mọi vấn đề đồng thời đều bắt nguồn từ đây.
Tiến trình (process)Luồng (thread)
Không gian bộ nhớRiêng biệt hoàn toànDùng chung heap
Chi phí tạoNặng (mili giây)Nhẹ hơn (micro giây)
Giao tiếpQua IPC, socket, tệpQua biến chung — nhanh nhưng nguy hiểm
Một cái sậpKhông ảnh hưởng cái khácCó 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

TaoLuong.java
// ① 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áiNghĩ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
WAITINGwait(), join() không hạn→ RUNNABLE
TIMED_WAITINGsleep(n), wait(n)→ RUNNABLE
TERMINATEDĐã kết thúc— (không quay lại được)

3. Race condition — vấn đề gốc rễ

RaceCondition.java
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:

javap -c BoDem
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:

Xen kẽ
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

Synchronized.java
// ① 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á
    }
}
⚠️ Getter cũng phải đồng bộ

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.

🕳️ Đừng khoá trên object công khai
KhoaSai.java
// ❌ 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ị

Volatile.java
// ❌ 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;
volatilesynchronizedAtomic*
Đảm bảo hiển thị✅✅✅
Đảm bảo nguyên tử❌ Không✅✅ (cho thao tác đơn)
Chặn luồngKhôngCóKhông (dùng CAS)
Chi phíThấpCao hơnThấp
Dùng khiCờ boolean, tham chiếu chỉ đọc-ghi đơnNhiều thao tác cần nguyên tử cùng nhauBộ đếm, cập nhật một biến
⚠️ volatile KHÔNG làm i++ an toàn
VolatileSai.java
private 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

Deadlock.java
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ùngDùng cấu trúc bất biến hoặc không khoá
Giữ và chờ — giữ khoá này, chờ khoá kiaLấ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áctryLock(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ự
PhongDeadlock.java
// ✅ 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

Executor.java
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
    }
}
FactorySố luồngDùng khiCảnh báo
newFixedThreadPool(n)Cố định nTá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ãnNhiều tác vụ ngắn⚠️ Không giới hạn — có thể tạo hàng nghìn luồng
newSingleThreadExecutor()1Cần thứ tự tuần tự
newScheduledThreadPool(n)Cố định nChạy định kỳ hoặc có độ trễ
newWorkStealingPool()= số nhânTác vụ chia nhỏ đệ quyDựa trên ForkJoinPool
newVirtualThreadPerTaskExecutor() ⭐Không giới hạnTác vụ chờ I/O (Java 21)Không dùng cho tác vụ nặng CPU
ThreadPoolTuChinh.java
// ⭐ 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

Future.java
// 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.
CompletableFuture.java
// --- 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ànAn toàn luồng ⭐Cơ chế
HashMapConcurrentHashMapKhoá theo từng bucket + CAS
ArrayListCopyOnWriteArrayListChép mảng khi ghi — hợp khi đọc nhiều ghi ít
HashSetConcurrentHashMap.newKeySet()Như ConcurrentHashMap
ArrayDequeConcurrentLinkedDequeKhông khoá (lock-free)
Hàng đợiLinkedBlockingQueue, ArrayBlockingQueueChặn khi rỗng/đầy — nền của mẫu producer-consumer
TreeMapConcurrentSkipListMapSkip list
int, longAtomicInteger, AtomicLongCAS
Bộ đếm tải caoLongAdderCộng phân tán — nhanh hơn AtomicLong khi nhiều luồng
AnToanLuong.java
// --- 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ànhN:1 — nhiều virtual trên một luồng OS
Bộ nhớ~1 MB stackVài trăm byte, co giãn theo nhu cầu
Số lượng khả thiVài nghìnHàng triệu
Chi phí tạo~1 ms~1 µs
Chuyển ngữ cảnhDo hệ điều hành (đắt)Do JVM (rẻ)
Khi chặn I/OLuồng OS bị chiếmJVM tháo khỏi luồng OS ⭐
Hợp vớiTính toán nặng CPUChờ I/O — mạng, database, tệp
VirtualThread.java
// 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());
}
⚠️ Ba lưu ý về virtual thread
  • 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ùng newFixedThreadPool.
  • 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ối synchronized có thể ghim (pin) virtual thread vào luồng OS, triệt tiêu lợi ích. Dùng ReentrantLock thay 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ốngDùng
Chạy tác vụ nềnExecutorService — không tự new Thread()
Nhiều tác vụ chờ I/OExecutors.newVirtualThreadPerTaskExecutor() (Java 21)
Tác vụ nặng CPUnewFixedThreadPool(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ộ đếmAtomicInteger; tải rất cao thì LongAdder
Cờ dừng luồngvolatile boolean
Map dùng chungConcurrentHashMap
List đọc nhiều ghi ítCopyOnWriteArrayList
Producer-consumerBlockingQueue có giới hạn
Nhiều thao tác cần nguyên tử cùng nhausynchronized trên object private final
Cần timeout khi lấy khoáReentrantLock.tryLock(t, unit) + unlock trong finally
Tránh deadlockLuôn lấy khoá theo cùng một thứ tự
Chẩn đoán treojstack <pid>
Bắt InterruptedExceptionLuôn Thread.currentThread().interrupt()
An toàn nhấtBất biến — record, List.of(), không trạng thái chung
Thứ tự ưu tiên khi thiết kế: (1) không chia sẻ trạng thái nào cả; (2) chia sẻ dữ liệu bất biến; (3) dùng cấu trúc an toàn luồng có sẵn (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:

  1. int dem thường.
  2. volatile int dem.
  3. synchronized method.
  4. AtomicInteger.
  5. 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:

  1. Viết chuyenTien(TaiKhoan tu, TaiKhoan den, long tien) khoá lần lượt tu rồi den.
  2. 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.
  3. Dùng jstack chụp lại phần báo cáo deadlock, chép vào bài làm.
  4. 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.
  5. Sửa bằng ReentrantLock.tryLock có timeout — thất bại thì thử lại sau một khoảng ngẫu nhiên.
  6. 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 Semaphore hoặ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:

Kiến trúc
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 LinkedBlockingQueue có 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. 1.000 tác vụ — luồng nền tảng vs virtual.
  2. 10.000 tác vụ.
  3. 100.000 tác vụ.
  4. 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 synchronized trong 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

Q1

t.start() và t.run() khác nhau thế nào?

Xem đáp án
✓ Đá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.

ChungMinh.java
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.

Q2

Vì sao dem++ không an toàn với đa luồng?

Xem đáp án
✓ Đáp án

Vì nó không phải một thao tác mà là ba — kiểm chứng bằng javap -c:

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

Xen kẽ
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ử.

Q3

volatile và synchronized khác nhau thế nào?

Xem đáp án
✓ Đá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?
volatilesynchronized
Hiển thị✅✅
Nguyên tử❌✅
Chặn luồngKhôngCó
Chi phíThấpCao 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ũ.

Q4

Deadlock là gì? Cách phòng tránh đơn giản nhất?

Xem đáp án
✓ Đá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:

ThuTuKhoa.java
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".

Q5

Vì sao dùng ExecutorService thay vì tự tạo Thread?

Xem đáp án
✓ Đá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/Callable trả về giá trị và truyền được exception ra ngoài. Với Thread trầ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.

Q6

ConcurrentHashMap hơn Hashtable và synchronizedMap ở đâu?

Xem đáp án
✓ Đá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").

CanThan.java
// ⚠️ 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);
Q7

Virtual thread khác luồng nền tảng thế nào? Khi nào nên dùng?

Xem đáp án
✓ Đá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ùng ReentrantLock thay thế.
Q8

Chiến lược an toàn nhất khi thiết kế code đa luồng là gì?

Xem đáp án
✓ Đáp án

Theo thứ tự ưu tiên — càng lên trên càng an toàn:

  1. 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.
  2. 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ường final.
  3. 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.
  4. Cuối cùng mới tự khoá. synchronized trên object private final, hoặc ReentrantLock với unlock trong finally. 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() vs run(); 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ên private final Object.
  • volatile chỉ 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.
  • ExecutorService thay việc tự quản lý luồng; hàng đợi có giới hạn cho áp lực ngược.
  • Future và 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 String khiến nó an toàn luồng miễn phí.
  • Chương 5 (OOP nâng cao) — record bất biến là chiến lược đồng thời tốt nhất; enum là singleton an toàn.
  • Chương 6 (Ngoại lệ) — InterruptedException và tầm quan trọng của việc khôi phục cờ ngắt.
  • Chương 7-8 (Collections) — vì sao HashMap không an toàn; ConcurrentHashMap khoá 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; asyncio tương ứng với virtual thread nhưng cần cú pháp async/await.