Chương 3. Concurrency và Multithreading
Concurrency là chủ đề phân loại ứng viên rõ nhất. Người 2–4 năm hầu như ai cũng biết synchronized và ExecutorService, nhưng rất ít người giải thích được vì sao một biến boolean không có volatile có thể khiến vòng lặp chạy mãi, vì sao Executors.newFixedThreadPool có thể làm sập service, hay vì sao một Spring bean "chạy tốt trên máy dev" lại trả sai dữ liệu khi có tải. Tôi thường đi từ một câu hỏi lý thuyết rồi xoáy vào một tình huống thật trong dự án của bạn. Chương này chuẩn bị cho cả hai.
Người phỏng vấn muốn kiểm tra
- Bạn hiểu Java Memory Model ở mức visibility, atomicity, happens-before, chứ không chỉ thuộc "volatile đọc từ RAM".
- Bạn chọn đúng công cụ: volatile, synchronized, ReentrantLock, Atomic, ConcurrentHashMap, và giải thích được trade-off.
- Bạn cấu hình ThreadPoolExecutor có chủ đích (kích thước, queue, rejection policy) và biết vì sao không dùng các factory mặc định trên production.
- Bạn viết được CompletableFuture gọi song song nhiều service, xử lý lỗi và timeout đúng cách.
- Bạn biết virtual threads (Java 21) giải quyết vấn đề gì, không giải quyết vấn đề gì, và nhận ra race condition trong Spring singleton bean.
3.1 Thread, process và vòng đời thread
Nhắc lại kiến thức
Ở N1.13 bạn đã gặp: start() mới tạo thread mới, run() chỉ là gọi method thường; hai thread cùng count++ cho kết quả thiếu; hai thread giữ khóa và chờ khóa của nhau thì deadlock. Ở Chương 2 (mục 2.2): mỗi thread có Stack riêng chứa biến cục bộ, còn mọi object nằm trên Heap dùng chung. Toàn bộ chương này xoay quanh một hệ quả: thứ gì nằm trên Heap mà nhiều thread cùng sửa thì phải được bảo vệ.
Process là một chương trình đang chạy với không gian bộ nhớ riêng; thread là luồng thực thi bên trong process. Các thread cùng process dùng chung Heap (nên có thể chia sẻ object, và cũng vì thế mà có race condition), nhưng mỗi thread có stack riêng. Tạo process tốn kém và giao tiếp giữa process phải qua IPC/mạng; tạo platform thread rẻ hơn nhưng vẫn là thread của hệ điều hành, tốn khoảng 1 MB stack và chi phí context switch.
Enum Thread.State có đúng sáu trạng thái:
| Trạng thái | Khi nào |
|---|---|
NEW | Đã tạo object, chưa gọi start() |
RUNNABLE | Đang chạy hoặc sẵn sàng chạy, kể cả đang chờ I/O ở tầng native |
BLOCKED | Chờ lấy monitor để vào khối synchronized |
WAITING | Chờ vô thời hạn: wait(), join(), LockSupport.park() |
TIMED_WAITING | Chờ có thời hạn: sleep(ms), wait(ms), poll(timeout) |
TERMINATED | Đã chạy xong |
Ghi chú
Nhiều tài liệu vẽ thêm trạng thái "Running". Java không có trạng thái đó; đang chạy trên CPU vẫn là RUNNABLE. Chi tiết này quan trọng khi đọc thread dump: thread đang chờ socket đọc dữ liệu từ DB vẫn hiện là RUNNABLE.
sleep() giữ nguyên lock đang cầm; wait() nhả monitor và phải được gọi trong khối synchronized trên chính object đó. Trong code hiện đại, bạn hiếm khi tự dùng wait/notify; BlockingQueue, CountDownLatch, CompletableFuture làm việc đó an toàn hơn.
Thread, race condition, lock và monitor bằng lời thường
Thread là một "người thợ" thực hiện lần lượt từng lệnh của chương trình. Một process có nhiều thợ làm việc cùng lúc trong cùng một xưởng (Heap): mỗi thợ có cuốn sổ tay riêng (Stack) nhưng dùng chung dụng cụ và bảng ghi trên tường (object trên Heap). Trên máy nhiều core, các thợ thật sự chạy song song; trên một core, hệ điều hành cho từng thợ chạy một lát rồi đổi sang thợ khác (context switch), và nó có thể đổi ở bất kỳ lệnh nào.
Race condition (điều kiện tranh chấp) là khi kết quả đúng hay sai phụ thuộc vào thứ tự xen kẽ ngẫu nhiên của các thread. Ví dụ kinh điển là count++: trông như một lệnh nhưng thực chất gồm ba bước đọc, cộng, ghi. Bảng dưới đây là một cách xen kẽ khiến mất một lần tăng, bắt đầu với count = 10:
| Thời điểm | Thread A | Thread B | Giá trị count trên Heap |
|---|---|---|---|
| t1 | Đọc count được 10 | 10 | |
| t2 | Đọc count được 10 | 10 | |
| t3 | Tính 10 + 1 = 11 | 10 | |
| t4 | Tính 10 + 1 = 11 | 10 | |
| t5 | Ghi 11 | 11 | |
| t6 | Ghi 11 | 11 (đúng ra phải là 12) |
Lock (khóa) là cơ chế đảm bảo mỗi lúc chỉ một thread được chạy một đoạn code gọi là vùng găng (critical section). Trong Java, mỗi object đều có sẵn một khóa gọi là monitor; từ khóa synchronized (obj) nghĩa là "phải cầm được monitor của obj mới được vào, ra khỏi khối thì trả lại". Thread đến sau phải đứng chờ ở trạng thái BLOCKED. Cùng kịch bản trên nhưng count++ nằm trong synchronized (this):
| Thời điểm | Thread A | Thread B | Monitor của object |
|---|---|---|---|
| t1 | Lấy monitor thành công | A giữ | |
| t2 | Đọc 10 | Muốn lấy monitor, phải chờ (BLOCKED) | A giữ |
| t3 | Tính 11, ghi 11 | Vẫn chờ | A giữ |
| t4 | Ra khỏi khối, nhả monitor | Tự do | |
| t5 | Lấy monitor, đọc 11 | B giữ | |
| t6 | Tính 12, ghi 12, nhả monitor | Tự do |
Giải thích: Lock không làm các bước nhanh hơn, nó chỉ biến ba bước đọc, cộng, ghi thành một khối không bị chen ngang từ thread khác cùng dùng khóa đó. Cái giá là thread B phải chờ, nên vùng găng càng ngắn càng tốt. Lưu ý quan trọng: hai thread chỉ loại trừ nhau khi khóa cùng một object; mỗi thread khóa một object khác nhau thì như không khóa (xem ví dụ WrongLockBug ở 3.4).
3.2 Tạo thread: thực tế không ai new Thread
Câu trả lời sách vở là "extends Thread hoặc implements Runnable". Câu trả lời tôi muốn nghe: trong code backend, bạn không tự tạo thread, mà gửi task vào một ExecutorService được quản lý (hoặc để Spring quản lý qua @Async với ThreadPoolTaskExecutor). Lý do: giới hạn số thread, tái sử dụng thread, đặt tên thread để đọc log và thread dump, tắt gọn gàng khi ứng dụng dừng.
Runnable: không trả kết quả, không ném checked exception.Callable<V>: trả kết quả, được ném checked exception;submit()trả vềFuture<V>.- Exception trong task gửi bằng
submit()bị giữ trongFuture: nếu không ai gọiget(), lỗi biến mất hoàn toàn, không có dòng log nào. Vớiexecute(), exception đi tớiUncaughtExceptionHandlercủa thread.
Đoạn code dưới đây gửi hai task bằng submit(): một task trả về kết quả, một task ném exception nhưng không ai lấy kết quả của nó:
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public class SubmitDemo {
public static void main(String[] args) throws Exception {
// Java 19+: ExecutorService is AutoCloseable, close() waits for running tasks
try (ExecutorService pool = Executors.newFixedThreadPool(4)) {
Future<Integer> f = pool.submit(() -> 40 + 2); // Callable
Future<?> bad = pool.submit(() -> { throw new IllegalStateException("lỗi"); }); // failing task
System.out.println(f.get()); // 42
// bad.get() is never called -> the exception is silently swallowed
}
}
}Giải thích: Chương trình chỉ in 42 rồi kết thúc bình thường. IllegalStateException("lỗi") không xuất hiện ở đâu cả: không stack trace, không dòng log nào. Lý do là submit() bọc task trong một FutureTask, mọi exception trong task bị bắt lại và cất vào Future; nó chỉ được ném ra (bọc trong ExecutionException) khi có người gọi bad.get(). Nếu dùng pool.execute(...) thay cho submit, exception đi tới UncaughtExceptionHandler và ít nhất in stack trace ra stderr. Trong dự án, cách sửa là luôn lấy kết quả bằng get()/join(), hoặc bắt và log exception ngay trong task, hoặc dùng CompletableFuture với exceptionally/whenComplete.
3.3 Java Memory Model: visibility, reordering, happens-before
Visibility và happens-before bằng lời thường
Visibility (khả năng nhìn thấy) là câu hỏi: khi thread A đã ghi một giá trị, thread B có thấy giá trị đó không? Nghe hiển nhiên là có, nhưng để chạy nhanh, CPU giữ giá trị trong register và bộ đệm riêng của từng core, còn JIT được phép sắp xếp lại lệnh hoặc chỉ đọc một biến một lần rồi dùng lại. Kết quả là B có thể nhìn thấy giá trị cũ rất lâu, thậm chí mãi mãi. Bảng dưới đây mô tả cờ dừng running không có volatile:
| Thời điểm | Thread main | Thread worker |
|---|---|---|
| t1 | Đọc running = true, JIT giữ giá trị này trong register | |
| t2 | Lặp n++, không đọc lại running từ bộ nhớ | |
| t3 | Ghi running = false | Vẫn lặp, dùng giá trị true cũ |
| t4 | join() chờ worker | Vẫn lặp mãi, chương trình treo |
Happens-before là "giấy bảo đảm" của Java: nếu thao tác X happens-before thao tác Y, thì mọi thứ X (và mọi lệnh trước X trong cùng thread) đã ghi đều chắc chắn được Y nhìn thấy. Không có giấy bảo đảm này thì không có gì chắc chắn cả. Ví dụ ghi volatile happens-before lần đọc biến đó sau này. Bảng dưới đây dùng một cờ volatile boolean ready để công bố dữ liệu data (không volatile):
| Thời điểm | Thread A (ghi) | Thread B (đọc) |
|---|---|---|
| t1 | data = 42 | Đọc ready = false, chờ tiếp |
| t2 | ready = true (ghi volatile) | |
| t3 | Đọc ready = true (đọc volatile) | |
| t4 | Đọc data: chắc chắn là 42 |
Giải thích: Chuỗi bảo đảm là: data = 42 đứng trước ready = true trong cùng thread A; ghi volatile ready happens-before lần đọc thấy true ở B; lần đọc đó đứng trước lệnh đọc data trong thread B. Nối ba mắt xích lại, data = 42 happens-before lệnh đọc data của B, nên B không thể thấy 0. Nếu bỏ volatile khỏi ready, chuỗi bị đứt: B có thể không bao giờ thấy ready = true, hoặc thấy ready = true nhưng data vẫn là 0 do lệnh bị sắp xếp lại.
JMM trả lời câu hỏi: khi thread A ghi một biến, lúc nào thread B chắc chắn đọc được giá trị mới? Câu trả lời mặc định là: không có gì đảm bảo, trừ khi hai thao tác có quan hệ happens-before. Có ba vấn đề riêng biệt:
- Visibility: giá trị A ghi có thể chưa được B nhìn thấy (nằm trong register, store buffer của CPU, hoặc JIT đã đưa việc đọc biến ra ngoài vòng lặp).
- Reordering: compiler, JIT và CPU được phép đổi thứ tự lệnh miễn là kết quả trong một thread không đổi. Thread khác có thể quan sát thấy thứ tự khác.
- Atomicity:
count++gồm đọc, cộng, ghi; hai thread xen kẽ nhau sẽ mất cập nhật.
Các quy tắc happens-before quan trọng: trong cùng một thread, lệnh trước happens-before lệnh sau; unlock một monitor happens-before mọi lần lock sau đó trên cùng monitor; ghi một biến volatile happens-before mọi lần đọc biến đó xảy ra sau; Thread.start() happens-before mọi lệnh trong thread mới; mọi lệnh trong thread happens-before khi thread khác join() xong nó.
Đoạn code dưới đây dùng một cờ running để thread main báo cho thread worker dừng vòng lặp sau nửa giây:
public class StopFlagDemo {
private static volatile boolean running = true; // without volatile the loop may run forever
public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
long n = 0;
while (running) { n++; } // without volatile, JIT may read running only once
System.out.println("Dừng sau " + n + " vòng");
});
worker.start();
Thread.sleep(500);
running = false; // volatile write -> worker is guaranteed to see it
worker.join();
}
}Giải thích: Có volatile, sau khoảng 500 ms worker thoát vòng lặp, in "Dừng sau ... vòng" và chương trình kết thúc. Bỏ volatile, chương trình có thể treo mãi. Lý do: không có quan hệ happens-before giữa lệnh ghi running = false của main và lệnh đọc của worker, nên JIT được phép coi running không đổi bên trong vòng lặp và biến while (running) { n++; } thành "đọc running một lần, nếu true thì lặp vô hạn" (hoisting). Lỗi thường chỉ xuất hiện khi vòng lặp đã "nóng" và được C2 biên dịch, nên chạy thử vài lần có thể không thấy, đúng kiểu bug "máy dev chạy bình thường". Cách sửa: khai báo volatile như trên, dùng AtomicBoolean, hoặc dùng cơ chế interrupt (worker.interrupt() và kiểm tra Thread.currentThread().isInterrupted()).
Ghi chú
Nhiều tài liệu giải thích "volatile buộc đọc/ghi trực tiếp RAM, bỏ qua CPU cache". Đó là cách hình dung đơn giản hóa và không chính xác: CPU hiện đại có giao thức đồng bộ cache. Bản chất là volatile cấm một số kiểu reordering (chèn memory barrier) và thiết lập quan hệ happens-before, nên mọi thứ thread A ghi trước khi ghi biến volatile đều hiển thị với thread B sau khi B đọc biến đó.
3.4 volatile, synchronized, ReentrantLock và Atomic
| Công cụ | Đảm bảo | Điểm riêng | Dùng khi |
|---|---|---|---|
volatile | Visibility, thứ tự | Không có atomicity cho thao tác phức hợp | Cờ trạng thái, publish object |
synchronized | Loại trừ lẫn nhau, visibility | Tự nhả lock, không timeout | Vùng găng đơn giản (mặc định) |
ReentrantLock | Như synchronized | tryLock(timeout), interrupt, fairness, nhiều Condition | Cần timeout, tránh pinning (Java 21) |
AtomicInteger/Long | Atomicity trên một biến | CAS, không chặn thread | Bộ đếm, cập nhật một giá trị |
volatilephù hợp cho cờ dừng, cấu hình được reload, và fieldinstancetrong double-checked locking. Không dùng chocount++.synchronizedlà reentrant (thread đang giữ lock vào lại được), tự nhả lock khi ra khỏi khối kể cả khi có exception. Đơn giản, JVM tối ưu tốt.ReentrantLocklinh hoạt hơn nhưng bắt buộcunlock()trongfinally. Chọn nó khi cầntryLockcó timeout (tránh deadlock), khi cần chờ có thể bị interrupt, hoặc (với Java 21) để tránh pinning virtual thread.AtomicInteger.incrementAndGet()dùng CAS (compare-and-swap): đọc giá trị, tính giá trị mới, chỉ ghi nếu giá trị hiện tại vẫn như lúc đọc, nếu không thì thử lại. Không chặn thread, rất nhanh khi tranh chấp thấp. Khi rất nhiều thread cùng tăng một bộ đếm (metric),LongAddernhanh hơn vì chia bộ đếm thành nhiều ô.
Câu hỏi đào sâu
"CAS có nhược điểm gì?" — Tranh chấp cao thì vòng lặp thử lại tốn CPU; chỉ nguyên tử trên một biến; và vấn đề ABA (giá trị đổi A thành B rồi về A, CAS vẫn tưởng không đổi), giải quyết bằng AtomicStampedReference gắn thêm version.
CAS bằng lời thường
CAS (compare-and-swap, so sánh rồi đổi) là một lệnh CPU nguyên tử với ý nghĩa: "nếu giá trị hiện tại vẫn đúng là giá trị tôi đã đọc, hãy đổi nó thành giá trị mới; nếu không, báo thất bại". Giống việc sửa một ô trên Google Sheet mà chỉ được lưu nếu chưa ai sửa ô đó kể từ lúc bạn mở. AtomicInteger.incrementAndGet() lặp lại "đọc, tính, CAS" cho tới khi CAS thành công. Hai thread cùng tăng từ 10:
| Thời điểm | Thread A | Thread B | Giá trị |
|---|---|---|---|
| t1 | Đọc 10 | Đọc 10 | 10 |
| t2 | CAS(10 → 11): thành công | 11 | |
| t3 | CAS(10 → 11): thất bại vì giá trị đã là 11 | 11 | |
| t4 | Đọc lại 11 | 11 | |
| t5 | CAS(11 → 12): thành công | 12 |
Giải thích: Không thread nào bị chặn (BLOCKED) như khi dùng lock; B chỉ phải thử lại một vòng. Khi tranh chấp thấp, cách này nhanh hơn lock nhiều. Khi hàng chục thread cùng tăng một biến, số lần thử lại tăng vọt và tốn CPU, đó là lúc dùng LongAdder.
volatile và synchronized: code sai rồi code sửa
Lỗi thứ nhất: dùng volatile cho bộ đếm vì nghĩ "volatile là thread-safe". Đoạn code dưới đây cho hai thread cùng tăng ba bộ đếm, mỗi thread 100.000 lần:
import java.util.concurrent.atomic.AtomicInteger;
public class VolatileCounterBug {
static volatile int volatileCount = 0; // WRONG: visibility only, no atomicity
static int syncCount = 0;
static final AtomicInteger atomicCount = new AtomicInteger();
static synchronized void incSync() { syncCount++; } // FIX 1: one thread at a time
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> {
for (int i = 0; i < 100_000; i++) {
volatileCount++; // read, add, write can interleave
incSync();
atomicCount.incrementAndGet(); // FIX 2: lock-free CAS loop
}
};
Thread t1 = new Thread(job), t2 = new Thread(job);
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println("volatile: " + volatileCount); // usually less than 200000, varies per run
System.out.println("synchronized: " + syncCount); // prints: synchronized: 200000
System.out.println("atomic: " + atomicCount.get()); // prints: atomic: 200000
}
}Giải thích: Dòng volatile thường in một số nhỏ hơn 200000 và mỗi lần chạy một khác, còn hai dòng sau luôn in 200000.
- Sai ở đâu:
volatilechỉ đảm bảo mỗi lần đọc thấy giá trị mới nhất và mỗi lần ghi được thấy ngay, nhưng không ngăn hai thread cùng đọc 10 rồi cùng ghi 11 như bảng race condition ở 3.1. - Sửa 1:
synchronizedgộp đọc, cộng, ghi thành vùng găng; đồng thời lúc nhả monitor, giá trị mới được công bố cho thread lấy monitor sau (happens-before). - Sửa 2:
AtomicIntegerdùng CAS, không chặn thread, là lựa chọn gọn nhất cho bộ đếm.
Lỗi thứ hai: có synchronized nhưng khóa nhầm object. Đoạn code dưới đây có bộ đếm static dùng chung, nhưng method synchronized thường lại khóa this, tức là mỗi instance một khóa:
public class WrongLockBug {
static int total = 0; // shared by ALL instances
synchronized void addWrong() { total++; } // WRONG: locks 'this', one lock per instance
static synchronized void addFixed() { total++; } // FIX: locks WrongLockBug.class, one shared lock
public static void main(String[] args) throws InterruptedException {
WrongLockBug a = new WrongLockBug(), b = new WrongLockBug();
Thread t1 = new Thread(() -> { for (int i = 0; i < 100_000; i++) a.addWrong(); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 100_000; i++) b.addWrong(); });
t1.start(); t2.start(); t1.join(); t2.join();
System.out.println("instance lock: " + total); // usually less than 200000, varies per run
total = 0;
Thread t3 = new Thread(() -> { for (int i = 0; i < 100_000; i++) addFixed(); });
Thread t4 = new Thread(() -> { for (int i = 0; i < 100_000; i++) addFixed(); });
t3.start(); t4.start(); t3.join(); t4.join();
System.out.println("class lock: " + total); // prints: class lock: 200000
}
}Giải thích: Dòng đầu thường in số nhỏ hơn 200000, dòng sau luôn in 200000. synchronized trên instance method khóa monitor của this: thread 1 khóa a, thread 2 khóa b, hai khóa khác nhau nên không loại trừ nhau, trong khi cả hai cùng sửa một field static. static synchronized khóa monitor của object WrongLockBug.class, chỉ có một cho cả chương trình, nên hai thread thực sự lần lượt. Quy tắc: khóa phải được chọn theo dữ liệu cần bảo vệ; dữ liệu static thì khóa ở mức class (hoặc một static final Object LOCK), dữ liệu của từng object thì khóa this. Trong Spring, bean là singleton nên synchronized method của bean khóa đúng một object, nhưng đừng quên nó chỉ đúng trong một JVM: chạy 3 pod là 3 khóa độc lập.
3.5 Deadlock: điều kiện, phòng tránh, phát hiện
Deadlock xảy ra khi có đủ bốn điều kiện: mutual exclusion (tài nguyên độc quyền), hold and wait (giữ cái này, chờ cái kia), no preemption (không ai giật lock được), circular wait (chờ vòng tròn). Phá một điều kiện là hết deadlock; trên thực tế ta phá circular wait hoặc hold and wait.
Ví dụ kinh điển: chuyển tiền A sang B và B sang A cùng lúc, mỗi thread khóa tài khoản nguồn trước. Cách sửa là lock ordering, luôn khóa theo thứ tự cố định (ví dụ theo id). Đoạn code dưới đây là method chuyển tiền đã áp dụng lock ordering:
import java.util.concurrent.locks.ReentrantLock;
public class TransferService {
static final class Account {
final long id;
long balance;
final ReentrantLock lock = new ReentrantLock();
Account(long id, long balance) { this.id = id; this.balance = balance; }
}
void transfer(Account from, Account to, long amount) {
Account first = from.id < to.id ? from : to; // always lock the smaller id first
Account second = from.id < to.id ? to : from;
first.lock.lock(); // every thread competes for the smaller id first -> no circular wait
try {
second.lock.lock();
try {
if (from.balance < amount) throw new IllegalStateException("Không đủ số dư");
from.balance -= amount;
to.balance += amount;
} finally { second.lock.unlock(); }
} finally { first.lock.unlock(); }
}
}Giải thích: Nếu viết đơn giản from.lock.lock(); to.lock.lock();, thread 1 chuyển A→B giữ lock A và chờ B, cùng lúc thread 2 chuyển B→A giữ lock B và chờ A: hai thread chờ nhau mãi mãi, không exception, không log, request cứ treo. Bản trên luôn khóa tài khoản có id nhỏ hơn trước, bất kể chiều chuyển tiền, nên cả hai thread đều tranh lock của A trước. Thread nào lấy được A mới được tranh B; thread kia đứng chờ ở A mà không giữ lock nào, nên vòng chờ không thể hình thành. Hai khối try/finally lồng nhau đảm bảo nhả cả hai lock theo thứ tự ngược lại, kể cả khi ném IllegalStateException vì không đủ số dư.
Các cách phòng khác: dùng tryLock(timeout) và thả hết lock rồi thử lại nếu không lấy được; thu nhỏ phạm vi lock; không gọi code bên ngoài (callback, gọi HTTP, gọi service khác) khi đang giữ lock.
Phát hiện: jstack <pid> (hoặc jcmd <pid> Thread.print) tự in mục "Found one Java-level deadlock" kèm các thread và lock liên quan. Trong code có thể dùng ThreadMXBean.findDeadlockedThreads() cho health check. Lưu ý deadlock trong database (hai transaction khóa row ngược thứ tự) là chuyện khác: DB tự phát hiện và hủy một transaction, ứng dụng nhận exception và cần retry.
3.6 ThreadPoolExecutor: cấu hình có chủ đích
Hiểu thứ tự xử lý khi submit task là chìa khóa:
- Số thread đang chạy nhỏ hơn
corePoolSize: tạo thread mới. - Đủ core thread: đưa task vào
workQueue. - Queue đầy và số thread nhỏ hơn
maximumPoolSize: tạo thêm thread. - Queue đầy và đã đạt max: gọi
RejectedExecutionHandler.
Hệ quả bất ngờ: với queue không giới hạn, bước 3 không bao giờ xảy ra, maximumPoolSize vô nghĩa. Đó chính là cấu hình của các factory tiện lợi:
| Factory | Cấu hình thực tế | Rủi ro production |
|---|---|---|
newFixedThreadPool(n) | n thread, LinkedBlockingQueue không giới hạn | Tải vượt khả năng xử lý thì queue phình đến OOM, request chờ vô hạn |
newSingleThreadExecutor() | 1 thread, queue không giới hạn | Như trên |
newCachedThreadPool() | Tối đa Integer.MAX_VALUE thread | Tải đột biến tạo hàng nghìn thread, cạn bộ nhớ native |
Trên production, tôi muốn thấy cấu hình tường minh với queue có giới hạn và rejection policy có chủ đích. Đoạn code dưới đây tạo một pool riêng cho việc xuất báo cáo:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class PoolConfig {
public static ThreadPoolExecutor reportPool() {
AtomicInteger seq = new AtomicInteger();
return new ThreadPoolExecutor(
8, // corePoolSize
16, // maximumPoolSize
60, TimeUnit.SECONDS, // non-core threads idle for 60s are removed
new ArrayBlockingQueue<>(500), // bounded queue
r -> new Thread(r, "report-worker-" + seq.incrementAndGet()), // named threads
new ThreadPoolExecutor.CallerRunsPolicy()); // overloaded: the caller runs the task itself -> natural backpressure
}
}Giải thích: Pool này nhận task theo đúng thứ tự core → queue → max → reject ở trên: 8 task đầu mỗi task có một thread core; task tiếp theo xếp vào queue, tối đa 500 task; chỉ khi queue đầy mới tạo thêm thread cho đến 16. Khi cả 16 thread đều bận và queue đầy, task mới bị CallerRunsPolicy bắt chạy ngay trên thread đã gọi execute()/submit() (ví dụ thread Tomcat). Thread đó bận chạy task nên tạm thời không nhận thêm việc mới, tạo ra backpressure tự nhiên thay vì để queue phình tới OOM. ThreadFactory đặt tên report-worker-N để khi đọc thread dump hay log bạn biết ngay pool nào đang nghẽn. Các thread vượt core (thứ 9 đến 16) rảnh quá 60 giây thì bị hủy để trả lại tài nguyên.
Bốn rejection policy có sẵn: AbortPolicy (mặc định, ném RejectedExecutionException, phù hợp khi muốn trả lỗi 503 nhanh), CallerRunsPolicy (tạo backpressure tự nhiên), DiscardPolicy và DiscardOldestPolicy (bỏ task âm thầm, chỉ dùng khi mất task là chấp nhận được, như metric).
Kích thước pool: tác vụ thuần CPU thì xấp xỉ số core; tác vụ I/O thì theo công thức số core * (1 + thời gian chờ / thời gian tính). Công thức chỉ để khởi điểm, con số cuối cùng phải đến từ đo tải. Và nhớ rằng pool lớn không giúp gì nếu phía sau là connection pool DB chỉ có 10 connection.
Đi từng bước: task thứ 1, 5, 11, 20 đến thì chuyện gì xảy ra?
Để thấy rõ thứ tự core → queue → max → reject, hãy lấy một pool nhỏ: corePoolSize = 4, maximumPoolSize = 8, queue ArrayBlockingQueue sức chứa 6, rejection policy mặc định AbortPolicy. Giả sử mỗi task chạy rất lâu (chưa task nào xong trong lúc đang gửi). Đoạn code dưới đây gửi 20 task như vậy và in trạng thái pool tại các task đáng chú ý:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class PoolWalkthrough {
public static void main(String[] args) throws InterruptedException {
CountDownLatch release = new CountDownLatch(1); // keeps every task busy
ThreadPoolExecutor pool = new ThreadPoolExecutor(4, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(6)); // default policy: AbortPolicy
Runnable longTask = () -> {
try { release.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
for (int id = 1; id <= 20; id++) {
try {
pool.execute(longTask);
if (id == 1 || id == 5 || id == 10 || id == 11 || id == 14)
System.out.printf("task %d accepted: threads=%d, queued=%d%n",
id, pool.getPoolSize(), pool.getQueue().size());
} catch (RejectedExecutionException e) {
if (id == 15 || id == 20) System.out.println("task " + id + " rejected");
}
}
release.countDown(); // let all tasks finish
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
}
}Output của chương trình (luôn như nhau vì không task nào xong trước khi gửi hết):
task 1 accepted: threads=1, queued=0
task 5 accepted: threads=4, queued=1
task 10 accepted: threads=4, queued=6
task 11 accepted: threads=5, queued=6
task 14 accepted: threads=8, queued=6
task 15 rejected
task 20 rejected| Task đến | Tình trạng pool lúc đó | Pool làm gì |
|---|---|---|
| Task 1 | 0 thread | Dưới core (0 < 4): tạo thread 1, giao task 1 cho nó |
| Task 5 | 4 thread bận, queue trống | Đủ core: đưa task 5 vào queue (vị trí 1/6) |
| Task 11 | 4 thread, queue đầy 6/6 (task 5–10) | Queue đầy, còn dưới max (4 < 8): tạo thread 5 chạy ngay task 11 |
| Task 20 | 8 thread bận, queue đầy | Đã đạt max và queue đầy: AbortPolicy ném RejectedExecutionException |
Giải thích: Có hai điều ngược trực giác. Thứ nhất, pool không tăng thread lên max trước rồi mới xếp hàng; nó xếp hàng trước, chỉ khi queue đầy mới tạo thêm thread. Thứ hai, thread mới tạo ở bước 3 chạy chính task vừa đến (task 11), trong khi task 5 đến trước lại vẫn nằm trong queue, nên pool không đảm bảo thứ tự FIFO tuyệt đối. Từ task 15 trở đi (8 thread + 6 chỗ queue = 14 task đang giữ) mọi task đều bị từ chối. Nếu thay ArrayBlockingQueue<>(6) bằng LinkedBlockingQueue không giới hạn, pool sẽ không bao giờ vượt 4 thread và cũng không bao giờ từ chối: 16 task còn lại nằm chờ trong queue, đúng rủi ro của newFixedThreadPool.
3.7 CompletableFuture: gọi song song, xử lý lỗi, timeout
Bài toán điển hình: trang chi tiết đơn hàng cần dữ liệu từ ba service. Gọi tuần tự mất tổng thời gian ba lần gọi; gọi song song chỉ mất thời gian của lần chậm nhất.
thenApply(f): biến đổi kết quả (giốngmap).thenCompose(f): khiftrả về một CompletableFuture khác, tránh lồngCompletableFuture<CompletableFuture<T>>(giốngflatMap).thenCombine(other, f): gộp kết quả hai future độc lập.allOf(...): chờ tất cả xong, trả vềCompletableFuture<Void>, sau đó tựjoin()từng future để lấy kết quả.exceptionally(f): trả giá trị dự phòng khi lỗi;handle/whenCompletexử lý cả hai nhánh.orTimeout,completeOnTimeout(Java 9+): đặt timeout mà không cần tự viết scheduler.
Đoạn code dưới đây ghép dữ liệu cho trang chi tiết đơn hàng: order và items được gọi song song; customer phải chờ có order mới gọi được; items là dữ liệu phụ nên chậm hoặc lỗi thì trả rỗng thay vì làm hỏng cả trang:
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class OrderDetailAggregator {
record Detail(String order, String customer, List<String> items) {}
private final ExecutorService ioPool = Executors.newVirtualThreadPerTaskExecutor();
Detail load(String orderId) {
var order = CompletableFuture.supplyAsync(() -> call("order:" + orderId), ioPool);
var customer = order.thenCompose(o -> // depends on the order result
CompletableFuture.supplyAsync(() -> call("customer-of:" + o), ioPool));
var items = CompletableFuture.supplyAsync(() -> List.of(call("items:" + orderId)), ioPool)
.completeOnTimeout(List.of(), 800, TimeUnit.MILLISECONDS) // too slow: fall back to an empty list
.exceptionally(ex -> List.of()); // on error: empty list
CompletableFuture.allOf(customer, items).orTimeout(2, TimeUnit.SECONDS).join(); // wait at most 2 seconds
return new Detail(order.join(), customer.join(), items.join()); // already done, join() returns immediately
}
private String call(String what) { return "data(" + what + ")"; } // simulated HTTP call
}Giải thích: order và items bắt đầu chạy ngay lập tức, song song trên virtual thread của ioPool. customer dùng thenCompose vì cần kết quả của order và bước gọi tiếp lại trả về một CompletableFuture; nếu dùng thenApply ta sẽ nhận được CompletableFuture<CompletableFuture<String>>. Với items, quá 800 ms thì completeOnTimeout điền sẵn list rỗng, còn nếu lỗi thì exceptionally trả list rỗng, nên trang vẫn hiển thị được phần chính. Dòng allOf(...).orTimeout(2, SECONDS).join() chặn tối đa 2 giây; nếu customer lỗi hoặc quá hạn, join() ném CompletionException và caller phải xử lý (ví dụ trả lỗi 502/504). Kết quả là tổng thời gian xấp xỉ max(order + customer, items) thay vì cộng dồn cả ba lời gọi.
Bẫy thường gặp
Gọi supplyAsync(supplier) không truyền executor thì task chạy trên ForkJoinPool.commonPool(), dùng chung với parallel stream của cả JVM, số thread chỉ bằng số core trừ một. Đưa lời gọi HTTP/DB blocking vào đó sẽ làm nghẽn mọi thứ khác dùng common pool. Với I/O, luôn truyền executor riêng (pool có giới hạn hoặc virtual thread executor).
Một số chi tiết đáng nói: join() ném CompletionException (unchecked) còn get() ném ExecutionException (checked), exception gốc nằm trong getCause(). Các method không có hậu tố Async có thể chạy trên thread đã hoàn thành stage trước, nên đừng đặt việc nặng trong thenApply nếu không kiểm soát được thread đó. Và cancel(true) trên CompletableFuture không interrupt thread đang chạy task.
Ví dụ chạy được: gộp ba nguồn dữ liệu có output
CompletableFuture là một "phiếu hẹn": bạn nhận phiếu ngay, còn kết quả sẽ có sau khi một thread khác làm xong. Bạn có thể dặn trước trên phiếu "có kết quả thì làm tiếp việc này" (thenApply, thenCombine) hoặc "nếu lỗi thì dùng giá trị này" (exceptionally) mà không phải đứng chờ. Đoạn code dưới đây gọi song song ba service giả lập: giá (300 ms), tồn kho (200 ms), khuyến mãi (100 ms nhưng lỗi):
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ProductPageDemo {
static String call(String result, long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
return result;
}
public static void main(String[] args) {
try (ExecutorService io = Executors.newFixedThreadPool(3)) {
long start = System.nanoTime();
var price = CompletableFuture.supplyAsync(() -> call("price=100", 300), io);
var stock = CompletableFuture.supplyAsync(() -> call("stock=7", 200), io);
var promo = CompletableFuture.supplyAsync(() -> {
call("", 100);
throw new IllegalStateException("promo service down");
}, io)
.thenApply(Object::toString) // skipped: stage failed
.exceptionally(ex -> "promo=none (" + ex.getCause().getMessage() + ")");
String page = price.thenCombine(stock, (p, s) -> p + ", " + s)
.thenCombine(promo, (ps, pr) -> ps + ", " + pr)
.join(); // waits ~300 ms, not 600
long ms = (System.nanoTime() - start) / 1_000_000;
System.out.println(page); // prints: price=100, stock=7, promo=none (promo service down)
System.out.println("parallel: " + (ms < 500)); // prints: parallel: true
}
}
}Giải thích: Chương trình in price=100, stock=7, promo=none (promo service down) rồi parallel: true.
- Ba lời gọi bắt đầu gần như cùng lúc trên ba thread của pool
io, nên tổng thời gian xấp xỉ lời gọi chậm nhất (300 ms), không phải tổng 600 ms. - Stage
promoném exception, nênthenApplyphía sau bị bỏ qua và lỗi chảy thẳng xuốngexceptionally. Exception nhận được làCompletionExceptionbọc exception gốc, vì vậy phải gọiex.getCause()mới lấy được thông điệppromo service down. thenCombinechờ cả hai future rồi gộp kết quả;join()cuối cùng là chỗ duy nhất main thread đứng chờ.- Nếu bỏ
exceptionally,join()sẽ némCompletionExceptionvà cả trang lỗi theo một service phụ, đúng điều bạn muốn tránh.
3.8 Collection đồng thời và công cụ đồng bộ
| Hashtable | Collections.synchronizedMap | ConcurrentHashMap | |
|---|---|---|---|
| Cách khóa | Mọi method synchronized | Một mutex cho cả map | CAS + khóa riêng từng bucket |
| Đọc | Bị khóa | Bị khóa | Không khóa |
| Null key/value | Không | Theo map gốc (HashMap cho phép) | Không |
| Duyệt khi có ghi | Fail-fast | Phải tự synchronized khi duyệt | Weakly consistent, không ném lỗi |
Hashtable là class di sản, không dùng trong code mới. ConcurrentHashMap ở Java 8+ không còn dùng 16 "segment" như Java 7: bucket trống được ghi bằng CAS, bucket đã có node thì khóa bằng synchronized trên node đầu, thao tác đọc gần như không khóa.
Điều quan trọng nhất: ConcurrentHashMap chỉ đảm bảo từng thao tác là nguyên tử. Chuỗi thao tác "kiểm tra rồi hành động" (check-then-act) vẫn là race condition. Đoạn code dưới đây đếm lượt truy cập theo key và cộng tồn kho theo SKU; các dòng comment WRONG là cách viết check-then-act hay gặp:
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.LongAdder;
public class CounterStore {
private final ConcurrentHashMap<String, LongAdder> counters = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Integer> stock = new ConcurrentHashMap<>();
void hit(String key) {
// WRONG: if (!counters.containsKey(key)) counters.put(key, new LongAdder());
counters.computeIfAbsent(key, k -> new LongAdder()).increment(); // atomic
}
void addStock(String sku, int qty) {
stock.merge(sku, qty, Integer::sum); // WRONG would be: get() then put(get + qty)
}
}Giải thích: Với cách viết sai (WRONG) containsKey rồi put, hai thread có thể cùng thấy key chưa tồn tại, mỗi thread tạo một LongAdder riêng, và lệnh put sau ghi đè lệnh put trước: các lượt đếm đã cộng vào adder bị ghi đè sẽ mất. Tương tự với stock.put(sku, stock.get(sku) + qty): hai thread cùng đọc được 10, cùng cộng 5, cùng ghi 15, trong khi đúng ra phải là 20 (lost update). Từng lệnh riêng lẻ của ConcurrentHashMap an toàn, nhưng khoảng hở giữa hai lệnh thì không. computeIfAbsent và merge gộp cả đọc, tính, ghi vào một thao tác nguyên tử trên key đó. Lưu ý: hàm truyền vào computeIfAbsent/merge phải ngắn và không được sửa chính map đó, vì nó chạy trong lúc map đang giữ khóa bucket.
Lý do ConcurrentHashMap cấm null: trong môi trường đồng thời, get(key) trả null không thể phân biệt "không có key" hay "key có value null", vì giữa lúc get và containsKey map đã có thể thay đổi.
Hai công cụ đồng bộ hay gặp:
CountDownLatch: một hoặc nhiều thread chờ cho đến khi N việc hoàn tất; dùng một lần, không reset được. Hay dùng trong test tích hợp để chờ consumer xử lý xong message.Semaphore: giới hạn số thread đồng thời truy cập tài nguyên, ví dụ chỉ cho phép 10 lời gọi cùng lúc tới API đối tác bị rate limit. Đặc biệt hữu ích với virtual threads (xem 3.10).
3.9 ThreadLocal và rò rỉ trong thread pool
Nhắc lại kiến thức
Memory leak trong Java là object không còn dùng nhưng vẫn reachable từ một GC root; thread đang sống là một GC root (xem 2.3 và 2.4). Thread của pool sống suốt đời ứng dụng, nên mọi thứ gắn vào nó cũng vậy.
ThreadLocal cho mỗi thread một bản sao biến riêng. Spring dùng nó khắp nơi: SecurityContextHolder, transaction đang mở, RequestContextHolder, MDC của logging. Vấn đề là thread trong pool (Tomcat, executor) không bao giờ chết: giá trị bạn set() mà không remove() sẽ còn nguyên cho request tiếp theo do thread đó xử lý. Hậu quả có hai loại: rò rỉ bộ nhớ (object lớn sống mãi), và nghiêm trọng hơn là rò rỉ dữ liệu, request của người dùng B đọc được tenantId hay userId của người dùng A.
Class dưới đây giữ tenantId của request hiện tại trong ThreadLocal và chỉ cho phép dùng theo mẫu an toàn: set, chạy nghiệp vụ, rồi remove:
public final class TenantContext {
private static final ThreadLocal<String> TENANT = new ThreadLocal<>();
public static String current() { return TENANT.get(); }
// Safe pattern: always remove in finally (usually inside a Filter/Interceptor)
public static void runAs(String tenantId, Runnable action) {
TENANT.set(tenantId);
try {
action.run();
} finally {
TENANT.remove(); // mandatory: return the thread to the pool in a clean state
}
}
}Giải thích: runAs("tenantA", action) gắn tenant cho thread hiện tại; mọi code bên trong action (service, repository) gọi TenantContext.current() đều nhận được "tenantA" mà không cần truyền tham số qua từng tầng. Khối finally xóa giá trị kể cả khi action ném exception. Nếu thiếu remove(), thread Tomcat quay về pool vẫn mang "tenantA"; request kế tiếp của tenant B rơi vào đúng thread đó mà vì lý do nào đó không set lại (ví dụ một endpoint đi đường vòng qua filter) sẽ đọc và ghi vào dữ liệu của tenant A. Trong Spring, runAs thường được gọi trong một OncePerRequestFilter để bao trọn cả request.
Ngoài ra, ThreadLocal không tự truyền sang thread khác: code chạy trong @Async hoặc CompletableFuture.supplyAsync sẽ không thấy SecurityContext hay MDC của thread gọi, trừ khi bạn tự truyền (ví dụ TaskDecorator của Spring).
3.10 Virtual Threads (Java 21)
Virtual thread là thread do JVM quản lý, rất nhẹ (stack nằm trên Heap, co giãn theo nhu cầu), có thể tạo hàng triệu cái. Chúng chạy trên một nhóm nhỏ carrier thread (platform thread, mặc định bằng số core). Khi virtual thread gặp thao tác blocking như đọc socket, sleep, chờ lock của java.util.concurrent, JVM tháo nó khỏi carrier (unmount) và cho virtual thread khác chạy. Nhờ vậy, code viết kiểu blocking đơn giản nhưng đạt khả năng xử lý đồng thời gần với lập trình reactive. Đoạn code dưới đây gửi 10.000 task, mỗi task ngủ 1 giây để giả lập một lời gọi I/O:
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
public class VirtualThreadDemo {
public static void main(String[] args) {
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10_000).forEach(i -> executor.submit(() -> {
Thread.sleep(Duration.ofSeconds(1)); // simulated I/O call
return i;
}));
} // 10,000 blocking tasks finish in about 1 second
}
}Giải thích: Chương trình kết thúc sau khoảng 1 giây. Mỗi task chạy trên một virtual thread riêng; khi gặp Thread.sleep, virtual thread được unmount khỏi carrier, nên chỉ vài carrier thread (bằng số core) vẫn phục vụ được cả 10.000 task đang chờ cùng lúc. Khối try-with-resources gọi close() và chờ mọi task xong mới thoát. Lambda có return i nên là một Callable, vì vậy được phép ném InterruptedException của sleep mà không cần try/catch. Nếu thay bằng newFixedThreadPool(200), 10.000 task phải xếp hàng chạy theo lượt và mất khoảng 10.000 / 200 = 50 giây; còn tạo 10.000 platform thread thì tốn nhiều bộ nhớ và dễ chạm giới hạn thread của hệ điều hành. Lưu ý ví dụ nhanh chỉ vì task chờ; nếu mỗi task tính toán CPU trong 1 giây thì virtual thread không nhanh hơn chút nào.
Những điều cần nói đúng:
- Có lợi khi: nhiều tác vụ đồng thời, phần lớn thời gian chờ I/O (gọi HTTP, DB, message broker). Kiểu thread-per-request của Spring MVC hưởng lợi trực tiếp; từ Spring Boot 3.2 chỉ cần
spring.threads.virtual.enabled=true. - Không có lợi khi: tác vụ thuần CPU. Virtual thread không làm code chạy nhanh hơn, chỉ giúp chờ rẻ hơn.
- Không pool virtual thread: tạo mới cho mỗi task (
newVirtualThreadPerTaskExecutor). Pool tồn tại vì platform thread đắt; virtual thread thì không. Muốn giới hạn truy cập tài nguyên (connection DB, API đối tác) thì dùngSemaphorehoặc chính connection pool. - Pinning: ở Java 21, virtual thread blocking bên trong khối
synchronized(hoặc native method) sẽ bị ghim vào carrier, không unmount được. Vài thread bị ghim là đủ chặn toàn bộ carrier. Cách xử lý trên Java 21: đổisynchronizedbao quanh I/O sangReentrantLock, phát hiện bằng JFR eventjdk.VirtualThreadPinnedhoặc-Djdk.tracePinnedThreads=full. Java 24 (JEP 491) đã khắc phục pinning vớisynchronized(Java 25 LTS có sẵn bản sửa này), nhưng nhiều hệ thống production vẫn chạy Java 21. - ThreadLocal vẫn hoạt động nhưng mỗi virtual thread một bản, với hàng triệu thread thì tốn bộ nhớ; không dùng ThreadLocal để cache object nặng. Scoped Values là giải pháp thay thế (preview ở Java 21, chính thức ở Java 25).
- Nút thắt dời xuống tầng dưới: 10.000 virtual thread cùng chờ một HikariCP pool 10 connection thì vẫn chỉ có 10 query chạy cùng lúc.
Carrier thread và pinning qua ví dụ
Carrier thread là platform thread thật của hệ điều hành mà virtual thread "cưỡi" lên để chạy, giống tài xế (carrier) và hành khách (virtual thread): số tài xế ít, số khách rất đông. Khách nào phải chờ (gọi I/O, sleep) thì xuống xe ngồi đợi, tài xế chở khách khác. Khi I/O xong, khách lên lại một xe bất kỳ, có thể là tài xế khác. Đoạn code dưới đây in ra thông tin của hai virtual thread, trong đó có tên carrier đang chở nó:
public class CarrierDemo {
public static void main(String[] args) throws InterruptedException {
Thread vt1 = Thread.ofVirtual().name("vt-1").start(() -> System.out.println(Thread.currentThread()));
Thread vt2 = Thread.ofVirtual().name("vt-2").start(() -> System.out.println(Thread.currentThread()));
vt1.join();
vt2.join();
// prints something like: VirtualThread[#22,vt-1]/runnable@ForkJoinPool-1-worker-1
}
}Giải thích: Mỗi dòng in có dạng VirtualThread[#22,vt-1]/runnable@ForkJoinPool-1-worker-1: phần trước @ là virtual thread, phần sau là carrier, một platform thread trong ForkJoinPool riêng của scheduler virtual thread. Số #22 và số worker thay đổi theo lần chạy, thứ tự hai dòng cũng có thể đảo. Số carrier mặc định bằng số core.
Pinning (bị ghim) là khi virtual thread đang chờ mà không xuống xe được, nên chiếm luôn carrier. Trên Java 21, điều này xảy ra khi virtual thread blocking bên trong khối synchronized hoặc trong native method. Giả sử máy có 2 carrier, 4 virtual thread cùng gọi một method synchronized (mỗi thread khóa một object riêng nên không chờ nhau về lock) bên trong có lời gọi HTTP mất 1 giây:
| Thời điểm | Carrier 1 | Carrier 2 | VT3, VT4 |
|---|---|---|---|
| 0 ms | VT1 vào synchronized, gọi HTTP, bị ghim | VT2 vào synchronized, gọi HTTP, bị ghim | Chờ carrier rảnh |
| 500 ms | Vẫn bị VT1 chiếm dù VT1 chỉ đang chờ | Vẫn bị VT2 chiếm | Vẫn chờ, không làm được gì |
| 1000 ms | VT1 xong, nhả carrier; VT3 lên | VT2 xong; VT4 lên | Bắt đầu chạy |
| 2000 ms | VT3 xong | VT4 xong |
Nếu không bị ghim, cả 4 virtual thread đều xuống xe khi chờ HTTP và tất cả xong trong khoảng 1 giây. Với 10.000 request, pinning biến virtual thread thành một pool chỉ có 2 thread. Đoạn code dưới đây là cặp sai/sửa trên Java 21:
import java.util.concurrent.locks.ReentrantLock;
public class PinningExample {
private final ReentrantLock lock = new ReentrantLock();
// WRONG on Java 21: blocking I/O inside synchronized pins the carrier thread
synchronized String refreshTokenPinned() throws InterruptedException {
Thread.sleep(1000); // stands for an HTTP call to the auth server
return "token";
}
// FIX on Java 21: ReentrantLock lets the virtual thread unmount while waiting
String refreshToken() throws InterruptedException {
lock.lock();
try {
Thread.sleep(1000); // virtual thread unmounts, carrier is free
return "token";
} finally {
lock.unlock();
}
}
}Giải thích: Hai method có cùng ý nghĩa (mỗi lúc một thread làm mới token), khác nhau ở loại khóa. Trên Java 21, refreshTokenPinned giữ carrier suốt 1 giây chờ; refreshToken dùng ReentrantLock, khi sleep (hay I/O) thì virtual thread được tháo khỏi carrier. Phát hiện pinning bằng JFR event jdk.VirtualThreadPinned. Từ Java 24 (JEP 491), synchronized không còn gây pinning trong trường hợp này nên hai method hoạt động như nhau; nhưng nếu production chạy Java 21, đây vẫn là điều phải kiểm tra, kể cả trong thư viện bạn dùng (driver JDBC cũ, HTTP client cũ).
3.11 Race condition trong Spring singleton bean
Bean mặc định là singleton: một instance dùng chung cho mọi request, tức là mọi thread của Tomcat. Bất kỳ field mutable nào trong bean đều là trạng thái chia sẻ. Đây là lỗi tôi gặp trong code review nhiều hơn mọi lỗi concurrency khác cộng lại. Đoạn code dưới đây là một service singleton có hai lỗi trạng thái chia sẻ, kèm phiên bản đã sửa headerFixed:
import java.text.SimpleDateFormat;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Date;
// @Service
public class InvoiceService {
private String currentCustomer; // WRONG: request A writes, B overwrites, A reads B's value
private final SimpleDateFormat legacyFmt = new SimpleDateFormat("dd/MM/yyyy"); // WRONG: not thread-safe
private static final DateTimeFormatter FMT = DateTimeFormatter.ofPattern("dd/MM/yyyy"); // OK: immutable
public String header(String customer, Date legacy, LocalDate issued) {
this.currentCustomer = customer;
return currentCustomer + " - " + legacyFmt.format(legacy) + " - " + FMT.format(issued);
}
public String headerFixed(String customer, LocalDate issued) {
return customer + " - " + FMT.format(issued); // only parameters and immutable objects
}
}Giải thích: Khi hai request chạy đồng thời trên hai thread Tomcat, request A gán currentCustomer = "An"; trước khi A kịp đọc lại field để ghép chuỗi, request B đã gán "Bình", và A trả về header mang tên khách hàng Bình, tức là lộ dữ liệu của người khác. SimpleDateFormat giữ trạng thái trung gian (một Calendar bên trong) trong lúc format/parse, nên dùng chung giữa nhiều thread có thể cho ra ngày sai, hoặc ném exception khó hiểu như NumberFormatException khi parse. Cả hai lỗi đều không lộ ra khi test tay từng request một. headerFixed an toàn vì chỉ dùng tham số (nằm trên stack riêng của mỗi thread) và DateTimeFormatter là object bất biến, dùng chung thoải mái.
Nguyên tắc: bean singleton phải stateless, chỉ giữ dependency final được inject qua constructor và object bất biến hoặc thread-safe. Dữ liệu theo request đi qua tham số method hoặc biến cục bộ. Nếu thật sự cần trạng thái chia sẻ (bộ đếm, cache), dùng AtomicLong, ConcurrentHashMap, Caffeine. Đổi scope sang prototype hoặc request thường là né vấn đề chứ không phải giải quyết.
Mẹo từ người phỏng vấn
Race condition gần như không bao giờ lộ ra khi test tay trên máy dev. Nếu bạn kể được cách mình tái hiện (bắn tải bằng JMeter/Gatling, hoặc viết test dùng CountDownLatch cho nhiều thread chạy đồng thời), người phỏng vấn sẽ đánh giá rất cao.
3.12 Câu hỏi phỏng vấn thường gặp
Câu hỏi (Cơ bản): volatile khác synchronized thế nào? Dùng volatile cho bộ đếm được không?
volatile đảm bảo visibility và thứ tự (happens-before giữa ghi và đọc), nhưng không đảm bảo atomicity cho thao tác phức hợp. count++ trên biến volatile vẫn mất cập nhật vì gồm đọc, cộng, ghi. synchronized đảm bảo cả loại trừ lẫn nhau và visibility cho cả khối lệnh. Bộ đếm thì dùng AtomicLong hoặc LongAdder.
Bẫy thường gặp
"volatile làm thread đọc thẳng từ RAM nên an toàn đa luồng." Vừa sai bản chất (là memory barrier và happens-before), vừa sai kết luận (không có atomicity).
Câu hỏi đào sâu
"Vì sao double-checked locking cần volatile?" — instance = new Singleton() gồm cấp phát, khởi tạo, gán tham chiếu; không có volatile, việc gán có thể bị sắp xếp trước khởi tạo, thread khác thấy instance khác null nhưng chưa khởi tạo xong. Trong Spring thì bạn hầu như không cần tự viết singleton.
Câu hỏi (Hiểu sâu): Khi nào dùng ReentrantLock thay vì synchronized?
Khi cần tryLock có timeout để tránh deadlock, cần chờ lock mà có thể bị interrupt, cần fairness, cần nhiều Condition, hoặc trên Java 21 cần tránh pinning virtual thread khi có I/O trong vùng găng. Ngoài các trường hợp đó, synchronized ngắn gọn và không thể quên unlock.
Bẫy thường gặp
Gọi lock() mà không đặt unlock() trong finally. Một exception là lock bị giữ vĩnh viễn.
Câu hỏi (Cơ bản): Deadlock là gì, phòng tránh và phát hiện thế nào?
Hai hoặc nhiều thread chờ vòng tròn lock của nhau. Phòng tránh bằng lock ordering (khóa theo id tăng dần), tryLock có timeout, thu nhỏ vùng găng và không gọi ra ngoài khi giữ lock. Phát hiện bằng jstack: thread dump in rõ "Found one Java-level deadlock" kèm lock mà mỗi thread giữ và chờ.
Câu hỏi đào sâu
"Deadlock ở DB khác gì?" — DB tự phát hiện và rollback một transaction; ứng dụng nhận exception, cần retry có giới hạn và cập nhật các row theo thứ tự nhất quán.
Câu hỏi (Thực chiến): Vì sao không nên dùng Executors.newFixedThreadPool trên production?
Nó dùng LinkedBlockingQueue không giới hạn. Khi task đến nhanh hơn tốc độ xử lý, queue phình mãi đến OutOfMemoryError, trong khi request của người dùng chờ vô hạn thay vì bị từ chối sớm. Tôi dùng ThreadPoolExecutor với queue có giới hạn, thread có tên, và rejection policy có chủ đích như CallerRunsPolicy hoặc AbortPolicy rồi trả 503.
Câu hỏi đào sâu
"Với core=10, max=50, queue=1000, khi nào có thread thứ 11?" — Chỉ khi queue đã đầy 1000 task. Nhiều người tưởng pool tăng thread trước rồi mới xếp hàng, thực tế là ngược lại.
Câu hỏi (Hiểu sâu): thenApply và thenCompose khác nhau thế nào? Xử lý lỗi trong CompletableFuture ra sao?
thenApply biến đổi giá trị (map); thenCompose dùng khi bước tiếp theo trả về một CompletableFuture, để làm phẳng (flatMap). Lỗi xử lý bằng exceptionally (giá trị dự phòng) hoặc handle (xử lý cả hai nhánh); timeout bằng orTimeout/completeOnTimeout. Chờ nhiều future bằng allOf rồi join từng cái.
Bẫy thường gặp
Dùng supplyAsync không truyền executor cho lời gọi I/O: toàn bộ chạy trên common pool với số thread bằng số core trừ một, nghẽn cả parallel stream của ứng dụng.
Câu hỏi (Hiểu sâu): ConcurrentHashMap khác synchronizedMap và Hashtable thế nào?
Hashtable và synchronizedMap khóa toàn bộ map cho mọi thao tác, kể cả đọc. ConcurrentHashMap (Java 8+) ghi bằng CAS hoặc khóa riêng từng bucket, đọc không khóa, iterator weakly consistent, không cho null. Nhưng nó chỉ nguyên tử từng thao tác; chuỗi "kiểm tra rồi ghi" phải dùng computeIfAbsent, merge, putIfAbsent.
Ghi chú
Tài liệu cũ mô tả ConcurrentHashMap chia 16 segment. Đó là cài đặt Java 7; từ Java 8 đã chuyển sang CAS và khóa theo bucket.
Câu hỏi (Hiểu sâu): ThreadLocal gây rò rỉ như thế nào?
Thread trong pool sống suốt vòng đời ứng dụng, nên giá trị ThreadLocal không được remove() sẽ sống mãi (rò rỉ bộ nhớ) và bị request sau trên cùng thread đọc nhầm (rò rỉ dữ liệu giữa người dùng). Luôn set và remove theo cặp trong try/finally, thường ở Filter/Interceptor.
Câu hỏi đào sâu
"Vì sao code trong @Async không thấy SecurityContext?" — Vì ThreadLocal không truyền sang thread khác; cần TaskDecorator hoặc cơ chế propagate context.
Câu hỏi (Hiểu sâu): Virtual threads là gì? Có nên bật cho mọi service không?
Là thread nhẹ do JVM quản lý, unmount khỏi carrier khi blocking I/O, cho phép hàng trăm nghìn tác vụ đồng thời với code blocking bình thường. Hợp với service I/O-bound kiểu thread-per-request. Trước khi bật tôi kiểm tra: tác vụ có chủ yếu là I/O không, thư viện có synchronized bao quanh I/O gây pinning trên Java 21 không, và tài nguyên phía sau (connection pool, API đối tác) có chịu được mức đồng thời cao hơn không.
Bẫy thường gặp
Đưa virtual thread vào một pool cố định, hoặc kỳ vọng tác vụ CPU chạy nhanh hơn. Virtual thread giúp chờ rẻ hơn, không giúp tính nhanh hơn.
Câu hỏi (Thực chiến): Spring bean là singleton, vậy có thread-safe không?
Không tự động. Singleton chỉ nói có một instance; thread-safe hay không phụ thuộc bean có trạng thái mutable hay không. Bean stateless (chỉ có dependency final) thì an toàn. Field như currentUser, List tích lũy, SimpleDateFormat là race condition.
Câu hỏi đào sâu
"Bạn phát hiện và sửa lỗi này thế nào?" — Review field không final trong bean, tái hiện bằng test nhiều thread với CountDownLatch hoặc test tải, sửa bằng cách đưa dữ liệu vào tham số/biến cục bộ, dùng object bất biến như DateTimeFormatter, hoặc cấu trúc thread-safe.
Câu hỏi (Cơ bản): Làm sao đảm bảo thread A chạy xong trước thread B?
Không dựa vào setPriority(): độ ưu tiên chỉ là gợi ý cho scheduler, không đảm bảo thứ tự. Dùng join(), CountDownLatch, hoặc nối bằng CompletableFuture (futureA.thenRunAsync(taskB, executor)). Nếu hai việc bắt buộc tuần tự, có khi câu trả lời tốt nhất là chạy chúng trong cùng một thread.
Câu hỏi (Cơ bản): Race condition là gì? Cho một ví dụ cụ thể.
Race condition là khi kết quả phụ thuộc vào thứ tự xen kẽ ngẫu nhiên của các thread cùng truy cập dữ liệu chung, trong đó ít nhất một thread ghi. Ví dụ: hai thread cùng count++ từ 10; cả hai đọc 10, cả hai ghi 11, mất một lần tăng. Ví dụ nghiệp vụ: hai request cùng kiểm tra "còn 1 sản phẩm trong kho" rồi cùng trừ kho, kết quả bán vượt số lượng. Race condition khó bắt vì chỉ xảy ra khi thời điểm trùng khớp, thường chỉ lộ ra dưới tải.
Câu hỏi đào sâu
"Race condition trong DB thì sao?" — Cùng bản chất (lost update). Sửa bằng câu lệnh nguyên tử UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty > 0, optimistic locking (@Version) hoặc SELECT ... FOR UPDATE. synchronized trong Java không giúp gì khi có nhiều pod.
Câu hỏi (Cơ bản): Monitor là gì? synchronized method khóa object nào?
Monitor là khóa có sẵn trong mọi object Java; synchronized lấy monitor khi vào và nhả khi ra. Instance method synchronized khóa this; static method synchronized khóa object Class của class đó (Foo.class); khối synchronized (obj) khóa obj. Hai thread chỉ loại trừ nhau khi cùng khóa một object. Monitor còn đi kèm wait()/notify(), chỉ gọi được khi đang giữ chính monitor đó.
Câu hỏi (Cơ bản): Visibility là gì? volatile giải quyết vấn đề gì và không giải quyết vấn đề gì?
Visibility là việc giá trị một thread ghi có được thread khác nhìn thấy hay không; volatile đảm bảo điều đó cho từng lần đọc/ghi biến, nhưng không làm thao tác phức hợp trở thành nguyên tử. Không có volatile, JIT có thể chỉ đọc một biến một lần và giữ trong register, khiến vòng lặp chờ cờ chạy mãi. volatile phù hợp với cờ dừng, cấu hình được thay nguyên object, cờ "dữ liệu đã sẵn sàng". Nó không sửa được count++ hay "kiểm tra rồi ghi".
Câu hỏi (Cơ bản): Runnable và Callable khác nhau thế nào? execute() và submit() xử lý exception ra sao?
Runnable không trả kết quả và không ném checked exception; Callable trả kết quả và được ném checked exception. execute(Runnable) để exception bay tới UncaughtExceptionHandler, nên ít nhất có stack trace trên stderr. submit(...) bọc task trong Future, exception bị giữ lại và chỉ ném ra (bọc trong ExecutionException) khi gọi get(); không ai gọi get() thì lỗi biến mất không dấu vết (xem 3.2).
Câu hỏi (Đọc code): Chương trình dưới đây có thể in ra những giá trị nào?
public class RaceQuiz {
static int count = 0;
public static void main(String[] args) throws InterruptedException {
Runnable job = () -> { for (int i = 0; i < 1000; i++) count++; };
Thread a = new Thread(job), b = new Thread(job);
a.start(); b.start();
a.join(); b.join();
System.out.println(count); // any value from 2 to 2000; often exactly 2000 for such short loops
}
}Đáp án: Về lý thuyết, bất kỳ giá trị nào từ 2 đến 2000. Thực tế với vòng lặp ngắn 1000 lần thường in đúng 2000, vì thread đầu có thể chạy xong trước khi thread sau kịp bắt đầu; tăng lên 1.000.000 vòng thì gần như luôn in số nhỏ hơn 2.000.000.
- Không có happens-before giữa các lần
count++của hai thread, nên mỗi lần tăng có thể bị ghi đè như bảng ở 3.1. - Giá trị nhỏ nhất 2 xảy ra với một cách xen kẽ cực đoan: A đọc 0, B chạy 999 vòng, A ghi 1, B đọc 1, A chạy hết 999 vòng, B ghi 2.
join()đảm bảo main thấy giá trị cuối cùng mà hai thread đã ghi, nhưng không sửa được việc mất cập nhật bên trong.- Bài học: "chạy thử thấy đúng" không chứng minh code thread-safe.
Câu hỏi (Đọc code): Đoạn code dưới đây có bị deadlock không? In ra gì?
public class ReentrantQuiz {
synchronized void outer() {
System.out.println("outer");
inner(); // needs the same monitor this thread already holds
}
synchronized void inner() {
System.out.println("inner");
}
public static void main(String[] args) {
new ReentrantQuiz().outer(); // prints: outer, then inner
}
}Đáp án: Không deadlock, in outer rồi inner. Monitor của Java là reentrant: thread đang giữ monitor của this được vào lại khối synchronized khác trên cùng object, JVM chỉ tăng bộ đếm số lần giữ và giảm khi ra. Nếu monitor không reentrant, một thread sẽ tự chờ chính nó. ReentrantLock cũng có tính chất này, như tên gọi của nó.
Câu hỏi (Đọc code): Method chuyển tiền dưới đây có thể deadlock không?
public class NaiveTransfer {
static final class Account { long balance; }
static void transfer(Account from, Account to, long amount) {
synchronized (from) {
synchronized (to) { // lock order depends on the call arguments
from.balance -= amount;
to.balance += amount;
}
}
}
}Đáp án: Có thể, khi hai thread chuyển ngược chiều nhau cùng lúc. Không phải lần nào cũng xảy ra, đó là điều nguy hiểm.
| Thời điểm | Thread 1: transfer(x, y) | Thread 2: transfer(y, x) |
|---|---|---|
| t1 | Khóa x | Khóa y |
| t2 | Chờ khóa y (B đang giữ) | Chờ khóa x (A đang giữ) |
| t3 | Chờ mãi | Chờ mãi |
Đủ bốn điều kiện deadlock, trong đó circular wait do thứ tự khóa phụ thuộc vào tham số. Sửa bằng lock ordering theo id như TransferService ở 3.5, hoặc dùng tryLock có timeout. Trên thực tế, chuyển tiền nên dựa vào transaction và khóa row của database chứ không dựa vào lock trong JVM.
Câu hỏi (Đọc code): Thread reader in ra gì?
public class PublishQuiz {
static int data = 0;
static volatile boolean ready = false;
public static void main(String[] args) throws InterruptedException {
Thread reader = new Thread(() -> {
while (!ready) { Thread.onSpinWait(); } // spin until the volatile flag flips
System.out.println(data); // prints: 42
});
reader.start();
data = 42; // plain write BEFORE the volatile write
ready = true; // volatile write publishes data too
reader.join();
}
}Đáp án: Luôn in 42. Ghi data = 42 đứng trước ghi volatile ready = true trong thread main; ghi volatile happens-before lần đọc thấy ready == true trong reader; do đó reader chắc chắn thấy data = 42 (xem bảng ở 3.3).
- Nếu bỏ
volatilekhỏiready: reader có thể lặp mãi (không bao giờ thấytrue), hoặc thấytruenhưng in0vì lệnh bị sắp xếp lại. - Nếu đảo thứ tự, ghi
ready = truetrướcdata = 42: reader có thể in0, vì phần ghi sau lệnh volatile không được bảo đảm.
Câu hỏi (Đọc code): Chuỗi CompletableFuture dưới đây in ra gì?
import java.util.concurrent.CompletableFuture;
public class CfChainQuiz {
public static void main(String[] args) {
String result = CompletableFuture.supplyAsync(() -> 10)
.thenApply(x -> x / 0) // throws ArithmeticException
.thenApply(x -> "never " + x) // skipped
.exceptionally(ex -> "fallback: " + ex.getCause().getClass().getSimpleName())
.thenApply(String::toUpperCase) // runs again after recovery
.join();
System.out.println(result); // prints: FALLBACK: ARITHMETICEXCEPTION
}
}Đáp án: In FALLBACK: ARITHMETICEXCEPTION.
x / 0némArithmeticException, stage đó hoàn thành với lỗi.thenApplythứ hai bị bỏ qua vì stage trước lỗi; lỗi chảy xuống stage kế tiếp.exceptionallybắt lỗi (được bọc trongCompletionException, nên dùnggetCause()), trả về chuỗi dự phòng; từ đây chuỗi trở lại trạng thái bình thường, nêntoUpperCasevẫn chạy.- Giống
try/catch: đặtexceptionallyở đâu thì nó chỉ bắt lỗi của các stage đứng trước nó.
Câu hỏi (Đọc code): Pool dưới đây có bao nhiêu thread và task nào bị từ chối?
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class PoolQuiz {
public static void main(String[] args) {
CountDownLatch gate = new CountDownLatch(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(2, 4, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2));
for (int id = 1; id <= 7; id++) {
try {
pool.execute(() -> { try { gate.await(); } catch (InterruptedException e) { } });
} catch (RejectedExecutionException e) {
System.out.println("rejected task " + id); // prints: rejected task 7
}
}
System.out.println("threads=" + pool.getPoolSize() + ", queued=" + pool.getQueue().size()); // prints: threads=4, queued=2
gate.countDown();
pool.shutdown();
}
}Đáp án: In rejected task 7 rồi threads=4, queued=2.
- Task 1, 2: dưới core, tạo thread 1 và 2.
- Task 3, 4: đủ core, vào queue (2/2).
- Task 5, 6: queue đầy, còn dưới max 4, tạo thread 3 và 4 chạy ngay task 5 và 6.
- Task 7: đủ max và queue đầy,
AbortPolicynémRejectedExecutionException. - Sức chứa tối đa của pool = max + queue = 6 task cùng lúc.
Câu hỏi (Đọc code): Tìm lỗi trong cách dùng ReentrantLock dưới đây.
import java.util.concurrent.locks.ReentrantLock;
public class LockMisuse {
private final ReentrantLock lock = new ReentrantLock();
private int stock = 10;
boolean reserve(int qty) {
try {
lock.lock(); // WRONG: lock() inside try
if (stock < qty) return false; // early return, still unlocked in finally: OK
stock -= qty;
validate(stock); // may throw
return true;
} finally {
lock.unlock();
}
}
private void validate(int s) { if (s < 0) throw new IllegalStateException("negative stock"); }
}Đáp án: Lỗi nằm ở vị trí lock.lock(): phải đặt trước try, không phải bên trong.
- Nếu
lock()thất bại (ví dụ dùnglockInterruptibly()và thread bị interrupt),finallyvẫn gọiunlock()trên khóa chưa giữ, némIllegalMonitorStateExceptionche mất exception gốc. - Mẫu chuẩn:
lock.lock(); try { ... } finally { lock.unlock(); }. - Phần còn lại đúng:
return falsesớm và exception từvalidateđều đi quafinallynên khóa được nhả. Một vấn đề thiết kế nữa: trừ kho trước rồi mới validate khiến trạng thái đã bị sửa khi ném exception; nên kiểm tra trước khi sửa.
Câu hỏi (Đọc code): Singleton dưới đây có lỗi gì?
public class ConfigHolder {
private static ConfigHolder instance; // missing volatile
private final java.util.Map<String, String> props;
private ConfigHolder() { props = java.util.Map.of("timeout", "30s"); }
public static ConfigHolder get() {
if (instance == null) { // 1st check without lock
synchronized (ConfigHolder.class) {
if (instance == null) { // 2nd check with lock
instance = new ConfigHolder();
}
}
}
return instance;
}
}Đáp án: Thiếu volatile trên instance, nên double-checked locking bị hỏng.
instance = new ConfigHolder()gồm cấp phát, chạy constructor, gán tham chiếu; không có volatile, việc gán có thể được thấy trước khi constructor chạy xong.- Thread khác đi qua lần kiểm tra thứ nhất (không khóa), thấy
instance != nullvà dùng một object chưa khởi tạo xong. Lần đọc không khóa đó không có happens-before với lần ghi trong khốisynchronized. - Sửa: thêm
volatile, hoặc tốt hơn dùng holder idiom (static class Holder { static final ConfigHolder I = new ConfigHolder(); }), tận dụng việc JVM khởi tạo class một lần và thread-safe (xem 2.1). Trong Spring, cứ để container quản lý singleton.
Câu hỏi (Hiểu sâu): Happens-before là gì? Kể các quy tắc bạn dùng thường xuyên.
Happens-before là bảo đảm của Java Memory Model: nếu X happens-before Y thì mọi thứ X đã ghi đều được Y nhìn thấy. Nó không nói về thời gian thực mà về khả năng nhìn thấy. Các quy tắc hay dùng:
- Trong cùng một thread, lệnh trước happens-before lệnh sau.
- Nhả monitor happens-before lần lấy monitor đó sau này (
synchronized,Lock). - Ghi
volatilehappens-before lần đọc biến đó sau này. Thread.start()happens-before mọi lệnh trong thread mới; mọi lệnh trong thread happens-beforejoin()trả về.- Đưa task vào
ExecutorServicehappens-before lúc task chạy; task hoàn thành happens-beforeFuture.get()trả về.
Câu hỏi đào sâu
"Happens-before có tính bắc cầu không?" — Có. Chính tính bắc cầu cho phép một biến volatile "công bố" cả những biến thường được ghi trước nó (xem PublishQuiz).
Câu hỏi (Hiểu sâu): CAS hoạt động thế nào và vì sao không cần lock?
CAS là một lệnh CPU nguyên tử: chỉ ghi giá trị mới nếu giá trị hiện tại vẫn bằng giá trị mong đợi, nếu không thì báo thất bại để thread thử lại. Không thread nào bị đưa vào trạng thái chờ; thread thua chỉ đọc lại và thử tiếp. Vì vậy CAS nhanh khi tranh chấp thấp. Nhược điểm: tranh chấp cao thì vòng thử lại đốt CPU, chỉ nguyên tử trên một biến, và có vấn đề ABA.
Câu hỏi (Hiểu sâu): AtomicLong và LongAdder khác nhau thế nào, khi nào dùng cái nào?
AtomicLong là một biến duy nhất cập nhật bằng CAS; LongAdder chia bộ đếm thành nhiều ô để các thread ít đụng nhau, và cộng các ô lại khi đọc.
| AtomicLong | LongAdder | |
|---|---|---|
| Tranh chấp cao | Nhiều CAS thất bại, chậm | Nhanh, mỗi thread một ô |
| Đọc giá trị | Chính xác tức thời | sum() có thể lệch khi đang ghi |
| Thao tác | compareAndSet, getAndUpdate... | Chỉ cộng, đọc tổng |
| Dùng khi | Sinh id, cần CAS có điều kiện | Metric, đếm request |
Câu hỏi (Hiểu sâu): Vì sao wait() phải được gọi trong vòng while chứ không phải if?
Vì thread có thể thức dậy mà điều kiện chưa đúng: spurious wakeup, hoặc thread khác đã "cướp" mất điều kiện trước khi nó lấy lại monitor. Mẫu đúng là synchronized (lock) { while (!condition) lock.wait(); ... }. Ví dụ hàng đợi: hai consumer cùng chờ, producer thêm một phần tử và notifyAll(); consumer thứ nhất lấy mất, consumer thứ hai thức dậy thấy queue rỗng; dùng if thì nó sẽ lấy từ queue rỗng. Trong code hiện đại, dùng BlockingQueue thay vì tự viết.
Câu hỏi (Hiểu sâu): Vì sao biến cục bộ tự nhiên thread-safe còn field của bean thì không?
Vì biến cục bộ nằm trong stack frame riêng của từng thread, còn field nằm trong object trên Heap mà mọi thread dùng chung. Đây là kiến thức nối từ 2.2: mỗi lần gọi method có frame riêng, hai request gọi cùng method trên hai thread có hai bản biến cục bộ độc lập. Field của singleton bean thì chỉ có một bản. Lưu ý: biến cục bộ kiểu object chỉ là tham chiếu; nếu nó trỏ tới một object dùng chung (ví dụ một List static), object đó vẫn không an toàn.
Câu hỏi (Hiểu sâu): ThreadLocal với virtual thread và memory leak liên quan nhau thế nào?
ThreadLocal gắn giá trị vào object thread; thread sống bao lâu thì giá trị sống bấy lâu, và đó là GC root dẫn tới leak (2.4).
- Với thread pool (Tomcat, executor), thread sống mãi nên giá trị không
remove()sống mãi và lẫn sang request sau. - Với virtual thread, mỗi task một thread mới nên giá trị chết theo thread, ít nguy cơ lẫn dữ liệu hơn. Nhưng một triệu virtual thread mỗi cái giữ một bản sao object nặng (ví dụ cache
SimpleDateFormat, buffer) thì Heap phình to. - Vì vậy không dùng ThreadLocal làm cache object nặng; Scoped Values là hướng thay thế.
Câu hỏi (Hiểu sâu): Pinning là gì? Carrier thread liên quan gì?
Carrier thread là platform thread thật mà virtual thread chạy trên đó; pinning là khi virtual thread đang chờ mà không tháo được khỏi carrier, nên carrier bị chiếm vô ích. Trên Java 21, blocking bên trong synchronized hoặc native method gây pinning. Số carrier bằng số core, nên vài thread bị ghim là đủ làm cả ứng dụng nghẽn dù có hàng nghìn virtual thread. Cách xử lý trên Java 21: dùng ReentrantLock quanh I/O, phát hiện bằng JFR event jdk.VirtualThreadPinned. Java 24 trở đi đã sửa trường hợp synchronized.
Câu hỏi (Hiểu sâu): Vì sao không nên pool virtual thread? Muốn giới hạn đồng thời thì làm gì?
Pool tồn tại để tái sử dụng thứ đắt, mà virtual thread thì rẻ; pool virtual thread chỉ thêm giới hạn nhân tạo và giữ ThreadLocal cũ. Mục đích thật khi người ta muốn "pool" thường là giới hạn số lời gọi đồng thời tới tài nguyên (DB, API đối tác). Hãy giới hạn đúng tài nguyên đó bằng Semaphore (ví dụ new Semaphore(20) quanh lời gọi API) hoặc bằng connection pool, và vẫn tạo virtual thread mới cho mỗi task.
Câu hỏi (Thực chiến): CPU của service lên 100% sau lần deploy mới. Thread dump cho thấy vài thread RUNNABLE ở cùng một vòng lặp chờ. Bạn xử lý thế nào?
Đó là busy-wait: thread quay vòng kiểm tra điều kiện liên tục thay vì ngủ chờ, đốt trọn một core cho mỗi thread.
- Xác nhận:
top -H -p <pid>, đổi TID sang hex, đối chiếunidtrong 3 lầnjstackliên tiếp (xem 2.5); cùng stack trace lặp lại ở vòngwhile (!done) {}hoặc vòng retry không cósleep. - Giảm tác động: rollback bản deploy nếu cần.
- Sửa: thay busy-wait bằng cơ chế chờ có thông báo (
CountDownLatch,CompletableFuture,BlockingQueue.take()); retry thì phải có backoff và giới hạn số lần. - Nếu thread nóng lại là thread GC thì vấn đề là bộ nhớ, quay về quy trình OOM ở 2.6.
Câu hỏi (Thực chiến): API timeout hàng loạt, thread dump cho thấy 200 thread Tomcat đều WAITING ở HikariPool.getConnection. Chuyện gì xảy ra?
Thread pool của Tomcat cạn vì mọi thread đang xếp hàng chờ connection DB; nút thắt thật là connection pool (mặc định 10 connection) hoặc các query đang giữ connection quá lâu.
- Tìm những thread đang giữ connection: chúng thường đang chạy query chậm, hoặc tệ hơn là gọi HTTP ra ngoài bên trong
@Transactionalnên giữ connection suốt thời gian chờ. - Xem metric HikariCP:
active,pending, thời gian chờ connection. - Sửa: tối ưu query chậm, đưa lời gọi ngoài ra khỏi transaction, đặt
connectionTimeoutngắn để fail nhanh thay vì treo, và chỉ tăng pool size khi DB chịu được.
Câu hỏi đào sâu
"Bật virtual thread có giải quyết được không?" — Không. Hàng nghìn virtual thread vẫn chờ cùng 10 connection; thậm chí còn đẩy nhiều request hơn vào hàng chờ. Virtual thread dời nút thắt xuống tầng dưới chứ không xóa nó.
Câu hỏi (Thực chiến): Log xuất hiện RejectedExecutionException vào giờ cao điểm. Bạn xử lý thế nào?
Đó là pool đang báo quá tải đúng như thiết kế; việc cần làm là quyết định hành vi khi quá tải, không phải chỉ tăng queue cho hết lỗi.
- Đo: thời gian chạy mỗi task, số task đến mỗi giây,
activeCount,queue.size()theo thời gian (Micrometer có sẵn metric executor). - Nếu task chậm do phụ thuộc bên ngoài (DB, API) thì tăng thread không giúp; phải sửa chỗ chậm hoặc đặt timeout.
- Chọn chính sách: trả 503 nhanh để client retry có backoff (
AbortPolicy), làm chậm bên gửi (CallerRunsPolicy), hoặc đẩy việc sang message queue (Kafka, RabbitMQ) nếu không cần xử lý ngay. - Tăng queue lên rất lớn chỉ chuyển lỗi từ "bị từ chối" thành "chờ rất lâu rồi timeout" hoặc OOM.
Câu hỏi (Thực chiến): Pod bị OOMKilled sau khi một đồng nghiệp đổi executor sang Executors.newCachedThreadPool(). Vì sao?
newCachedThreadPool không giới hạn số thread; tải đột biến tạo hàng nghìn platform thread, mỗi thread một Stack ngoài Heap, tổng bộ nhớ process vượt limit của container. Như ở 2.2 và 2.5, Stack của thread nằm ở native memory, Heap vẫn trông bình thường nên dashboard Heap không báo gì, và kernel kill process không để lại heap dump. Có thể gặp OutOfMemoryError: unable to create native thread trước đó. Sửa: ThreadPoolExecutor có maximumPoolSize và queue giới hạn, hoặc virtual thread kèm Semaphore nếu tác vụ là I/O.
Câu hỏi (Thực chiến): Heap dump sau OOM cho thấy một LinkedBlockingQueue giữ 3 triệu Runnable. Nguyên nhân và cách sửa?
Đây là newFixedThreadPool (hoặc pool với queue không giới hạn) nhận task nhanh hơn tốc độ xử lý, queue phình tới hết Heap. Path to GC Roots trong MAT sẽ dẫn tới ThreadPoolExecutor.workQueue của pool đó. Mỗi Runnable còn giữ tham chiếu tới dữ liệu của task (payload, DTO), nên bộ nhớ lớn hơn nhiều so với số object. Sửa: queue có giới hạn kèm rejection policy (3.6), đo và sửa tốc độ xử lý, và cân nhắc backpressure từ phía nguồn (giới hạn consumer Kafka, rate limit API).
3.13 Tóm tắt trọng tâm
- Các thread dùng chung Heap nhưng có Stack riêng, vì vậy biến cục bộ an toàn còn field của object dùng chung (kể cả Spring singleton bean) thì phải được bảo vệ.
- Race condition xảy ra vì
count++và "kiểm tra rồi ghi" gồm nhiều bước có thể bị thread khác chen vào giữa, nên chạy thử thấy đúng không chứng minh được gì. - Visibility không tự có: thiếu happens-before thì JIT và CPU được phép để thread khác thấy giá trị cũ mãi mãi.
volatilecho visibility và cấm reorder nhưng không cho atomicity, vì vậy chỉ dùng cho cờ và công bố object, không dùng cho bộ đếm.synchronizedchỉ loại trừ khi các thread khóa cùng một object, nên dữ liệu static phải khóa ở mức class hoặc một lock static.ReentrantLockphảilock()trướctryvàunlock()trongfinally, vì một exception là đủ để khóa bị giữ vĩnh viễn.- CAS cập nhật không chặn thread nên nhanh khi tranh chấp thấp; tranh chấp cao thì
LongAddertốt hơn cho bộ đếm. - Deadlock cần vòng chờ, vì vậy khóa theo thứ tự cố định hoặc
tryLockcó timeout là cách phòng tránh thực tế nhất. - ThreadPoolExecutor xếp hàng trước rồi mới tăng thread lên max, nên queue không giới hạn làm
maximumPoolSizevô nghĩa và dẫn tới OOM. - Không dùng factory của
Executorstrên production vì chúng có queue hoặc số thread không giới hạn. - CompletableFuture cho I/O phải có executor riêng, xử lý lỗi và timeout, vì common pool nhỏ và dùng chung cả JVM.
- ThreadLocal trong thread pool phải
remove()trongfinally, vì thread sống mãi và mang dữ liệu sang request sau. - Virtual thread giúp chờ I/O rẻ chứ không giúp tính nhanh hơn, và trên Java 21 phải tránh blocking trong
synchronizedđể không bị pinning. - Khi thread pool cạn, hãy xem thread đang chờ cái gì (connection DB, lock, HTTP), vì nút thắt thật thường nằm ở tầng dưới chứ không ở số thread.