Ba thứ gãy sau khi bật virtual threads
Pinning khi gặp synchronized và JDBC pool, chi phí ThreadLocal khi thread rẻ đi, và chuyện thread pool thôi làm van chặn tải — ba lỗi hay gặp nhất.
TL;DR: Bật spring.threads.virtual.enabled=true chỉ đổi loại executor bên dưới: code nghiệp vụ cũ vẫn chạy nguyên logic, và ba thứ hay gãy theo. Pinning: virtual thread block bên trong khối synchronized không nhả được carrier trên JDK 21, còn JDK 24 (JEP 491) đã gỡ phần lớn giới hạn đó. ThreadLocal thành đắt: SecurityContextHolder, MDC, @RequestScope nhân bản theo số thread, từ vài trăm platform thread nhảy lên hàng nghìn virtual thread cùng sống. Thread pool cũ vốn là van chặn tải; bỏ pool thì backpressure biến mất theo — connection pool của HikariCP không tự lớn lên, phải tự dựng lại giới hạn bằng Semaphore hoặc rate limiter.
TaskFlow bật spring.threads.virtual.enabled=true cho service xử lý đơn hàng đúng theo quyết định ở bài trước — phần lớn thời gian một request là chờ I/O (gọi payment gateway, query đơn hàng), CPU gần như rảnh trong lúc chờ đó. Trước khi đọc tiếp, thử nhớ lại: quyết định đó dựa trên tiêu chí gì, và virtual threads không giúp gì cho loại tải nào?
Tiêu chí là I/O-bound so với CPU-bound: virtual threads thắng khi phần lớn thời gian xử lý nằm ở chờ mạng hoặc DB, vì carrier thread được nhả ra cho việc khác trong lúc chờ. Với tải CPU-bound — vòng lặp tính toán nặng, không blocking — số lượng thread không phải nút thắt, bật virtual threads không đổi gì, đôi khi còn chậm hơn vì chi phí quản lý continuation.
Đợt Cyber Monday tiếp theo, traffic TaskFlow nhảy từ khoảng 300 request/giây lên đỉnh 3.000 request/giây trong một cửa sổ 10 phút. Ba thứ gãy trong cùng một buổi tối đó — không phải vì quyết định bật virtual threads sai, mà vì ba chỗ khác của hệ thống vẫn đang giả định một thế giới có 200 thread bị giới hạn.
1. Pinning — vì sao virtual thread bị ghim vào carrier?
21:03, dashboard latency của TaskFlow vọt từ p99 80ms lên hơn 4 giây. CPU trên node gần như rảnh, dưới 15%, nhưng thread dump cho thấy hàng trăm request đang chờ — không phải chờ DB hay payment gateway, mà chờ chính nhau.
Bạn đã biết từ java-internals: carrier thread là platform thread thật chạy virtual thread bên trên, cơ chế mount/unmount cho phép một carrier phục vụ luân phiên hàng nghìn virtual thread. Pinning là khi unmount đó không chạy được — virtual thread giữ chặt carrier suốt thời gian block. Theo JEP 444, pinning xảy ra trong đúng hai tình huống: chạy bên trong khối/method synchronized, hoặc gọi native method/foreign function. TaskFlow rơi vào tình huống đầu.
Thủ phạm nằm trong một class ghi audit log có từ thời TaskFlow còn chạy Java 8, chưa ai đụng vào từ đó. Đọc code dưới và thử tìm bug trước khi xem đáp án:
public class AuditLogger {
private final RestTemplate restTemplate;
public AuditLogger(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public synchronized void log(AuditEvent event) {
// audit call chay tren virtual thread sau khi bat spring.threads.virtual.enabled
restTemplate.postForEntity("/audit-events", event, Void.class);
}
}flowchart LR
VT["Virtual Thread<br/>goi log(event)"] -- "enter synchronized" --> MON["JVM monitor lock"]
MON --> CT["Carrier Thread"]
CT -- "restTemplate blocking call" --> WAIT{"JDK 21:<br/>unmount duoc?"}
WAIT -- "khong - bi ghim" --> STUCK["CT bi khoa toi khi HTTP xong<br/>khong VT nao khac dung duoc CT"]
WAIT -. "JDK 24 JEP 491: co" .-> FREE["CT duoc nha ngay<br/>chay VT khac trong luc cho"]Cách phát hiện chắc chắn và chạy được trên mọi bản từ JDK 21 trở đi: JDK Flight Recorder, event jdk.VirtualThreadPinned bật mặc định với ngưỡng 20ms — soi event này ra đúng thủ phạm mà không tốn overhead đáng kể, nên dùng được cả trên production.
Tài liệu và bài viết cũ thường chỉ bạn chạy -Djdk.tracePinnedThreads=full để in stack trace lúc bị ghim. Cờ đó có thật trên JDK 21–23, nhưng JDK 24 đã gỡ hẳn nó, không có cờ thay thế — chính vì JEP 491 làm synchronized hết ghim nên công cụ debug riêng cho nó không còn lý do tồn tại. Chạy cờ này trên JDK 24 trở lên thì không có gì xảy ra cả, và đó là cái bẫy: bạn tưởng "không có output nghĩa là không pin". Dùng JFR để khỏi phụ thuộc version.
JDK 21 (JEP 444): synchronized luôn ghim virtual thread block bên trong nó — không có cách nào tránh ngoài đổi cơ chế khoá. JDK 24 (JEP 491 — Synchronize Virtual Threads without Pinning): JVM tách monitor khỏi carrier, virtual thread có thể unmount ngay cả khi đang giữ lock — gỡ phần lớn giới hạn này. Native method và foreign function call thì JEP 491 không đụng tới, hai trường hợp đó vẫn ghim bất kể version.
Cách sửa không phụ thuộc version: đổi synchronized sang java.util.concurrent.locks.ReentrantLock. Field khoá đã khai sẵn, thân log() để trống — tự điền trước khi xem đáp án:
public class AuditLogger {
private final RestTemplate restTemplate;
private final ReentrantLock lock = new ReentrantLock();
public AuditLogger(RestTemplate restTemplate) {
this.restTemplate = restTemplate;
}
public void log(AuditEvent event) {
/* TODO: khoa bang lock, goi restTemplate.postForEntity ben trong,
dam bao lock.unlock() luon chay du postForEntity co nem loi hay khong */
}
}
Viết thân log(): gọi lock.lock(), thực hiện restTemplate.postForEntity(...), và đảm bảo lock.unlock() luôn chạy — kể cả khi có exception giữa chừng. Khối lệnh Java nào đảm bảo một đoạn code luôn chạy dù có lỗi hay không?
Đáp án — thân log() đầy đủ:
public void log(AuditEvent event) {
lock.lock();
try {
restTemplate.postForEntity("/audit-events", event, Void.class);
} finally {
lock.unlock();
}
}
ReentrantLock không phải monitor của JVM — block bên trong nó, hoặc chờ để giữ nó, đều để carrier unmount bình thường. Xác nhận đã sửa: theo dõi event jdk.VirtualThreadPinned qua JFR, count cho AuditLogger về 0 (trên JDK 21–23 có thể đối chiếu thêm bằng -Djdk.tracePinnedThreads=full — không còn stack trace nào trỏ vào class này).
2. ThreadLocal thành đắt khi thread rẻ đi
21:11, tám phút sau khi audit logging được vá tạm bằng cách tắt tính năng, một cảnh báo khác nổ ra: heap usage của TaskFlow tăng dựng đứng, full GC chạy liên tục dù throughput không tăng tương ứng.
Bạn đã biết cơ chế ThreadLocal từ java-internals: mỗi Thread object giữ một map riêng, key là instance ThreadLocal. Cơ chế không đổi khi chuyển sang virtual thread — điều đổi là mẫu số. Platform thread pool cũ giới hạn 200, nên tối đa 200 map ThreadLocal tồn tại cùng lúc bất kể tải cao thế nào. Virtual thread không có trần đó — mỗi request là một Thread object mới toanh, và ở đỉnh burst Cyber Monday, TaskFlow có hơn 6.000 virtual thread sống cùng lúc.
Ba chỗ trong Spring TaskFlow đang dựa thẳng vào ThreadLocal: SecurityContextHolder giữ user đang đăng nhập (spring-security), MDC giữ context log như request-id (spring-core — MDC là ThreadLocal), và TransactionSynchronizationManager giữ Connection của một @Transactional theo thread hiện tại (spring-rest-data). Ở 200 thread, ba thứ đó cộng lại là một khoản bộ nhớ cố định, dễ đoán; ở 6.000 virtual thread, cùng khoản đó nhân lên 30 lần — không phải vì có nhiều việc hơn, mà vì có nhiều Thread object hơn.
Hướng đi dài hạn là ScopedValue (đã học cơ chế ở java-internals: giá trị bất biến, phạm vi rõ ràng). Nhưng Spring framework hiện chưa thay SecurityContextHolder hay TransactionSynchronizationManager sang ScopedValue nội bộ — việc đó nằm ngoài tầm một service team. Thứ bạn kiểm soát được: đừng nhét object nặng (cache lớn, danh sách dài) vào ThreadLocal tự tạo, và cân nhắc ScopedValue cho context bạn tự truyền tay qua các lớp gọi nhau.
ThreadLocal không đắt vì bản thân nó nặng — nó đắt vì số Thread giờ nhiều gấp mấy chục lần, và ThreadLocal nhân bản đúng theo số Thread đó.
Cách phát hiện: chụp allocation profile (async-profiler --event alloc, hoặc heap dump ngay lúc đỉnh tải) và tìm object type nào tăng tuyến tính theo số thread đang sống — MDC map hoặc SecurityContext là ứng viên đầu tiên cần soi. Xác nhận đã sửa: theo dõi heap usage ở cùng mức tải sau khi đã cắt bớt object nặng khỏi ThreadLocal tự viết — đường cong phải phẳng lại thay vì tăng tuyến tính theo số request đồng thời.
3. Mất backpressure — van chặn tải biến mất
21:14, ba phút sau, đợt gãy nghiêm trọng nhất trong đêm: dashboard payment API báo lỗi hàng loạt, log service tràn ngập dòng "Connection is not available, request timed out after 30000ms".
Trước khi bật virtual threads, Tomcat giới hạn 200 thread xử lý đồng thời — con số đó vô tình làm luôn vai trò van chặn tải: dù 3.000 request/giây cùng gõ cửa, chỉ 200 request được xử lý cùng lúc, phần còn lại xếp hàng ở tầng network, chưa từng chạm tới tầng nghiệp vụ. Sau khi bật virtual threads, Tomcat chấp nhận gần như không giới hạn số connection đồng thời — mỗi request có ngay một virtual thread riêng, hàng đợi 200 đó không còn.
Hệ quả: cả 3.000 request cùng chạm tới HikariCP pool của TaskFlow (mặc định 10 connection) gần như đồng thời. Mười request đầu tiên lấy được connection, xử lý bình thường. 2.990 request còn lại xếp hàng chờ ở HikariCP, mỗi request chờ tối đa 30 giây (connectionTimeout mặc định) rồi văng lỗi. Payment gateway ở downstream cũng nhận đúng kiểu burst đó — nó có giới hạn rate riêng, và khi TaskFlow không còn tự chặn tải ở tầng của mình, downstream bị dội ngược theo.
flowchart TB
BURST["Cyber Monday burst<br/>3000 request/giay"] --> TOMCAT["Tomcat + virtual threads<br/>khong con hang doi 200"]
TOMCAT --> POOL{"HikariCP pool<br/>10 connection"}
POOL -- "10 request dau" --> OK["Xu ly binh thuong"]
POOL -- "2990 request con lai" --> TIMEOUT["Cho toi 30s roi timeout"]
TIMEOUT --> DOWNSTREAM["Payment gateway<br/>nhan burst tuong tu"]Điểm quan trọng nhất trong cả ba cạm bẫy: connection pool không tự lớn theo virtual thread. HikariCP vẫn giữ đúng maximumPoolSize đã cấu hình — 10 hay 20, không đổi. Cái đổi là số request cùng lúc đòi lấy 10 chỗ đó: từ tối đa 200 (bị Tomcat chặn từ trước) lên hàng nghìn (không còn gì chặn).
Cách sửa: dựng lại giới hạn tường minh ở đúng chỗ pool cũ từng làm — ví dụ một Semaphore kích thước khớp maximumPoolSize, đặt trước bước cần connection:
public class OrderService {
private final Semaphore dbGate = new Semaphore(10);
private final OrderRepository orderRepository;
public Order placeOrder(Order order) {
dbGate.acquireUninterruptibly();
try {
return orderRepository.save(order);
} finally {
dbGate.release();
}
}
}
Ở quy mô production, rate limiter hoặc bulkhead của Resilience4j làm cùng việc với Semaphore viết tay, cộng thêm timeout/fallback có sẵn — nguyên tắc giống nhau: giới hạn số request chạm vào một tài nguyên hữu hạn, thay vì để tất cả cùng chen.
Xác nhận đã sửa: lặp lại burst test ở cùng 3.000 request/giây, theo dõi thời gian lấy connection của HikariCP — phải quay về mức bình thường, và request vượt giới hạn nên fail nhanh, có kiểm soát, thay vì chờ 30 giây rồi timeout hàng loạt.
Đào sâu
- JEP 444 — Virtual Threads — định nghĩa chính xác hai tình huống pin (
synchronized, native/foreign call) và event JFRjdk.VirtualThreadPinned; cờ-Djdk.tracePinnedThreadsmô tả ở đây chỉ tồn tại tới JDK 23. - JEP 491 — Synchronize Virtual Threads without Pinning — thay đổi ở JDK 24: monitor gắn với virtual thread thay vì carrier, gỡ phần lớn pinning do
synchronizedgây ra. - Spring Boot Reference — Task Execution and Scheduling — hành vi chính xác của
spring.threads.virtual.enabled: executor và scheduler chuyển sangSimpleAsyncTaskExecutor/SimpleAsyncTaskScheduler, và mọi property liên quan tới pooling bị bỏ qua.
Ghi chú: hai JEP là spec chính thức của OpenJDK — đọc để biết đúng điều kiện pin thay vì suy đoán theo trí nhớ. Tài liệu Spring Boot xác nhận flag chỉ đổi loại executor, không thêm bảo vệ nào cho ThreadLocal hay connection pool — đúng lý do vì sao ba cạm bẫy này là trách nhiệm của bạn.
Liên hệ các bài khác
- Bài 02 — Virtual threads trong Boot — quyết định bật virtual threads dựa trên tiêu chí I/O-bound; bài này giả định bạn đã bật rồi và cần biết nó gãy ở đâu.
- Bài 04 — RestClient — cách gọi HTTP outbound tường minh với timeout. Nếu
AuditLoggerở trên dùng client hiện đại với timeout rõ ràng thay vì lời gọi blocking trần trongsynchronized, một phần rủi ro pinning đã giảm ngay từ cách viết. - Bài 07 — Mini-challenge TaskFlow v4 — áp lại cả ba cạm bẫy này vào một tình huống thực hành trên chính TaskFlow.
Tóm tắt
- Trước khi đổ lỗi cho virtual threads khi có sự cố lạ, hỏi: chỗ nghẽn có đang nằm trong khối
synchronizedbọc một lời gọi blocking không? - JDK bạn đang chạy quyết định mức độ nghiêm trọng của pinning: 21 vẫn ghim ở
synchronized, 24 (JEP 491) đã gỡ phần lớn — kiểm version trước khi áp kết luận cũ. - Đối tượng từng "rẻ" khi chỉ có 200 platform thread —
SecurityContext, MDC map, resource của transaction — có thể thành nguồn ngốn bộ nhớ khi số thread nhảy lên hàng nghìn. - Van chặn tải dựng lại bằng
Semaphoređặt trước bước gọi DB, kích thước khớpmaximumPoolSizeHikariCP — thiếu nó, virtual thread dồn nghìn request vào một pool cố định. Semaphore, rate limiter hay bulkhead không phải tuỳ chọn "cho chắc" — chúng thay đúng vai trò van chặn tải mà pool cũ từng làm.
Tự kiểm tra
Q1Đoạn code AuditLogger ở phần 1 vốn đã chạy blocking bên trong synchronized từ thời platform thread, nhưng không ai để ý. Vì sao nó chỉ gây sự cố nghiêm trọng SAU khi TaskFlow bật virtual threads?▸
AuditLogger ở phần 1 vốn đã chạy blocking bên trong synchronized từ thời platform thread, nhưng không ai để ý. Vì sao nó chỉ gây sự cố nghiêm trọng SAU khi TaskFlow bật virtual threads?Q2Nếu TaskFlow nâng lên JDK 24 nhưng vẫn giữ nguyên code synchronized ở SpotTheBug phần 1, đúng đoạn code đó có còn pin không? Bạn có nên vẫn đổi sang ReentrantLock?▸
synchronized ở SpotTheBug phần 1, đúng đoạn code đó có còn pin không? Bạn có nên vẫn đổi sang ReentrantLock?Q3Cơ chế ThreadLocal ở phần 2 không hề thay đổi khi chuyển sang virtual thread. Vậy chính xác điều gì đã đổi khiến nó trở thành vấn đề đáng để ý hơn?▸
Q4Trong đợt Cyber Monday, vì sao pool HikariCP 10 kết nối lại timeout hàng loạt dù bản thân DB server hoàn toàn khoẻ mạnh?▸
Q5Sau khi thêm Semaphore giới hạn 10 lượt gọi DB đồng thời như ở phần 3, 2.990 request còn lại trong đợt burst sẽ ra sao?▸
Semaphore giới hạn 10 lượt gọi DB đồng thời như ở phần 3, 2.990 request còn lại trong đợt burst sẽ ra sao?Bài tiếp theo: RestClient — HTTP client outbound tường minh
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên