Trang chủ Engineering

Tất cả về Thread trong Java

31 May 2026 · 31 phút đọc
Mục lục

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.

Drag · Scroll to zoom

Đ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ất cả Cách Tạo Thread trong Java

Java cung cấp nhiều cơ chế tạo thread ở các mức độ trừu tượng khác nhau. Dùng cơ chế sai cho bài toán là nguồn gốc phổ biến của performance bug và memory issue.

Thread và Runnable: Nền tảng

// Cách 1: Subclass Thread
// Vấn đề: không thể subclass thêm class khác, khó test, tightly coupled
class OrderProcessor extends Thread {
    @Override
    public void run() { processOrder(); }
}
new OrderProcessor().start();

// Cách 2: Runnable - tách biệt task khỏi cơ chế chạy
Runnable task = () -> processOrder();
new Thread(task).start();

Tại sao Runnable tốt hơn Thread subclass: Runnable là pure task definition. Bạn có thể truyền cùng một Runnable vào Thread, ExecutorService, hay bất kỳ thread abstraction nào. Thread subclass ràng buộc task với mechanism của Thread class.

Cả hai đều có vấn đề chung: Mỗi lần gọi new Thread().start() tạo một OS thread mới. Tạo và hủy OS thread tốn khoảng 1ms và vài MB stack. Với 1,000 request/giây mỗi request tạo một thread, bạn tạo 1,000 OS thread/giây - memory cạn, OS scheduler bị overload.

Callable và Future: Task Có Return Value

Runnable.run() không trả về giá trị và không throw checked exception. Callable<V> giải quyết cả hai:

ExecutorService executor = Executors.newFixedThreadPool(10);

// Callable: trả về kết quả, có thể throw Exception
Callable<ProductData> task = () -> {
    return externalApiClient.fetchProduct(productId); // có thể throw IOException
};

Future<ProductData> future = executor.submit(task);

// Làm việc khác trong khi task chạy async
doOtherWork();

// Lấy kết quả, block nếu chưa xong
try {
    ProductData data = future.get(5, TimeUnit.SECONDS); // timeout tránh block mãi
} catch (TimeoutException e) {
    future.cancel(true); // interrupt task nếu timeout
    throw new ServiceUnavailableException("External API timeout");
} catch (ExecutionException e) {
    throw new RuntimeException("Task failed", e.getCause());
}

Điều gì xảy ra nếu dùng Future.get() không timeout:

// Bug: get() không timeout
ProductData data = future.get(); // block mãi nếu external API treo
// HTTP request của user cũng block mãi → timeout từ load balancer
// Thread bị giữ trong pool → pool exhaustion → toàn bộ service treo

Trade-off: Future là blocking model. future.get() giữ thread trong khi chờ. Với nhiều parallel call, cần nhiều thread để chờ. Với I/O-heavy workload, CompletableFuture hiệu quả hơn.

ScheduledExecutorService: Task Định kỳ

ScheduledExecutorService thay thế cho Timer (không dùng Timer trong production vì một task throw exception sẽ kill toàn bộ Timer thread, dừng mọi scheduled task).

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(4);

// Chạy một lần sau delay
scheduler.schedule(() -> sendReminder(order), 24, TimeUnit.HOURS);

// Chạy định kỳ với fixed rate (bắt đầu tính từ lần trước bắt đầu)
scheduler.scheduleAtFixedRate(
    () -> syncInventory(),
    0,          // initial delay
    5,          // period
    TimeUnit.MINUTES
);

// Chạy định kỳ với fixed delay (bắt đầu tính từ lần trước kết thúc)
// An toàn hơn khi task mất thời gian không đoán được
scheduler.scheduleWithFixedDelay(
    () -> cleanExpiredSessions(),
    0,
    10,
    TimeUnit.MINUTES
);

scheduleAtFixedRate vs scheduleWithFixedDelay: Nếu task sync inventory mất 6 phút nhưng period là 5 phút, AtFixedRate sẽ queue task tiếp theo ngay khi task hiện tại xong (do đã overdue). Với workload lớn, tasks tích lũy. WithFixedDelay đảm bảo luôn có 10 phút nghỉ giữa các lần chạy - an toàn hơn cho task có duration biến động.

Trade-off: ScheduledExecutorService phù hợp cho scheduled tasks trong một JVM. Khi cần distributed scheduling (nhiều instance), dùng Quartz, Spring @Scheduled với distributed lock, hoặc Kubernetes CronJob.

CompletableFuture: Async Pipeline

CompletableFuture là cách hiện đại để compose async operations mà không block thread cho từng bước chờ.

// Sequential: mỗi bước phải chờ bước trước - chậm
ProductData product = productService.fetch(productId);     // 100ms
StockInfo stock     = inventoryService.check(productId);   // 80ms
PriceData price     = pricingService.calculate(productId); // 60ms
// Tổng: 240ms

// Parallel với CompletableFuture: chạy cùng lúc
CompletableFuture<ProductData> productFuture =
    CompletableFuture.supplyAsync(() -> productService.fetch(productId), executor);

CompletableFuture<StockInfo> stockFuture =
    CompletableFuture.supplyAsync(() -> inventoryService.check(productId), executor);

CompletableFuture<PriceData> priceFuture =
    CompletableFuture.supplyAsync(() -> pricingService.calculate(productId), executor);

// Chờ tất cả hoàn thành
CompletableFuture.allOf(productFuture, stockFuture, priceFuture).join();
// Tổng: ~100ms (bằng task chậm nhất)

ProductResponse response = new ProductResponse(
    productFuture.join(),
    stockFuture.join(),
    priceFuture.join()
);

Pipeline composition:

CompletableFuture<OrderConfirmation> pipeline =
    CompletableFuture.supplyAsync(() -> validateOrder(request), executor)
        .thenApplyAsync(valid -> enrichWithProductData(valid), executor)
        .thenApplyAsync(enriched -> calculateShipping(enriched), executor)
        .thenComposeAsync(order -> paymentService.charge(order), executor) // trả về CF
        .thenApplyAsync(paid -> orderRepository.save(paid), executor)
        .exceptionally(ex -> {
            log.error("Order pipeline failed", ex);
            return OrderConfirmation.failed(ex.getMessage());
        });

Cạm bẫy phổ biến: CompletableFuture.supplyAsync(task) khi không chỉ định executor sẽ dùng ForkJoinPool.commonPool(). Đây là common pool chia sẻ toàn JVM - một task blocking trong đó ảnh hưởng đến parallelStream() và mọi CompletableFuture khác. Luôn truyền executor tường minh:

// WRONG: dùng common pool
CompletableFuture.supplyAsync(() -> blockingDbCall());

// CORRECT: dùng executor riêng
CompletableFuture.supplyAsync(() -> blockingDbCall(), dbExecutor);

ForkJoinPool: Work-Stealing cho Divide & Conquer

ForkJoinPool được thiết kế cho bài toán có thể chia nhỏ đệ quy (divide and conquer). Điểm khác biệt với ThreadPoolExecutor là cơ chế work-stealing: khi một thread xử lý xong các task trong queue của nó, nó “ăn trộm” task từ cuối queue của thread khác đang bận. Kết quả là tất cả CPU luôn bận, không có thread ngồi chờ trong khi thread khác bị overload.

Drag · Scroll to zoom

RecursiveTask: tính tổng mảng lớn song song

public class SumTask extends RecursiveTask<Long> {
    private static final int THRESHOLD = 10_000; // chia nhỏ đến khi đủ nhỏ
    private final long[] data;
    private final int start, end;

    public SumTask(long[] data, int start, int end) {
        this.data = data; this.start = start; this.end = end;
    }

    @Override
    protected Long compute() {
        if (end - start <= THRESHOLD) {
            // Base case: đủ nhỏ, tính trực tiếp
            long sum = 0;
            for (int i = start; i < end; i++) sum += data[i];
            return sum;
        }
        // Recursive case: chia đôi
        int mid = (start + end) / 2;
        SumTask left  = new SumTask(data, start, mid);
        SumTask right = new SumTask(data, mid, end);

        left.fork();                    // đẩy left vào queue của thread hiện tại
        long rightResult = right.compute(); // chạy right trực tiếp trên thread này
        long leftResult  = left.join(); // lấy kết quả left (hoặc chờ nếu chưa xong)

        return leftResult + rightResult;
    }
}

// Dùng
ForkJoinPool pool = new ForkJoinPool(); // mặc định: số core CPU
long total = pool.invoke(new SumTask(data, 0, data.length));

Tại sao left.fork() rồi right.compute() thay vì fork cả hai:

Nếu fork cả hai, thread hiện tại ngồi chờ. Bằng cách right.compute() trực tiếp, thread hiện tại tiếp tục làm việc thay vì idle. Đây là pattern chuẩn trong ForkJoin.

parallelStream() dùng ForkJoinPool ngầm:

// parallelStream() dùng ForkJoinPool.commonPool()
List<OrderSummary> summaries = orders.parallelStream()
    .filter(o -> o.getStatus() == COMPLETED)
    .map(orderMapper::toSummary)
    .collect(Collectors.toList());

// VẤN ĐỀ: commonPool được chia sẻ toàn JVM
// Nếu một task trong parallelStream() block (DB call, HTTP call),
// nó chiếm worker của commonPool, ảnh hưởng CompletableFuture và parallelStream khác
// Production fix: dùng ForkJoinPool riêng để cô lập
ForkJoinPool customPool = new ForkJoinPool(4);
List<OrderSummary> summaries = customPool.submit(() ->
    orders.parallelStream()
        .filter(o -> o.getStatus() == COMPLETED)
        .map(orderMapper::toSummary)
        .collect(Collectors.toList())
).get();

Khi nào dùng ForkJoinPool:

  • CPU-bound divide-and-conquer (merge sort, parallel aggregation, image processing)
  • Tập dữ liệu lớn, mỗi phần tử xử lý độc lập
  • parallelStream() trên collection lớn với pure computation

Khi nào KHÔNG dùng ForkJoinPool:

  • I/O-bound tasks (database, HTTP): thread block mà không làm gì, lãng phí worker
  • Task có side effect phụ thuộc thứ tự
  • Collection nhỏ (overhead của fork/join lớn hơn lợi ích)

Trade-off: ForkJoinPool tối ưu CPU utilization nhưng không phù hợp cho blocking I/O. Với Java 21+, Virtual Thread giải quyết bài toán này tốt hơn cho I/O-bound.

Virtual Threads (Java 21+): Lightweight ở Scale

Virtual Thread là Project Loom - một thread model mới không map 1-1 với OS thread. JVM quản lý hàng triệu virtual thread, multiplexing chúng lên một số nhỏ OS thread (carrier thread).

// Tạo virtual thread
Thread vt = Thread.ofVirtual().start(() -> processRequest(request));

// Với ExecutorService
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    // Tạo virtual thread cho mỗi task - không tốn kém như OS thread
    for (Order order : orders) {
        executor.submit(() -> processOrder(order)); // hàng nghìn task, ổn
    }
} // tự động shutdown khi try-with-resources kết thúc

Tại sao Virtual Thread ra đời:

Với platform thread (OS thread), mỗi thread tốn ~1MB stack. Server với 10,000 concurrent request cần 10GB RAM chỉ cho stack. Với Virtual Thread, overhead mỗi thread chỉ vài KB. Bạn có thể có 100,000 virtual thread mà không lo memory.

Quan trọng hơn: khi Virtual Thread block trên I/O (database, HTTP), JVM unmount nó khỏi carrier thread. Carrier thread tự do chạy virtual thread khác. Không có thread nào ngồi chờ idle.

// Với Platform Thread: cần thread pool sized để tránh thread exhaustion
ExecutorService platformExecutor = Executors.newFixedThreadPool(200);
// Với 200 threads, tối đa 200 concurrent request

// Với Virtual Thread: thread-per-request model trở lại viable
ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor();
// Hàng nghìn concurrent request, mỗi cái có virtual thread riêng
// Khi block I/O → unmount, carrier thread xử lý request khác

Cạm bẫy với Virtual Thread:

// WRONG: synchronized block pin virtual thread vào carrier thread
// Khi virtual thread trong synchronized block bị block I/O,
// nó không thể unmount - carrier thread cũng bị block
synchronized (lock) {
    dbConnection.query(sql); // I/O block → pin carrier thread!
}

// CORRECT: dùng ReentrantLock thay vì synchronized
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    dbConnection.query(sql); // virtual thread unmount khi block I/O
} finally {
    lock.unlock();
}

Trade-off: Virtual Thread tốt cho I/O-bound workload. Với CPU-bound (tight loops, heavy computation), không có lợi ích so với platform thread. ForkJoinPool vẫn là lựa chọn đúng cho CPU-bound. Virtual Thread không giải quyết race condition hay deadlock - các vấn đề concurrency cơ bản vẫn áp dụng.

Bảng so sánh

Cơ chếKhi nào dùngTránh khi
new Thread()Không bao giờ trong production-
ExecutorService (fixed pool)I/O-bound, kiểm soát concurrencyCần task scheduling hoặc return value
Callable + FutureTask đơn lẻ cần return valueNhiều async bước cần compose
ScheduledExecutorServicePeriodic, delayed taskDistributed scheduling
CompletableFutureAsync pipeline, parallel callsCPU-bound heavy computation
ForkJoinPoolCPU-bound divide & conquerI/O-bound, small data
parallelStream()Pure computation trên collection lớnI/O trong stream operation
Virtual Thread (Java 21+)I/O-bound, high concurrencyCPU-bound computation

Tạo Thread: Đừng dùng new Thread() trong Production

Dù có nhiều cơ chế, rule đơn giản nhất: không bao giờ tạo thread thủ công trong production code. Luôn dùng executor để kiểm soát số lượng thread.

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 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 ở dướ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.

Điều gì xảy ra :

// 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.

Điều gì xảy ra 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.

Điều gì xảy ra 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ứ:

  1. Mọi write đến biến volatile được flush ra main memory ngay lập tức
  2. 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.

Drag · Scroll to zoom

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:

  1. Lock ordering: Luôn acquire nhiều lock theo thứ tự nhất quán
  2. Lock timeout với tryLock: Dùng ReentrantLock.tryLock(timeout) thay vì chờ vô hạn
  3. Minimize lock scope: Giữ lock ngắn nhất có thể, không gọi external code khi đang giữ lock
  4. Dùng higher-level abstraction: ConcurrentHashMap, BlockingQueue thay 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 Condition object 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 đủ.

Drag · Scroll to zoom
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:

  1. Nếu số thread đang chạy < corePoolSize: tạo thread mới ngay, kể cả khi có thread nhàn rỗi
  2. Nếu corePoolSize <= thread đang chạy: đưa vào queue
  3. Nếu queue đầy VÀ thread đang chạy < maximumPoolSize: tạo thêm thread
  4. Nếu queue đầy VÀ đã đạt maximumPoolSize: gọi RejectionHandler

Điều gì xảy ra 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.

Context Propagation trong Spring Boot và Quarkus

Hiểu thread là chưa đủ. Trong thực tế, bạn làm việc trong một framework quản lý thread cho bạn. Vấn đề không phải là tạo thread như thế nào, mà là làm sao để các metadata quan trọng (security identity, request ID, transaction, CDI scope) không bị mất khi code chuyển sang thread khác.

Spring Boot: Context bị mất khi dùng @Async

Khi một method được đánh dấu @Async, Spring thực thi nó trên một thread pool riêng. Thread đó không tự động kế thừa bất kỳ context nào từ thread gọi nó, vì tất cả context trong Spring đều dựa trên ThreadLocal.

Điều gì xảy ra :

@Service
public class OrderService {

    @Async
    public CompletableFuture<Void> sendConfirmationAsync(Order order) {
        // SecurityContextHolder.getContext().getAuthentication() → null
        // MDC.get("requestId") → null
        // RequestContextHolder.getRequestAttributes() → null
        String userId = getCurrentUserId(); // NullPointerException
        auditService.log(userId, "ORDER_CONFIRMED", order.getId());
        return CompletableFuture.completedFuture(null);
    }
}

Thread trong pool không biết request nào đang được xử lý, ai đang đăng nhập, hay requestId cho logging là gì. Kết quả: audit log thiếu userId, distributed tracing mất requestId, security check bên trong async method throw NullPointerException.

Fix: TaskDecorator để copy context trước khi dispatch

TaskDecorator là hook của Spring cho phép bạn bọc mỗi task trước khi nó chạy trên thread pool. Dùng nó để capture context từ calling thread và restore vào worker thread:

public class ContextCopyingTaskDecorator implements TaskDecorator {

    @Override
    public Runnable decorate(Runnable task) {
        // Chạy trên calling thread: capture context hiện tại
        SecurityContext securityCtx = SecurityContextHolder.getContext();
        Map<String, String> mdcCtx   = MDC.getCopyOfContextMap();

        return () -> {
            // Chạy trên worker thread: restore context
            SecurityContextHolder.setContext(securityCtx);
            if (mdcCtx != null) MDC.setContextMap(mdcCtx);
            try {
                task.run();
            } finally {
                SecurityContextHolder.clearContext(); // bắt buộc: thread về pool
                MDC.clear();
            }
        };
    }
}
@Configuration
@EnableAsync
public class AsyncConfig implements AsyncConfigurer {

    @Override
    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(10);
        executor.setMaxPoolSize(50);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("async-worker-");
        executor.setTaskDecorator(new ContextCopyingTaskDecorator());
        executor.initialize();
        return executor;
    }
}

Giờ mọi @Async method tự động nhận đúng SecurityContext và MDC từ request ban đầu mà không cần thay đổi bất kỳ business logic nào.

Tại sao không dùng MODE_INHERITABLETHREADLOCAL:

Spring Security có thể được cấu hình để tự động kế thừa context sang child thread:

SecurityContextHolder.setStrategyName(SecurityContextHolder.MODE_INHERITABLETHREADLOCAL);

Trông có vẻ đơn giản hơn, nhưng nguy hiểm với thread pool. InheritableThreadLocal copy context khi thread được tạo ra, không phải khi task được submit. Vì thread pool tái dụng thread, thread được tạo một lần từ lúc khởi động và không bao giờ “inherit” context của request sau. Kết quả vẫn là null. TaskDecorator copy tại thời điểm submit task - đây là behavior đúng.

Production example: order audit với đầy đủ context

@Service
public class OrderProcessingService {

    @Async("orderExecutor")
    public CompletableFuture<OrderResult> processAsync(Order order) {
        // SecurityContext và MDC đã được propagate qua TaskDecorator
        String requestId = MDC.get("requestId"); // có giá trị từ request gốc
        String userId    = SecurityContextHolder.getContext()
                               .getAuthentication().getName();

        log.info("[{}] Processing order {} for user {}",
            requestId, order.getId(), userId);

        OrderResult result = doHeavyProcessing(order);

        auditRepository.save(new AuditEntry(userId, "ORDER_PROCESSED",
            order.getId(), requestId));

        return CompletableFuture.completedFuture(result);
    }
}

Trade-off: TaskDecorator copy SecurityContext là shallow copy - bạn đang share cùng một Authentication object giữa calling thread và worker thread. Nếu Authentication là mutable và ai đó modify nó trong async thread, calling thread cũng thấy thay đổi. Trong thực tế, Authentication trong Spring Security là immutable, nên không thành vấn đề. Nhưng với custom context object, hãy đảm bảo nó là immutable hoặc thực hiện deep copy.

Quarkus: Thread Model Khác hoàn toàn

Quarkus dùng Vert.x reactive engine. Điều này tạo ra hai loại thread với vai trò rõ ràng:

Drag · Scroll to zoom

Đừng block event loop thread:

// WRONG trong Quarkus: block event loop
@GET
@Path("/orders/{id}")
public Order getOrder(String id) {
    return orderRepository.findById(id).await().indefinitely(); // blocking!
    // event loop bị chặn → toàn bộ server không nhận request khác
}

// ĐÚNG: annotate blocking method
@GET
@Path("/orders/{id}")
@Blocking  // Quarkus dispatch sang worker thread tự động
public Order getOrder(String id) {
    return orderRepository.findById(id); // JDBC blocking, OK trên worker thread
}

ManagedExecutorService thay cho ExecutorService:

Trong Quarkus (và Jakarta EE), khi bạn cần chạy background task, dùng ManagedExecutorService thay vì tự tạo ExecutorService. Sự khác biệt cốt lõi: ManagedExecutorService tự động propagate CDI context, security identity, và transaction context sang task.

@ApplicationScoped
public class BackgroundJobService {

    @Inject
    ManagedExecutorService executor; // inject, không tự tạo

    @Inject
    SecurityIdentity identity;

    public void scheduleInventoryUpdate(List<Product> products) {
        executor.submit(() -> {
            // CDI context có sẵn - không cần setup thủ công
            String currentUser = identity.getPrincipal().getName(); // hoạt động
            products.forEach(inventoryService::update); // @Transactional works
        });
    }
}

So sánh với new ThreadPoolExecutor(): task chạy trong executor tự tạo không có CDI context. Gọi một @ApplicationScoped bean từ trong đó sẽ hoạt động (vì ApplicationScoped không cần request scope), nhưng gọi @RequestScoped bean sẽ throw ContextNotActiveException.

Kích hoạt Request Scope thủ công khi cần:

@ApplicationScoped
public class ReportService {

    @Inject
    ManagedExecutorService executor;

    @Inject
    InjectableContext requestContext; // Quarkus CDI request context

    public void generateReportAsync(ReportRequest request) {
        executor.submit(() -> {
            // Request scope mặc định không active trong background thread
            // Activate thủ công:
            requestContext.activate();
            try {
                doGenerateReport(request); // code dùng @RequestScoped beans
            } finally {
                requestContext.terminate(); // cleanup
            }
        });
    }
}

Reactive pipeline với Mutiny:

Quarkus khuyến khích dùng Mutiny cho async/reactive code. Context propagation được SmallRye xử lý tự động khi chuyển giữa các thread trong pipeline:

@GET
@Path("/orders/process")
public Uni<OrderResult> processOrder(@Valid OrderRequest request) {
    return Uni.createFrom().item(request)
        .onItem().transformToUni(req ->
            // Vẫn trên event loop: validate, nhẹ
            Uni.createFrom().item(validateAndEnrich(req))
        )
        .emitOn(executor)                    // switch sang worker thread
        .onItem().transform(enriched -> {
            // Trên worker thread: blocking DB call
            // SecurityIdentity, CDI context được propagate bởi SmallRye
            return orderRepository.save(enriched);
        })
        .emitOn(Infrastructure.getDefaultWorkerPool())
        .onItem().transform(saved -> {
            notificationService.sendConfirmation(saved);
            return new OrderResult(saved.getId());
        });
}

Tóm tắt: Spring Boot vs Quarkus

Spring BootQuarkus
Context propagation mặc địnhKhông (cần TaskDecorator)Có (ManagedExecutorService)
Security contextCần copy thủ côngPropagate tự động
MDC propagationCần copy thủ côngThủ công hoặc qua filter
Background task@Async + TaskDecoratorManagedExecutorService
Blocking operationBất kỳ thread nàoWorker thread, tránh event loop
CDI request scope trong asyncCần setuprequestContext.activate()
Reactive pipelineCompletableFuture, ReactorMutiny + SmallRye propagation

Tổng kết

Vấn đềNguyên nhânCông cụ đúng
I/O bottleneckSingle-threadedThread pool
Race conditionUnprotected shared stateAtomicInteger, synchronized, ReentrantLock
VisibilityCPU cache, JIT optimizationvolatile
DeadlockCircular lock dependencyLock ordering, tryLock(timeout)
OOM từ thread poolUnbounded queueLinkedBlockingQueue(n)
Memory leakThreadLocal không cleartry-finally { ThreadLocal.remove() }
Context mất trong @AsyncThreadLocal không propagateTaskDecorator (Spring), ManagedExecutorService (Quarkus)

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ể.