본문으로 건너뛰기
안동민 개발노트 아이콘

안동민 개발노트

본문 시작
38장 : 선택 심화 — JVM 진단

디버거·스레드 덤프·프로파일러

원인을 버린 예외의 진단 손실을 재현하고 상태·CPU·할당·락 가설에 맞는 근거와 도구를 선택해 재현 가능한 결론을 만듭니다.

도구를 먼저 켜면 많은 데이터 속에서 눈에 띄는 항목을 원인으로 오해하기 쉽습니다. 증상, 시간 범위, 영향을 받은 요청, 예상 동작을 적고 “CPU 사용이 집중된 메서드”, “락 대기”, “할당 압력”, “I/O 지연”처럼 반증 가능한 가설을 만듭니다. 디버거는 한 실행의 상태, 스레드 덤프는 대기 관계, 프로파일러와 JFR은 시간에 따른 분포를 보여 줍니다.

예외 재생성과 원인 손실

아래 경계 코드는 최초의 NumberFormatException을 버리고 새 예외만 던집니다. 호출자가 원인 연쇄를 조사하면 출력은 wrong-cause-null=true입니다.

lab/DiscardedExceptionCauseBug.java
public final class DiscardedExceptionCauseBug {
    static int parseMinutes(String value) {
        try {
            return Integer.parseInt(value);
        } catch (NumberFormatException exception) {
            throw new IllegalArgumentException("invalid minutes: " + value);
        }
    }

    public static void main(String[] args) {
        try {
            parseMinutes("forty");
        } catch (IllegalArgumentException exception) {
            System.out.println("message=" + exception.getMessage());
            System.out.println("wrong-cause-null=" + (exception.getCause() == null));
        }
    }
}

원칙은 추상화 경계에서 문맥을 추가하되 원인을 연결하는 것입니다. new IllegalArgumentException(message, exception)으로 원인을 보존하고 민감한 입력은 가립니다. 스택 추적, 요청 식별자, 버전, 연산을 함께 남겨 재현 경로를 좁힙니다.

가설·반증 조건의 구조화

장애 분석 메모에 “느리다”라고만 쓰지 않고 지표와 근거 자료를 기록합니다. 다음 모델은 가설을 확인하거나 기각하려면 무엇이 필요한지 명시하게 합니다.

src/DiagnosticHypothesisModel.java
import java.time.Instant;
import java.util.List;

public final class DiagnosticHypothesisModel {
    private enum Status {
        OPEN,
        CONFIRMED,
        REJECTED
    }

    private record Hypothesis(
            String symptom,
            String claim,
            List<String> evidenceNeeded,
            String falsifier,
            Instant startedAt,
            Status status) {}

    public static void main(String[] args) {
        Hypothesis lock =
                new Hypothesis(
                        "save p99 > 2s",
                        "threads wait on StudyRepository monitor",
                        List.of("three thread dumps", "JFR JavaMonitorEnter", "request timestamps"),
                        "no blocked threads and monitor wait below 10ms",
                        Instant.parse("2026-07-14T10:00:00Z"),
                        Status.OPEN);
        System.out.println(lock);
    }
}

상태를 확인 완료로 바꾸기 전에 대안 가설과 관찰 누락을 검토합니다. 상관관계가 원인인지 통제된 변경이나 재현 가능한 실험으로 확인합니다.

ThreadMXBean 대기 분석

다음 예제는 작업자가 WAITING 상태로 래치에서 기다리는 동안 스레드 정보를 읽고 래치를 해제합니다. 운영 환경에서는 jcmd <pid> Thread.print -l을 일정한 간격으로 여러 번 수집해 지속적으로 BLOCKED 상태인 스레드와 락 소유자, 대기 큐를 찾습니다.

src/ThreadStateEvidence.java
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadInfo;
import java.lang.management.ThreadMXBean;
import java.util.concurrent.CountDownLatch;

public final class ThreadStateEvidence {
    public static void main(String[] args) throws Exception {
        CountDownLatch waiting = new CountDownLatch(1);
        CountDownLatch release = new CountDownLatch(1);
        Thread worker =
                Thread.ofPlatform()
                        .name("study-waiter")
                        .start(
                                () -> {
                                    waiting.countDown();
                                    try {
                                        release.await();
                                    } catch (InterruptedException exception) {
                                        Thread.currentThread().interrupt();
                                    }
                                });

        waiting.await();
        ThreadMXBean threads = ManagementFactory.getThreadMXBean();
        ThreadInfo info = threads.getThreadInfo(worker.threadId(), 8);
        System.out.println("name=" + info.getThreadName());
        System.out.println("state=" + info.getThreadState());
        System.out.println("top=" + info.getStackTrace()[0]);

        release.countDown();
        worker.join();
    }
}

한 번의 덤프에 나타난 WAITING은 정상적인 유휴 상태일 수 있습니다. 여러 덤프에서 같은 요청 스레드가 같은 락 소유자 때문에 계속 BLOCKED 상태인지, 소유자의 스택은 무엇인지, 가상 스레드인지 함께 살펴봅니다.

CPU 시간과 경과 시간 분리

I/O나 락을 기다린 연산은 경과 시간이 길어도 스레드 CPU 시간은 짧습니다. 현재 스레드의 CPU 시간 측정을 지원하는 런타임에서 두 값을 비교합니다.

src/CpuAndWallTimeEvidence.java
import java.lang.management.ManagementFactory;
import java.lang.management.ThreadMXBean;
import java.time.Duration;

public final class CpuAndWallTimeEvidence {
    public static void main(String[] args) throws Exception {
        ThreadMXBean threads = ManagementFactory.getThreadMXBean();
        if (!threads.isCurrentThreadCpuTimeSupported()) {
            System.out.println("cpu-time-unsupported");
            return;
        }
        if (!threads.isThreadCpuTimeEnabled()) {
            threads.setThreadCpuTimeEnabled(true);
        }

        long wallStart = System.nanoTime();
        long cpuStart = threads.getCurrentThreadCpuTime();
        Thread.sleep(Duration.ofMillis(20));
        long checksum = 0;
        for (int value = 0; value < 100_000; value++) {
            checksum += (long) value * value;
        }
        long cpu = threads.getCurrentThreadCpuTime() - cpuStart;
        long wall = System.nanoTime() - wallStart;
        System.out.println("wall-ns=" + wall);
        System.out.println("cpu-ns=" + cpu);
        System.out.println("checksum=" + checksum);
    }
}

관찰값은 머신마다 달라 정확한 시간을 단언하지 않습니다. 전체 경과 시간은 길고 CPU 사용 시간은 짧으면 I/O·sleep()·락 대기 가설을 우선하고, CPU 사용률이 높으면 JFR 실행 표본이나 비동기 프로파일러의 불꽃 그래프를 봅니다.

원인과 문맥을 보존한 경계

수정 구현은 원인 타입·스택을 유지하고 연산 문맥을 추가합니다.

app/DiagnosticExceptionBoundary.java
public final class DiagnosticExceptionBoundary {
    static int parseMinutes(String value) {
        try {
            int minutes = Integer.parseInt(value);
            if (minutes <= 0) {
                throw new IllegalArgumentException("not positive");
            }
            return minutes;
        } catch (RuntimeException exception) {
            throw new IllegalArgumentException("parse study minutes failed", exception);
        }
    }

    public static void main(String[] args) {
        try {
            parseMinutes("forty");
        } catch (IllegalArgumentException exception) {
            System.out.println("context=" + exception.getMessage());
            System.out.println("cause=" + exception.getCause().getClass().getSimpleName());
        }
    }
}

증상별 도구 선택

  • 잘못된 한 상태: 디버거 중단점, 조건부 중단점, 감시점을 사용합니다.
  • 멈춤·락: 여러 스레드 덤프, 소유 가능한 동기화 도구, JFR 모니터 이벤트를 봅니다.
  • CPU: JFR 실행 표본, 비동기 프로파일러 CPU 모드, 컴파일러 활동을 봅니다.
  • 할당·GC: JFR 할당, GC 로그, class 히스토그램, 힙 덤프 보유 경로를 봅니다.
  • I/O: 소켓·파일 이벤트, 하위 시스템 지연 시간, 연결 풀 큐를 봅니다.
  • 클래스·연결 오류: 클래스 적재 로그, 모듈 그래프, javap 디스크립터를 봅니다.
jcmd -l
jcmd <pid> Thread.print -l
jcmd <pid> JFR.start name=incident settings=profile duration=60s filename=incident.jfr
jfr summary incident.jfr

수집 명령, PID·시작 시간, 배포 버전, 시간대를 장애 분석 기록에 넣습니다. 진단 명령도 오버헤드와 민감 데이터 위험이 있으므로 운영 절차서를 따릅니다.

불꽃 그래프의 가장 넓은 프레임이 원인인가요?

그 프레임은 표본 시간을 많이 차지했다는 근거일 뿐입니다. 정상적으로 가장 많은 일을 하는 메서드일 수도 있습니다. 요청 지연 시간과 시간 범위를 맞추고 입력, 호출 횟수, 락, 할당 근거로 가설을 확인합니다.

연습 문제

CPU, LOCK, MEMORY, IO 증상을 enum으로 받고 각 값을 빠짐없이 처리하는 switch로 첫 진단 동작을 반환하세요.

해설 보기
exercise/DiagnosticFirstActionSolution.java
public final class DiagnosticFirstActionSolution {
    private enum Symptom {
        CPU,
        LOCK,
        MEMORY,
        IO
    }

    static String firstAction(Symptom symptom) {
        return switch (symptom) {
            case CPU -> "JFR execution samples";
            case LOCK -> "three thread dumps";
            case MEMORY -> "GC log and class histogram";
            case IO -> "socket events and downstream latency";
        };
    }

    public static void main(String[] args) {
        for (Symptom symptom : Symptom.values()) {
            System.out.println(symptom + " -> " + firstAction(symptom));
        }
    }
}

종료 기준은 네 가지 증상이 빠짐없이 서로 다른 근거로 연결되는 것입니다. 다음 단계에서는 관찰 결과에 따라 가설을 갱신합니다.