Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog
in java

threadpool executor pool size

Tung Bui Thanh (TungDaDev)15 mins read

Thread pool sizing.webp

Nhiều người nghĩ quản lý đa luồng (Concurrency) chỉ là gọi Executors.newFixedThreadPool(100). Tới lúc tải nặng, server sập với java.lang.OutOfMemoryError: Java heap space, hoặc CPU lên 100% mà throughput tụt thê thảm vì Context Switching. Chỉnh Thread Pool không nên đoán mò: có thể ước lượng được từ phần cứng, định luật Little và cách kiểm soát hàng đợi.

Trong hệ sinh thái Java, mỗi Thread nền tảng (Platform Thread) ánh xạ 1-1 với một Kernel Thread của hệ điều hành. Nghĩa là mỗi Thread tốn:

  • Một vùng Thread Stack nằm ngoài heap, mặc định 1MB trên Linux/macOS 64-bit (chỉnh bằng -Xss). Đây là vùng địa chỉ được giữ chỗ; RAM thật chỉ bị chiếm dần khi stack sâu lên, nhưng vài nghìn thread vẫn là con số đáng kể.
  • Chu kỳ CPU cho mỗi lần chuyển ngữ cảnh (Context Switch): vào kernel, lưu và nạp lại registers, rồi thread mới chạy trên cache L1/L2 đang "nguội" vì đầy dữ liệu của thread trước.

Tạo quá ít thread thì CPU ngồi chơi trong lúc I/O network chờ dữ liệu. Tạo quá nhiều thì CPU tốn hết vào việc đảo luồng thay vì xử lý nghiệp vụ. Phần dưới xem ThreadPoolExecutor hoạt động ra sao bên trong và chọn size Thread Pool cho production thế nào.


# giải mã biến điều khiển ctl

Bên trong mã nguồn của java.util.concurrent.ThreadPoolExecutor, Doug Lea (tác giả của gói java.util.concurrent) dùng một kỹ thuật đóng gói bit khá hay để đồng bộ trạng thái mà không cần lock nặng: biến ctl.

// Trích xuất mã nguồn ThreadPoolExecutor.java
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // 32 - 3 = 29 bits
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1; // 2^29 - 1 (Khoảng 536 triệu workers)
 
// 3 bits cao nhất đại diện cho Trạng Thái Vận Hành (RunState)
private static final int RUNNING    = -1 << COUNT_BITS; // 111...
private static final int SHUTDOWN   =  0 << COUNT_BITS; // 000...
private static final int STOP       =  1 << COUNT_BITS; // 001...
private static final int TIDYING    =  2 << COUNT_BITS; // 010...
private static final int TERMINATED =  3 << COUNT_BITS; // 011...
 
// Đóng gói và trích xuất chỉ trong một chu kỳ CPU (Bitwise operation)
private static int runStateOf(int c)     { return c & ~COUNT_MASK; }
private static int workerCountOf(int c)  { return c & COUNT_MASK; }

# 7 tham số của ThreadPoolExecutor

Gom cả trạng thái lẫn số worker vào một int thì chỉ cần một phép CAS là đọc/đổi được cả hai cùng lúc, không sợ hai giá trị lệch nhau. Tuy vậy, không phải mọi thứ đều lock-free: tập workers (một HashSet) và vài thống kê vẫn được bảo vệ bằng mainLock (ReentrantLock), chỉ là lock này không nằm trên đường đẩy task vào queue.

Muốn dùng chắc ThreadPoolExecutor thì cần hiểu 7 tham số khởi tạo phối hợp với nhau thế nào:

public ThreadPoolExecutor(
    int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler
)

NOTE

Chỗ này hay trái với trực giác: ThreadPoolExecutor không tăng số luồng lên maximumPoolSize ngay khi corePoolSize bận. Nó đẩy đầy hàng đợi workQueue trước, hàng đợi tràn thì mới sinh thêm luồng vượt mức core. Hệ quả: với queue lớn, maximumPoolSize gần như chỉ để trang trí. Ngược lại, khi số worker còn dưới corePoolSize, pool vẫn tạo thread mới cho task mới dù các core thread khác đang rảnh.


# tính pool size: công thức goetz & định luật little

Đừng đoán mò kích thước Thread Pool. Con số phải dựa trên bản chất của tải:

# đối với tác vụ cpu-bound (nặng về tính toán)

Với CPU-bound, thread không phải chờ I/O bên ngoài. Tạo nhiều hơn số core CPU chỉ tốn tài nguyên vì Context Switch.

Pool Size (CPU-Bound) = N_CPU + 1

(Cộng thêm 1 luồng dự phòng cho trường hợp Page Fault hoặc thread bị ngắt quãng nhẹ.)

# đối với tác vụ i/o-bound (nặng về chờ mạng / database)

Brian Goetz (Java Language Architect) đưa ra công thức này trong cuốn Java Concurrency in Practice:

N_threads = N_CPU * U_CPU * (1 + W / C)
  • N_CPU: Số lượng CPU Cores khả dụng (Runtime.getRuntime().availableProcessors()).
  • U_CPU: Mục tiêu sử dụng CPU mong muốn (từ 0 đến 1, ví dụ 0.8 cho 80%).
  • W: Thời gian chờ I/O (Wait Time).
  • C: Thời gian tính toán CPU thực tế (Compute Time).

Ví dụ trong hệ thống Microservice:

  • Một API mất trung bình 100ms để hoàn thành.
  • Thời gian thực thi code Java chỉ tốn 5ms (C = 5ms).
  • Thời gian chờ gọi sang Database PostgreSQL và Redis là 95ms (W = 95ms).
  • Tỷ lệ W / C = 95 / 5 = 19.
  • Trên máy chủ 8 Cores, với mục tiêu CPU 80%:
N_threads = 8 * 0.8 * (1 + 19) = 8 * 0.8 * 20 = 128 threads

# định luật little (little's law) để chọn dung lượng queue

queueCapacity nên chứa bao nhiêu phần tử? Định luật Little nói:

L = λ * W
  • L: Số lượng requests trung bình nằm trong hệ thống (Queue + In-flight).
  • λ: Tốc độ đến của request (Arrival Rate - ví dụ: 2,000 requests/giây).
  • W: Thời gian một request lưu lại trong hệ thống (Latency trung bình - ví dụ: 50ms = 0.05s).
L = 2000 * 0.05 = 100 tasks

Lưu ý chữ W ở đây là tổng thời gian request nằm trong hệ thống, khác với W (thời gian chờ I/O) trong công thức Goetz. Và L là số task trong cả hệ thống, gồm cả những task đang chạy trên thread. Nếu pool đã có đủ thread cho lượng đó, queue chỉ cần đủ hấp thụ phần dao động (burst) quanh mức trung bình. Little cho bạn một mốc để chọn, vài lần mức này là vừa; đặt queue gấp hàng nghìn lần thì chỉ đang giấu quá tải đi thôi.

WARNING

Lưu ý: Nếu bạn đặt queueCapacity = 100,000, khi dịch vụ phía sau chậm (latency tăng từ 50ms lên 2,000ms), hàng đợi sẽ phình ra và ăn hết RAM của JVM, gây Garbage Collection Pause kéo dài (Stop-The-World) rồi OOM Crash.


# cạm bẫy thường gặp trên production

# unbounded queue (executors.newFixedThreadPool)

Một lỗi rất hay gặp là dùng các factory method tiện lợi của Executors (newSingleThreadExecutor() cũng dùng LinkedBlockingQueue không giới hạn; còn newCachedThreadPool() thì ngược lại, không giới hạn số thread):

// NGUY HIỂM CHẾT NGƯỜI:
ExecutorService executor = Executors.newFixedThreadPool(16);

Nhìn mã nguồn bên trong JDK:

public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
                                  0L, TimeUnit.MILLISECONDS,
                                  new LinkedBlockingQueue<Runnable>()); // DEFAULT CAPACITY = Integer.MAX_VALUE!
}

LinkedBlockingQueue không truyền tham số dung lượng có capacity mặc định là hơn 2,14 tỷ tasks (Integer.MAX_VALUE).

Khi downstream service gặp sự cố, task mới cứ dồn vào queue. Queue không bao giờ đầy nên Rejection Policy không bao giờ chạy (với pool tự tạo dùng queue kiểu này thì maximumPoolSize cũng vô dụng luôn). Heap âm thầm tích hàng triệu object cho đến khi OOM làm sập cả server.

# thread starvation deadlock

Xảy ra khi các task trong cùng một ThreadPool lại submit sub-task vào chính ThreadPool đó rồi đứng đợi kết quả:

TIP

Nguyên tắc: Đừng dùng chung một ThreadPool cho các tác vụ phụ thuộc nhau (Parent-Child tasks). Tách ra: 1 Pool cho Orchestration và 1 Pool riêng cho Worker tasks.


# chiến lược xử lý khi từ chối task

Khi hàng đợi đầy và luồng chạm maximumPoolSize, hệ thống cần từ chối task một cách an toàn.

PolicyCơ chếĐánh giá & Rủi ro
AbortPolicy (Mặc định)Ném ngoại lệ RejectedExecutionExceptionLựa chọn an toàn cho REST API để trả HTTP 503 (Service Unavailable) về cho Gateway.
CallerRunsPolicyÉp luồng gọi (Caller thread) tự thực thi taskTạo Backpressure tự nhiên, làm chậm tốc độ đẩy dữ liệu của Producer. Nhưng nếu caller là luồng Netty/Tomcat I/O thì có thể làm đơ cả server.
DiscardPolicyÂm thầm vứt bỏ task không ném lỗiRẤT NGUY HIỂM. Mất dữ liệu nghiệp vụ âm thầm, không có log cảnh báo.
DiscardOldestPolicyVứt bỏ task cũ nhất trong hàng đợi để nhường chỗ cho task mớiChỉ hợp với hệ thống streaming dữ liệu chứng khoán/cảm biến IoT, nơi dữ liệu mới có giá trị hơn dữ liệu cũ.

# custom rejection policy với kafka fallback

Trong hệ thống tài chính, không giao dịch nào được phép bị bỏ. Khi ThreadPool quá tải, ta đẩy task sang một topic dự phòng trên Kafka / RabbitMQ (hay được gọi là DLQ, dù đúng ra đây là hàng đợi tràn chứ chưa phải message lỗi) để xử lý sau:

public class EnterpriseFallbackRejectionHandler implements RejectedExecutionHandler {
    private static final Logger log = LoggerFactory.getLogger(EnterpriseFallbackRejectionHandler.class);
    private final KafkaTemplate<String, Object> fallbackKafkaTemplate;
 
    public EnterpriseFallbackRejectionHandler(KafkaTemplate<String, Object> kafkaTemplate) {
        this.fallbackKafkaTemplate = kafkaTemplate;
    }
 
    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        log.warn("ThreadPool bão hòa! Active: {}, Queue Size: {}. Bắt đầu chuyển hướng sang Kafka DLQ.",
                executor.getActiveCount(), executor.getQueue().size());
 
        if (r instanceof FinancialTransactionTask transactionTask) {
            // Đẩy sang Kafka topic dự phòng để worker phụ tiêu thụ dần.
            // send() là bất đồng bộ: phải xử lý kết quả, nếu không Kafka lỗi là mất giao dịch trong im lặng.
            fallbackKafkaTemplate.send("financial.dlq.transactions", transactionTask.getPayload())
                    .whenComplete((result, ex) -> {
                        if (ex != null) {
                            log.error("Không đẩy được giao dịch sang DLQ", ex);
                        }
                    });
        } else {
            // Với các tác vụ không thể serialize, chạy luôn trên luồng gọi (giống CallerRunsPolicy)
            if (!executor.isShutdown()) {
                r.run();
            }
        }
    }
}

whenComplete dùng được vì từ Spring Kafka 3.0, KafkaTemplate.send() trả về CompletableFuture (bản 2.x trả ListenableFuture, dùng addCallback). Ví dụ này chỉ log lỗi; với giao dịch tiền thật, bạn cần thêm một chỗ lưu cuối cùng (ví dụ ghi bảng outbox trong DB). Cũng nhớ rằng send() có thể block luồng gọi tới max.block.ms (mặc định 60 giây) khi producer chưa lấy được metadata hoặc buffer đầy, mà luồng gọi ở đây thường là luồng đang xử lý request.


# so sánh với java 21 virtual threads

Từ Java 21 (Project Loom), Virtual Threads đã thay đổi đáng kể mô hình đa luồng trong Java.

# bảng so sánh

Đặc tínhThreadPoolExecutor (Platform Threads)Virtual Threads (Executors.newVirtualThreadPerTaskExecutor())
Dung lượng bộ nhớ~1MB stack giữ chỗ / threadVài trăm byte tới vài KB / thread, tuỳ độ sâu stack (nằm trên heap)
Số lượng tối đaVài nghìn (Bị giới hạn bởi OS Kernel & RAM)Hàng triệu luồng đồng thời
Phù hợp nhất choCác tác vụ CPU-Bound (Nén file, mã hóa, xử lý đồ họa)Các tác vụ I/O-Bound (REST client, Database, microservice calls)
Pooling StrategyPHẢI DÙNG POOL để tái sử dụng luồngKHÔNG POOLING. Tạo mới cho mỗi task và để GC thu dọn
Cạm bẫy PinningKhông bị ảnh hưởngKẹt carrier thread khi block bên trong synchronized (Java 21–23) hoặc native/JNI

TIP

Gợi ý: Với Java 21+, tác vụ I/O-bound (gọi HTTP, query SQL) nên chuyển dần sang Virtual Threads. Từ Java 24 (JEP 491), block trong synchronized không còn ghim carrier thread nữa; trên Java 21 thì nên đổi các đoạn synchronized có I/O sang ReentrantLock. Giữ ThreadPoolExecutor với size kiểm soát chặt cho tác vụ CPU-Bound hoặc khi cần giới hạn số kết nối tới hệ thống downstream (Backpressure / Rate-limiting).


# cấu hình production trong spring boot

Dưới đây là cấu hình ThreadPoolTaskExecutor đầy đủ, có Graceful Shutdown và Micrometer Metrics:

@Configuration
@EnableAsync
public class EnterpriseThreadPoolConfiguration {
 
    @Bean(name = "orderProcessingExecutor")
    public ThreadPoolTaskExecutor orderProcessingExecutor(MeterRegistry meterRegistry) {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
 
        // Cấu hình định cỡ dựa trên Goetz Formula cho máy chủ 8 Cores
        executor.setCorePoolSize(16);
        executor.setMaxPoolSize(64);
        executor.setQueueCapacity(500); // Bounded Queue bảo vệ bộ nhớ
        executor.setKeepAliveSeconds(60);
        executor.setThreadNamePrefix("order-worker-");
 
        // Chiến lược xử lý khi quá tải
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
 
        // Cấu hình Graceful Shutdown: Chờ các task đang dở hoàn thành trước khi kill app
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(30);
 
        executor.initialize();
 
        // Gắn Metrics vào Prometheus / Grafana Dashboard.
        // Đặt tên riêng để không đụng metric executor.* mà Spring Boot Actuator tự sinh.
        meterRegistry.gauge("order.executor.active.threads", executor, ThreadPoolTaskExecutor::getActiveCount);
        meterRegistry.gauge("order.executor.queue.size", executor, e -> e.getThreadPoolExecutor().getQueue().size());
        meterRegistry.gauge("order.executor.pool.size", executor, ThreadPoolTaskExecutor::getPoolSize);
 
        return executor;
    }
}

Vài điểm cần hiểu ở cấu hình này. Core 16, max 64 thấp hơn con số 128 tính ở trên vì Goetz cho mức trần lý thuyết; thực tế còn bị giới hạn bởi connection pool DB và sức chịu của downstream, nên bắt đầu thấp rồi đo. Và nhớ quy tắc "queue trước, thread sau": thread thứ 17 trở đi chỉ được tạo khi 500 chỗ trong queue đã đầy. CallerRunsPolicy ở đây nghĩa là khi quá tải, chính luồng Tomcat gọi method @Async sẽ phải tự chạy task, tức request đó chậm lại (đúng tinh thần backpressure, nhưng phải chấp nhận được).

Nếu dùng Spring Boot Actuator thì thật ra không cần tự gắn gauge: Boot tự instrument mọi bean ThreadPoolTaskExecutor qua Micrometer ExecutorServiceMetrics (executor.active, executor.queued, executor.pool.size..., tag name theo tên bean). Gauge tự viết mà trùng tên các metric đó nhưng khác bộ tag thì Prometheus registry sẽ báo lỗi hoặc bỏ qua, nên trong code trên mình đặt tiền tố riêng.


# checklist

  • 1. Không dùng Unbounded Queue: Mọi ThreadPoolExecutor đều phải truyền dung lượng giới hạn cho BlockingQueue (ví dụ: ArrayBlockingQueue(N) hoặc LinkedBlockingQueue(N)).
  • 2. Phân lập Thread Pool: Có ít nhất 2 pool riêng: 1 pool xử lý tác vụ quan trọng (Critical Transaction) và 1 pool cho tác vụ phụ trợ (Gửi mail, thông báo).
  • 3. Cấu hình Graceful Shutdown: Đặt waitForTasksToCompleteOnShutdown = true và awaitTerminationSeconds để task đang dở có thời gian chạy xong. Tổng thời gian shutdown phải nhỏ hơn terminationGracePeriodSeconds của Pod (mặc định 30 giây), nếu không Kubernetes vẫn SIGKILL giữa chừng.
  • 4. Giám sát độ bão hòa Queue: Đặt alert trên Grafana khi Queue Size > 70% Capacity liên tục trong 1 phút.
  • 5. Đặt tên Thread rõ ràng (ThreadNamePrefix): Không để tên mặc định pool-1-thread-1. Đặt tên theo ngữ cảnh nghiệp vụ (payment-exec-1) để đọc Thread Dump dễ khi có sự cố.

# kết luận

Chỉnh thread pool cho đúng cần hiểu tương tác giữa CPU, bộ nhớ Heap/Stack của JVM và hành vi của hệ điều hành, chứ không phải tăng giảm vài con số cho tới khi hết lỗi. Công thức ở trên chỉ cho điểm xuất phát; con số cuối cùng nên chốt sau khi load test và nhìn metrics queue, active threads, latency.


Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.