JVM Safepoint 내부 동작 — 모든 자바 스레드가 같은 순간에 멈추는 방법
GC가 끝나기를 기다리는 200ms, Thread dump를 떴을 뿐인데 멈춰 있는 1초, "GC time은 5ms 미만"이라는 로그 옆에서 애플리케이션 응답이 100ms씩 튀어 오르는 현상의 공통 원인은 같은 한 가지입니다. JVM이 모든 자바 스레드를 동시에 "잘 정의된 상태"로 끌고 가는 메커니즘, 즉 safepoint입니다. 이 글은 HotSpot이 safepoint를 어떻게 구현하는지, JIT가 폴(poll)을 어디에 어떻게 끼워 넣는지, JEP 312 thread-local handshake와 JEP 376 ZGC concurrent stack scanning이 무엇을 바꿨는지를 같은 그림 위에서 풀어냅니다. 대상 독자는 GC 튜닝·thread dump 분석을 자주 하지만 "stop-the-world가 정확히 어디서 시작되는가"가 늘 손에 잡히지 않던 백엔드 개발자입니다.
Safepoint란 무엇인가
Safepoint는 자바 스레드의 실행 중에 "JVM이 그 스레드의 상태를 완전히 알고 있는 지점"입니다. 더 정확히는, 다음 두 조건이 동시에 성립하는 코드 위치입니다.
- 스택 프레임 위에 살아 있는 객체 참조(oop)의 위치가 OopMap에 기록되어 있어, GC가 root set을 정확히 식별할 수 있다.
- 그 시점부터 스레드가 더 이상 자바 코드를 실행하지 않는다는 약속을 지킬 수 있다.
이 정의가 왜 GC와 묶이는지는 명확합니다. Generational GC, G1, ZGC, Shenandoah 어느 쪽이든 시작점에서는 root set — 각 스레드의 스택과 레지스터에 올라와 있는 객체 참조 — 을 알아야 객체를 따라 마킹을 시작할 수 있습니다. 그런데 자바 스레드는 1ns에 한 번씩 oop를 레지스터에 로드하고 다른 곳에 덮어쓰기를 반복하므로, "지금 이 순간의 root"라는 개념 자체가 불안정합니다. JVM이 이 불안정성을 봉인하는 방법은 단순합니다. 모든 자바 스레드를 동시에 OopMap이 미리 깔려 있는 지점에 세워 두는 것, 그게 safepoint입니다.
OopMap이라는 정적 메타데이터
OopMap은 JIT 컴파일러가 코드를 생성하면서 함께 만들어 두는 부속 자료구조입니다. "이 PC에서는 RDI 레지스터가 oop, 스택 슬롯 +16이 oop, 슬롯 +24는 int" 같은 정보를 비트맵으로 들고 있습니다. GC root scan은 이 OopMap을 그대로 읽어 어떤 비트가 oop인지 알아내고 그 메모리만 root로 다룹니다.
문제는 OopMap이 모든 PC에 대해 정확하지 않다는 점입니다. JIT는 OopMap을 미리 정한 안전한 위치(safepoint candidate) 에만 생성합니다. 모든 명령 단위로 OopMap을 만들면 메모리 비용이 너무 크고, 빈번한 레지스터 사용 패턴 때문에 정확도를 유지하기도 어렵기 때문입니다. 그래서 "OopMap이 깔린 PC == safepoint 도달 가능 지점" 이라는 등식이 성립합니다.
GC만이 아닙니다. 다음 모든 작업은 root와 메모리 상태가 흔들리면 안 되므로 safepoint 위에서 실행됩니다.
- Stop-the-world GC 단계 (Young GC, G1 initial-mark, mixed GC 등)
- Deoptimization (JIT 코드가 invalid해져 인터프리터로 되돌릴 때)
- Class redefinition (
RedefineClasses, Instrumentation API, hotswap) - Biased locking revocation (JDK 14까지)
Thread.getAllStackTraces(),jstack, JVMTIGetStackTrace- Heap dump, JFR safepoint 샘플링
- Code cache flushing, lock deflation
이 모든 작업이 "VM operation"이라는 같은 추상 위에 올려져 있고, VMThread라는 단일 스레드가 큐에서 하나씩 꺼내 safepoint에서 실행합니다.
폴을 통한 협력형 정지
자바 스레드를 한순간에 멈추는 방법은 두 가지가 있습니다. 외부에서 강제로 정지시키는 방식(OS signal, suspend syscall)과, 스레드가 스스로 "지금 멈춰도 안전한가?"를 묻고 답에 따라 멈추는 방식입니다. HotSpot은 후자, 즉 협력형(cooperative) 방식을 택했습니다.
협력형의 가장 큰 장점은 정지가 "잘 정의된 지점에서만" 일어난다는 보장입니다. SIGSTOP으로 무작위 PC에서 멈추면 그 PC에 OopMap이 있을 거라는 보장이 없고, locked monitor를 들고 멈춘 스레드가 데드락을 일으킬 수 있습니다. 협력형이면 JVM이 "여기서는 멈춰도 좋다"고 미리 정한 지점만 폴 위치로 삼으므로 OopMap 부재나 잘못된 lock 상태가 원천 차단됩니다.
대가는 시간입니다. 외부에서 "지금 멈춰라"라고 신호를 보내고 모든 스레드가 실제로 멈출 때까지 걸리는 시간을 time-to-safepoint(TTSP)라 부르는데, 협력형은 가장 느린 스레드가 다음 폴에 도달할 때까지 기다려야 합니다. 이 TTSP가 운영에서 가장 흔히 꼬리를 잡는 지표입니다.
flowchart LR
VM[VMThread<br/>SafepointSynchronize::begin] -->|arm poll page| T1[Java Thread 1]
VM -->|arm poll page| T2[Java Thread 2]
VM -->|arm poll page| T3[Java Thread 3]
T1 -->|next poll| S[Safepoint]
T2 -->|next poll| S
T3 -->|next poll<br/>SLOW| S
S --> OP[VM Operation<br/>e.g. GC]
OP --> R[SafepointSynchronize::end<br/>disarm + resume]
VMThread가 폴을 "arm"하는 순간 SafepointSynchronize::begin이 시작되고, 가장 마지막으로 도착한 스레드가 폴에 닿는 순간 모든 자바 스레드가 멈춰 있는 상태가 됩니다. TTSP는 begin에서 마지막 스레드 도착까지의 간격입니다.
폴은 어디에 들어가 있는가
폴은 자바 스레드가 실행할 가능성이 있는 모든 코드 경로에 끼워져 있어야 합니다. HotSpot은 코드 경로 종류별로 다음과 같이 다룹니다.
JIT 컴파일된 코드
C1·C2가 생성하는 네이티브 코드는 다음 위치에 폴을 끼웁니다.
- 메서드 진입과 반환
- Uncounted 루프의 backward branch (back-edge)
- Counted 루프의 back-edge (단, 옵션·전략에 따라 다름, 아래 절 참조)
폴 자체의 머신 코드는 한 줄입니다. 미리 정해진 "polling page"의 한 주소를 단순히 load 하는 것입니다.
test [polling_page_addr], eax ; x86 의사 코드
이 페이지가 read-protect 되어 있으면 load가 SIGSEGV를 발생시키고, JVM의 시그널 핸들러가 그 PC가 폴 명령이라는 사실을 확인한 뒤 스레드를 safepoint blocked 상태로 전이시킵니다. SIGSEGV는 "도착했다"는 신호로 재사용된 것이고, OS의 페이지 보호 비트 한 개로 모든 자바 스레드를 동시에 트랩시키는 트릭이 핵심입니다.
폴 자체는 정상 경로에서는 한 메모리 로드에 불과하므로 비용이 아주 작습니다. Shipilev 등의 벤치마크 보고에서 폴 자체의 오버헤드는 보통 1% 미만으로 측정됩니다.
인터프리터
인터프리터는 바이트코드 디스패치 루프 안에서 각 바이트코드 실행 후 또는 backward branch 시 폴을 확인합니다. JIT보다는 폴 빈도가 높아서 TTSP가 짧다는 장점이 있지만, 실행 자체가 느립니다.
Native 코드와 JNI
스레드가 _thread_in_native 상태(JNI 호출 중)에 있으면 OopMap이 의미가 없고, JVM이 그 스레드의 자바 상태를 추적할 필요도 없습니다. 그래서 VMThread는 이 스레드들을 "이미 safepoint safe하다"고 간주하고 기다리지 않습니다.
대신 JNI에서 자바로 복귀할 때 transition 코드가 폴 페이지를 다시 점검합니다. polling page가 아직 armed이면 자바로 돌아가지 못하고 safepoint blocked 상태로 들어갑니다.
이 모델의 함정은 명확합니다. JNI에서 오래 걸리는 syscall(예: accept(), slow disk read)을 호출 중이면 그 스레드는 "이미 safepoint safe"이므로 VMThread가 기다리지 않습니다. 하지만 그 스레드가 자바로 돌아가는 순간 polling page에서 막혀 다른 모든 스레드가 진행되는 동안 혼자 멈춰 있게 됩니다. 일반적으로 문제가 되지 않지만, JNI 핸들·로컬 ref를 들고 있는 사이의 GC 진입은 주의가 필요합니다.
Counted 루프와 uncounted 루프
JIT가 폴을 어디에 끼우느냐는 루프의 종류에 따라 달라집니다. C2 컴파일러는 다음 조건을 만족하는 루프를 "counted loop"로 인식합니다.
- 정수형 index (int, 또는 JDK-8223051 이후 long, 다만 long counted는 strip-mined 형태로만)
- 컴파일 시점에 induction variable의 증분·종료조건이 결정 가능
- 루프 내부에서 index를 임의 수정하지 않음
Counted loop는 C2가 vectorization·escape analysis·loop unswitching 같은 강력한 최적화를 적용하기 위해 매우 많은 가정을 끌어다 씁니다. 이 가정 중 하나가 "루프 본문이 빨리 끝난다"이고, 그 가정 위에서 C2는 backward branch에서 폴을 제거합니다.
for (int i = 0; i < array.length; i++) {
sum += compute(array[i]);
}
만약 compute()가 인라인되어 사라지고 루프가 10억 번 도는데 그 동안 폴이 없다면, GC가 시작되려고 polling page를 armed로 바꿔도 이 스레드는 도달하지 않습니다. 다른 모든 스레드는 stopped인데 한 스레드만 루프를 돌면서 VMThread를 몇 초씩 막아 버리는 상태가 됩니다. 이게 TTSP 폭주의 전형적인 패턴입니다.
이 문제를 해결하기 위해 JDK 10에서 두 가지가 도입되었습니다.
-XX:+UseCountedLoopSafepoints
Counted loop의 back-edge에도 폴을 강제로 넣는 플래그입니다. 단순하지만 C2의 최적화 효과를 크게 깎아 먹어서, 단독으로 켜기는 부담스러웠습니다.
Loop strip mining (JDK-8186027)
-XX:+UseCountedLoopSafepoints의 효과를 살리되 최적화 손실을 줄이는 방식입니다. C2가 원래의 counted loop를 두 겹으로 분해합니다.
- 안쪽 inner loop: 폴 없이 N회 반복 (N =
-XX:LoopStripMiningIter, 기본 1000) - 바깥쪽 outer loop: inner 한 strip을 끝낼 때마다 폴 한 번
원래 루프:
for (i = 0; i < N; i++) body(i); // back-edge에 폴 없음, TTSP 폭주
Strip mined:
for (i = 0; i < N; i += K) { // outer: 폴 있음
for (j = i; j < min(i+K, N); j++)
body(j); // inner: 폴 없음, 빠른 본문
}
이렇게 하면 inner loop 안에서는 C2의 모든 최적화가 그대로 적용되면서, K iteration마다 한 번씩 폴이 들어가 TTSP가 K iteration의 본문 실행 시간으로 묶입니다.
Strip mining은 G1, ZGC, Shenandoah에서만 자동 활성화됩니다. Parallel GC를 쓰면서 strip mining 효과를 보려면 -XX:+UseCountedLoopSafepoints -XX:LoopStripMiningIter=1000을 명시적으로 켜야 합니다.
JDK-8220374에서 long iteration counted loop가 strip mining의 inner/outer가 같은 종료 조건을 쓰는 버그가 있었고, JDK 13에서 수정되어 이후 JDK 11.x 업데이트 라인에 백포트되었습니다 (JBS: JDK-8220374).
Polling page와 thread-local handshake
폴이 트랩되는 메커니즘은 위에서 본 "page protect → SIGSEGV → signal handler"입니다. 이 모델은 단순하고 빠르지만 본질적인 한계가 하나 있었습니다. 폴 페이지가 전역(single global page) 이라는 점입니다.
VMThread가 폴을 arm 하면 모든 자바 스레드가 동시에 트랩됩니다. 즉 한 스레드만 safepoint에 끌어들이고 싶은 경우 — 예를 들어 한 스레드의 스택만 샘플링하거나, 한 메서드만 deoptimize 하고 싶은 경우 — 에도 전체를 멈춰야 했습니다.
JEP 312 "Thread-Local Handshakes" (Java 10, openjdk.org/jeps/312)가 이 한계를 풉니다.
변경된 폴 모델
JEP 312 이후의 폴은 다음과 같습니다.
- 폴 페이지가 두 개 — "항상 guarded" 페이지 하나, "항상 unguarded" 페이지 하나
- 각 자바 스레드는 thread-local 슬롯에 두 페이지 중 하나의 주소를 저장
- 폴 명령은 그 슬롯을 거쳐 간접 참조로 load
test [thread->polling_page_ptr], eax
전역 safepoint가 필요하면 VMThread가 모든 스레드의 thread-local 슬롯을 guarded 페이지로 갈아끼웁니다. 한 스레드만 잡고 싶으면 그 스레드의 슬롯만 guarded로 갈아끼웁니다. 후자가 "thread-local handshake"입니다.
safepointMechanism.cpp의 핵심 함수는 다음과 같습니다.
default_initialize()— guarded/unguarded 페이지 할당과 polling word 비트 설정update_poll_values(JavaThread*)— 한 스레드의 polling word를 armed/disarmed로 전환process(JavaThread*, bool allow_suspend, bool check_async_exception)— 폴 트랩에서 호출되어 전역 safepoint와 handshake를 분기 처리
어떤 작업이 handshake로 옮겨졌나
JEP 312 자체는 메커니즘만 제공하고, 점진적으로 여러 VM operation이 전역 safepoint에서 handshake로 옮겨졌습니다.
- 단일 스레드 stack trace 수집 (JFR, JVMTI
GetStackTrace) - Single-thread biased locking revocation (deprecate 전까지)
- Per-thread code cache flushing
- Single thread deoptimization
대표적으로 "JStack 한 번 떠 본다"는 행위가 JDK 10 이전에는 모든 스레드를 함께 멈췄지만, JDK 10 이후로는 한 스레드만 잠시 잡고 끝납니다. 운영 중 thread dump가 다른 스레드에 미치는 영향이 크게 줄어든 변화의 출발점입니다.
Oracle이 보고한 JEP 312의 성능 영향은 표준 벤치마크에서 1% 미만이며, 전역 safepoint 도달 시간은 늘어나지 않았습니다.
flowchart TB
subgraph Before["Before JEP 312"]
GP[Single global polling page]
GP -->|"all threads SEGV"| T1A[Thread 1]
GP -->|"all threads SEGV"| T2A[Thread 2]
GP -->|"all threads SEGV"| T3A[Thread 3]
end
subgraph After["After JEP 312"]
GPG[Guarded page]
GPU[Unguarded page]
T1B[Thread 1<br/>local ptr]
T2B[Thread 2<br/>local ptr]
T3B[Thread 3<br/>local ptr]
T1B -.->|"armed"| GPG
T2B -.->|"disarmed"| GPU
T3B -.->|"armed"| GPG
end
VM operation의 종류
VMThread는 단일 스레드 큐(VMOperationQueue)에서 VM operation을 하나씩 꺼내 실행합니다. 각 operation은 자기가 safepoint를 요구하는지, 그렇다면 전역 safepoint인지 handshake인지를 정의합니다.
자주 마주치는 VM operation을 분류하면 다음과 같습니다.
| Operation | 트리거 | Safepoint 종류 | TTSP 민감도 |
|---|---|---|---|
| Young GC / Mixed GC | 메모리 압력 | Global | 매우 높음 |
| ZGC pause | mark start/end, relocate start | Global, 수십 us 목표 | 높음 |
| Deoptimize compiled code | 추측 가정 위반 | Global (대규모) / Handshake (단일) | 중간 |
| Class redefinition | JVMTI RedefineClasses |
Global | 낮음 (드물게 발생) |
| Biased lock revocation | Lock contention | Global (bulk) / Handshake (single) | 중간 (JDK 15+ 비활성) |
| Thread dump (JStack/JFR) | 외부 도구 | Handshake (per-thread) | 낮음 |
| Heap dump | JMX, jmap | Global | 매우 높음 |
| Code cache GC | 메서드 unload | Mixed | 낮음 |
운영에서 "GC time은 짧은데 stop-the-world가 길다"고 느낀다면 거의 대부분 TTSP가 길거나, GC 외 VM operation이 같은 safepoint 안에서 함께 처리된 경우입니다.
JEP 376 — ZGC가 safepoint를 거의 없앤 방법
ZGC의 목표는 "1ms 미만의 GC pause, 힙 크기에 무관"입니다. 마킹과 relocation은 동시(concurrent) 수행으로 옮겼지만, 한 가지가 남아 있었습니다. 각 스레드의 스택 스캐닝입니다.
GC 사이클 시작 시 모든 스레드의 스택을 스캔해서 root oop를 찾아야 하는데, 1000개 스레드라면 그것만으로 수십 ms가 걸렸습니다. 힙은 1ms 안에 끝나도 스레드 스택이 발목을 잡았습니다.
JEP 376 "ZGC: Concurrent Thread-Stack Processing" (Java 16, openjdk.org/jeps/376)이 이걸 풉니다.
Stack watermark barrier
스레드 스택은 더 이상 safepoint에서 한꺼번에 스캔되지 않습니다. 대신 각 스레드의 스택에 "watermark"가 깔리고, 다음 규칙으로 점진적으로 처리됩니다.
- GC safepoint(매우 짧은 한 번)에서 전역 epoch을 flip
- 각 스레드는 safepoint에서 깨어날 때 epoch 비교로 자기 스택이 invalid라는 사실을 확인하고, watermark를 설치
- 스레드는 본인이 다음에 실행할 가장 위쪽 프레임 몇 개만 직접 스캔하고 자바 코드로 복귀
- 그 사이 concurrent GC thread가 watermark 아래 프레임을 마저 스캔
스레드 자신이 "다시 실행하기 위해 본인이 필요로 하는" 만큼만 동기적으로 처리하고, 나머지는 GC thread에게 위임하는 분산 모델입니다.
이 변화로 ZGC safepoint pause는 사실상 "epoch flip + 일부 root" 수준의 상수 시간으로 떨어졌고, JEP 376 직후 측정에서 보통 수백 us를 넘지 않습니다. 동일한 아이디어가 Shenandoah에도 별도 작업으로 들어가 있습니다.
무엇이 사라졌고 무엇이 남았나
JEP 376 이전 ZGC pause는 다음을 포함했습니다.
- 전역 epoch flip
- 모든 스레드 스택 스캔
- 다른 per-thread root 처리 (JNI handle 등)
JEP 376 이후 ZGC pause는 다음만 포함합니다.
- 전역 epoch flip
- per-thread root 중 stack을 제외한 일부만
per-thread root까지도 후속 작업으로 빠져나가 ZGC pause의 "유의미한 일거리는 거의 없다"는 상태에 도달했습니다.
JEP 374 — Biased locking 제거가 safepoint를 가볍게 만든 이유
JEP 374 "Deprecate and Disable Biased Locking" (Java 15, openjdk.org/jeps/374)은 표면적으로는 lock 최적화 하나를 끄는 변경이지만, safepoint 측면에서도 의미가 있었습니다.
Biased locking은 monitor를 가장 먼저 잡은 스레드에게 "이 락은 너의 것"이라는 표식을 박아 두고, 같은 스레드의 재진입을 거의 무비용으로 처리하는 최적화였습니다. 단, 다른 스레드가 그 락을 잡으려고 시도하는 순간 "revocation"이 필요했고, revocation은 safepoint operation이었습니다.
문제는 revocation이 자주 일어났다는 점입니다.
- bulk rebias (한 클래스의 모든 인스턴스 revoke)
- bulk revoke (이후 영원히 biased 사용 안 함)
- single revoke (한 인스턴스만)
-XX:+PrintSafepointStatistics로 측정해 보면 GC도 아닌 RevokeBias가 safepoint 시간의 절반 가까이를 차지하는 워크로드가 드물지 않았습니다.
자바 9 이후 java.util.concurrent 위주 코드, 그리고 Loom의 가상 스레드처럼 한 락이 짧고 여러 스레드를 거치는 패턴이 늘어나면서, biased locking은 점차 손실이 더 큰 최적화가 되어 갔습니다. JDK 15에서 기본 비활성, JDK 18부터 사실상 제거 — 그만큼 safepoint 호출자 한 종류가 통째로 사라진 셈입니다.
자바 스레드의 상태 모델
JavaThread는 safepoint 협상을 위해 명시적인 상태 모델을 가집니다. 핵심 상태는 다음과 같습니다.
_thread_in_Java— JIT 컴파일된 자바 코드를 실행 중. 다음 폴에서 협조해야 함._thread_in_vm— JVM 런타임 코드를 실행 중. 이미 안전한 상태로 간주._thread_in_native— JNI 호출 중. VMThread가 기다리지 않음._thread_blocked—Object.wait(), monitor enter 대기 등으로 OS에 의해 blocking. 이미 안전한 상태._thread_new,_thread_uninitialized— 시작 직전. 자바 코드 실행 전.
VMThread의 safepoint 동기화 루프는 이 상태를 보고 "이 스레드는 기다려도 됨" vs "이 스레드는 빨리 폴에 닿아야 함"을 분류합니다. 정확히는 다음 의사 코드와 같습니다.
SafepointSynchronize::begin():
for each JavaThread t:
arm t's polling page
while exists t in _thread_in_Java:
sleep_or_spin()
// 모든 스레드가 _thread_blocked 또는 _thread_in_native 상태
// 또는 폴에 트랩되어 safepoint blocked가 된 상태
핵심은 _thread_in_native에 있는 스레드를 기다리지 않는다는 점입니다. 대신 그 스레드가 자바로 돌아올 때 _thread_in_native -> _thread_in_Java transition 코드가 polling page를 다시 점검합니다. armed이면 safepoint blocked로 전이하고, VMThread가 disarm 할 때까지 자바 코드 실행을 시작하지 못합니다.
진단 — -Xlog:safepoint와 TTSP
운영에서 safepoint를 들여다보는 도구는 두 종류가 있습니다.
Unified logging
JEP 158·271로 통합된 -Xlog 인터페이스 위에서 safepoint 로그를 켭니다.
# safepoint pause/TTSP 기본 로그
-Xlog:safepoint
# 좀 더 자세히 (시작/종료/동기화 시간 분리)
-Xlog:safepoint*=debug
# 파일로 빼고 시간 표시
-Xlog:safepoint:file=safepoint.log:time,uptime,level,tags
출력 예시:
[10.234s][info][safepoint] Application time: 0.1234567 seconds
[10.234s][info][safepoint] Total time for which application threads were stopped: 0.0023456 seconds, Stopping threads took: 0.0001234 seconds
- "Stopping threads took" 가 TTSP입니다.
- "Total time stopped" 가 STW pause 총 길이입니다 (TTSP + VM operation 실행).
- "Application time"이 stop 사이의 정상 실행 시간입니다.
GC time이 짧은데 STW가 길다면 TTSP를 먼저 의심합니다. TTSP가 길면 본문이 긴 counted loop가 의심됩니다.
SafepointTimeout
TTSP가 임계치를 넘으면 어떤 스레드가 늦었는지 로그로 남기는 옵션입니다.
-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000
2000ms 안에 도달 못한 스레드의 스택을 함께 덤프합니다. 운영에서 TTSP 폭주를 잡을 때 가장 직접적인 도구입니다.
async-profiler TTSP 모드
전통적인 JVM 프로파일러는 safepoint에서만 샘플링하기 때문에 "safepoint에 도달하지 못해서 발생한 문제"를 본질적으로 못 봅니다. 이게 safepoint bias이고, async-profiler가 JVMTI 대신 perf/AsyncGetCallTrace를 쓰는 이유 중 하나입니다.
async-profiler 2.0+에는 TTSP 전용 모드가 있습니다.
asprof -e wall \
--begin SafepointSynchronize::begin \
--end RuntimeService::record_safepoint_synchronized \
-d 60 <pid>
SafepointSynchronize::begin에서 시작해 모든 스레드가 도달한 순간까지를 샘플링하므로, TTSP 동안 어떤 자바 메서드에서 늦어졌는지가 그대로 잡힙니다.
운영 함정 7가지
실전에서 자주 부딪히는 함정들을 정리해 두면 다음과 같습니다.
| 증상 | 원인 | 대응 |
|---|---|---|
| GC time 5ms인데 STW 500ms | TTSP가 길어진 counted loop | -Xlog:safepoint로 TTSP 확인, strip mining 활성화 확인 |
| Parallel GC에서 TTSP 폭주 | strip mining이 G1/ZGC/Shenandoah에서만 자동 활성화 | -XX:+UseCountedLoopSafepoints 명시 |
| jstack 한 번 떠도 다른 스레드가 멈춤 | JDK 9 이하 (전역 safepoint) | JDK 11+ 업그레이드 (handshake) |
| ZGC인데 GC pause가 ms 단위 | JEP 376 이전(JDK 15 이하) | JDK 16+ 업그레이드 |
| RevokeBias가 safepoint 시간 절반 | biased locking이 켜져 있음 | JDK 15+ 기본 비활성, 명시적으로 끔 |
| JNI에서 자바 복귀가 가끔 느림 | polling page가 armed인 동안 transition 막힘 | 정상 동작, GC pause와 겹친 것으로 해석 |
| Heap dump 트리거 후 1초 멈춤 | jmap 등이 전역 safepoint를 잡음 |
운영 중 heap dump는 GC pause와 같은 영향이라고 간주 |
가상 스레드와 safepoint
JEP 444 "Virtual Threads" (Java 21)와 JEP 491 (Java 24)을 따라가는 입장에서 한 가지 질문이 자연스럽게 떠오릅니다. 100만 개의 가상 스레드가 동시에 safepoint를 협상하면 TTSP가 폭주하지 않을까요.
답은 "그렇지 않다"입니다. 가상 스레드는 carrier(플랫폼 스레드) 위에 다중 마운트되는 사용자 공간 스케줄링 객체이고, JVM이 safepoint 협상의 단위로 다루는 것은 항상 carrier 스레드입니다. 가상 스레드가 마운트된 carrier에서 폴이 트리거되면 그 carrier가 멈추고, 그 위에 마운트된 가상 스레드도 자연히 멈춥니다. 마운트되지 않은(언마운트된, OS 입장에서는 존재하지 않는) 가상 스레드는 safepoint 협상에 관여하지 않습니다.
이게 작동하려면 가상 스레드가 carrier에 들러붙어 있지 않아야 합니다 — 즉 pinning이 없어야 합니다. JEP 491 "Synchronize Virtual Threads without Pinning" (Java 24)이 synchronized 블록 안에서 발생하던 pinning을 해소한 이후, 가상 스레드는 대부분의 차단 지점에서 carrier에서 언마운트되어 다른 가상 스레드에게 carrier를 넘깁니다. 결과적으로 safepoint 협상은 "활성 carrier 수"에 비례하지 "가상 스레드 수"에 비례하지 않습니다.
이 설계는 동시에 한 가지 결과를 함의합니다. 가상 스레드 안에서 긴 counted loop를 돌리면, 그 carrier가 폴에 도달할 때까지 TTSP가 길어집니다. 가상 스레드를 쓴다고 safepoint 협상 문제가 사라지는 것이 아니라, "협상 단위가 carrier로 옮겨졌을 뿐"이라고 이해해 두는 게 정확합니다.
마치며
Safepoint는 "GC를 위한 메커니즘"으로 출발했지만 지금은 JVM이 자바 스레드의 실행을 협력적으로 멈추기 위한 거의 모든 길의 공통 통로입니다. 폴이 단순한 메모리 load 한 줄이라는 점, 페이지 보호 비트 한 개로 모든 자바 스레드를 동시에 트랩한다는 점, JEP 312가 그걸 thread-local로 쪼개고, JEP 376이 ZGC pause에서 스택 스캐닝을 들어내고, JEP 374가 가장 흔한 safepoint 호출자 하나를 통째로 없앤 과정을 따라가면, "GC time이 0ms에 가까워졌는데 왜 응답이 튀는가"라는 질문이 정확히 어디서 시작되는지 답을 찾기 쉬워집니다.
다음 thread dump를 떴을 때 어떤 스레드가 어떤 폴에서 멈추고 있는지를 그림 위에 올려 보면, JVM이라는 거대한 기계가 사실은 폴 하나에 매달려 동작한다는 인상을 받게 됩니다.
참고자료
- JEP 312: Thread-Local Handshakes — https://openjdk.org/jeps/312
- JEP 376: ZGC: Concurrent Thread-Stack Processing — https://openjdk.org/jeps/376
- JEP 374: Deprecate and Disable Biased Locking — https://openjdk.org/jeps/374
- JEP 158: Unified JVM Logging — https://openjdk.org/jeps/158
- JEP 271: Unified GC Logging — https://openjdk.org/jeps/271
- JEP 444: Virtual Threads — https://openjdk.org/jeps/444
- JEP 491: Synchronize Virtual Threads without Pinning — https://openjdk.org/jeps/491
- OpenJDK
safepointMechanism.cpp— https://github.com/openjdk/jdk/blob/master/src/hotspot/share/runtime/safepointMechanism.cpp - JDK-8186027 C2: loop strip mining — https://bugs.openjdk.org/browse/JDK-8186027
- JDK-8220374 C2: LoopStripMining doesn't strip as expected — https://bugs.openjdk.org/browse/JDK-8220374
- JDK-8223051 support loops with long (64b) trip counts — https://bugs.openjdk.org/browse/JDK-8223051
- Erik Österlund, "Concurrent thread-stack processing in the Z Garbage Collector" — https://assets.ctfassets.net/oxjq45e8ilak/4yskskpB4fABTmTs5PZ1kD/bbe0c1c0f0f082899bec39e598adc848/Erik_sterlund_Concurrent_thread-stack_processing_in_the_Z_Garbage_Collector_2021_10_25_16_06_59.pdf
- Roland Westrelin, "Loop Strip Mining in C2" — https://cr.openjdk.org/~roland/loop_strip_mining.pdf

