Buổi 9: Lập trình đa luồng
Hãy hình dung một ứng dụng đặt vé của nhà ga vào dịp lễ: hàng nghìn khách cùng mở app, mỗi người vừa xem chuyến tàu, vừa chọn ghế, vừa thanh toán. Nếu máy chủ chỉ phục vụ lần lượt từng người, người đứng sau phải đợi người đứng trước làm xong cả một chuỗi thao tác chậm chạp (đọc cơ sở dữ liệu, gọi cổng thanh toán…) — hệ thống nhanh chóng "đứng hình". Ngược lại, nếu cho nhiều việc chạy đồng thời mà không kiểm soát, hai người có thể cùng "mua" một ghế cuối cùng, số ghế còn lại bị tính sai, dữ liệu hỏng mà không có thông báo lỗi nào.
Lập trình đa luồng (multithreading) là công cụ để làm việc thứ nhất mà tránh việc thứ hai. Buổi này bạn học cách tạo và điều khiển luồng (thread), hiểu vì sao dữ liệu dùng chung sinh ra race condition, dùng synchronized, volatile, lớp Atomic* để bảo vệ nó, nhận diện và tránh deadlock, phối hợp luồng bằng wait/notify, rồi chuyển sang các công cụ cấp cao của java.util.concurrent: thread pool, Callable/Future, CountDownLatch, Fork/Join. Mọi ví dụ trong bài đều được viết để cho kết quả cố định (luôn chờ các luồng xong bằng join(), Future.get() hay CountDownLatch trước khi in), nhờ vậy bạn chạy đi chạy lại vẫn thấy đúng một kết quả.
Mục tiêu buổi học
- Sau buổi này bạn có thể phân biệt tiến trình với luồng, giải thích mô hình bộ nhớ của luồng (mỗi luồng một stack, chung heap).
- Sau buổi này bạn có thể tạo luồng bằng
Thread,Runnable, lambda; dùngstart,join,sleep,interruptvà mô tả vòng đời của luồng. - Sau buổi này bạn có thể nhận ra race condition và sửa bằng
synchronized,volatile,AtomicInteger,ReentrantLock, tập hợp an toàn luồng. - Sau buổi này bạn có thể giải thích deadlock, bốn điều kiện sinh ra nó và cách tránh bằng thứ tự khóa.
- Sau buổi này bạn có thể phối hợp luồng bằng
wait/notify,BlockingQueueđể giải bài toán Producer–Consumer. - Sau buổi này bạn có thể dùng
ExecutorService,Callable/Future,CountDownLatch,ForkJoinPoolđể chia việc, chờ kết quả và gộp lại.
Bản đồ buổi học
| Phần | Nội dung | Thời lượng gợi ý |
|---|---|---|
| 1. Tiến trình và luồng | Khác biệt tiến trình/luồng, vì sao dùng đa luồng | 15 phút |
| 2. Tạo và điều khiển luồng | Thread, Runnable, start/join/sleep/interrupt, vòng đời |
30 phút |
| 3. Đồng bộ hóa | Race condition, synchronized, volatile, Atomic*, khóa tường minh |
35 phút |
| 4. Deadlock | Bốn điều kiện, thứ tự khóa | 15 phút |
| 5. Phối hợp luồng | wait/notify, Producer–Consumer, BlockingQueue |
25 phút |
| 6. Thread pool | ExecutorService, Callable, Future, invokeAll |
30 phút |
| 7. Chờ nhau và chia để trị | CountDownLatch, Fork/Join, luồng song song |
20 phút |
| 8. Tổng kết | Sai lầm thường gặp, tóm tắt | 10 phút |
Khoảng 50–60% thời gian là thực hành: sau mỗi phần có một nhóm bài tập chấm tự động.
1. Tiến trình và luồng
1.1. Tiến trình (process) và luồng (thread)
Tiến trình là một chương trình đang chạy, được hệ điều hành cấp vùng nhớ riêng: bạn mở trình duyệt và trình soạn thảo thì đó là hai tiến trình khác nhau, không đọc được bộ nhớ của nhau. Luồng (thread) là một dòng thực thi bên trong tiến trình; mọi luồng của cùng một tiến trình chia sẻ chung vùng nhớ (heap), nhưng mỗi luồng có stack và bộ đếm lệnh riêng.
| Tiêu chí | Tiến trình | Luồng |
|---|---|---|
| Vùng nhớ | Riêng, cách ly hoàn toàn | Chia sẻ heap trong tiến trình, stack riêng |
| Chi phí tạo / chuyển ngữ cảnh | Nặng | Nhẹ hơn nhiều |
| Giao tiếp | Phải qua cơ chế riêng (pipe, socket, file…) | Đọc/ghi trực tiếp biến dùng chung |
| Một bên lỗi | Thường không kéo bên kia chết | Dữ liệu dùng chung có thể bị hỏng, ngoại lệ không bắt có thể làm luồng chết |
Một chương trình Java khi chạy luôn có ít nhất một luồng là luồng main. Bạn có thể hỏi tên luồng đang chạy đoạn code bằng Thread.currentThread():
Thread me = Thread.currentThread();
System.out.println("Đang chạy trong luồng: " + me.getName());Chính vì chung heap nên luồng vừa tiện (trao đổi dữ liệu rất rẻ) vừa nguy hiểm (hai luồng cùng sửa một đối tượng sẽ giẫm chân nhau). Phần lớn nội dung buổi này xoay quanh việc giữ cho điều tiện lợi đó không biến thành lỗi.
void main() {
int age = 20;
Student s = new Student("An");
greet(s); // p trong greet() cũng trỏ tới đối tượng đó
}Biến nguyên thủy lưu giá trị ngay trong ô ở stack; biến tham chiếu chỉ lưu địa chỉ, còn đối tượng thật nằm ở heap. Hai biến s và p cùng trỏ một đối tượng — không có bản sao.
Hình trên nhắc lại ba vùng nhớ của JVM. Khi có nhiều luồng, mỗi luồng có stack riêng (biến cục bộ, tham số, lời gọi hàm của luồng đó), còn heap và Method Area chỉ có một bản, ai cũng nhìn thấy. Đối tượng new ra nằm trên heap; hai luồng cầm cùng một tham chiếu thì cùng sửa một đối tượng.
1.2. Đa luồng để làm gì?
- Tận dụng nhiều nhân CPU: chia một bài toán lớn (tính tổng mảng, xử lý ảnh) thành nhiều phần chạy song song → nhanh hơn.
- Không bị "đứng hình": việc chậm (đọc file, gọi mạng, truy vấn CSDL) chạy ở luồng khác, luồng giao diện/luồng nhận yêu cầu vẫn phản hồi.
- Phục vụ nhiều người cùng lúc: máy chủ web, game, ứng dụng chat đều xử lý mỗi yêu cầu bằng một luồng hoặc một nhiệm vụ trong thread pool.
Cần phân biệt hai khái niệm hay bị nhầm: đồng thời (concurrency) là nhiều việc được xen kẽ tiến triển (kể cả trên một nhân), còn song song (parallelism) là nhiều việc thật sự chạy cùng một lúc trên nhiều nhân. Chương trình đa luồng Java có thể là cả hai, tùy máy chạy.
Lưu ý: Thêm luồng không tự động làm chương trình nhanh hơn. Tạo luồng tốn tài nguyên, tranh nhau khóa còn có thể làm chậm đi. Việc nhỏ, chạy vài micro-giây thì cứ để một luồng làm.
2. Tạo và điều khiển luồng
2.1. Cách 1 — kế thừa Thread
Lớp Thread có phương thức run() chứa việc cần làm. Bạn kế thừa Thread, ghi đè run(), rồi gọi start() để JVM tạo luồng thật và gọi run() trong luồng đó.
class Greeter extends Thread {
private final String who;
Greeter(String who) { this.who = who; }
@Override
public void run() {
System.out.println("Xin chào " + who + " từ luồng " + getName());
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Greeter t = new Greeter("An");
t.setName("worker-1");
t.start();
t.join();
System.out.println("main: luồng con đã xong");
}
}Dòng t.join() bắt luồng main đứng đợi tới khi t kết thúc. Nhờ vậy dòng in cuối cùng luôn xuất hiện sau dòng của luồng con.
2.2. Cách 2 — Runnable (khuyến nghị) và lambda
Runnable là giao diện hàm chỉ có một phương thức void run(). Tách "việc cần làm" (Runnable) khỏi "người làm" (Thread) là thiết kế tốt hơn: lớp của bạn vẫn có thể kế thừa lớp khác, và cùng một Runnable có thể giao cho nhiều luồng hoặc một thread pool. Vì là giao diện hàm nên viết gọn bằng lambda:
public class Main {
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> System.out.println("Chạy trong " + Thread.currentThread().getName());
Thread t1 = new Thread(task, "luong-A");
t1.start();
t1.join();
Thread t2 = new Thread(task, "luong-B");
t2.start();
t2.join();
System.out.println("Xong ở " + Thread.currentThread().getName());
}
}extends Thread |
implements Runnable |
|
|---|---|---|
| Kế thừa lớp khác | Không (Java chỉ đơn kế thừa) | Có |
| Dùng với thread pool | Không tiện | Tự nhiên |
| Tách việc khỏi luồng | Không | Có |
| Khuyến nghị | Ít dùng | Nên dùng |
2.3. start() khác run()
Sai lầm kinh điển của người mới: gọi run() thay vì start(). run() chỉ là một phương thức bình thường, chạy ngay trong luồng đang gọi; chỉ start() mới tạo luồng mới.
Thread t = new Thread(() -> System.out.println("Tên luồng: " + Thread.currentThread().getName()), "T1");
t.run(); // gọi thường: chạy trong luồng main
t.start(); // tạo luồng mới T1
t.join();Kết quả cho thấy lần đầu in main, lần sau mới in T1. Ngoài ra mỗi đối tượng Thread chỉ được start() một lần; gọi lần thứ hai sẽ ném IllegalThreadStateException.
2.4. Chia việc cho nhiều luồng rồi gộp kết quả
Mẫu thiết kế hay gặp nhất: chia dữ liệu thành các phần, mỗi luồng xử lý một phần và ghi vào ô riêng của mảng kết quả, luồng main join() tất cả rồi mới cộng lại. Vì mỗi luồng chỉ ghi vào ô của mình nên không tranh chấp; vì join() xong mới đọc nên thấy đủ kết quả.
int[] data = new int[1000];
for (int i = 0; i < data.length; i++) data[i] = i + 1;
int parts = 4;
long[] partial = new long[parts];
Thread[] ts = new Thread[parts];
for (int p = 0; p < parts; p++) {
final int id = p;
ts[p] = new Thread(() -> {
int from = id * 250, to = from + 250;
long s = 0;
for (int i = from; i < to; i++) s += data[i];
partial[id] = s;
});
ts[p].start();
}
for (Thread t : ts) t.join();
long total = 0;
for (long s : partial) total += s;
System.out.println(Arrays.toString(partial));
System.out.println("Tổng = " + total);Chú ý final int id = p;: lambda chỉ được dùng biến cục bộ effectively final, nên phải chép biến vòng lặp sang một biến mới cho từng luồng. Đây cũng là cấu trúc của hầu hết bài tập đa luồng chấm tự động: chia mảng → mỗi luồng một phần → join → gộp.
2.5. Vòng đời của luồng
Một luồng trải qua các trạng thái được liệt kê trong Thread.State:
Quả bóng chạy theo một kịch bản mẫu. RUNNABLE gộp cả sẵn sàng lẫn đang chạy; BLOCKED / WAITING / TIMED_WAITING là ba kiểu 'đứng chờ', rồi luôn quay về RUNNABLE. TERMINATED là trạng thái cuối.
Hình trên cho thấy: NEW (vừa tạo, chưa start) → RUNNABLE (sẵn sàng/đang chạy) → có thể rẽ sang BLOCKED (chờ vào khối synchronized), WAITING (chờ vô hạn: join(), wait()), TIMED_WAITING (chờ có hạn: sleep(ms), join(ms)) → rồi quay lại RUNNABLE → cuối cùng TERMINATED. Bạn đọc trạng thái bằng getState().
Thread t = new Thread(() -> System.out.println("đang chạy"));
System.out.println("Trước start: " + t.getState());
t.start();
t.join();
System.out.println("Sau join: " + t.getState());
System.out.println("isAlive = " + t.isAlive());Mẹo: Đừng viết code dựa vào việc quan sát trạng thái trung gian như
RUNNABLEhayBLOCKED— chúng thay đổi trong vài micro-giây và phụ thuộc lịch của hệ điều hành.getState()chủ yếu dùng để gỡ lỗi và kiểm traNEW/TERMINATED.
2.6. sleep, join, interrupt
Thread.sleep(ms): luồng hiện tại ngủ ít nhấtmsmili-giây (trạng tháiTIMED_WAITING). Có thể némInterruptedException.t.join(): đợi luồngtkết thúc;t.join(ms)chỉ đợi tối đams.t.interrupt(): gửi yêu cầu ngắt. Java không giết luồng bằng vũ lực; luồng bị ngắt khi đangsleep/wait/joinsẽ nhậnInterruptedException, còn luồng đang tính toán thì phải tự kiểm traisInterrupted().
Thread sleeper = new Thread(() -> {
try {
Thread.sleep(10_000);
System.out.println("Ngủ đủ giấc");
} catch (InterruptedException e) {
System.out.println("Bị đánh thức sớm, cờ interrupt = " + Thread.currentThread().isInterrupted());
}
});
sleeper.start();
sleeper.interrupt();
sleeper.join();
System.out.println("main xong");Khi InterruptedException được ném, JVM đã xóa cờ ngắt nên isInterrupted() trả về false. Nếu bạn bắt ngoại lệ này mà không xử lý hết, thông lệ tốt là gọi lại Thread.currentThread().interrupt() để các tầng trên còn biết.
Cảnh báo: Không bao giờ nuốt
InterruptedExceptionbằng một khốicatchrỗng. Đó là cách để luồng "không chịu dừng" khi chương trình cố tắt.
2.7. Ngoại lệ trong luồng không "chui" ra luồng main
Mỗi luồng có ngăn xếp riêng, nên ngoại lệ ném ra trong run() mà không bắt sẽ làm chỉ luồng đó chết; luồng gọi start() không biết gì. Bạn có thể gắn một UncaughtExceptionHandler để nhận thông báo:
Thread t = new Thread(() -> { throw new IllegalStateException("lỗi trong luồng"); });
t.setUncaughtExceptionHandler((th, ex) -> System.out.println("Bắt được: " + ex.getMessage()));
t.start();
t.join();
System.out.println("main vẫn sống");Với thread pool, cách tự nhiên hơn là lấy ngoại lệ qua Future.get() (xem phần 6).
Ví dụ thực tế: Trong máy chủ đặt vé, một yêu cầu gặp lỗi chỉ được phép làm hỏng yêu cầu đó. Nhờ ngoại lệ gói gọn trong từng luồng (hoặc từng nhiệm vụ của pool), các khách khác vẫn được phục vụ bình thường.
Một vài thuộc tính khác của Thread hay gặp: luồng nền (daemon) — setDaemon(true) trước khi start(); JVM thoát khi mọi luồng không phải daemon đã xong, nên luồng daemon bị bỏ lại giữa chừng. Độ ưu tiên setPriority(1..10) chỉ là gợi ý cho bộ lập lịch, đừng dựa vào nó để đảm bảo thứ tự.
Vừa học xong cách tạo luồng và gộp kết quả bằng join, hãy làm các bài sau để quen mẫu "chia mảng → mỗi luồng một phần → join → gộp" với Thread và Runnable.
3. Đồng bộ hóa: race condition, synchronized, volatile, Atomic*
3.1. Race condition — khi kết quả phụ thuộc "ai chạy trước"
Xét một bộ đếm dùng chung, nhiều luồng cùng gọi inc():
class Counter {
int count = 0;
void inc() { count++; } // trông như một bước, nhưng thật ra là ba bước
}
// 4 luồng, mỗi luồng gọi inc() 100000 lần → kỳ vọng 400000
// thực tế thường nhận được một số nhỏ hơn, và mỗi lần chạy mỗi khácBiểu thức count++ là ba thao tác: đọc count vào thanh ghi, cộng 1, ghi lại. Hai luồng có thể xen vào giữa nhau, và một lần cộng bị "mất" (lost update). Ta không chạy đoạn trên với dấu hiệu "chạy thử" vì kết quả không cố định — đúng bản chất của lỗi này. Có thể mô phỏng riêng một lịch xen kẽ xấu ngay trong một luồng để thấy cơ chế:
int count = 0;
int readByA = count; // luồng A đọc 0
int readByB = count; // luồng B cũng đọc 0 (A chưa kịp ghi)
count = readByA + 1; // A ghi 1
count = readByB + 1; // B ghi đè 1 → mất một lần cộng
System.out.println("count = " + count + " (đáng lẽ phải là 2)");| Bước | Luồng A | Luồng B | count |
|---|---|---|---|
| 1 | đọc → 0 | 0 | |
| 2 | đọc → 0 | 0 | |
| 3 | ghi 0+1 | 1 | |
| 4 | ghi 0+1 | 1 |
Hãy tự thử với mô phỏng dưới đây: hai luồng cộng dồn không đồng bộ so với có synchronized.
Hai luồng cùng tăng một biến đếm
Mô phỏng tất định (không dùng luồng thật): count++ gồm 3 bước READ → +1 → WRITEint count = 0; // chia sẻ giữa T1 và T2
void inc() { count++; }
// READ count → tmp; tmp + 1; WRITE tmp → count| Đang chạy | Giá trị |
|---|---|
| Lượt đã xong / mỗi luồng | 0 / 1000 |
| count hiện tại | 0 |
| Cập nhật đã mất | 0 |
Cách dùng: chạy chế độ không đồng bộ nhiều lần và để ý tổng cuối thường thiếu so với kỳ vọng, mỗi lần một khác; chuyển sang synchronized thì luôn đủ. Lỗi này nguy hiểm vì chương trình không báo lỗi gì, chỉ cho ra kết quả sai — và đôi khi chạy ở máy bạn lại đúng (ít nhân, tải nhẹ) nhưng sai khi triển khai thật.
Race condition xuất hiện khi hội đủ: (1) nhiều luồng, (2) cùng truy cập một dữ liệu có thể thay đổi (mutable), (3) ít nhất một luồng ghi, (4) không có cơ chế đồng bộ. Phá vỡ bất kỳ điều kiện nào cũng chữa được: không chia sẻ dữ liệu (mỗi luồng ghi ô riêng như phần 2.4), dùng dữ liệu bất biến (immutable), hoặc đồng bộ.
3.2. synchronized — khóa nội tại (monitor lock)
Mỗi đối tượng Java có một khóa nội tại. Khối/phương thức synchronized bắt luồng phải giành được khóa trước khi vào; luồng khác muốn vào phải chờ (trạng thái BLOCKED) đến khi khóa được nhả. Nhờ vậy chỉ một luồng chạy đoạn code đó tại một thời điểm — gọi là loại trừ tương hỗ (mutual exclusion).
class Counter {
private int count = 0;
synchronized void inc() { count++; }
synchronized int get() { return count; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter c = new Counter();
Runnable job = () -> { for (int i = 0; i < 10000; i++) c.inc(); };
Thread[] ts = new Thread[4];
for (int i = 0; i < ts.length; i++) { ts[i] = new Thread(job); ts[i].start(); }
for (Thread t : ts) t.join();
System.out.println("count = " + c.get());
}
}Kết quả luôn là 40000. Có hai dạng:
// Dạng 1: phương thức synchronized — khóa là chính đối tượng (this)
synchronized void inc() { count++; }
// Dạng 2: khối synchronized — chọn đối tượng khóa, thu hẹp phạm vi
private final Object lock = new Object();
void inc() {
synchronized (lock) { count++; }
}| Cách viết | Khóa lấy | Ghi chú |
|---|---|---|
synchronized trên phương thức thường |
this |
Mọi phương thức synchronized của cùng đối tượng chặn nhau |
synchronized trên phương thức static |
đối tượng Class (Counter.class) |
Dùng cho dữ liệu static |
synchronized (lock) { … } |
lock do bạn chọn |
Giữ khóa ngắn, tách nhiều khóa độc lập |
Lưu ý: Đồng bộ hóa phải nhất quán: mọi chỗ đọc/ghi biến dùng chung đều phải dùng cùng một khóa. Chỉ khóa chỗ ghi mà để chỗ đọc "tự do" vẫn có thể thấy giá trị cũ hoặc giá trị nửa vời.
3.3. Hành động ghép "kiểm tra rồi làm" (check-then-act)
Chỉ làm từng thao tác an toàn là chưa đủ nếu logic của bạn gồm nhiều bước phụ thuộc nhau. Ví dụ rút tiền: "nếu đủ số dư thì trừ" — nếu hai luồng cùng thấy "đủ" rồi cùng trừ, tài khoản âm. Cả cụm kiểm tra + hành động phải nằm trong một vùng synchronized:
class Account {
private int balance = 100;
synchronized boolean withdraw(int amount) {
if (balance >= amount) { balance -= amount; return true; }
return false;
}
synchronized int getBalance() { return balance; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Account acc = new Account();
AtomicInteger ok = new AtomicInteger();
Thread[] ts = new Thread[5];
for (int i = 0; i < ts.length; i++) {
ts[i] = new Thread(() -> { if (acc.withdraw(30)) ok.incrementAndGet(); });
ts[i].start();
}
for (Thread t : ts) t.join();
System.out.println("Rút thành công " + ok.get() + " lần, còn " + acc.getBalance());
}
}Năm luồng tranh nhau rút 30 từ số dư 100: dù lịch chạy thế nào, đúng 3 luồng thành công và số dư còn 10. Không có synchronized, đôi khi 4 luồng "thấy đủ tiền" cùng lúc và số dư thành âm.
Ví dụ thực tế: Đây chính là bài toán "hai người cùng mua ghế cuối cùng" ở đầu bài: thao tác "còn ghế → giữ ghế" phải là một khối không thể chia cắt.
3.4. volatile — đảm bảo nhìn thấy (visibility)
Mỗi nhân CPU có bộ nhớ đệm riêng, và trình biên dịch/JIT được phép sắp xếp lại lệnh. Hệ quả: một luồng ghi flag = false nhưng luồng khác có thể không bao giờ thấy giá trị mới và chạy vòng lặp mãi. Khai báo volatile buộc mọi lần đọc lấy giá trị mới nhất từ bộ nhớ chính và mọi lần ghi được công bố ngay.
class Worker extends Thread {
private volatile boolean running = true;
@Override
public void run() {
long loops = 0;
while (running) { loops++; }
System.out.println("Worker dừng đúng hẹn");
}
void shutdown() { running = false; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Worker w = new Worker();
w.start();
Thread.sleep(50);
w.shutdown();
w.join();
System.out.println("main kết thúc");
}
}Cờ dừng như trên là ứng dụng điển hình của volatile. Nhưng hãy nhớ giới hạn:
volatile |
synchronized |
AtomicInteger |
|
|---|---|---|---|
| Đảm bảo nhìn thấy giá trị mới | Có | Có | Có |
| Đảm bảo thao tác không bị cắt (atomic) | Không (với count++) |
Có | Có (với các phép của nó) |
| Có chặn luồng khác không | Không | Có | Không (lock-free) |
Cảnh báo:
volatile int count; count++;vẫn là race condition —volatilechỉ lo "thấy", không lo "ba bước đọc–cộng–ghi không bị chen ngang".
3.5. Lớp Atomic* — thao tác nguyên tử không khóa
Gói java.util.concurrent.atomic có AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference… dựa trên lệnh CPU compare-and-set (CAS): "nếu giá trị hiện tại đúng là X thì đổi thành Y, ngược lại báo thất bại". Đơn giản, nhanh và không bị BLOCKED.
AtomicInteger counter = new AtomicInteger();
Thread[] ts = new Thread[4];
for (int i = 0; i < ts.length; i++) {
ts[i] = new Thread(() -> { for (int k = 0; k < 10000; k++) counter.incrementAndGet(); });
ts[i].start();
}
for (Thread t : ts) t.join();
System.out.println("counter = " + counter.get());Các phương thức thường dùng, chạy tuần tự để bạn xem từng giá trị trả về:
AtomicInteger a = new AtomicInteger(5);
System.out.println(a.compareAndSet(5, 8)); // đúng là 5 → đổi thành 8
System.out.println(a.compareAndSet(5, 9)); // giờ là 8, không phải 5 → thất bại
System.out.println(a.get());
System.out.println(a.updateAndGet(x -> x * 2));
System.out.println(a.getAndAdd(4)); // trả về giá trị TRƯỚC khi cộng
System.out.println(a);Quy ước tên: incrementAndGet() trả về giá trị sau khi cộng; getAndIncrement() trả về giá trị trước khi cộng. Khi cần cộng dồn rất nhiều lần từ nhiều luồng (thống kê), LongAdder còn nhanh hơn AtomicLong.
Mẹo: Một biến đơn lẻ (bộ đếm, cờ, tổng) → ưu tiên
Atomic*. Nhiều biến phải đổi cùng nhau theo một bất biến (số dư và lịch sử giao dịch…) → dùngsynchronized/Lock.
3.6. Khóa tường minh ReentrantLock
Gói java.util.concurrent.locks cho khóa linh hoạt hơn synchronized: có tryLock() (thử lấy, không chờ vô hạn), tryLock(timeout), khóa công bằng, nhiều điều kiện chờ. Đổi lại bạn phải tự nhả khóa, luôn đặt unlock() trong finally.
import java.util.concurrent.locks.*;
class SafeCounter {
private final Lock lock = new ReentrantLock();
private int n;
void inc() {
lock.lock();
try { n++; } finally { lock.unlock(); }
}
int get() { return n; }
}
public class Main {
public static void main(String[] args) throws InterruptedException {
SafeCounter c = new SafeCounter();
Thread a = new Thread(() -> { for (int i = 0; i < 5000; i++) c.inc(); });
Thread b = new Thread(() -> { for (int i = 0; i < 5000; i++) c.inc(); });
a.start(); b.start(); a.join(); b.join();
System.out.println("n = " + c.get());
}
}"Reentrant" nghĩa là chính luồng đang giữ khóa có thể lấy lại khóa đó (đếm số lần) mà không tự chặn mình — synchronized cũng có tính chất này.
3.7. Tập hợp an toàn với nhiều luồng
ArrayList, HashMap… không an toàn luồng: nhiều luồng cùng ghi có thể mất phần tử, hỏng cấu trúc, thậm chí ném ngoại lệ lạ. Có ba hướng:
| Hướng | Ví dụ | Khi nào dùng |
|---|---|---|
| Bọc bằng khóa | Collections.synchronizedList(list) |
Đơn giản, ít luồng |
| Tập hợp đồng thời | ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentLinkedQueue |
Nhiều luồng đọc/ghi, hiệu năng tốt |
| Khóa tự quản | synchronized (list) { … } |
Cần gộp nhiều thao tác thành một |
ConcurrentHashMap.merge(key, 1, Integer::sum) là cách đếm tần suất an toàn chỉ bằng một lời gọi:
ConcurrentHashMap<String, Integer> freq = new ConcurrentHashMap<>();
String[] words = {"java", "thread", "java", "lock", "thread", "java"};
Thread[] ts = new Thread[3];
for (int p = 0; p < ts.length; p++) {
final int id = p;
ts[p] = new Thread(() -> {
for (int i = id * 2; i < id * 2 + 2; i++) freq.merge(words[i], 1, Integer::sum);
});
ts[p].start();
}
for (Thread t : ts) t.join();
System.out.println(new TreeMap<>(freq));Ta bọc vào TreeMap khi in để thứ tự khóa cố định.
3.8. Quy tắc "xảy ra trước" (happens-before)
Mô hình bộ nhớ Java (Java Memory Model) đảm bảo một luồng thấy kết quả ghi của luồng khác khi giữa chúng có quan hệ happens-before. Bốn quan hệ bạn dùng hằng ngày:
- Mọi thứ luồng cha làm trước
t.start()thì luồngtnhìn thấy. - Mọi thứ luồng
tlàm thì luồng gọit.join()nhìn thấy sau khijointrả về. (Vì vậy ví dụ ở 2.4 đọcpartial[]an toàn saujoin.) - Nhả một khóa
synchronizedhappens-before lần lấy khóa đó kế tiếp. - Ghi biến
volatilehappens-before lần đọc biến đó kế tiếp.
Mở rộng (Java 14+): Từ Java 21, bạn có thể tạo hàng triệu luồng ảo (virtual thread) rất nhẹ bằng
Thread.ofVirtual(). Khóa học này dùng Java 13 nên chưa dùng đến, nhưng mô hìnhsynchronized/Atomicbạn học hôm nay vẫn áp dụng nguyên vẹn.
4. Deadlock — bế tắc
4.1. Deadlock là gì?
Deadlock xảy ra khi hai (hoặc nhiều) luồng chờ nhau mãi mãi: mỗi luồng đang giữ một khóa và cần khóa mà luồng kia đang giữ. Chương trình không báo lỗi, không dừng, chỉ "treo".
// KHÔNG chạy thử — dễ treo vĩnh viễn!
// Luồng 1: lấy khóa A rồi xin khóa B
synchronized (a) {
// ... làm gì đó ...
synchronized (b) { /* chuyển tiền a → b */ }
}
// Luồng 2: lấy khóa B rồi xin khóa A (THỨ TỰ NGƯỢC)
synchronized (b) {
// ... làm gì đó ...
synchronized (a) { /* chuyển tiền b → a */ }
}Nếu luồng 1 đã giữ a và luồng 2 đã giữ b, thì luồng 1 chờ b, luồng 2 chờ a — không ai nhường.
4.2. Bốn điều kiện cần để có deadlock
| Điều kiện | Ý nghĩa | Cách phá |
|---|---|---|
| Loại trừ tương hỗ | Tài nguyên chỉ một luồng dùng một lúc | Dùng dữ liệu bất biến, Atomic thay khóa |
| Giữ và chờ | Giữ khóa này trong khi xin khóa khác | Xin mọi khóa cùng lúc, hoặc xin theo thứ tự |
| Không cưỡng đoạt | Không ép luồng khác nhả khóa | tryLock(timeout) rồi nhả hết, thử lại |
| Chờ vòng tròn | A chờ B, B chờ A | Đánh số khóa, luôn lấy theo thứ tự tăng dần |
Cách phá thực dụng nhất là điều kiện cuối: quy ước một thứ tự khóa toàn cục (ví dụ theo id của tài khoản). Khi đó không thể tạo vòng tròn chờ.
class Account {
final int id;
long balance;
Account(int id, long balance) { this.id = id; this.balance = balance; }
}
public class Main {
static void transfer(Account from, Account to, long x) {
Account first = from.id < to.id ? from : to; // khóa id nhỏ trước
Account second = (first == from) ? to : from;
synchronized (first) {
synchronized (second) { from.balance -= x; to.balance += x; }
}
}
public static void main(String[] args) throws InterruptedException {
Account a = new Account(1, 1000), b = new Account(2, 1000);
Thread t1 = new Thread(() -> { for (int i = 0; i < 1000; i++) transfer(a, b, 1); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 1000; i++) transfer(b, a, 1); });
t1.start(); t2.start(); t1.join(); t2.join();
System.out.println("a = " + a.balance + ", b = " + b.balance);
}
}Hai luồng chuyển tiền ngược chiều nhau 1000 lần nhưng cả hai đều khóa tài khoản có id nhỏ trước, nên không bao giờ bế tắc, và kết quả cuối cùng cân bằng (a = 1000, b = 1000).
4.3. Phát hiện và phòng tránh
- Phát hiện: khi chương trình treo, lấy thread dump bằng
jstack <pid>(hoặc Ctrl+\ trên Linux, nút Dump Threads trong IntelliJ) — JVM sẽ chỉ ra "Found one Java-level deadlock" cùng các luồng và khóa liên quan. Trong code có thể dùngThreadMXBean.findDeadlockedThreads(). - Phòng tránh: thứ tự khóa nhất quán; giữ khóa càng ngắn càng tốt, không gọi mã lạ (callback,
printlnchậm, I/O) khi đang giữ khóa; dùngtryLock(timeout); ưu tiên cấu trúc đã thread-safe (ConcurrentHashMap,BlockingQueue,Atomic*) thay vì tự phối nhiều khóa. - Hai hiện tượng "họ hàng": livelock (các luồng liên tục nhường nhau nên không tiến triển) và starvation (một luồng không bao giờ giành được tài nguyên).
Cảnh báo: Deadlock thường chỉ xảy ra khi hai luồng chạy đan xen đúng một kiểu, nên có thể qua mặt cả trăm lần test rồi treo trên máy chủ thật. Phải phòng bằng thiết kế (thứ tự khóa), không thể dựa vào việc "chạy thử không thấy treo".
Vừa học xong synchronized, race condition và deadlock, hãy làm các bài sau để tự tay bảo vệ dữ liệu dùng chung (bộ đếm, tài khoản, bảng tần suất) và tránh bế tắc khi chuyển tiền giữa nhiều tài khoản.
5. Phối hợp giữa các luồng: wait/notify và Producer–Consumer
5.1. Vì sao cần phối hợp?
synchronized chỉ lo loại trừ lẫn nhau. Nhiều bài toán còn đòi hỏi luồng này đợi một điều kiện do luồng khác tạo ra: người tiêu thụ đợi có hàng, người sản xuất đợi kho còn chỗ. Cách ngây thơ là "chờ bận" (busy waiting): vòng while (!dieuKien) { } — đốt CPU vô ích. Java có sẵn cơ chế ngủ–đánh thức trong Object:
| Phương thức | Ý nghĩa |
|---|---|
wait() |
Nhả khóa của đối tượng và ngủ cho đến khi bị đánh thức |
notify() |
Đánh thức một luồng đang wait trên đối tượng này |
notifyAll() |
Đánh thức tất cả luồng đang wait trên đối tượng này |
Ba quy tắc sống còn:
- Chỉ gọi
wait/notify/notifyAllbên trongsynchronizedtrên cùng đối tượng, nếu không sẽ némIllegalMonitorStateException. - Luôn đặt
wait()trong vòngwhile (điều kiện chưa thỏa), không dùngif— luồng có thể bị đánh thức "giả" (spurious wakeup) hoặc bị luồng khác "cướp" điều kiện trước. - Sau khi đổi trạng thái, gọi
notifyAll()(an toàn hơnnotify()khi có nhiều loại người chờ).
5.2. Hai luồng in xen kẽ A, B
Bài kinh điển: luồng A in A, luồng B in B, kết quả phải là ABABAB…. Ta dùng một cờ aTurn để biết đến lượt ai; luồng nào chưa đến lượt thì wait().
class Printer {
private boolean aTurn = true;
synchronized void print(String s, boolean forA) throws InterruptedException {
while (aTurn != forA) wait(); // chưa đến lượt → ngủ
System.out.print(s);
aTurn = !aTurn; // đổi lượt
notifyAll(); // đánh thức luồng kia
}
}
public class Main {
static Thread player(Printer p, String s, boolean isA, int times) {
return new Thread(() -> {
try { for (int i = 0; i < times; i++) p.print(s, isA); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
}
public static void main(String[] args) throws InterruptedException {
Printer p = new Printer();
Thread a = player(p, "A", true, 5), b = player(p, "B", false, 5);
a.start(); b.start(); a.join(); b.join();
System.out.println();
}
}Dù hai luồng chạy song song, khóa + cờ lượt bắt chúng nhường nhau, nên đầu ra luôn là ABABABABAB. Cùng kỹ thuật này giải bài "N luồng, mỗi luồng in một số, kết quả đúng thứ tự 1..N" — mỗi luồng đợi đến khi biến next bằng số của mình.
5.3. Producer–Consumer với bộ đệm có giới hạn
Producer (sản xuất) bỏ phần tử vào kho; Consumer (tiêu thụ) lấy phần tử ra. Kho có sức chứa hữu hạn: đầy thì producer chờ, rỗng thì consumer chờ. Đây là xương sống của hàng đợi tin nhắn, hàng đợi in, pipeline xử lý dữ liệu.
class Buffer {
private final LinkedList<Integer> q = new LinkedList<>();
private final int capacity;
Buffer(int capacity) { this.capacity = capacity; }
synchronized void put(int x) throws InterruptedException {
while (q.size() == capacity) wait(); // đầy → chờ
q.addLast(x);
notifyAll();
}
synchronized int take() throws InterruptedException {
while (q.isEmpty()) wait(); // rỗng → chờ
int x = q.removeFirst();
notifyAll();
return x;
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Buffer buf = new Buffer(2);
List<Integer> got = new ArrayList<>();
Thread producer = new Thread(() -> {
try { for (int i = 1; i <= 6; i++) buf.put(i); } catch (InterruptedException e) { }
});
Thread consumer = new Thread(() -> {
try { for (int i = 0; i < 6; i++) got.add(buf.take()); } catch (InterruptedException e) { }
});
producer.start(); consumer.start(); producer.join(); consumer.join();
System.out.println(got + " tổng = " + got.stream().mapToInt(Integer::intValue).sum());
}
}Kho chỉ chứa tối đa 2 phần tử nên hai luồng liên tục nhường nhau, nhưng thứ tự lấy ra luôn là thứ tự bỏ vào (hàng đợi FIFO): [1, 2, 3, 4, 5, 6]. Ta chỉ in sau khi join() cả hai nên không có xen kẽ đầu ra.
Lưu ý: Với nhiều producer/consumer, dùng
notifyAll()chứ không dùngnotify(): nếunotify()đánh thức nhầm một luồng cùng loại (producer đánh thức producer khác khi kho đầy), cả hệ thống có thể ngủ mãi.
5.4. BlockingQueue — cách làm hiện đại
Tự viết wait/notify dễ sai. Gói java.util.concurrent có sẵn BlockingQueue (ArrayBlockingQueue, LinkedBlockingQueue): put() tự chờ khi đầy, take() tự chờ khi rỗng. Cách báo "hết hàng" thông dụng là gửi một giá trị kết thúc (poison pill).
BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(3);
AtomicInteger sum = new AtomicInteger();
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 10; i++) queue.put(i);
queue.put(-1); // poison pill: hết dữ liệu
} catch (InterruptedException e) { }
});
Thread consumer = new Thread(() -> {
try {
for (int x = queue.take(); x != -1; x = queue.take()) sum.addAndGet(x);
} catch (InterruptedException e) { }
});
producer.start(); consumer.start();
producer.join(); consumer.join();
System.out.println("Tổng = " + sum.get());wait/notify thủ công |
BlockingQueue |
|
|---|---|---|
| Độ khó viết đúng | Cao (khóa, cờ, while, notifyAll) |
Thấp |
Cần synchronized |
Có | Không (đã tích hợp) |
| Khi nào dùng | Học nguyên lý, trạng thái điều kiện tùy biến | Hầu hết ứng dụng thực tế |
Mẹo: Khi có nhiều consumer, producer cần gửi một poison pill cho mỗi consumer; hoặc consumer nhận pill thì gửi lại pill vào hàng đợi cho bạn mình rồi thoát.
Vừa học xong wait/notify và Producer–Consumer, hãy làm các bài sau để in xen kẽ theo lượt, in đúng thứ tự bằng nhiều luồng và mô phỏng kho hàng bằng BlockingQueue.
6. Thread pool: ExecutorService, Callable, Future
6.1. Vì sao cần thread pool?
Tạo một Thread mới cho mỗi việc nhỏ rất tốn: cấp stack, đăng ký với hệ điều hành, và nếu có 100.000 yêu cầu thì có 100.000 luồng — hết bộ nhớ. Thread pool tạo sẵn một số luồng cố định, các việc cần làm xếp vào hàng đợi, luồng rảnh sẽ lấy việc ra làm: tái sử dụng luồng và giới hạn mức song song. Giao diện chính là ExecutorService; lớp tiện ích Executors tạo các loại pool phổ biến:
| Phương thức | Hành vi | Dùng khi |
|---|---|---|
newFixedThreadPool(n) |
Đúng n luồng, hàng đợi không giới hạn |
Việc tính toán, biết số nhân CPU |
newCachedThreadPool() |
Tạo luồng khi cần, tái dùng luồng rảnh | Nhiều việc ngắn, tải biến động |
newSingleThreadExecutor() |
Một luồng, việc chạy tuần tự đúng thứ tự gửi | Cần thứ tự, tránh tranh chấp |
newScheduledThreadPool(n) |
Chạy việc trễ/lặp theo lịch | Tác vụ định kỳ |
6.2. Gửi việc bằng submit, đóng pool bằng shutdown
execute(Runnable) gửi việc không cần kết quả; submit(...) gửi Runnable hoặc Callable và trả về một Future. Pool phải được đóng khi xong, nếu không các luồng của pool giữ chương trình không thoát:
shutdown(): ngừng nhận việc mới, chạy nốt các việc đã xếp.awaitTermination(t, đơn_vị): đợi tối đatcho đến khi mọi việc xong.shutdownNow(): cố ngắt các việc đang chạy, bỏ hàng đợi.
ExecutorService pool = Executors.newFixedThreadPool(3);
AtomicInteger done = new AtomicInteger();
for (int i = 0; i < 10; i++) {
pool.execute(() -> done.incrementAndGet());
}
pool.shutdown();
boolean finished = pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("Xong hết: " + finished + ", số việc = " + done.get());Cảnh báo: Quên
shutdown()là lỗi rất hay gặp: chương trình in xong kết quả nhưng không bao giờ kết thúc vì luồng của pool vẫn sống. Thói quen tốt: bọc trongtry { … } finally { pool.shutdown(); }.
6.3. Callable và Future — việc có kết quả
Runnable.run() trả void và không được ném checked exception. Callable<V> có V call() throws Exception — trả giá trị và được ném ngoại lệ. submit trả Future<V>, một "phiếu hẹn" lấy kết quả sau.
Runnable |
Callable<V> |
|
|---|---|---|
| Phương thức | void run() |
V call() throws Exception |
| Trả kết quả | Không | Có |
| Ném checked exception | Không | Có |
| Dùng với | Thread, execute, submit |
submit, invokeAll |
Phương thức Future |
Ý nghĩa |
|---|---|
get() |
Chờ đến khi xong rồi trả kết quả (chặn luồng gọi) |
get(t, đơn_vị) |
Chờ tối đa t, quá hạn ném TimeoutException |
isDone() |
Đã xong chưa (không chặn) |
cancel(true) |
Hủy việc, ngắt luồng nếu đang chạy |
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<Integer>> fs = new ArrayList<>();
for (int i = 1; i <= 5; i++) {
final int n = i;
fs.add(pool.submit(() -> n * n)); // lambda có giá trị trả về → Callable
}
for (Future<Integer> f : fs) System.out.println(f.get());
pool.shutdown();Dù các việc chạy song song, ta lấy kết quả theo thứ tự đã gửi bằng get() nên đầu ra cố định 1 4 9 16 25. Đây chính là cách đảm bảo thứ tự kết quả trong nhiều bài tập: gửi N việc, lưu N Future, rồi duyệt tuần tự để get() và in.
6.4. Ngoại lệ trong việc của pool
Ngoại lệ ném trong call() không in ra mà được giữ trong Future; chỉ khi gọi get() bạn mới nhận ExecutionException, nguyên nhân thật nằm ở getCause().
ExecutorService pool = Executors.newSingleThreadExecutor();
Future<Integer> f = pool.submit(() -> Integer.parseInt("abc"));
try {
f.get();
} catch (ExecutionException e) {
System.out.println("Việc thất bại: " + e.getCause().getClass().getSimpleName());
}
pool.shutdown();Mẹo: Nếu bạn dùng
execute(Runnable)và việc ném ngoại lệ, luồng của pool in stack trace rồi chết; cònsubmitthì "nuốt" ngoại lệ vàoFuture— nếu không bao giờ gọiget(), lỗi sẽ im lặng biến mất.
6.5. invokeAll — gửi cả lô và đợi
Khi có sẵn danh sách việc, invokeAll(tasks) gửi hết, chờ tất cả xong và trả danh sách Future theo đúng thứ tự đầu vào.
ExecutorService pool = Executors.newFixedThreadPool(4);
int[] data = new int[100];
for (int i = 0; i < data.length; i++) data[i] = (i * 37) % 101;
List<Callable<Integer>> tasks = new ArrayList<>();
for (int b = 0; b < 4; b++) {
final int from = b * 25, to = from + 25;
tasks.add(() -> {
int best = Integer.MIN_VALUE;
for (int i = from; i < to; i++) best = Math.max(best, data[i]);
return best;
});
}
int max = Integer.MIN_VALUE;
for (Future<Integer> f : pool.invokeAll(tasks)) max = Math.max(max, f.get());
pool.shutdown();
System.out.println("Max toàn mảng = " + max);Mẫu "chia khối → mỗi khối một Callable → invokeAll → gộp kết quả (max, tổng, top-K, sắp xếp rồi merge)" rất hay gặp. Bạn cũng sẽ gặp invokeAny (trả kết quả của việc xong sớm nhất) nhưng ít dùng hơn.
6.5b. Thứ tự thực thi trong pool
Pool nhiều luồng không đảm bảo thứ tự chạy của các việc; chỉ pool một luồng mới chạy theo đúng thứ tự gửi:
ExecutorService one = Executors.newSingleThreadExecutor();
List<Future<?>> fs = new ArrayList<>();
for (int i = 1; i <= 3; i++) {
final int n = i;
fs.add(one.submit(() -> System.out.println("Việc " + n)));
}
for (Future<?> f : fs) f.get();
one.shutdown();Lưu ý: Với pool nhiều luồng, đừng
printlntừ trong các việc rồi mong kết quả đúng thứ tự — dòng in sẽ xen kẽ không đoán trước. Hãy để mỗi việc trả về giá trị (hoặc ghi vào ôres[i]riêng), chờ xong rồi mới in từ luồngmaintheo thứ tự mong muốn.
6.6. Giới thiệu nhanh CompletableFuture
CompletableFuture (Java 8) cho phép xâu chuỗi các bước bất đồng bộ mà không phải get() chặn ở giữa:
CompletableFuture<Integer> f = CompletableFuture
.supplyAsync(() -> 6 * 7)
.thenApply(x -> x + 8);
System.out.println("Kết quả = " + f.join());Bạn chỉ cần biết nó tồn tại và đọc được cú pháp; buổi sau (Lambda, Stream) sẽ giúp bạn quen phong cách này.
Vừa học xong thread pool, Callable và Future, hãy làm các bài sau để tính song song bằng ExecutorService, lấy kết quả theo thứ tự bằng Future và gộp bằng invokeAll.
7. Chờ nhau và chia để trị: CountDownLatch, Fork/Join
7.1. CountDownLatch — "chờ cho đủ N người"
CountDownLatch(n) là một bộ đếm lùi: các luồng làm việc gọi countDown() khi xong, luồng khác gọi await() sẽ ngủ cho đến khi bộ đếm về 0. Khác join() ở chỗ bạn không cần cầm tham chiếu từng Thread và dùng được với thread pool (nơi không có Thread để join).
int n = 5;
CountDownLatch done = new CountDownLatch(n);
AtomicInteger sum = new AtomicInteger();
for (int i = 1; i <= n; i++) {
final int v = i;
new Thread(() -> {
sum.addAndGet(v);
done.countDown(); // báo "tôi xong rồi"
}).start();
}
done.await(); // chờ cả 5 luồng
System.out.println("Tổng = " + sum.get());Có hai kiểu dùng:
- Chờ hoàn thành: latch đếm N, luồng
mainawait()(như trên). - Cổng xuất phát: latch đếm 1, N luồng
await()ở cửa; khi luồng điều phối gọicountDown()một lần, tất cả cùng chạy.
Latch dùng một lần: về 0 rồi thì không đặt lại được (cần dùng lại thì chọn CyclicBarrier).
| Công cụ | Ý nghĩa | Dùng lại được |
|---|---|---|
CountDownLatch |
Chờ N sự kiện hoàn thành | Không |
CyclicBarrier |
N luồng chờ nhau tại một điểm hẹn | Có |
Semaphore |
Giới hạn số luồng cùng vào một tài nguyên | Có (cấp/trả giấy phép) |
7.2. Pipeline nhiều pha với CountDownLatch
Nhiều bài toán gồm các pha nối tiếp: pha 2 chỉ bắt đầu khi pha 1 xong hoàn toàn. Mỗi pha dùng một latch.
int n = 6;
long[] sq = new long[n];
CountDownLatch phase1 = new CountDownLatch(n);
for (int i = 0; i < n; i++) {
final int id = i;
new Thread(() -> {
sq[id] = (long) id * id; // pha 1: mỗi luồng ghi ô riêng
phase1.countDown();
}).start();
}
phase1.await(); // đợi hết pha 1
long total = 0; // pha 2: cộng dồn
for (long v : sq) total += v;
System.out.println(Arrays.toString(sq) + " → tổng = " + total);Nhờ countDown() → await() tạo quan hệ happens-before, luồng main thấy đủ mọi sq[i] sau await().
7.3. Fork/Join — chia để trị song song
Với bài toán chia đệ quy (tổng mảng, sắp xếp trộn), framework Fork/Join chia nhỏ việc thành nhiều nhiệm vụ con, chạy trên một ForkJoinPool, rồi gộp kết quả. Mỗi luồng của pool có hàng đợi riêng và có thể "ăn trộm" việc (work stealing) của luồng bận hơn, nên rất cân bằng tải.
Bạn kế thừa RecursiveTask<V> (có kết quả) hoặc RecursiveAction (không có kết quả), viết compute(): nhỏ thì tính thẳng, lớn thì chia đôi, fork() một nửa, compute() nửa kia, rồi join().
class SumTask extends RecursiveTask<Long> {
private final int[] a;
private final int from, to;
SumTask(int[] a, int from, int to) { this.a = a; this.from = from; this.to = to; }
@Override
protected Long compute() {
if (to - from <= 1000) { // đủ nhỏ → tính thẳng
long s = 0;
for (int i = from; i < to; i++) s += a[i];
return s;
}
int mid = (from + to) / 2;
SumTask left = new SumTask(a, from, mid), right = new SumTask(a, mid, to);
left.fork(); // chạy nửa trái ở luồng khác
long r = right.compute(); // tự tính nửa phải
return left.join() + r;
}
}
public class Main {
public static void main(String[] args) {
int[] a = new int[100000];
for (int i = 0; i < a.length; i++) a[i] = i + 1;
ForkJoinPool pool = new ForkJoinPool(4);
System.out.println("Tổng = " + pool.invoke(new SumTask(a, 0, a.length)));
pool.shutdown();
}
}Ngưỡng (1000 ở trên) quan trọng: quá nhỏ thì tốn công tạo nhiệm vụ hơn tính toán; quá lớn thì không đủ song song. Quy tắc quen tay: fork một nửa, tự tính nửa còn lại để luồng hiện tại không ngồi chờ.
| Công cụ | Dùng khi |
|---|---|
Thread + join |
Ít luồng, bài toán đơn giản |
ExecutorService |
Nhiều việc độc lập, cần giới hạn số luồng |
Fork/Join |
Chia đệ quy kiểu chia để trị (sắp xếp trộn, duyệt cây, tổng đoạn) |
7.4. Luồng song song (parallel stream) — bạn sẽ gặp ở buổi 10
Từ Java 8, nhiều phép tính trên dãy số có thể song song hóa chỉ bằng .parallel(); bên dưới nó dùng ForkJoinPool.commonPool(). Đoạn dưới in Tổng 1..1.000.000 = 500000500000 khi chạy trên máy của bạn (môi trường chấm trực tuyến giới hạn số luồng nên ta không đặt nút chạy thử):
long sum = LongStream.rangeClosed(1, 1_000_000).parallel().sum();
System.out.println("Tổng 1..1.000.000 = " + sum);Cảnh báo:
parallel()chỉ an toàn khi các phép toán không có tác dụng phụ và không sửa biến dùng chung. Nếu bạnforEachrồi cộng vào biến ngoài, bạn lại rơi vào race condition của phần 3.
7.5. Chia khối rồi gộp bằng sắp xếp trộn
Ví dụ tổng hợp các ý của buổi: chia mảng thành các khối, mỗi khối do một Callable sắp xếp, sau đó main gộp (merge) các khối đã sắp xếp. Kết quả cố định vì ta chờ Future theo thứ tự rồi mới gộp.
int[] a = {9, 4, 7, 1, 8, 2, 6, 3, 5, 0, 12, 11};
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Future<int[]>> parts = new ArrayList<>();
for (int b = 0; b < 3; b++) {
final int from = b * 4, to = from + 4;
parts.add(pool.submit(() -> {
int[] part = Arrays.copyOfRange(a, from, to);
Arrays.sort(part);
return part;
}));
}
List<Integer> merged = new ArrayList<>();
for (Future<int[]> f : parts) for (int x : f.get()) merged.add(x);
Collections.sort(merged); // gộp đơn giản; bài tập sẽ tự viết merge hai mảng đã sort
pool.shutdown();
System.out.println(merged);Ví dụ thực tế: Hệ thống báo cáo cuối tháng của ngân hàng chia sao kê của hàng triệu tài khoản thành từng lô, mỗi lô do một luồng trong pool tổng hợp, rồi một bước cuối gộp thành báo cáo chung — chính là mẫu chia khối → pool → gộp này.
Vừa học xong CountDownLatch và cách chia khối rồi gộp, hãy làm các bài sau để chờ các luồng khởi động, chạy pipeline nhiều pha và sắp xếp song song theo khối.
Sai lầm thường gặp
| Sai lầm | Hậu quả | Cách đúng |
|---|---|---|
Gọi t.run() thay vì t.start() |
Không có luồng mới, chạy tuần tự trong main |
Luôn dùng start() |
Đọc kết quả mà chưa join()/get()/await() |
Thấy kết quả thiếu hoặc bằng 0 | Chờ xong rồi mới đọc/in |
count++ trên biến chung không đồng bộ |
Mất lần cộng, kết quả sai mỗi lần một khác | synchronized, AtomicInteger |
Dùng volatile cho count++ |
Vẫn race (volatile không atomic) | Atomic* hoặc synchronized |
| Chỉ khóa chỗ ghi, không khóa chỗ đọc | Đọc giá trị cũ/nửa vời | Mọi truy cập dùng cùng một khóa |
| Tách "kiểm tra" và "hành động" ra hai khối khóa | Hai luồng cùng thấy "đủ", cùng làm | Gộp cả hai vào một khối đồng bộ |
| Lấy nhiều khóa theo thứ tự khác nhau | Deadlock | Quy ước thứ tự khóa toàn cục |
wait() trong if, hoặc ngoài synchronized |
Chạy sai khi bị đánh thức giả; IllegalMonitorStateException |
while (điều kiện) wait(); trong synchronized |
Quên shutdown() pool |
Chương trình không thoát | try/finally + shutdown() |
println từ nhiều luồng rồi mong đúng thứ tự |
Đầu ra xen kẽ, không lặp lại được | Thu kết quả (Future, mảng) rồi in từ main |
Nuốt InterruptedException |
Luồng không dừng được khi cần | Dừng việc hoặc gọi lại interrupt() |
Dùng HashMap/ArrayList chung giữa các luồng |
Mất dữ liệu, hỏng cấu trúc | ConcurrentHashMap, hoặc đồng bộ |
Tóm tắt
| Chủ đề | Điều cần nhớ |
|---|---|
| Luồng | Dòng thực thi trong tiến trình; chung heap, riêng stack; luôn có luồng main |
| Tạo luồng | Runnable + new Thread(...), dùng start() (không phải run()), chờ bằng join() |
| Vòng đời | NEW → RUNNABLE → BLOCKED/WAITING/TIMED_WAITING → TERMINATED |
| Race condition | Nhiều luồng ghi dữ liệu chung không đồng bộ → kết quả không cố định |
| Đồng bộ | synchronized (loại trừ + nhìn thấy), volatile (chỉ nhìn thấy), Atomic* (CAS), ReentrantLock |
| Deadlock | Chờ vòng tròn giữa các khóa; phòng bằng thứ tự khóa nhất quán |
| Phối hợp | wait/notify trong synchronized + while; ưu tiên BlockingQueue |
| Thread pool | ExecutorService, submit, Callable/Future, invokeAll, nhớ shutdown() |
| Chờ & chia nhỏ | CountDownLatch cho pha/chờ đủ; Fork/Join (RecursiveTask) cho chia để trị |
| Quy tắc vàng | Chờ xong rồi mới in; mỗi luồng ghi ô riêng; ưu tiên công cụ cấp cao |