실무 백엔드 서비스에서 "느려졌다"는 신고가 들어올 때, 신입은 코드를 본다. 시니어는 GC 로그를 본다. JVM 튜닝은 단순히 플래그 몇 개를 외우는 것이 아니라, 애플리케이션이 CPU/메모리/스레드 자원을 어떻게 쓰는지를 JVM 관점에서 역산하는 능력이다. 이 능력은 크게 세 가지 상황에서 실전 가치가 폭발한다.
첫째, 레이턴시 SLO가 무너질 때다. p99 응답이 100ms에서 갑자기 1.5s로 튀면, 코드 변경이 없었다면 거의 대부분 GC pause 혹은 Safepoint 지연이 원인이다. 둘째, OOM이 난 뒤 재발 방지 회의에 불려갈 때다. "Heap을 늘렸다"는 답은 중간급 엔지니어의 답이고, "Heap이 아니라 Metaspace였고 클래스로더 누수가 원인이었다"는 답이 시니어의 답이다. 셋째, Java 21 이후 Virtual Threads가 들어오면서 기존 튜닝 상식 중 일부가 무효화되고 있다. 스레드 풀 크기 산정, 블로킹 I/O 재평가, backpressure 전략이 다시 설계 테이블 위에 올라왔다.
이 문서는 이 세 가지를 한 번에 훑을 수 있는 시니어용 체크포인트다. 개념 → 실습 → 안티패턴 → 면접 답변 순으로 구성했다.
JVM의 프로세스 메모리는 -Xmx에 잡히지 않는 영역이 훨씬 많다. 컨테이너 환경에서 OOMKilled가 나는 대부분의 원인이 여기에 있다.
Heap (-Xms, -Xmx)
new로 만든 객체, 배열이 올라가는 공간.Metaspace (-XX:MaxMetaspaceSize)
Thread Stack (-Xss)
Direct Memory / Native Buffer (-XX:MaxDirectMemorySize)
ByteBuffer.allocateDirect(), Netty, gRPC, Kafka 클라이언트가 사용.jcmd <pid> VM.native_memory 또는 NMT로 본다.Code Cache (-XX:ReservedCodeCacheSize)
실무 체크리스트: 컨테이너 memory limit을 L이라 할 때, -Xmx는 대략 L * 0.5 ~ 0.7로 잡는다. 나머지를 Metaspace, Direct, Stack, Code Cache, JVM 자체 오버헤드에 남겨야 한다. -Xmx = L로 잡는 설정은 거의 항상 사고를 부른다.
Weak Generational Hypothesis: 대부분의 객체는 금방 죽는다. 이 가정 위에서 GC는 Young 영역을 자주, 빠르게 훑고, 살아남은 객체만 Old로 승격시킨다.
-XX:MaxTenuringThreshold, 기본 15) Old로 승격(promotion).Stop-the-world(STW): GC가 애플리케이션 스레드를 전부 멈추는 구간. Minor GC도 STW가 있지만 보통 수 ms. 문제는 Full GC의 STW로, 초 단위가 나올 수 있다. 시니어가 "GC 튜닝"이라고 할 때 실제로 말하는 것은 STW의 빈도와 길이를 SLO 이내로 눌러 두는 작업이다.
Allocation rate: 초당 얼마나 많은 바이트가 Eden에 할당되는가. GC 로그의 핵심 지표. 200MB/s를 넘어가면 Young GC가 너무 자주 돌고 p99가 흔들린다. 이때 답은 "힙을 늘린다"가 아니라 "불필요한 객체 할당을 찾아서 줄인다"인 경우가 더 많다.
Java 17/21 기준 실무 선택지는 세 가지다.
-XX:G1HeapRegionSize, 기본 1~32MB 자동)으로 쪼개고 "가비지가 많은 Region부터" 수거.-XX:MaxGCPauseMillis=200(기본) 같은 pause 목표를 주면 G1이 Young 크기를 자동 조정.MaxGCPauseMillis 튜닝.-Xms4g -Xmx4g # Xms == Xmx 로 고정 (리사이즈 비용 제거)
-XX:+UseG1GC # G1 명시
-XX:MaxGCPauseMillis=200 # pause 목표 (soft goal)
-XX:G1HeapRegionSize=8m # 큰 객체(>Region/2)가 Humongous로 빠지는 것 방지
-XX:InitiatingHeapOccupancyPercent=45 # Old 점유율 이 값에서 Concurrent Marking 시작
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/
-Xlog:gc*,gc+age=trace,safepoint:file=/var/log/app/gc.log:time,level,tags:filecount=10,filesize=50m
힙 크기 결정 원리 (시니어 관점):
L이면 힙은 L * 2.5 ~ 3 정도가 출발점.MaxGCPauseMillis를 낮추면 Young이 작아지고, 높이면 커진다. 트레이드오프.Java 9+에서는 -Xlog:gc* 통합 로깅을 쓴다. 예시 로그:
[2.341s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 512M->128M(2048M) 18.234ms
[2.890s][info][gc] GC(13) Pause Young (Concurrent Start) (G1 Humongous Allocation) 540M->150M(2048M) 22.100ms
[3.450s][info][gc] GC(14) Concurrent Mark Cycle
[5.120s][info][gc] GC(15) Pause Mixed (G1 Evacuation Pause) 800M->200M(2048M) 45.600ms
읽는 법:
Pause Young (Normal) → 일반 Minor GC. 512M에서 128M로 줄었고 18ms 걸렸다. 좋은 상태.G1 Humongous Allocation → Region 절반 이상 크기 객체가 할당됨. 큰 배열/버퍼 풀을 의심.Concurrent Mark Cycle → Old 마킹 시작. STW 아님.Pause Mixed → Young + 일부 Old 동시 수거. G1 후반 단계.계산해야 할 3가지 지표:
1 - (GC 총 시간 / 전체 시간). 95% 미만이면 의심.(Young 크기 변화) / 간격. GCViewer, GCeasy.io에 로그를 올리면 자동 계산.면접에서 "GC 로그 어떻게 보냐"는 질문이 나오면 반드시 이 세 숫자를 언급하라. 단순히 "pause를 본다"는 답은 주니어 답이다.
java.lang.OutOfMemoryError: Java heap space-XX:+HeapDumpOnOutOfMemoryError로 덤프를 받고 **MAT(Eclipse Memory Analyzer)**의 Leak Suspects 리포트를 연다. Dominator Tree로 가장 많이 점유한 루트를 찾는다.java.lang.OutOfMemoryError: Metaspace-XX:MaxMetaspaceSize를 반드시 명시한다(무한 성장 방지). jcmd <pid> VM.classloader_stats로 누가 클래스를 찍어내는지 본다.java.lang.OutOfMemoryError: Direct buffer memory-XX:MaxDirectMemorySize 미설정 시 기본값이 Heap과 비슷해 탐지 지연.-Dio.netty.leakDetection.level=paranoid, NMT(-XX:NativeMemoryTracking=detail + jcmd VM.native_memory).java.lang.OutOfMemoryError: GC overhead limit exceededmaximumSize/maximumWeight).jcmd — 가장 먼저 쓸 스위스 나이프.
jcmd <pid> VM.flags # 실제 적용된 GC 플래그 확인
jcmd <pid> GC.heap_info # 힙 현재 상태
jcmd <pid> GC.class_histogram # 클래스별 인스턴스/바이트
jcmd <pid> VM.native_memory summary # NMT (미리 -XX:NativeMemoryTracking 필요)
jcmd <pid> Thread.print # 스레드 덤프
jcmd <pid> GC.heap_dump /tmp/heap.hprof
jstat — GC 1초 단위 모니터링.
jstat -gcutil <pid> 1000
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 45.32 67.12 72.45 98.21 95.43 125 2.345 3 0.892 3.237
async-profiler — 샘플링 기반, 거의 오버헤드 없음.
./profiler.sh -d 30 -f /tmp/cpu.html <pid> # CPU Flamegraph
./profiler.sh -d 30 -e alloc -f /tmp/alloc.html <pid> # Allocation hotspot
./profiler.sh -d 30 -e lock -f /tmp/lock.html <pid> # Lock contention
실무에서 "allocation rate가 높다"를 진단한 뒤 바로 -e alloc으로 어디서 만드는지 찾는 흐름이 표준.
JFR (Java Flight Recorder) — Java 11+ 무료, 상시 가동 가능.
jcmd <pid> JFR.start duration=120s filename=/tmp/app.jfr
# JDK Mission Control(JMC)로 열어서 분석
MAT (heap dump 분석)
플랫폼 스레드는 OS 스레드에 1:1로 매핑된다. 각 1MB 스택 + 컨텍스트 스위칭 비용 때문에 수만 개를 만들 수 없다. 그래서 우리는 늘 ExecutorService + 스레드 풀로 재사용했다.
Virtual Thread는 JVM이 관리하는 경량 스레드로, 블로킹 시 플랫폼 스레드(캐리어)에서 unmount 되어 캐리어를 해방시킨다. 결과적으로 블로킹 I/O를 하는 코드도 수십만 개 동시 요청을 "스레드 하나당 하나의 요청(thread-per-request)" 스타일로 짤 수 있다.
// 기존
var pool = Executors.newFixedThreadPool(200);
// Java 21
var executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
var user = userRepository.findById(id); // JDBC 블로킹도 OK
var profile = profileClient.fetch(user.id()); // HTTP 블로킹도 OK
return render(user, profile);
});
효과가 큰 경우:
효과가 작거나 오히려 해로운 경우:
synchronized 블록 안에서 블로킹 — 캐리어를 pin시켜 unmount가 안 된다. Java 21에서 여전히 주의점, Java 24에서 개선 예정. java.util.concurrent.locks(ReentrantLock)로 대체.튜닝 포인트:
getConnection()을 부르면 그대로 블로킹 큐에 줄 선다.Virtual Thread 이전/이후에도, 복수 API를 합성해야 할 때 CompletableFuture는 여전히 유용하다.
CompletableFuture<User> u = supplyAsync(() -> userRepo.find(id), ex);
CompletableFuture<List<Order>> o = supplyAsync(() -> orderRepo.findByUser(id), ex);
CompletableFuture<Profile> p = supplyAsync(() -> profileClient.fetch(id), ex);
return CompletableFuture.allOf(u, o, p)
.thenApply(v -> new Dashboard(u.join(), o.join(), p.join()))
.orTimeout(500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Dashboard.fallback());
안티패턴: join()을 체인 중간에 부르는 것. 그 순간 비동기가 끝난다.
Java 21 Structured Concurrency (preview):
try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
var fUser = scope.fork(() -> userRepo.find(id));
var fOrders = scope.fork(() -> orderRepo.findByUser(id));
scope.join().throwIfFailed();
return new Dashboard(fUser.get(), fOrders.get());
}
하나 실패하면 나머지도 자동 cancel. 에러 전파/타임아웃/취소가 try-with-resources 범위로 묶인다. CompletableFuture의 "orphan task" 문제를 근본적으로 푼다.
공유 상태에 "읽기 훨씬 많고, 쓰기 드물다"는 조건이 있을 때 선택한다.
ReentrantReadWriteLock:
StampedLock (Java 8+):
validate(stamp)로 쓰기가 끼어들었는지만 확인.private final StampedLock sl = new StampedLock();
private double x, y;
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead();
double cx = x, cy = y;
if (!sl.validate(stamp)) { // 쓰기가 끼어들었다면 재시도
stamp = sl.readLock();
try { cx = x; cy = y; }
finally { sl.unlockRead(stamp); }
}
return Math.sqrt(cx * cx + cy * cy);
}
실측상 읽기 비율이 95% 이상인 핫패스에서 RWLock 대비 수 배 처리량이 나온다. 다만 재진입, Condition, 업그레이드 시 코드가 까다로우니 정말 병목일 때만 쓴다. 일반 서비스 코드에서는 RWLock이 기본.
System.currentTimeMillis() 기반 자체 측정은 거의 항상 거짓말이다. JIT가 충분히 데워지지 않았거나, 결과를 안 쓰는 계산을 dead code로 제거해 버린다. JMH는 이 문제를 해결한다.
@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
public class HashBench {
private String data;
@Setup public void setup() { data = "x".repeat(1024); }
@Benchmark
public int baseline() {
return data.hashCode();
}
@Benchmark
public void sinkToBlackhole(Blackhole bh) {
bh.consume(data.hashCode()); // dead code elimination 방지
}
}
함정 5가지:
@State로 주입.@Warmup으로 최소 5회.@Fork(2+).@Threads로 명시.실제 사례: 과거 StampedLock 기반 좌표 접근 구조를 만들면서 동일 연산을 synchronized → ReentrantReadWriteLock → StampedLock optimistic read로 바꾸며 JMH로 측정했더니, 읽기 95% 워크로드에서 처리량이 약 58배 차이가 났다. 이 숫자는 JMH가 아니었으면 훨씬 작게 보였을 가능성이 높다. Dead code elimination과 warm-up 차이가 결과를 20~100배씩 흔들기 때문이다. 시니어가 "58배 빨라졌다"라고 말할 때는 반드시 JMH 리포트와 함께 말해야 신뢰를 산다.
# JDK 21 (Temurin)
sdk install java 21.0.5-tem
sdk use java 21.0.5-tem
# async-profiler
curl -LO https://github.com/async-profiler/async-profiler/releases/latest/download/async-profiler-linux-x64.tar.gz
tar xzf async-profiler-linux-x64.tar.gz
# JMH skeleton
mvn archetype:generate \
-DinteractiveMode=false \
-DarchetypeGroupId=org.openjdk.jmh \
-DarchetypeArtifactId=jmh-java-benchmark-archetype \
-DgroupId=com.example -DartifactId=jmh-lab -Dversion=0.1
JVM 실습용 최소 실행 스크립트:
#!/usr/bin/env bash
java \
-Xms512m -Xmx512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp/heap.hprof \
-Xlog:gc*,safepoint:file=/tmp/gc.log:time,level,tags:filecount=5,filesize=10m \
-XX:+FlightRecorder \
-jar app.jar
부하는 wrk나 k6로 주면서 별도 터미널에서:
watch -n1 "jstat -gcutil $(pgrep -f app.jar) | tail -n +2"
Bad 1: 힙만 키우면 된다
-Xmx16g # 커지면 해결되겠지
→ Full GC 한 번에 STW가 수 초. Allocation rate 자체가 문제라면 힙은 증상 완화일 뿐.
Improved 1: async-profiler -e alloc으로 할당 핫스팟 찾기 → 불필요한 new byte[], 로그 포매팅, String.format 제거 → 그 다음에도 필요하면 힙 조정.
Bad 2: 스레드 풀 크기를 무한대로
Executors.newCachedThreadPool()
→ downstream이 느려지면 스레드가 무제한 생성, 결국 OOM.
Improved 2: ThreadPoolExecutor로 coreSize/maxSize/queue를 명시하거나, Java 21 이상이면 Virtual Thread + 상위 세마포어.
Bad 3: 모든 GC 문제는 G1으로 해결된다 → 힙 64GB + p99.9 5ms 요구 서비스라면 G1은 부족. ZGC Generational로 간다.
Bad 4: JMH 없이 System.nanoTime()으로 "빨라졌다"고 주장
→ JIT 상태에 따라 100배까지 튄다. JMH로 다시 측정.
시니어 답변 구조는 관측 → 가설 → 검증 → 대응 4단으로 짠다.
jstat -gcutil 1s로 YGC/FGC 빈도·시간.-Xlog:gc* 로그에서 allocation rate, pause time 분포.jcmd GC.heap_dump + MAT로 Dominator Tree.-e alloc로 할당 핫스팟.MaxGCPauseMillis 재조정.피해야 할 답변: "힙 덤프 떠서 봅니다" 단독 답변. 이건 도구 이름일 뿐, 방법론이 아니다. 반드시 "먼저 메트릭으로 GC 구간을 특정하고, 그 시점의 힙 덤프와 프로파일을 교차 확인"이라고 말해야 한다.
메모리 / 설정
-Xms == -Xmx 고정 (리사이즈 비용 제거, 컨테이너 OOMKill 방지)-Xmx는 50~70%-XX:MaxMetaspaceSize 명시-XX:MaxDirectMemorySize 명시 (Netty/Kafka 쓰면 필수)-XX:+HeapDumpOnOutOfMemoryError + HeapDumpPath-Xlog:gc* 롤링 파일로 상시 기록GC 선택
MaxGCPauseMillis 기본 200에서 SLO에 맞춰 조정관측
jcmd VM.flags로 실제 적용 플래그 기록코드
synchronized 블록에서 블로킹 금지 → ReentrantLock@Fork(2+) @Warmup(5) @Measurement(10) 최소 기준면접