Bài hôm qua kết ở một con số làm tôi phải đo lại hai lần: ZGC dừng lâu nhất 0,025 mili giây, trong khi Serial dừng 43,69 ms.
Hôm nay là câu hỏi tiếp theo, và là câu quan trọng hơn: đổi lấy cái gì?
Đo đúng thứ cần đo
Thời gian tạm dừng GC không phải thứ người dùng cảm nhận. Thứ họ cảm nhận là độ trễ của request, và một lần dừng GC 20 ms có thể làm chậm nhiều request cùng lúc.
Nên tôi bỏ cách đo thời gian dừng, và đo trực tiếp 300.000 "request" — mỗi request sinh rác và chạm vào một bộ nhớ đệm 400.000 mục — rồi sắp xếp lại theo phân vị:
bộ thu gom tổng p50 p99 p99.9 p99.99 tệ nhất
Parallel 655 ms 1,5 us 4,4 us 11,5 us 235,8 us 26.715 us
G1 755 ms 1,5 us 9,4 us 17,2 us 262,6 us 17.913 us
ZGC 1171 ms 3,3 us 10,1 us 104,1 us 373,8 us 1.716 us
Shenandoah 724 ms 1,9 us 7,5 us 19,3 us 262,1 us 1.355 us
Bảng này nói nhiều hơn mọi lời giới thiệu.
Đọc theo cột
Cột p50 — Parallel và G1 nhanh nhất (1,5 µs). ZGC chậm hơn hơn hai lần (3,3 µs). Đây là chi phí của rào chắn đọc: mỗi lần chương trình đọc một tham chiếu, ZGC chèn thêm kiểm tra để biết đối tượng đã bị di dời chưa. Bạn trả phí đó ở mọi thao tác, kể cả khi GC không chạy.
Cột tệ nhất — đây mới là thứ ZGC và Shenandoah bán. Parallel có một request mất 26,7 mili giây; Shenandoah tệ nhất chỉ 1,4 mili giây. Gần hai mươi lần khác biệt.
Cột tổng — ZGC mất 1171 ms so với 655 ms của Parallel. Chậm hơn 79%.
Ba cột đó gói trọn đánh đổi: bạn trả bằng thông lượng và độ trễ trung vị, để mua lấy phần đuôi.
Vì sao phần đuôi lại đáng mua
Một request chậm 26 ms nghe không ghê gớm. Nhưng hãy nghĩ theo cách hệ thống thật hoạt động.
Nếu trang của bạn gọi 10 dịch vụ song song, xác suất ít nhất một trong số đó rơi vào phân vị 99,99 không còn là 1 phần 10.000 — nó gấp mười lần. Với kiến trúc vi dịch vụ nhiều tầng, phân vị 99,99 của một thành phần trở thành phân vị 99,9 của cả hệ thống.
Đây là lý do các đội vận hành hệ thống lớn quan tâm tới p99.9 hơn p50, và là lý do một bộ thu gom "chậm hơn 79%" vẫn có thể là lựa chọn đúng.
Nhưng nó chỉ đúng khi bạn thực sự bị phần đuôi làm phiền. Nếu hệ thống của bạn xử lý theo lô, hoặc người dùng chấp nhận 30 ms, thì 79% thông lượng là cái giá vô nghĩa.
Kết quả bất ngờ của phép đo này
Tôi vào bài với giả định "ZGC là lựa chọn độ trễ thấp". Bảng số nói khác:
Shenandoah : 724 ms tổng | tệ nhất 1.355 us
ZGC : 1171 ms tổng | tệ nhất 1.716 us
Parallel : 655 ms tổng | tệ nhất 26.715 us
Shenandoah đạt phần đuôi tốt nhất bảng mà chỉ chậm hơn Parallel 10%.
Nó cũng thắng ZGC ở mọi phân vị: p50 1,9 so với 3,3 µs, p99.9 19,3 so với 104,1 µs, và tệ nhất 1.355 so với 1.716 µs.
Tôi không định biến bài này thành "Shenandoah thắng" — với heap hàng chục gigabyte, ZGC thường được đánh giá cao hơn, và tải của tôi chỉ dùng heap 2GB. Nhưng nó là lời nhắc rõ ràng: hai bộ thu gom độ trễ thấp không thay thế nhau, và cái nào hợp là chuyện phải đo.
Chú ý thêm dòng p99.9 của ZGC: 104,1 µs, cao hơn hẳn ba bộ còn lại. ZGC ổn định ở phần đuôi cực xa nhưng lại kém hơn ở phần đuôi gần — đúng đặc tính của một hệ thống làm nhiều việc đồng thời liên tục.
Cái giá bộ nhớ
Cùng -Xmx2g, cùng tải, cùng vùng sống khoảng 230MB:
Shenandoah : RSS 870 MB
Parallel : RSS 1006 MB
G1 : RSS 1374 MB
ZGC : RSS 1434 MB
ZGC tốn nhiều RAM nhất, hơn Shenandoah 65%.
Cần đọc con số này cho đúng: nó phản ánh chính sách nới heap của từng bộ thu gom nhiều như phản ánh chi phí cấu trúc nội bộ. Bộ thu gom đồng thời phải để dư chỗ, vì trong lúc nó dọn thì ứng dụng vẫn cấp phát tiếp — nếu hết chỗ trước khi dọn xong, nó buộc phải dừng cả thế giới và bạn mất toàn bộ lợi ích.
Bài học vận hành: đừng chuyển sang ZGC mà giữ nguyên -Xmx cũ. Cho nó rộng hơn, nếu không bạn sẽ gặp đúng những lần dừng dài mà mình đang cố tránh.
Chúng làm gì mà không cần dừng
Cả hai giải cùng một bài toán: di dời đối tượng trong khi ứng dụng vẫn đang chạy và vẫn đang cầm tham chiếu tới chúng.
ZGC dùng con trỏ màu — nó nhét vài bit trạng thái vào chính giá trị tham chiếu 64-bit, cộng một rào chắn đọc. Khi chương trình đọc một tham chiếu, rào chắn kiểm các bit đó; nếu đối tượng đã bị di dời, nó sửa tham chiếu ngay tại chỗ rồi mới trả về.
Shenandoah dùng con trỏ chuyển tiếp: mỗi đối tượng có thêm một ô nhớ trỏ tới bản mới của chính nó, cộng rào chắn để đi theo con trỏ đó.
Cả hai đều là "trả phí nhỏ ở mọi thao tác đọc để không phải trả phí lớn một lần" — và cột p50 trong bảng chính là hoá đơn.
Chi tiết đáng biết: cả hai đã có bản thế hệ. ZGC thế hệ bật bằng -XX:+ZGenerational trên Java 21 (và thành mặc định ở các bản sau). Nó áp dụng đúng giả thuyết ở bài hôm qua — dọn vùng trẻ riêng — nên tốn ít CPU hơn hẳn bản không thế hệ. Nếu bạn đọc so sánh ZGC cũ và thấy nó ngốn CPU, hãy đo lại với bản thế hệ.
Chọn thế nào
Vẫn dùng G1 nếu chưa đo được vấn đề độ trễ. Nó mặc định vì nó cân bằng, và bảng đầu bài cho thấy nó không hề tệ.
Chuyển sang Parallel nếu chạy theo lô và chỉ quan tâm tổng thời gian. Nó thắng ở cột đầu.
Chuyển sang ZGC hoặc Shenandoah khi bạn có bằng chứng rằng phần đuôi độ trễ đang gây hại — biểu đồ p99.9 có gai trùng với log GC. Và khi chuyển thì đo cả hai, đừng chọn theo tiếng tăm.
Heap rất lớn (hàng chục tới hàng trăm GB) là chỗ ZGC tách hẳn khỏi phần còn lại: thời gian dừng của nó gần như không phụ thuộc kích thước heap, trong khi G1 thì có.
Ba việc cần làm khi chuyển:
Cho thêm heap, ít nhất 20–30%.
Đo lại thông lượng, không chỉ độ trễ. Bảng đầu bài cho thấy phần thông lượng mất đi có thể rất lớn.
Kiểm số nhân CPU. Bộ thu gom đồng thời cần CPU rảnh để làm việc; trên container hai nhân, nó sẽ tranh CPU với chính ứng dụng của bạn.
Thử ba mươi giây
Đừng đo thời gian dừng GC. Đo độ trễ request của chính bạn theo phân vị:
java -XX:+UseG1GC ... > g1.txt
java -XX:+UseZGC -XX:+ZGenerational ... > zgc.txt
rồi so cột p99.9 và cột tệ nhất, chứ không so p50.
Nếu hai cột ấy gần như nhau, bạn không có vấn đề mà ZGC giải được — và giữ G1 là quyết định đúng. Đó cũng là kết quả tôi gặp ở phần lớn ứng dụng cỡ vừa.
Ngày mai: đọc GC log mà không hoảng — bật -Xlog:gc* cho đúng, ba con số cần nhìn trước tiên, và những dấu hiệu cho biết heap đang quá nhỏ.