GC 로그를 볼 때 Pause Young 같은 문구만 보고 "GC가 80ms 걸렸구나"라고 이해하기 쉽다. 그런데 운영 장애를 따라가다 보면 애플리케이션이 멈춘 시간과 GC 작업 시간이 딱 맞지 않는 경우가 있다. JVM이 멈춰야 하는 지점까지 모든 Java 스레드를 모으는 시간, 즉 세이프포인트(safepoint) 진입 비용이 숨어 있기 때문이다.
OpenJDK HotSpot 공식 문서와 HotSpot VM 옵션 설명에서 세이프포인트는 VM이 모든 Java 스레드의 상태를 안전하게 관찰하거나 변경할 수 있는 실행 지점으로 설명된다. GC는 대표적인 예시지만, 세이프포인트는 GC만을 위한 장치는 아니다. 클래스 재정의, 코드 디옵티마이제이션, 스레드 덤프, biased locking 관련 작업처럼 VM 전역 상태를 다뤄야 하는 작업도 세이프포인트를 요구할 수 있다.
이 글에서는 세이프포인트가 무엇인지보다, stop-the-world 지연이 어디서 생기는지를 중심으로 정리했다. 특히 "VM 작업 자체가 느린 것"과 "스레드들이 세이프포인트에 모이는 시간이 긴 것"을 구분하는 관점이 중요하다.
세이프포인트는 JVM이 모든 Java 스레드에 대해 일관된 상태를 기대할 수 있는 지점이다.
Java 코드는 JIT 컴파일을 거쳐 기계어로 실행될 수 있다. 이때 VM은 아무 명령어 중간에서나 스레드를 세워 객체 참조, 스택 프레임, 레지스터 값을 정확히 해석할 수 없다. 어떤 위치에서는 참조가 레지스터에 있고, 어떤 위치에서는 스택에 있으며, 어떤 값은 최적화 때문에 실제 메모리에 존재하지 않을 수도 있다.
그래서 HotSpot은 특정 위치에 "여기서는 런타임 상태를 해석할 수 있다"는 정보를 남긴다. 메서드 호출, 루프 백엣지, 예외 처리 경로 같은 위치가 대표적인 후보로 알려져 있다. VM이 전역 작업을 시작해야 하면 각 Java 스레드는 실행을 계속하다가 다음 세이프포인트 지점에서 멈춘다.
세이프포인트가 필요한 이유는 단순하다. JVM 내부 작업 중에는 힙, 코드 캐시, 스택, 클래스 메타데이터를 서로 맞물린 상태로 다뤄야 하는 일이 많다. 한 스레드가 객체 그래프를 훑는 동안 다른 스레드가 참조를 바꾸면 결과가 깨질 수 있다. 그래서 일정 순간에는 모든 Java 스레드를 멈추고, VM 스레드가 필요한 작업을 수행한 뒤 다시 풀어준다.
다만 "모든 스레드를 멈춘다"는 말에는 두 구간이 들어 있다.
| 구간 | 의미 | 지연에 미치는 영향 |
|---|---|---|
| 세이프포인트 동기화 | 모든 Java 스레드가 세이프포인트에 도달하기를 기다림 | 한 스레드만 늦어도 전체 대기 증가 |
| VM 작업 실행 | GC, 디옵티마이제이션, 스레드 덤프 등 실제 작업 수행 | 작업 종류와 힙/메타데이터 규모에 따라 증가 |
장애 분석에서는 두 구간을 나눠 봐야 한다. GC 작업이 50ms라도 세이프포인트 동기화에 300ms가 걸리면 사용자가 체감하는 멈춤은 350ms에 가까워질 수 있다.
세이프포인트 기반 stop-the-world 흐름을 거칠게 그리면 다음과 같다.
VM 작업 요청
|
v
전역 safepoint 요청 플래그 설정
|
v
각 Java 스레드가 실행 중 다음 safepoint poll 확인
|
+--> 이미 blocked/native 상태면 VM이 안전 여부를 확인
|
v
모든 스레드가 모이면 VM Operation 실행
|
v
스레드 재개
핵심은 JVM이 보통 실행 중인 Java 스레드를 즉시 강제로 끊지 않는다는 점이다. 스레드는 컴파일된 코드 안에 삽입된 safepoint poll을 만나거나, 런타임 호출 경로로 들어가면서 세이프포인트 요청을 인지한다. 공식 HotSpot 문서에서 세이프포인트 동기화는 각 스레드가 안전한 상태에 도달해야 완료되는 과정으로 설명된다.
여기서 느린 스레드가 하나만 있어도 전체 pause가 늘어난다. 예를 들어 CPU를 오래 잡고 도는 tight loop가 있는데 루프 안에 세이프포인트 poll이 충분히 들어가지 않는다면, VM은 그 스레드가 안전 지점에 도달할 때까지 기다려야 한다. 현대 HotSpot은 루프에도 poll을 넣는 것으로 알려져 있지만, 네이티브 코드, 긴 JNI 호출, OS 스케줄링 지연, 과도한 CPU 경합 같은 요소는 여전히 도달 시간을 늘릴 수 있다.
세이프포인트 대기 시간은 보통 다음 세 가지로 나눠 생각하면 이해하기 쉽다.
로그에서 "GC 시간이 길다"는 표현만 보면 마지막 구간만 떠올리게 된다. 하지만 실제 stop-the-world 체감은 앞의 두 구간까지 포함한다.
세이프포인트 지연은 "나쁜 코드 한 줄"로만 생기지 않는다. 애플리케이션 코드, JVM 컴파일 결과, 네이티브 호출, OS 스케줄러가 함께 만든다. 그래도 운영 관점에서 의심해볼 수 있는 패턴은 있다.
첫째, CPU를 오래 점유하는 계산 루프다. 아래 코드는 일부러 단순화한 예시다.
public final class BusyLoop {
static long consume(long n) {
long acc = 0;
for (long i = 0; i < n; i++) {
acc += (i ^ (i >>> 3)); // 긴 계산 루프
}
return acc;
}
}
이런 루프가 곧바로 세이프포인트 문제를 만든다고 단정할 수는 없다. HotSpot은 컴파일된 루프에 safepoint poll을 넣어 장시간 미응답을 줄이려 한다. 다만 루프가 매우 길고, CPU가 포화되어 있고, 특정 스레드가 오랫동안 스케줄링되지 못하는 상황이 겹치면 세이프포인트 도달이 늦어질 수 있다.
둘째, JNI나 네이티브 코드에 오래 머무르는 경우다. Java 스레드가 네이티브 영역에 있을 때 JVM은 그 스레드의 상태를 별도로 추적한다. 모든 네이티브 실행이 위험하다는 뜻은 아니지만, VM이 즉시 스택과 객체 참조를 원하는 방식으로 다룰 수 없는 경계가 생긴다. 공식 문서에서도 세이프포인트와 스레드 상태의 관계는 VM이 안전한 시점을 판단하는 핵심으로 다뤄진다.
셋째, 스레드 수가 지나치게 많은 경우다. 세이프포인트는 모든 Java 스레드를 대상으로 한다. 실제로 실행 중인 스레드가 적어도, JVM이 추적해야 하는 스레드가 많으면 동기화와 상태 확인 비용이 커질 수 있다. 특히 짧은 주기로 전역 VM 작업이 반복된다면 작은 비용도 누적된다.
마지막으로, 관측 도구가 만든 세이프포인트도 있다. 스레드 덤프, 힙 덤프, 일부 진단 명령은 애플리케이션을 멈추거나 세이프포인트를 요구할 수 있다. 장애 상황에서 여러 도구를 동시에 붙이면 "원인을 보려고 한 행동"이 pause를 더 키울 수도 있다.
JDK 버전에 따라 옵션 이름과 로그 형식은 달라졌지만, 관점은 같다. 예전에는 -XX:+PrintSafepointStatistics 같은 HotSpot 진단 옵션이 자주 언급됐고, JDK 9 이후에는 unified logging의 safepoint 태그를 통해 관련 정보를 확인하는 방식이 사용된다. 정확한 옵션 지원 여부는 사용하는 JDK 배포판과 버전의 공식 문서를 확인해야 한다.
예시 형태는 대략 다음처럼 읽을 수 있다.
Safepoint "G1CollectForAllocation"
Total time: 120 ms
Time to safepoint: 70 ms
At safepoint: 50 ms
여기서 At safepoint가 실제 VM Operation 시간에 가깝고, Time to safepoint는 스레드들이 멈출 수 있는 지점까지 모이는 시간으로 볼 수 있다. 값의 이름은 버전마다 다를 수 있지만, "모으는 시간"과 "멈춘 뒤 작업한 시간"을 분리해 보는 습관이 중요하다.
문제를 좁힐 때는 다음 순서가 실용적이었다.
세이프포인트는 JVM 내부 구현과 강하게 연결되어 있어서, 한 가지 로그만으로 원인을 확정하기 어렵다. 그래도 이 구분을 해두면 "GC 튜닝"으로 풀 문제인지, "특정 스레드가 세이프포인트에 늦게 도착하는 문제"인지 방향이 갈린다.
세이프포인트는 JVM이 전역 상태를 안전하게 다루기 위해 필요한 장치다. stop-the-world pause를 볼 때 중요한 점은 멈춘 뒤 수행한 VM 작업만이 전체 지연이 아니라는 것이다. 모든 Java 스레드가 세이프포인트에 도달할 때까지 기다리는 시간도 사용자가 체감하는 멈춤에 포함된다.
그래서 세이프포인트 이슈를 볼 때는 "왜 GC가 느렸나"에서 바로 시작하지 않고, "모이는 시간이 느렸나, 모인 뒤 작업이 느렸나"를 먼저 나눠야 한다. 전자는 CPU 경합, 긴 루프, 네이티브 호출, 스레드 상태 문제로 이어지고, 후자는 GC 알고리즘과 힙 상태, VM Operation 종류로 이어진다.
다음에 더 파고들 주제로는 JDK unified logging의 safepoint 로그 읽기, 그리고 JVM 컴파일러가 루프에 safepoint poll을 삽입하는 방식이 있다. 이 둘을 보면 "세이프포인트가 있다"는 설명에서 한 단계 더 내려가 실제 pause를 해석할 수 있다.