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

안동민 개발노트

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

JIT 준비와 벤치마크

반복 사이 가변 상태 오답을 재현하고 예열, `fork`, 체크섬 소비, 다형적 프로필, JIT 로그를 갖춘 측정 전제를 설계합니다.

HotSpot은 메서드 실행 프로필을 모아 인터프리터 실행에서 컴파일된 코드로 전환하고 더 뜨거워지면 재컴파일할 수 있습니다. 첫 실행에는 클래스 로딩·초기화·JIT 컴파일이 섞이고 이후 실행도 CPU 주파수, GC, OS 스케줄링의 영향을 받습니다. 한 번의 nanoTime() 측정은 숫자를 주지만 신뢰할 수 있는 벤치마크를 자동으로 만들지는 않습니다.

측정 반복 사이 상태를 초기화하지 않는 이유

아래 벤치마크 설정은 매 반복에서 같은 입력을 처리한다고 가정하지만 누산기 필드가 이전 값을 유지합니다. 출력은 wrong-same=false입니다. 속도를 비교하기 전에 결과부터 다르므로 이 벤치마크는 무효입니다.

lab/BenchmarkStateCarryoverBug.java
import java.util.stream.IntStream;

public final class BenchmarkStateCarryoverBug {
    private static final class Fixture {
        private long accumulator;

        long run(int[] values) {
            for (int value : values) {
                accumulator += value;
            }
            return accumulator;
        }
    }

    public static void main(String[] args) {
        int[] values = IntStream.rangeClosed(1, 100).toArray();
        Fixture fixture = new Fixture();
        long first = fixture.run(values);
        long second = fixture.run(values);
        System.out.println("first=" + first + ", second=" + second);
        System.out.println("wrong-same=" + (first == second));
    }
}

원칙은 벤치마크 상태 범위와 설정 수준을 명시하는 것입니다. 연산마다 새 상태가 필요한지, 반복마다 초기화할지, 실제 운영처럼 공유 상태를 유지할지 구분합니다. JMH의 @State, @Setup 같은 생명 주기를 사용하면 의도가 코드에 남습니다.

최소 측정 틀에서도 예열과 측정 분리

다음 예제는 교육용이며 JMH를 대신하지 않습니다. 동일한 입력, 여러 차례의 예열과 측정, 체크섬 동등성, ns/op 계산을 갖춥니다.

src/WarmupMeasurementHarness.java
import java.util.Arrays;
import java.util.stream.IntStream;

public final class WarmupMeasurementHarness {
    private static long transform(int value) {
        long result = value;
        for (int round = 0; round < 30; round++) {
            result = (result * 1_664_525L + 1_013_904_223L) & 0x7fff_ffffL;
        }
        return result;
    }

    private static long operation(int[] values) {
        return Arrays.stream(values).mapToLong(WarmupMeasurementHarness::transform).sum();
    }

    public static void main(String[] args) {
        int[] values = IntStream.range(0, 20_000).toArray();
        for (int warmup = 0; warmup < 5; warmup++) {
            operation(values);
        }

        long expected = operation(values);
        for (int iteration = 1; iteration <= 5; iteration++) {
            long start = System.nanoTime();
            long checksum = operation(values);
            long elapsed = System.nanoTime() - start;
            if (checksum != expected) {
                throw new AssertionError("checksum");
            }
            System.out.println(
                    "iteration=" + iteration + ", ns=" + elapsed + ", checksum=" + checksum);
        }
    }
}

실행 명령과 JIT 로그를 함께 남깁니다.

javac --release 25 WarmupMeasurementHarness.java
java -Xlog:jit+compilation=debug WarmupMeasurementHarness

관찰 로그에서 operationtransform 컴파일 시점을 측정 출력과 비교합니다. JVM 로그 태그는 java -Xlog:help로 확인합니다.

결과 소비와 죽은 코드 제거

JIT는 관찰 가능한 결과에 영향을 주지 않는 계산을 제거할 수 있습니다. 체크섬을 반환·검증하거나 JMH Blackhole을 사용합니다. 단, 결과 소비 자체가 병목이 되지 않게 합니다.

src/ConsumedChecksumProbe.java
public final class ConsumedChecksumProbe {
    static long calculate(int limit) {
        long checksum = 0;
        for (int value = 1; value <= limit; value++) {
            checksum ^= (long) value * value * 31;
        }
        return checksum;
    }

    public static void main(String[] args) {
        long combined = 0;
        for (int iteration = 0; iteration < 10; iteration++) {
            combined ^= calculate(100_000 + iteration);
        }
        if (combined == Long.MIN_VALUE) {
            throw new AssertionError("impossible sentinel");
        }
        System.out.println("combined=" + combined);
    }
}

상수 입력과 결과가 컴파일 시점에 접힐 가능성, 할당 탈출, 분기 예측 가능성도 확인합니다. 벤치마크가 운영보다 지나치게 규칙적인 데이터를 쓰면 다른 머신 코드 프로필이 생깁니다.

호출 타입 프로파일에 따른 인라이닝 변화

한 구현만 보이는 단형 호출 지점과 여러 구현이 섞인 다형 호출 지점은 JIT 최적화가 다를 수 있습니다. 다음 예제는 같은 인터페이스 호출을 두 입력 프로필로 실행하고 체크섬을 고정합니다.

src/PolymorphicCallProfile.java
public final class PolymorphicCallProfile {
    private interface Rule {
        int apply(int value);
    }

    private static final class DoubleRule implements Rule {
        public int apply(int value) {
            return value * 2;
        }
    }

    private static final class AddRule implements Rule {
        public int apply(int value) {
            return value + 3;
        }
    }

    private static long run(Rule[] rules, int rounds) {
        long checksum = 0;
        for (int round = 0; round < rounds; round++) {
            for (Rule rule : rules) {
                checksum += rule.apply(round);
            }
        }
        return checksum;
    }

    public static void main(String[] args) {
        Rule doubleRule = new DoubleRule();
        System.out.println("mono=" + run(new Rule[] {doubleRule}, 20_000));
        System.out.println("mixed=" + run(new Rule[] {doubleRule, new AddRule()}, 20_000));
    }
}

성능 차이를 한 번의 실행으로 결론 내리지 않습니다. JMH의 fork를 분리해 이전 프로필의 영향을 막고 컴파일러 로그와 JFR 프로필에서 인라이닝 결과를 확인합니다.

벤치마크 결과의 전제 조건

  • JDK 공급자·버전, JVM 플래그, 운영체제·CPU·전원 모드를 기록합니다.
  • 예열·측정 반복과 JVM fork 수를 명시합니다.
  • 입력 크기·분포·시드와 예상 결과를 저장합니다.
  • 할당률, GC 일시 중지, 컴파일러 활동을 함께 봅니다.
  • 평균뿐 아니라 분산과 백분위수도 확인합니다.
  • 같은 프로세스에서 벤치마크 순서를 무작위로 바꾸거나 fork를 분리합니다.

JMH를 선택하는 이유

JMH는 예열, 측정, fork, 상태 범위, 결과 소비를 체계적으로 다룹니다. 그래도 잘못된 작업 부하와 운영과 다른 입력은 고쳐 주지 않습니다. 미세 벤치마크 결과를 종단 간 지연 시간과 함께 확인합니다.

충분히 오래 반복하면 직접 사용한 nanoTime도 정확해지나요?

반복 시간만 늘려도 상태 변화, 죽은 코드 제거, 이전 프로필의 영향, GC, 측정 누락 같은 문제는 남습니다. 최소한 별도 fork와 생명 주기를 제공하는 JMH를 사용하고 작업 부하의 타당성을 검토합니다.

연습 문제

배열 합계를 반복 측정하되 각 반복이 독립적인 지역 누산기를 사용하고 예상 체크섬과 같아야 합니다.

해설 보기
exercise/ResetBenchmarkStateSolution.java
import java.util.stream.IntStream;

public final class ResetBenchmarkStateSolution {
    static long sum(int[] values) {
        long total = 0;
        for (int value : values) {
            total += value;
        }
        return total;
    }

    public static void main(String[] args) {
        int[] values = IntStream.rangeClosed(1, 1_000).toArray();
        long expected = 500_500;
        for (int iteration = 1; iteration <= 3; iteration++) {
            long result = sum(values);
            if (result != expected) {
                throw new AssertionError("iteration=" + iteration);
            }
            System.out.println("iteration=" + iteration + ", result=" + result);
        }
    }
}

종료 기준은 세 반복이 모두 500500이고 공유 누산기 필드가 없는 것입니다.