Safepoint và STW — vì sao GC pause dài hơn GC log báo
Safepoint — điểm JVM dừng thread cho global operation: cơ chế polling, time-to-safepoint (TTSP), STW pause, và cách diagnose TTSP spike.
TL;DR: Safepoint là vị trí trong luồng thực thi nơi JVM biết chính xác trạng thái mọi thread — mọi object reference đều xác định được, không operation nào đang dở. Trước mỗi global operation (GC, deoptimization, JFR sample), JVM bắt mọi thread dừng tại safepoint. Time-to-safepoint (TTSP) là thời gian chờ tất cả thread arrive; thread đang chạy counted loop dài có thể không poll safepoint hàng chục ms. GC log chỉ tính thời gian sau khi mọi thread đã dừng, nên app có thể freeze lâu hơn con số pause được log — phần chênh chính là TTSP. Đọc -Xlog:safepoint để tách hai phần và tìm đúng thủ phạm.
Bài 06 giới thiệu GC pause như thời gian STW (Stop-The-World). Nhưng đo trên production đôi khi thấy app freeze dài hơn hẳn GC pause trong log. Khoảng chênh đó là TTSP — thời gian "chờ mọi thread đến điểm an toàn". Bài này giải thích cơ chế safepoint, tại sao TTSP tồn tại, và cách diagnose.
1. Vì sao GC log báo 50ms nhưng app freeze 200ms?
Team nhận alert SLA breach: latency p99 vượt 200ms. Kiểm tra GC log:
[info][gc] GC(42) Pause Young (Normal) 48.234ms
[info][gc] GC(43) Pause Young (Normal) 51.891ms
GC pause rõ ràng chỉ khoảng 50ms — không đủ gây breach. Nhưng trace từ user request cho thấy:
Request A: start 10:00:00.000, end 10:00:00.198 -> 198ms
GC(42): start 10:00:00.020, end 10:00:00.068 -> 48ms (GC log)
Request bị block từ 00:000 đến 00:068 = 68ms, nhưng GC thực sự chỉ chạy từ 00:020 đến 00:068 (48ms). Vậy 20ms từ 00:000 đến 00:020 là gì?
Đó là khoảng JVM đã yêu cầu stop nhưng thread xử lý request A chưa đến safepoint tiếp theo. Thread A freeze, GC chưa bắt đầu được, và GC log chỉ tính thời gian sau khi mọi thread đã arrive. Để hiểu toàn bộ latency, phải hiểu safepoint.
2. Safepoint — điểm execution biết toàn bộ
Safepoint là vị trí trong luồng thực thi (cụ thể là một instruction) nơi JVM xác định hoàn toàn trạng thái của thread — biết chính xác mọi object reference đang live ở đâu (stack frame, register), không có allocation đang dở, không có monitor acquisition đang dở.
Tại safepoint, GC có thể scan toàn bộ stack của thread để tìm GC root, di chuyển (relocate) object mà không sợ đọc stale pointer, và cập nhật reference table an toàn.
JIT compiler chèn safepoint poll instruction tại các vị trí xác định trong compiled code:
- Method return (trước khi return về caller).
- Loop back-edge (mỗi lần vòng lặp quay lại điểm đầu).
- Allocation site (trước khi alloc object lớn).
- Native call boundary (khi gọi JNI native method).
// JIT compile method nay va chen safepoint poll
public void processItems(List<String> items) {
for (String item : items) { // safepoint poll tai loop back-edge
process(item); // safepoint poll tai method return
}
}
// Khi JVM can stop: poll page bi protect -> thread nao hit poll se trap -> suspend
Safepoint giống đèn giao thông đặt sẵn trên xa lộ. Bình thường xe chạy qua đèn xanh không dừng (poll không tốn gì). Khi cảnh sát (JVM) cần mọi xe dừng, họ bật đèn đỏ tại mọi điểm kiểm soát (protect polling page). Xe (thread) đến đèn đỏ tiếp theo thì dừng. Xe đang giữa hai đèn (giữa hai back-edge) phải chạy hết tới đèn kế mới dừng được — đó chính là TTSP.
3. Cơ chế safepoint polling — page protection trick
JIT không check một biến boolean mỗi lần poll — quá tốn. HotSpot dùng trick với memory page protection.
Fast path (bình thường):
; x86 assembly - safepoint poll instruction
; polling_page la dia chi 1 memory page JVM giu
TEST RAX, QWORD PTR [polling_page] ; 1 instruction, doc memory
; Neu page readable -> no-op (result bo di), ~1-5 ns khi in cache
Khi JVM muốn stop tất cả thread: JVM gọi OS để mark polling page là unreadable. Thread nào thực thi TEST RAX, [polling_page] tiếp theo sẽ gây segfault; JVM đã cài signal handler catch segfault này, hiểu là thread đó đã arrive safepoint và suspend nó. Thread nào đang chạy counted loop không có back-edge poll thì mãi tới poll kế tiếp mới trap — đó là nguồn TTSP.
Lý do dùng page protection thay vì boolean check: fast path là một TEST (không branch, luôn "đi thẳng") nên không có branch misprediction — overhead gần zero cho app bình thường.
4. Stop-The-World — các operation cần STW
STW (Stop-The-World) là cơ chế JVM bắt tất cả Java thread arrive safepoint và suspend, thực hiện một global operation, rồi resume tất cả. Các operation cần STW:
| Operation | Vì sao cần STW |
|---|---|
| GC Young / Mixed / Full | Scan stack tìm GC root, relocate object, update reference — heap phải bất động |
| Deoptimization | JIT assumption sai (call site có type mới) — patch compiled frame về interpreted |
| Class redefinition (JVMTI) | Hot reload class qua Java agent — mọi thread phải rời method bị đổi |
| JFR thread sample | Dump stack mỗi thread — stack phải ổn định để đọc |
| Biased lock revocation | Revoke bias khi thread khác muốn lock — disable mặc định + deprecated JDK 15 (JEP 374), obsolete JDK 18 |
STW latency = TTSP + thời gian operation. GC log thường chỉ report phần operation (sau khi mọi thread đã suspend).
5. Time-to-safepoint — vì sao TTSP quan trọng
TTSP (Time-to-safepoint) là khoảng từ khi JVM request safepoint (protect polling page) đến khi thread cuối cùng arrive. Trong lúc đó:
- Thread đã arrive: suspend, chờ.
- Thread chưa arrive: vẫn chạy code giữa hai safepoint poll.
- JVM: chờ — GC còn chưa bắt đầu.
- HTTP thread pool có thể đã kẹt vì vài thread bị suspend, request mới dồn hàng đợi.
TTSP tệ nhất khi một thread chạy đoạn code dài mà không gặp poll — điển hình là counted loop bị JIT tối ưu (mục 6) hoặc JNI native call dài (mục 7). GC log chỉ đo phần sau TTSP, nên nó không bao giờ cho thấy khoảng chờ này.
gantt
title Timeline STW Pause (thuc te user thay)
dateFormat X
axisFormat %Lms
section JVM
GC request safepoint : milestone, 0, 0
Wait all threads arrive : crit, 0, 50
GC work (STW) : active, 50, 120
Resume all threads : milestone, 120, 120
section Thread 1
Running : 0, 10
Suspended at safepoint : crit, 10, 120
Resumed : 120, 130
section Thread 2
Running (counted loop) : 0, 50
Suspended : crit, 50, 120
Resumed : 120, 130
section GC Log
Reports pause : active, 50, 120GC log chỉ báo 70ms (phần GC work). User thực sự mất 120ms: 50ms TTSP + 70ms GC. Thread 2 là "slow thread" — chạy counted loop 50ms không gặp safepoint poll.
6. Pitfall 1 — counted loop bị JIT bỏ safepoint poll
Đây là nguyên nhân TTSP spike phổ biến nhất trong production.
// Counted loop - bound kieu int
// JIT C2 CO THE bo safepoint poll tai back-edge
public long sumArray(int[] data) {
long total = 0;
for (int i = 0; i < data.length; i++) { // counted loop
total += data[i];
}
return total;
}
Khi bound là int, JIT C2 nhận dạng đây là counted loop (số vòng biết trước) và tối ưu mạnh — gồm việc bỏ safepoint poll tại back-edge để chạy nhanh hơn. Nếu GC request safepoint giữa lúc thread đang trong loop, TTSP bằng thời gian chạy hết phần còn lại: 100 triệu vòng nhân 1 ns/vòng bằng 100ms không poll. Cơ chế C2 nhận diện counted loop này chính là thứ đã học ở bài 04 — JIT tiered compilation.
Từ JDK 10, -XX:+UseCountedLoopSafepoints bật mặc định, đi cùng kỹ thuật loop strip mining (chia loop dài thành từng đoạn ngắn, chèn poll giữa các đoạn) nên vừa giữ poll vừa gần như không mất throughput. Trên Java 8-9 phải bật thủ công (chưa có strip mining nên overhead khoảng 1-5%), hoặc dùng long làm bound để JIT không coi là counted loop cổ điển.
7. Pitfall 2 — JNI native call chặn safepoint
Thread gọi JNI native method chuyển sang trạng thái in_native. JVM không thể force-stop thread đang chạy native code vì native code không có safepoint poll — phải chờ nó return về Java, lúc đó poll tại native return boundary mới được kiểm.
// Neu native method chay 500ms, TTSP co the tang len 500ms
public native void processNative(byte[] data, int length);
// Thread ket trong JNI 500ms -> GC cho 500ms truoc khi safepoint hoan tat
Cách giảm: chia batch để native call không chạy quá lâu, hoặc trong native code định kỳ detach/attach thread (mỗi vài nghìn vòng) để JVM chèn được safepoint. Thực tế: document rõ native method chạy dài và đặt timeout.
8. Chẩn đoán TTSP — công cụ và cách đọc
-Xlog:safepoint=info (Java 9+) là cách nhanh nhất:
java -Xlog:safepoint=info:file=safepoint.log:time MyApp
Mỗi safepoint in một dòng:
[10.234s][info][safepoint] Safepoint "G1CollectForAllocation", Time since last: 4234567900 ns, Reaching safepoint: 128000000 ns, At safepoint: 48234000 ns, Total: 176234000 ns
Trong dòng log trên, tổng thời gian app bị dừng là bao nhiêu, và phần "chờ thread đến safepoint" (chứ không phải GC thực sự làm việc) chiếm bao nhiêu trong số đó? Viết ra hai con số trước khi đọc tiếp.
Ba trường cần đọc:
- Reaching safepoint — TTSP, thời gian chờ thread arrive (ở đây 128ms).
- At safepoint — thời gian operation thật sự chạy, ví dụ GC (48ms). Gần đúng con số GC log báo.
- Total — tổng app bị dừng (176ms), bằng Reaching safepoint cộng At safepoint.
Ở dòng trên, app dừng 176ms nhưng 128ms trong đó (khoảng 73%) chỉ là chờ thread, không phải GC. Quy tắc đọc: khi "Reaching safepoint" lớn hơn hẳn "At safepoint", vấn đề là TTSP — đổi collector sẽ vô ích.
JFR (production-safe):
java -XX:StartFlightRecording=duration=60s,filename=recording.jfr MyApp
Trong JDK Mission Control: Events → VM Operations, lọc "Safepoint Begin/End"; cột "Time To Safepoint" chính là TTSP, sort giảm dần để tìm spike lớn nhất. JFR non-invasive nên ưu tiên trước các flag khác. Đọc toàn bộ GC log (chọn collector, phân tích pause profile) ở bài 06b — Chọn collector & đọc GC log.
Bốn nguyên nhân TTSP spike thường gặp:
- Counted loop JIT bỏ back-edge poll (mục 6) — phổ biến nhất; kiểm
-XX:+UseCountedLoopSafepoints(JDK 10+ mặc định bật). - JNI native call dài (mục 7) — thread
in_nativekhông stop được tới khi return. String.indexOftrên String rất lớn — trong Java 8 và vài bản Java 11 cũ, intrinsic SIMD củaindexOfchạy hàng chục ms không poll khi quét String hàng trăm MB; JDK 17+ chia intrinsic thành chunk có kiểm safepoint. Gặp trên JDK cũ thì xử lý theo dòng bằngBufferedReaderthay vìindexOfcả file.- Allocation bất thường — method alloc quá nhiều làm Eden đầy nhanh, GC (và do đó safepoint) xảy ra thường xuyên hơn.
9. Thread-local handshake — JEP 312 tối ưu STW
JEP 312 (Java 10) cho phép pause một thread cụ thể mà không cần full STW. Trước đó, mọi operation cần JVM control đều stop tất cả thread — kể cả việc chỉ đụng một thread (lấy stack trace, revoke biased lock của một thread). Sau JEP 312, JVM gửi một "closure" tới đúng thread cần; thread đó thực thi closure tại safepoint poll kế tiếp rồi chạy tiếp, các thread khác không bị ảnh hưởng.
Operation hưởng lợi: JFR sampling (sample tuần tự, pause quan sát được gần 0), biased lock revocation, deoptimization, một phần class unloading.
ZGC dùng thread-local handshake cùng load barrier để relocate object đồng thời với app: mỗi thread tự sửa pointer khi đọc reference, chỉ cần STW ngắn (dưới 1ms) cho vài pha đồng bộ như Mark Start / Mark End. Cơ chế barrier này được mổ ở bài 12b — ZGC, Shenandoah & barrier.
10. 📚 Deep Dive Oracle
- JEP 312 — Thread-Local Handshakes (Java 10) — openjdk.org/jeps/312 — mechanism handshake, các operation chuyển từ STW sang per-thread; JVM set "handshake closure" trên thread descriptor, thread check khi qua safepoint poll.
- HotSpot wiki — Safepoints — wiki.openjdk.org/display/HotSpot/Safepoints — chi tiết polling mechanism, page protection trick, thread state machine (in_Java, in_native, in_vm, blocked).
- Aleksey Shipilёv — "Safepoints: Meaning, Side Effects and Overheads" — shipilev.net/blog/2015/safepoints/ — deep dive về safepoint overhead, counted loop problem, benchmark đo TTSP thực tế. Bắt buộc đọc nếu cần diagnose TTSP production.
- JFR Safepoint events — docs.oracle.com/en/java/javase/21/jfapi/ — events
jdk.SafepointBegin,jdk.SafepointEnd; field "timeToSafepoint" chính là TTSP.
11. Liên hệ các bài khác
- Bài 06 — Garbage Collection: STW và GC pause được giới thiệu ở đó; bài này giải thích phần latency mà GC log không cho thấy.
- Bài 06b — Chọn collector & đọc GC log: đọc GC log đầy đủ + chọn collector theo SLA; kết hợp với safepoint log để tách TTSP khỏi GC time.
- Bài 04 — JIT tiered compilation: cơ chế C2 nhận diện counted loop — gốc của pitfall TTSP phổ biến nhất.
12. Tóm tắt
- Safepoint là instruction nơi JVM biết chính xác trạng thái thread (qua oop map) — điều kiện để GC scan root và relocate object an toàn.
- JVM bắt thread dừng bằng page protection trick: fast path là một memory read gần như miễn phí; khi cần stop, page bị protect và thread poll kế tiếp trap qua signal handler.
- STW latency = TTSP + thời gian operation, nhưng GC log chỉ báo phần operation — nên app freeze lâu hơn con số pause.
- Counted loop và JNI call dài là hai nguồn TTSP spike chính; từ JDK 10 loop strip mining bật mặc định để vá counted loop.
- Diagnose bằng
-Xlog:safepoint=info(so "Reaching safepoint" với "At safepoint") hoặc JFR; JEP 312 handshake giảm STW cho operation chỉ đụng một thread.
13. Tự kiểm tra
Q1Safepoint là gì và tại sao GC cần tất cả thread đến safepoint trước khi chạy?▸
Safepoint là vị trí trong execution nơi JVM xác định hoàn toàn trạng thái thread: biết mọi object reference đang ở đâu (stack, register) và không có operation nào đang dở. GC cần mọi thread ở safepoint vì nó phải đọc stack để tìm GC root và relocate object — nếu thread còn chạy, stack đổi liên tục và một reference cũ có thể trỏ vào object vừa bị di chuyển (stale pointer, dẫn tới crash). Tại safepoint, JVM đọc oop map (bảng do JIT sinh, mô tả register/slot nào là reference) để scan chính xác.
Q2Cơ chế safepoint polling hoạt động thế nào, và tại sao chi phí fast path gần như zero?▸
JIT chèn một instruction TEST RAX, [polling_page] tại mỗi safepoint location. Fast path: polling page readable, instruction chỉ là một memory read bị discard — không branch, không syscall, khoảng 1-5 ns khi page đã cache. Khi cần stop, JVM gọi mprotect đánh dấu page unreadable; thread poll kế tiếp nhận SIGSEGV, signal handler biết thread đã arrive và suspend nó. Dùng page protection thay vì check biến boolean để tránh branch misprediction — fast path luôn "đi thẳng" nên overhead gần zero.
Q3Time-to-safepoint (TTSP) khác GC pause time thế nào?▸
TTSP là thời gian chờ mọi thread đến safepoint; GC pause time là thời gian từ khi tất cả đã suspend đến khi GC xong. GC log chỉ báo phần thứ hai. Tổng latency user thấy bằng TTSP cộng GC pause. Ví dụ GC log báo "50ms" nhưng một thread chạy counted loop 100ms không gặp poll thì user bị block 150ms; con số 100ms đó không xuất hiện trong GC log, phải đọc Reaching safepoint từ -Xlog:safepoint.
Q4Counted loop tại sao gây TTSP spike, và cách fix?▸
Counted loop có bound int được JIT C2 tối ưu bằng cách bỏ safepoint poll ở back-edge, nên thread chạy hết N vòng liên tục không poll — với N lớn, TTSP bằng thời gian còn lại của loop (100 triệu vòng nhân 1 ns có thể là 100ms). Từ JDK 10, -XX:+UseCountedLoopSafepoints bật mặc định kèm loop strip mining: JIT chia loop thành từng đoạn và chèn poll giữa các đoạn, giữ được poll mà gần như không mất throughput. Trên Java 8-9 phải bật thủ công hoặc dùng long làm bound.
Q5App bạn gặp GC log báo 30ms nhưng SLA breach 200ms xảy ra đồng thời. Bạn diagnose thế nào?▸
Chênh 170ms giữa GC log (30ms) và SLA breach (200ms) gợi ý thủ phạm ngoài GC — nhiều khả năng là TTSP. Bước 1: bật -Xlog:safepoint=info, tìm dòng có Reaching safepoint khớp khoảng 170ms để xác nhận. Bước 2: thu JFR trong lúc reproduce, xem thread nào vẫn chạy khi các thread khác đã suspend — đó là slow thread; CPU sampling cho biết nó kẹt ở method nào. Bước 3: fix theo nguyên nhân — counted loop thì kiểm -XX:+UseCountedLoopSafepoints, JNI dài thì chia nhỏ, String.indexOf trên file lớn thì xử lý theo dòng hoặc upgrade JDK.
Bài tiếp theo: GC Modern Deep — G1, ZGC, Shenandoah
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