본문 바로가기
Java/Java

JMH로 warm up 후 테스트하기

by oneny 2023. 7. 31.
Java에서 warm up을 하는 이유에 대해서 살펴보기 위해서는 JVM의 특성에 대해서 알아볼 필요가 있다.

 

JVM

작성된 Java 코드에 대해 1차적으로 중간언어로 컴파일을 해야한다. 주로 Byte Code는 jar이나 war 파일로 아카이브하여 활용하게 될텐데 빌드된 파일을 실행하게 되면 JVM에서는 바이트 코드를 번역하여 기계어로 만들고 이 기계어를 CPU에서 처리하는 절차는 갖는다. 이렇게 빌드된 바이트 코드는 별도의 추가 빌드없이 자바가 실행 가능한 CPU 아키텍처, 즉 여러 OS에서 실행할 수 있는 장점이 있다. 이렇게 자바는 compile과 interpret라는 두 가지 동작에 의해 실행되는 하이브리드 언어이다.

 

그러다보니 컴파일 과정에서 바로 기계어를 만드는 C/C++, Rust, Golang과 같은 컴파일 개발언어에 비해 성능이 뒤쳐진다. 왜냐하면 컴파일 언어들은 런타임 환경에서 준비된 기계어를 즉시 실행 가능하기 때문이다. 컴파일을 통해 기계어를 만들 때 코드 최적화 또한 진행하기 때문에 일반적으로 인터프리터 언어 보다는 더 빠른 성능을 보장할 수 있다. 다만 컴파일을 통해 생성된 기계어는 빌드된 CPU 아키텍처에 종속적이기 때문에 다른 아키텍처 환경에서는 돌아가지 않아 해당 환경에서 빌드를 다시 해야하는 단점이 있다.

 

JIT(Just In Time) Compiler

Java에서는 컴파일 언어보다 느린 성능 차이를 해결하기 위해 JVM에 JIT Compiler를 도입하고 있다. 적시에 기계어를 만들어 낸다는 의미이다. JIT Compiler는 바이트 코드를 Machine Code를 변환하는 과정에서 Machine Code를 캐시에 저장하고 활용한다. 이를 통해 반복되는 기계어 변환 과정을 줄이게 되어 성능을 향상시키고, 런타임 환경에 맞춰 코드를 최적화함으로써 추가적인 성능 향상을 이루게 한다.

 

참고: Oracle에서는 JIT Compiler를 Hotspot이라고 부른다.

 

Warm up

JIT Compiler가 Java 성능 향상에 도움은 되지만 애플리케이션이 시작하는 단계에서는 캐시된 내역들이 없기 때문에 자연스레 성능 이슈가 발생할 수가 있다. 그래서 애플리케이션 시작 후 의도적으로 미리 로직을 실행하여 기계어가 캐시에 저장되고, 최적화될 수 있도록 하는 Warm up 절차가 필요하다고 할 수 있다.

 

그리고 성능을 테스트할 때에도 startTime과 endTime을 측정해서 테스트 결과를 출력해서 비교하는데 로직 성능만을 검사하고 싶지만 warm up, 스레드 갯수 등 고려해야할 사항들이 많다. 이러한 부분을 해결해주기 위한 성능 측정 프레임워크인 벤치마크 프레임워크를 사용해서 로직에 대한 성능을 위해 부수적인 것들을 최대한 줄여보자.

 

JMH(Java Microbenchmark Harness)

JMH는 OpenJDK에서 만든 벤치마크 라이브러리이다. JVM warm-up 기능을 제공하여 편리하게 성능을 측정할 수 있다. 

 

JMH 디렉터리 생성

JMH을 사용하여 성능을 측정할 소스코드는 별도의 JMH 디렉터리를 사용해야 한다.

  1. gradle 기반 프로젝트 생성 시 기본으로 생성되는 main, test와 동일 레벨에 jmh 디렉터리를 생성한다.
  2. jmh 디렉터리 하위에는 java와 resource 디렉터리를 생성해준다.
  3. java 디렉터리에 패키지를 하나 만들고 그 안에 소스코드를 작성한다.
    (java 디렉터리 하위에 패키지를 생성하지 않고 바로 클래스를 넣으면 컴파일 오류가 발생한다.)

 

JMH 관련 애노테이션

  • @Fork: fork 실행 횟수 지정
  • @Warmup: warm up 수행 횟수 지정
  • @Mearsurement, iterations: 측정횟수 지정
  • @Timeout: 테스트 함수의 timeout 지정
  • @Threads: 사용할 스레드의 개수 지정
  • @BenchmarkMode
    • Throughput: 디폴트 값. 초당 작업 수 측정
    • AverageTime: 작업이 수행되는 평균 시간 측정
    • SampleTime: 최대, 최소 시간 등 작업이 수행되는 시간 측정
    • SingleShotTime: 단일 작업 수행 소요 시간 측정
    • All: 모든 옵션 측정
  • @OutputTimeUnit: 측정 단위, 디폴트는 ns
  • @State: argument의 상태 지정. State 애노테이션을 지정한 클래스는 public이어야 하며, no arg 생성자를 가지고 있어야 한다.
    • Scope.Thread: 스레드 별로 인스턴스 생성
    • Scope.Benchmark: 동일한 테스트 내의 모든 스레드에서 동일한 인스턴스 공유, 멀티 스레딩 성능 테스트에 사용
    • Scope.Group: 스레드 그룹마다 인스턴스 생성
  • @Setup/@Teardown: JUnit의 @Before과 @After과 같은 역할을 한다.
    • @Setup은 벤치마크가 시작되기 전 Object를 설정하기 위해 사용
    • @Teardown은 벤치마크가 종료된 후 Object를 정리하기 위해 사용

 

성능 측정 코드 작성

@BenchmarkMode(Mode.AverageTime) // 평균 시간 측정. 결과 파일에 Score로 표시
@OutputTimeUnit(TimeUnit.NANOSECONDS) // 시간 단위 설정
@Fork(2) // 벤치 마크 수행 횟수, 횟수가 늘어날수록 정밀도는 높아지지만 오래걸림
@Warmup(iterations = 5) // warm-up 5번 수행
public class ListBenchmarkTest {

  @Benchmark
  public void measureInArrayList() {
    ArrayList<Integer> arrayList = new ArrayList<>();

    for (int i = 0; i < 1_000_000; i++) {
      arrayList.add(0, i);
    }
  }

  @Benchmark
  public void measureInLinkedList() {
    LinkedList<Integer> linkedList = new LinkedList<>();

    for (int i = 0; i < 1_000_000; i++) {
      linkedList.add(0, i);
    }
  }
}

이전에 ArrayList와 LinkedList 성능 차이에 대해 블로그를 작성할 때 0번 인덱스를 백만번 넣는 작업을 통해 차이를 알아본 적이 있다. 이를 JMH를 사용해서 warm up 이후 ArrayList와 LinkedList 차이를 다시 비교해보고자 위처럼 코드를 작성했다. 

 

./gradlew jmh​

프로젝트 최상위 레벨에 있는 gradlew를 이용해 jmh를 실행할 수 있다.

 

결과

Benchmark result is saved to /프로젝트 경로/build/results/jmh/results.txt

정상적으로 실행되었다면 위처럼 출력 메시지를 볼 수 있고, 해당 경로에 사진처럼 실행 결과를 확인할 수 있다. ArrayList 같은 경우에는 평균적으로 78.58초가 걸렸고, LinkedList 같은 경우에는 평균적으로 0.0039초가 걸렸다.

 

 

 

출처

어디에 담아야 하는지

[JAVA] JMH(Java Microbenchmark Harness)를 사용하여 성능 테스트를 해보자

JMH: Java Benchmark Tool

JVM warm up / if(kakao)2022