Java 21 đưa luồng ảo vào bản chính thức. Spring Boot 3.2 cho bật bằng một dòng. Bài này đo xem nó đáng bao nhiêu.

Một dòng

spring:
  threads:
    virtual:
      enabled: true
  mặc định        : {"ten":"http-nio-8080-exec-4", "laLuongAo":false}
  bật luồng ảo    : {"ten":"tomcat-handler-1",     "laLuongAo":true}

Tomcat đổi hẳn mô hình: mỗi request một luồng ảo thay vì mượn từ pool cố định.

Cờ này còn ảnh hưởng @Async, @Scheduled và các bộ lắng nghe thông điệp — chúng cũng chuyển sang luồng ảo.

Thời gian khởi động gần như không đổi: 1,128 s so với 1,166 s.

Đo với 200 request đồng thời

Mỗi request chặn 100 mili giây:

  Tomcat luồng nền, tối đa 20 : 200 ok, 1211 ms
  luồng ảo                    : 200 ok,  551 ms

Nhanh hơn 2,2 lần.

Con số 1211 ms giải thích được ngay: 200 request chia cho 20 luồng là 10 đợt, mỗi đợt 100 ms — đúng khoảng 1000 ms cộng chi phí. Luồng nền là nút thắt cứng.

Với luồng ảo không có nút thắt đó: 200 luồng ảo cùng chờ, và chờ trên luồng ảo gần như không tốn gì.

Nhưng chú ý con số 20. Mặc định của Tomcat là 200 luồng, không phải 20. Với cấu hình mặc định và phép thử này, hai bên sẽ gần bằng nhau — tôi phải hạ pool xuống 20 để nút thắt lộ ra.

Nói cách khác: luồng ảo giúp khi pool luồng đang là nút thắt. Nếu ứng dụng của bạn chưa bao giờ chạm trần 200 luồng, bật nó lên sẽ không đổi gì.

Và nhắc lại bài 79 sê-ri Java: với tải tốn CPU, luồng ảo đo được 89 ms so với 90 ms của pool nền — tức là bằng nhau. Lời khuyên "đừng dùng luồng ảo cho tải CPU" đúng kết luận nhưng sai lý do: không phải vì chậm, mà vì chẳng được gì.

Ba chỗ nó không giúp

Pool kết nối CSDL vẫn là trần. Bài 34 đo: pool 5, tám luồng xin, ba luồng nhận ngoại lệ. Luồng ảo cho bạn mười nghìn luồng cùng chờ — chờ trên cùng mười kết nối CSDL. Nút thắt chỉ dịch chỗ.

Đây là điểm quan trọng nhất của bài: luồng ảo bỏ giới hạn về số luồng, không bỏ giới hạn về tài nguyên phía sau.

Tải tốn CPU không nhanh hơn. Số nhân vẫn là số nhân.

Dịch vụ ngoài không có phép chờ vẫn treo. Bài 20 đo RestClient mặc định chờ 5027 ms cho endpoint ngủ 5 giây — tức là chờ mãi. Với luồng ảo, bạn treo mười nghìn luồng ảo thay vì treo 200 luồng nền. Vẫn hỏng, chỉ là hỏng ở quy mô lớn hơn.

Ghim luồng: vấn đề đã nhẹ đi

Luồng ảo bị "ghim" vào luồng nền khi nó chặn bên trong synchronized — luồng nền không được trả lại, và lợi ích biến mất.

Bài 68 sê-ri Java đo được khác biệt này: 1217 ms so với 303 ms giữa synchronizedReentrantLock.

Tin tốt: JDK 24 đã gỡ phần lớn nguyên nhân ghim. Trên JDK 21 (bản LTS mà phần lớn dự án đang chạy) thì vấn đề vẫn còn, và cách chữa là đổi synchronized thành ReentrantLock ở những chỗ có chặn I/O bên trong.

Phát hiện bằng:

java -Djdk.tracePinnedThreads=full -jar app.jar

Nó in ra ngăn xếp mỗi lần một luồng ảo bị ghim — đọc là biết chính xác dòng nào.

ThreadLocal vẫn hoạt động, nhưng đổi tính chất

ThreadLocal chạy bình thường trên luồng ảo. Khác biệt là mỗi tác vụ một luồng mới, nên cái bẫy ở bài 78 sê-ri Java — request thứ tư đọc ra dữ liệu của request thứ hai vì luồng trong pool mang giá trị cũ — biến mất.

Nhưng đổi lại: mười nghìn luồng ảo là mười nghìn bản sao của mỗi ThreadLocal. Với SecurityContextHolderMDC thì nhỏ; với thứ gì nặng thì đó là bộ nhớ thật.

Và việc truyền ngữ cảnh sang luồng khác vẫn phải làm — bài 52 đo được trace id biến mất trong luồng @Async.

Đừng gộp luồng ảo

Executors.newFixedThreadPool(200, Thread.ofVirtual().factory());   // VÔ NGHĨA
Executors.newVirtualThreadPerTaskExecutor();                       // đúng

Luồng ảo rẻ tới mức không cần gộp. Gộp chúng là tự đặt lại đúng cái trần mà bạn vừa bỏ đi.

Nên bật không

Bật khi: ứng dụng chủ yếu chờ I/O (gọi CSDL, gọi dịch vụ ngoài), bạn đang chạy JDK 21+, và pool luồng là nút thắt đã đo được.

Chưa vội khi: tải tốn CPU, hoặc bạn dùng nhiều thư viện cũ có synchronized quanh I/O, hoặc bạn chưa đo thấy pool luồng là vấn đề.

Với ứng dụng web bình thường ở tải vừa, thành thật mà nói: bạn sẽ không thấy khác biệt. Trần 200 luồng của Tomcat đủ cho phần lớn hệ thống, và nút thắt thật thường nằm ở CSDL.

Cách tôi khuyên: bật ở môi trường thử, chạy tải thật, đo. Nếu số không đổi thì đó cũng là một câu trả lời — và bạn tiết kiệm được một biến số khi gỡ lỗi.

Thử ba mươi giây

curl -s localhost:8080/actuator/metrics/tomcat.threads.busy | jq '.measurements[0].value'
curl -s localhost:8080/actuator/metrics/tomcat.threads.config.max | jq '.measurements[0].value'

Lúc tải cao, busy chạm config.max nghĩa là pool luồng đang là nút thắt — và bảng đầu bài cho biết luồng ảo sẽ giúp. Còn nếu busy luôn thấp hơn nhiều, nút thắt của bạn nằm chỗ khác, và bật luồng ảo chỉ đổi tên luồng trong log.

Ngày mai: khởi động nhanh và tốn ít bộ nhớ — đo thật, kèm ảnh native.