threadpool executor pool size
Tung Bui Thanh (TungDaDev)15 mins read

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ớijava.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đến1, ví dụ0.8cho 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 = λ * WL: 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 tasksLư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.
| Policy | Cơ chế | Đánh giá & Rủi ro |
|---|---|---|
AbortPolicy (Mặc định) | Ném ngoại lệ RejectedExecutionException | Lự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 task | Tạ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ỗi | RẤT NGUY HIỂM. Mất dữ liệu nghiệp vụ âm thầm, không có log cảnh báo. |
DiscardOldestPolicy | Vứt bỏ task cũ nhất trong hàng đợi để nhường chỗ cho task mới | Chỉ 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ính | ThreadPoolExecutor (Platform Threads) | Virtual Threads (Executors.newVirtualThreadPerTaskExecutor()) |
|---|---|---|
| Dung lượng bộ nhớ | ~1MB stack giữ chỗ / thread | Vài trăm byte tới vài KB / thread, tuỳ độ sâu stack (nằm trên heap) |
| Số lượng tối đa | Và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 cho | Cá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 Strategy | PHẢI DÙNG POOL để tái sử dụng luồng | KHÔNG POOLING. Tạo mới cho mỗi task và để GC thu dọn |
| Cạm bẫy Pinning | Không bị ảnh hưởng | Kẹ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 choBlockingQueue(ví dụ:ArrayBlockingQueue(N)hoặcLinkedBlockingQueue(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 = truevàawaitTerminationSecondsđể task đang dở có thời gian chạy xong. Tổng thời gian shutdown phải nhỏ hơnterminationGracePeriodSecondscủ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% Capacityliên tục trong 1 phút. - 5. Đặt tên Thread rõ ràng (
ThreadNamePrefix): Không để tên mặc địnhpool-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 😎 👍🏻 🚀 🔥.