TungDaDev's Blog

threadpool executor pool size

Thread pool sizing.webp
Published on
/13 mins read/

Rất nhiều lập trình viên nghĩ rằng quản lý đa luồng (Concurrency) chỉ đơn giản là gọi Executors.newFixedThreadPool(100). Khi hệ thống bước vào tải nặng, máy chủ bắt đầu sập với lỗi java.lang.OutOfMemoryError: Java heap space hoặc CPU đạt 100% nhưng Throughput tụt về 0 do Context Switching. Quản trị Thread Pool không phải là trò chơi đoán mò — đó là một bài toán khoa học tính toán chính xác dựa trên tài nguyên 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. Điều này đồng nghĩa với việc mỗi Thread tiêu tốn:

  • Khoảng 1MB dung lượng bộ nhớ Off-heap (cho Thread Stack: -Xss).
  • Chi phí chu kỳ CPU đắt đỏ cho mỗi lần chuyển ngữ cảnh (Context Switch): Lưu trữ registers, cache-invalidation L1/L2/L3, chuyển đổi kernel/user mode.

Nếu tạo quá ít thread: CPU rơi vào trạng thái nhàn rỗi trong khi I/O network đang chờ dữ liệu. Nếu tạo quá nhiều thread: Toàn bộ CPU bị đốt cháy cho việc đảo luồng thay vì xử lý nghiệp vụ. Bài viết này đi sâu vào cơ chế hoạt động của ThreadPoolExecutor và cung cấp cẩm nang định cỡ Thread Pool chuẩn production.


# 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) đã sử dụng một kỹ thuật đóng gói bit cực kỳ tinh vi để đồng bộ hóa trạng thái mà không cần dùng đến khóa lock cồng kềnh: 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ốt lõi của ThreadPoolExecutor

Một kỹ sư muốn làm chủ ThreadPoolExecutor cần hiểu rõ quy luật phối hợp giữa 7 tham số khởi tạo:

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

NOTE

Cơ Chế Khác Biệt So Với Trực Giác Lập Trình Viên: ThreadPoolExecutor KHÔNG tăng số luồng lên maximumPoolSize ngay khi corePoolSize bận. Nó bắt buộc phải nhồi đầy hàng đợi workQueue trước, chỉ khi hàng đợi tràn thì các luồng vượt mức core mới được sinh ra!


# toán học định cỡ pool size: công thức goetz & định luật little

Đừng bao giờ đoán mò kích thước của Thread Pool. Mọi con số phải được quy đổ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)

Trong bài toán CPU-bound, thread không bao giờ phải chờ I/O bên ngoài. Tạo nhiều hơn số core CPU chỉ dẫn đến lãng phí tài nguyên do Context Switch.

Pool Size (CPU-Bound) = N_CPU + 1

(Cộng thêm 1 luồng dự phòng cho các 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 kinh điển 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ụ thực tế 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) trong kiểm soát dung lượng queue

Hàng đợi queueCapacity cần chứa bao nhiêu phần tử? Định luật Little phát biểu:

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

WARNING

Cảnh báo Kiến trúc sư: Nếu bạn đặt queueCapacity = 100,000, trong trường hợp dịch vụ phía sau bị chậm (latency tăng từ 50ms lên 2,000ms), hàng đợi sẽ phình to ra và ngốn sạch RAM của JVM, gây ra lỗi Garbage Collection Pause kéo dài (Stop-The-World) và cuối cùng là OOM Crash!


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

# cơn ác mộng unbounded queue (executors.newFixedThreadPool)

Một lỗi cực kỳ phổ biến của các lập trình viên là dùng các phương thức factory tiện ích của Executors:

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

Nhìn vào mã nguồn bên trong của 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 sẽ mặc định có capacity là hơn 2.14 tỷ tasks (Integer.MAX_VALUE)!

Khi downstream service gặp sự cố, các task mới tiếp tục dồn vào queue. Do queue không bao giờ đầy, tham số maximumPoolSize không bao giờ được kích hoạt, và Rejection Policy cũng không bao giờ chạy. Hệ thống âm thầm tích lũy hàng triệu object trong Heap cho đến khi OOM Crash toàn bộ máy chủ.

# thread starvation deadlock

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

TIP

Nguyên Tắc Vàng: Không bao giờ dùng chung một ThreadPool cho các tác vụ phụ thuộc lẫn nhau (Parent-Child tasks). Luôn tách rời: 1 Pool cho Orchestration và 1 Pool riêng biệt cho Worker tasks.


# chiến lược xử lý từ chối chuẩn enterprise

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

PolicyCơ chếĐánh giá kiến trúc & Rủi ro
AbortPolicy (Mặc định)Ném ngoại lệ RejectedExecutionExceptionChuẩn mực an toàn cho REST API để phản hồi HTTP 503 (Service Unavailable) về cho Gateway.
CallerRunsPolicyÉp luồng gọi (Caller thread) tự thực thi taskTạo cơ chế Backpressure tự nhiên. Làm chậm tốc độ đẩy dữ liệu của Producer. Tuy nhiên, nếu caller là luồng Netty/Tomcat I/O, nó có thể làm đơ toàn bộ server mạng!
DiscardPolicyÂm thầm vứt bỏ task không ném lỗiCỰC KỲ NGUY HIỂM. Gây mất dữ liệu nghiệp vụ âm thầm mà không hề có log cảnh báo.
DiscardOldestPolicyVứt bỏ task cũ nhất trong hàng đợi để nhường chỗ cho task mớiChỉ phù 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 nhất có giá trị hơn dữ liệu cũ.

# xây dựng custom rejection policy với kafka fallback

Trong các hệ thống tài chính, không một giao dịch nào được phép bị vứt bỏ. Khi ThreadPool quá tải, ta đẩy task sang Dead-Letter Queue (Kafka / RabbitMQ) để 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
            fallbackKafkaTemplate.send("financial.dlq.transactions", transactionTask.getPayload());
        } else {
            // Với các tác vụ không thể serialize, chuyển sang CallerRuns có giới hạn timeout
            if (!executor.isShutdown()) {
                r.run();
            }
        }
    }
}

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

Từ Java 21 (Project Loom), khái niệm Virtual Threads đã làm thay đổi hoàn toàn cục diện của mô hình đa luồng trong Java.

# bảng so sánh chiến lược kiến trúc

Đặc tínhThreadPoolExecutor (Platform Threads)Virtual Threads (Executors.newVirtualThreadPerTaskExecutor())
Dung lượng bộ nhớ~1MB / threadDưới 1KB / thread
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 StrategyBẮT BUỘC PHẢI DÙNG POOL để tái sử dụng luồngCẤM POOLING! Tạo mới cho mỗi task và để GC thu dọn
Cạm bẫy PinningKhông bị ảnh hưởngBị kẹt carrier thread nếu dùng khối synchronized nặng hoặc JNI native code

TIP

Quyết định của Architect: Với Java 21+, đối với các tác vụ I/O-bound (gọi HTTP, query SQL), hãy chuyển dần sang Virtual Threads. Giữ lại ThreadPoolExecutor có kiểm soát kích thước nghiêm ngặt cho các tác vụ CPU-Bound hoặc khi cần giới hạn số lượng kết nối tới hệ thống downstream (Backpressure / Rate-limiting).


# cấu hình production chuẩn mực trong spring boot

Dưới đây là một cấu hình ThreadPoolTaskExecutor hoàn chỉnh, có tích hợp Graceful Shutdown và giám sát 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
        meterRegistry.gauge("executor.active.threads", executor, ThreadPoolTaskExecutor::getActiveCount);
        meterRegistry.gauge("executor.queue.size", executor, e -> e.getThreadPoolExecutor().getQueue().size());
        meterRegistry.gauge("executor.pool.size", executor, ThreadPoolTaskExecutor::getPoolSize);
 
        return executor;
    }
}

# checklist sẵn sàng vận hành

  • 1. Tuyệt đối không dùng Unbounded Queue: Mọi instance của ThreadPoolExecutor đều phải truyền tham số 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 pools riêng biệt: 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: Thiết lập waitForTasksToCompleteOnShutdown = true và awaitTerminationSeconds để Kubernetes không kill container khi task đang xử lý dở.
  • 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 có ngữ cảnh nghiệp vụ (payment-exec-1) để phân tích Thread Dump dễ dàng khi xảy ra sự cố.

# kết luận

Quản trị đa luồng trong Java không đơn thuần là việc tăng giảm vài con số trong cấu hình. Đó là nghệ thuật thấu hiểu tương tác giữa phần cứng CPU, bộ nhớ Heap/Stack của JVM và hành vi của hệ điều hành. Nắm vững bản chất của ThreadPoolExecutor chính là lằn ranh phân định giữa một lập trình viên chỉ biết viết code chạy được và một Kỹ sư Hệ thống / Kiến trúc sư Phần mềm có khả năng xây dựng các hệ thống chịu tải hàng triệu giao dịch mỗi giây.


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 😎 👍🏻 🚀 🔥.

← Previous postjava ThreadPoolExecutor
Next post →java string pool