Tất cả về Thread trong Java
Table of contents
Concurrency bug là loại bug khó nhất để tìm và sửa trong production. Không có stack trace đầy đủ, không tái hiện được ổn định, chỉ có behavior ngẫu nhiên sai sau vài giờ chạy. Phần lớn những bug đó không đến từ việc không biết API, mà đến từ việc không hiểu tại sao các cơ chế concurrency được thiết kế theo cách đó. Bài này giải thích từ vấn đề, không từ định nghĩa.
Tại sao Thread tồn tại?
Hãy xét một HTTP server xử lý request đặt hàng. Mỗi request phải: validate input, query database (50ms), gọi payment gateway (200ms), ghi log. Nếu server xử lý tuần tự:
// Single-threaded server - xử lý tuần tự
while (true) {
Request req = acceptConnection(); // nhận request
processOrder(req); // 250ms+ mỗi request
// Request tiếp theo phải CHỜ request hiện tại xong
}
Request thứ hai phải chờ request thứ nhất xử lý xong 250ms. Với 100 concurrent users, user cuối đợi 25 giây. Đây là vấn đề mà thread giải quyết: trong khi thread 1 đang chờ database trả về (I/O-bound, CPU không làm gì), thread 2 có thể xử lý request khác.
Trade-off ngay từ đầu: Thread giải quyết I/O-bound bottleneck rất tốt. Với CPU-bound work (tính toán nặng), thêm thread vượt quá số CPU core không giúp ích gì và còn tốn overhead context-switching. Hiểu rõ bài toán là I/O-bound hay CPU-bound trước khi quyết định dùng bao nhiêu thread.
Thread Lifecycle
Một thread không chỉ “chạy” hoặc “dừng”. Nó có 6 trạng thái, và biết thread đang ở trạng thái nào là kỹ năng debugging quan trọng.
Điều quan trọng nhất để hiểu khi debugging:
BLOCKED nghĩa là thread đang chờ lấy một lock đang bị thread khác giữ. Nếu bạn thấy nhiều thread BLOCKED trong thread dump, có thể là lock contention (một thread giữ lock quá lâu) hoặc deadlock.
WAITING nghĩa là thread chủ động nhường CPU và chờ được notify. Khác với BLOCKED: WAITING không tranh lock, nó chờ một điều kiện.
TIMED_WAITING là trạng thái phổ biến nhất bạn thấy khi thread đang sleep() hoặc chờ I/O với timeout.
Production debugging: Khi service treo, chạy kill -3 <pid> (JVM thread dump) hoặc dùng jstack. Nhìn vào số lượng thread BLOCKED. Nếu hàng chục thread BLOCKED trên cùng một lock, đó là lock contention. Nếu A đang chờ lock B và B đang chờ lock A, đó là deadlock.
Tạo Thread: Đừng dùng new Thread() trong Production
Ba cách tạo thread, nhưng chỉ một cách phù hợp cho production:
// Cách 1: Subclass Thread - không bao giờ dùng trong production
class OrderProcessor extends Thread {
@Override
public void run() {
processOrder();
}
}
new OrderProcessor().start(); // khó test, không reusable
// Cách 2: Runnable - tốt hơn nhưng vẫn thiếu
Runnable task = () -> processOrder();
new Thread(task).start(); // vẫn tạo thread mới mỗi lần, không kiểm soát được
// Cách 3: ExecutorService - cách đúng
ExecutorService executor = Executors.newFixedThreadPool(10);
executor.submit(() -> processOrder());
Tại sao không dùng new Thread() trực tiếp: Mỗi Java thread map sang một OS thread. Tạo và hủy OS thread tốn khoảng 1ms và vài MB stack memory. Nếu mỗi request tạo một thread mới, với 1,000 requests/giây bạn tạo 1,000 OS threads/giây. Memory hết, OS scheduler bị overload, latency tăng vọt.
Production bug thực tế:
// Bug: tạo thread không giới hạn cho mỗi incoming order event
@KafkaListener(topics = "order-events")
public void handleOrder(OrderEvent event) {
new Thread(() -> {
enrichWithExternalData(event); // HTTP call 500ms
orderRepository.save(event);
}).start(); // tạo thread mới mỗi event, không bounded
}
Khi Kafka consumer lag tăng đột biến (ví dụ sau maintenance), service nhận hàng nghìn events cùng lúc, mỗi event tạo một thread. Heap exhaustion, OOM error, service crash.
Trade-off: ExecutorService pooling threads là đúng, nhưng pool size cần được tune. Quá ít thread: throughput thấp. Quá nhiều: memory pressure và context-switching overhead. Cách chọn pool size trong phần ThreadPoolExecutor ở cuối bài.
Race Condition: Khi hai Thread đụng nhau
Race condition xảy ra khi kết quả phụ thuộc vào thứ tự thực thi của các thread, và thứ tự đó không được đảm bảo.
Cái gì vỡ:
// Shared counter không được bảo vệ
public class OrderCounter {
private int count = 0;
public void increment() {
count++; // KHÔNG phải atomic! Đây là 3 bước: read, add, write
}
public int get() { return count; }
}
count++ trông như một thao tác nhưng thực ra là: đọc giá trị hiện tại, cộng 1, ghi lại. Hai thread làm việc đồng thời:
Thread 1: đọc count = 100
Thread 2: đọc count = 100 ← đọc trước khi Thread 1 ghi xong
Thread 1: ghi count = 101
Thread 2: ghi count = 101 ← ghi đè kết quả của Thread 1
Kết quả: hai thread increment nhưng count chỉ tăng từ 100 lên 101, không phải 102. Với 1,000 thread cùng increment, kết quả cuối có thể là bất kỳ con số nào từ 1 đến 1,000.
Production example: Order counter dùng để giới hạn số đơn hàng trong flash sale.
@Service
public class FlashSaleService {
private int orderCount = 0; // race condition!
private static final int MAX_ORDERS = 1000;
public boolean tryPlaceOrder(Order order) {
if (orderCount >= MAX_ORDERS) {
return false; // sold out
}
orderCount++; // race condition ở đây
orderRepository.save(order);
return true;
}
}
Kết quả: 1,200 đơn hàng được tạo ra khi giới hạn là 1,000. Inventory âm, fulfillment team phải xử lý thủ công.
Fix đúng với AtomicInteger:
private final AtomicInteger orderCount = new AtomicInteger(0);
public boolean tryPlaceOrder(Order order) {
// compareAndSet là atomic: check và increment trong một operation
int current;
do {
current = orderCount.get();
if (current >= MAX_ORDERS) return false;
} while (!orderCount.compareAndSet(current, current + 1));
// Hoặc đơn giản hơn với getAndIncrement + kiểm tra sau
int slot = orderCount.getAndIncrement();
if (slot >= MAX_ORDERS) {
orderCount.decrementAndGet(); // trả lại slot
return false;
}
orderRepository.save(order);
return true;
}
AtomicInteger dùng CAS (Compare-And-Swap) instruction ở CPU level - không cần lock, nên không gây contention khi read-heavy.
Trade-off: AtomicInteger hoạt động tốt cho single variable. Khi bạn cần update nhiều biến cùng lúc một cách atomic, CAS không đủ - cần lock.
synchronized: Bảo vệ Critical Section
synchronized đặt một lock (monitor) lên một object. Chỉ một thread được giữ lock tại một thời điểm. Thread khác phải chờ ở trạng thái BLOCKED.
Tại sao tồn tại: AtomicInteger đủ cho một biến, nhưng khi cần update nhiều biến phải nhất quán với nhau, bạn cần đảm bảo không thread nào thấy trạng thái “nửa vời” ở giữa.
Cái gì vỡ nếu thiếu:
// Chuyển tiền giữa hai tài khoản - cần atomic!
public class BankAccount {
private int balance;
public void transfer(BankAccount target, int amount) {
this.balance -= amount;
// Nếu thread khác đọc balance ở đây: thấy tiền đã bị trừ
// nhưng chưa được cộng vào target. Tổng tiền trong hệ thống bị âm.
target.balance += amount;
}
}
Production example: Payment service xử lý refund đồng thời với purchase.
public class WalletService {
private final Map<String, Long> balances = new HashMap<>();
// Synchronized trên method: lock là 'this' (instance của WalletService)
public synchronized boolean transfer(String fromId, String toId, long amount) {
long fromBalance = balances.getOrDefault(fromId, 0L);
if (fromBalance < amount) return false;
balances.put(fromId, fromBalance - amount);
balances.put(toId, balances.getOrDefault(toId, 0L) + amount);
return true;
}
// Đây là vấn đề: lock toàn bộ method → chỉ một transfer được xử lý tại một lúc
// Ngay cả transfer giữa hai cặp tài khoản hoàn toàn khác nhau cũng phải chờ nhau
}
Vấn đề: synchronized trên method là lock trên this. Mọi transfer đều serialize, kể cả những transfer không liên quan đến nhau. Throughput bị giới hạn nghiêm trọng.
Dùng synchronized block thay vì synchronized method để giảm scope:
public boolean transfer(String fromId, String toId, long amount) {
// Lock chỉ khi thực sự cần thay đổi state
synchronized (balances) {
long fromBalance = balances.getOrDefault(fromId, 0L);
if (fromBalance < amount) return false;
balances.put(fromId, fromBalance - amount);
balances.put(toId, balances.getOrDefault(toId, 0L) + amount);
return true;
}
// Các operation không cần lock (logging, audit trail) có thể ở ngoài
}
Trade-off: synchronized là blocking. Thread bị block đang giữ CPU slot và OS resource trong khi không làm gì. Với workload nhiều thread tranh cùng một lock, throughput bị siết chặt. Nếu bạn thấy nhiều thread BLOCKED trong thread dump trên cùng một lock, đó là tín hiệu cần redesign.
volatile: Visibility Giữa các Thread
volatile giải quyết một vấn đề khác với synchronized: visibility. Mỗi CPU core có L1/L2 cache riêng. Khi thread A ghi vào biến, giá trị có thể nằm trong CPU cache của core A mà thread B (chạy trên core khác) chưa thấy.
Cái gì vỡ nếu thiếu:
public class DataProcessor {
private boolean running = true; // không có volatile
public void process() {
while (running) { // Thread A đọc running từ CPU cache
doWork();
}
}
public void stop() {
running = false; // Thread B ghi vào RAM, nhưng Thread A
// vẫn thấy running = true từ cache của nó
// → vòng lặp không bao giờ dừng
}
}
JVM được phép tối ưu hóa bằng cách cache giá trị running vào register. Thread gọi stop() ghi false vào RAM, nhưng thread đang process() không bao giờ re-read từ RAM. Trong release build với JIT optimization, while (running) thậm chí có thể bị biến thành while (true).
Fix:
private volatile boolean running = true;
volatile đảm bảo hai thứ:
- Mọi write đến biến
volatileđược flush ra main memory ngay lập tức - Mọi read từ biến
volatileđược đọc từ main memory, không từ cache
Production example: Graceful shutdown trong service.
@Component
public class EventProcessor implements DisposableBean {
private volatile boolean shutdownRequested = false;
private final ExecutorService executor = Executors.newSingleThreadExecutor();
@PostConstruct
public void start() {
executor.submit(() -> {
while (!shutdownRequested) {
try {
Event event = eventQueue.poll(100, TimeUnit.MILLISECONDS);
if (event != null) processEvent(event);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
log.info("EventProcessor stopped cleanly");
});
}
@Override
public void destroy() {
shutdownRequested = true; // visible đến thread xử lý ngay lập tức
executor.shutdown();
}
}
Trade-off quan trọng: volatile đảm bảo visibility nhưng KHÔNG đảm bảo atomicity. volatile int count; count++ vẫn là race condition vì count++ là 3 bước. Dùng volatile khi: một thread ghi, nhiều thread đọc. Dùng AtomicInteger/synchronized khi: nhiều thread ghi.
Deadlock: Khi hai Thread Chờ nhau Mãi mãi
Deadlock xảy ra khi thread A giữ lock X và chờ lock Y, trong khi thread B giữ lock Y và chờ lock X. Cả hai chờ nhau vô hạn.
Production bug thực tế: transfer giữa hai tài khoản
public class DeadlockExample {
public void transfer(Account from, Account to, long amount) {
synchronized (from) { // Thread 1: lock account A
synchronized (to) { // Thread 1: chờ lock account B
from.debit(amount);
to.credit(amount);
}
}
}
}
// Thread 1: transfer(accountA, accountB, 100) → lock A, chờ B
// Thread 2: transfer(accountB, accountA, 200) → lock B, chờ A
// Cả hai chờ nhau mãi mãi
Fix: lock ordering - luôn acquire lock theo thứ tự nhất định
public void transfer(Account from, Account to, long amount) {
// Dùng System.identityHashCode để xác định thứ tự lock nhất quán
Account first = System.identityHashCode(from) < System.identityHashCode(to) ? from : to;
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
Bây giờ mọi thread luôn acquire lock theo cùng một thứ tự (account có hashCode nhỏ hơn trước). Thread 1 và Thread 2 cùng cố gắng lock account A trước - một thread win, thread kia chờ. Deadlock không thể xảy ra.
Cách phòng tránh deadlock:
- Lock ordering: Luôn acquire nhiều lock theo thứ tự nhất quán
- Lock timeout với
tryLock: DùngReentrantLock.tryLock(timeout)thay vì chờ vô hạn - Minimize lock scope: Giữ lock ngắn nhất có thể, không gọi external code khi đang giữ lock
- Dùng higher-level abstraction:
ConcurrentHashMap,BlockingQueuethay vì tự manage lock
Trade-off: Lock ordering giải quyết deadlock nhưng yêu cầu discipline nghiêm ngặt. Một developer mới không biết convention sẽ phá vỡ nó. ReentrantLock.tryLock() an toàn hơn nhưng cần xử lý trường hợp lock không acquire được.
ReentrantLock: Khi synchronized Không Đủ
ReentrantLock cung cấp mọi thứ synchronized làm, cộng thêm:
- tryLock(timeout): Thử lấy lock, nếu không được sau timeout thì bỏ qua (tránh deadlock)
- lockInterruptibly(): Có thể interrupt thread đang chờ lock
- Fair lock: Thread chờ lâu nhất được ưu tiên (tránh starvation)
- Multiple conditions: Nhiều
Conditionobject cho cùng một lock
Production example: payment với timeout để tránh deadlock
public class PaymentService {
private final ReentrantLock lock = new ReentrantLock();
public boolean processPayment(PaymentRequest request) throws InterruptedException {
// Thử lấy lock, tối đa 2 giây
if (!lock.tryLock(2, TimeUnit.SECONDS)) {
// Không lấy được lock sau 2 giây → từ chối thay vì chờ mãi
log.warn("Payment processing timeout for order {}", request.getOrderId());
throw new PaymentTimeoutException("System busy, please retry");
}
try {
return doProcessPayment(request);
} finally {
lock.unlock(); // LUÔN unlock trong finally
}
}
}
Trade-off: ReentrantLock phức tạp hơn synchronized và dễ mắc lỗi nếu quên unlock trong finally block. Dùng synchronized khi đủ dùng. Chỉ chuyển sang ReentrantLock khi cần tryLock, interruptible lock, hoặc fair ordering.
ThreadPoolExecutor: Cấu hình đúng hay Cháy Production
Executors.newFixedThreadPool(n) là shortcut tiện lợi nhưng che giấu các thông số quan trọng. Trong production, bạn cần hiểu ThreadPoolExecutor đầy đủ.
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize: số thread luôn sống
50, // maximumPoolSize: giới hạn tuyệt đối
60L, TimeUnit.SECONDS, // keepAliveTime: thread dư sống bao lâu
new LinkedBlockingQueue<>(500), // workQueue: giới hạn 500 task đang chờ
new ThreadPoolExecutor.CallerRunsPolicy() // rejection: caller tự xử lý
);
Tại sao cần hiểu từng thông số:
Khi submit task:
- Nếu số thread đang chạy <
corePoolSize: tạo thread mới ngay, kể cả khi có thread nhàn rỗi - Nếu
corePoolSize<= thread đang chạy: đưa vào queue - Nếu queue đầy VÀ thread đang chạy <
maximumPoolSize: tạo thêm thread - Nếu queue đầy VÀ đã đạt
maximumPoolSize: gọi RejectionHandler
Cái gì vỡ với Executors.newFixedThreadPool(n):
// Executors.newFixedThreadPool source code:
return new ThreadPoolExecutor(n, n, 0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // KHÔNG CÓ BOUND!
Queue không bounded. Nếu workers không xử lý kịp, task tích lũy trong queue vô hạn → OOM. Đây là một trong những nguyên nhân OOM phổ biến nhất trong Java services.
Production configuration cho REST API service:
@Bean
public ThreadPoolExecutor orderProcessingExecutor() {
int cpuCores = Runtime.getRuntime().availableProcessors();
return new ThreadPoolExecutor(
cpuCores * 2, // core: I/O-bound tasks thường dùng 2x cores
cpuCores * 4, // max: burst capacity
30L, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1000), // bounded: tránh OOM
new ThreadFactory() {
private final AtomicInteger count = new AtomicInteger();
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "order-worker-" + count.incrementAndGet());
t.setDaemon(false); // non-daemon: JVM không thoát khi còn task
return t;
}
},
(task, executor) -> {
// Rejection: log + throw để caller biết và có thể retry
log.error("Order processing queue full, rejecting task");
throw new RejectedExecutionException("Order processing at capacity");
}
);
}
Trade-off của queue type:
LinkedBlockingQueue(n): buffer có giới hạn, task chờ đợt. Phù hợp khi có thể chấp nhận latency cao hơn khi busy.
SynchronousQueue: không buffer, mỗi submit phải có thread sẵn sàng. Latency thấp, nhưng cần maximumPoolSize đủ lớn hoặc rejection xảy ra thường.
ArrayBlockingQueue(n): tương tự LinkedBlockingQueue nhưng pre-allocate memory, cache-friendly hơn với throughput cao.
Cách tune pool size:
Cho I/O-bound (database, HTTP calls): poolSize = N * (1 + waitTime/serviceTime). Nếu call mất 200ms và processing mất 10ms, ratio là 21. Với 4 cores: 4 * 21 = 84 threads tối ưu. Với I/O-bound, nhiều thread hơn số CPU core là đúng.
Cho CPU-bound (tính toán, compression): poolSize = N + 1 (N = số CPU core). Nhiều hơn không giúp, chỉ tốn context-switch.
ThreadLocal: State Riêng cho Mỗi Thread
ThreadLocal<T> cung cấp một bản sao riêng của biến cho mỗi thread. Không cần synchronization vì mỗi thread chỉ thấy và sửa bản sao của chính nó.
Tại sao tồn tại: Một số objects không phải thread-safe nhưng tốn kém để tạo lại mỗi lần dùng. Hoặc bạn cần truyền context (user info, request ID, DB transaction) qua nhiều lớp mà không muốn pass qua từng method parameter.
Production example: request context tracking
// RequestContext.java
public class RequestContext {
private static final ThreadLocal<RequestContext> CONTEXT =
ThreadLocal.withInitial(RequestContext::new);
private String requestId;
private String userId;
private long startTime;
public static RequestContext current() { return CONTEXT.get(); }
public static void clear() { CONTEXT.remove(); } // QUAN TRỌNG
}
// OrderFilter.java (Servlet Filter)
public class RequestContextFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
try {
RequestContext ctx = RequestContext.current();
ctx.setRequestId(UUID.randomUUID().toString());
ctx.setUserId(extractUserId((HttpServletRequest) req));
ctx.setStartTime(System.currentTimeMillis());
chain.doFilter(req, resp);
} finally {
RequestContext.clear(); // BẮT BUỘC để tránh memory leak trong thread pool
}
}
}
// Dùng ở bất kỳ đâu trong call stack mà không cần pass parameter
@Service
public class AuditService {
public void logAction(String action) {
String requestId = RequestContext.current().getRequestId(); // không cần inject
log.info("[{}] Action: {}", requestId, action);
}
}
Memory leak với ThreadLocal trong thread pool:
// BUG: không clear ThreadLocal trong thread pool
ThreadPoolExecutor executor = new ThreadPoolExecutor(10, 10, ...);
executor.submit(() -> {
RequestContext.current().setUserId("user-42");
processRequest();
// Quên gọi RequestContext.clear()!
// Thread trở về pool với userId = "user-42" còn trong ThreadLocal
// Request tiếp theo trên thread này đọc được userId của request trước
});
Thread pool reuse thread. Nếu không remove(), thread tiếp theo dùng lại thread đó sẽ thấy data cũ. Trong security context, điều này có nghĩa là request của user A thấy thông tin của user B. Ngoài security, còn là memory leak vì strong reference ngăn GC.
Quy tắc: ThreadLocal luôn đi kèm với try-finally { ThreadLocal.remove() }.
Trade-off: ThreadLocal ẩn dependency - code gọi RequestContext.current() ngầm phụ thuộc vào Filter đã set context, nhưng điều này không hiển thị qua method signature. Khó test hơn (phải setup ThreadLocal trong test). Dùng ThreadLocal cho cross-cutting concerns thực sự (logging, tracing), không cho business logic.
Tổng kết
| Vấn đề | Nguyên nhân | Công cụ đúng |
|---|---|---|
| I/O bottleneck | Single-threaded | Thread pool |
| Race condition | Unprotected shared state | AtomicInteger, synchronized, ReentrantLock |
| Visibility | CPU cache, JIT optimization | volatile |
| Deadlock | Circular lock dependency | Lock ordering, tryLock(timeout) |
| OOM từ thread pool | Unbounded queue | LinkedBlockingQueue(n) |
| Memory leak | ThreadLocal không clear | try-finally { ThreadLocal.remove() } |
Mental model: Thread là unit of execution, không phải unit of isolation. Hai thread trong cùng JVM chia sẻ toàn bộ heap. Mọi thứ bạn đặt trong field của một object là shared state tiềm năng. Câu hỏi đầu tiên khi viết concurrent code: “biến này có được truy cập từ nhiều thread không?” Nếu có, cần cơ chế bảo vệ. Nếu không, không cần.
Concurrency không phải về việc dùng đúng API. Đó là về việc thiết kế data flow để minimize shared mutable state. Code dễ lý luận nhất về concurrency là code không có shared mutable state - dùng immutable objects, local variables, và message passing thay vì shared memory khi có thể.