JMH(Java Microbenchmark Harness)
JMH는 Java 언어로 작성된 코드의 성능을 측정하는 도구로, 특히 벤치 마이크로벤치마크를 수행하는 데 사용한다. 마이크로벤치마크는 작은 단위의 코드에 대한 경과 시간, 명령어 처리 속도 등을 측정하는 프로그램을 의미한다. 이는 성능 최적화나 코드 변경에 대한 영향을 정량적으로 측정할 때 유용하게 사용할 수 있다.
환경 설정
신뢰할 수 있는 결과를 얻기 위해서는 Maven을 사용해서 jar 파일로 빌드하고 이를 실행해서 테스트하길 권장하고 있다. IDE에서 실행하는 테스트는 결과의 신뢰성이 떨어진다는게 공식 문서의 입장이다.
샘플 maven project 생성
$ mvn archetype:generate -DinteractiveMode=false
-DarchetypeGroupId=org.openjdk.jmh -DarchetypeArtifactId=jmh-java-benchmark-archetype
-DgroupId=org.sample -DartifactId=test -Dversion=1.0
혹은 maven dependency 추가
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.36</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.36</version>
</dependency>
jar 파일로 테스트시 plugin 추가
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>2.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<finalName>jmh-ex</finalName>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
예제 코드
기본적으로 JMH에서 지원하는 어노테이션 기반으로 테스트 코드를 작성하면 된다.
다음은 HashSet, TreeSet, LinkedHash의 순회 속도를 비교하는 간단한 예제이다.
@State(Scope.Benchmark)
@Fork(1)
@Warmup(iterations = 2)
@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@BenchmarkMode(Mode.AverageTime)
public class BenchmarkSet {
private int size = 1_000_000;
private Set<String> hashSet;
private Set<String> treeSet;
private Set<String> linkedHashSet;
@Setup(Level.Invocation)
public void setup() {
hashSet = new HashSet<>();
treeSet = new TreeSet<>();
linkedHashSet = new LinkedHashSet<>();
for (int i = 0; i < size; i++) {
String str = UUID.randomUUID().toString();
hashSet.add(str);
treeSet.add(str);
linkedHashSet.add(str);
}
}
@TearDown
public void tearDown() {
hashSet = null;
treeSet = null;
linkedHashSet = null;
}
@Benchmark
public String iterateHashSet() {
Iterator<String> iterator = hashSet.iterator();
String res = null;
while (iterator.hasNext())
res = iterator.next();
return res;
}
@Benchmark
public String iterateTreeSet() {
Iterator<String> iterator = treeSet.iterator();
String res = null;
while (iterator.hasNext())
res = iterator.next();
return res;
}
@Benchmark
public String iterateLinkedHashSet() {
Iterator<String> iterator = linkedHashSet.iterator();
String res = null;
while (iterator.hasNext())
res = iterator.next();
return res;
}
}
jar 패키징 후 실행하면
# packaging
mvn clean package
# run
java -jar target/benchmark-ex.jar
각 테스트에 대한 결과를 얻을 수 있다.
Benchmark Mode Cnt Score Error Units
BenchmarkSet.iterateHashSet thrpt 3 3286.341 ± 2083.538 ops/s
BenchmarkSet.iterateLinkedHashSet thrpt 3 6190.420 ± 11425.523 ops/s
BenchmarkSet.iterateTreeSet thrpt 3 3402.930 ± 8328.370 ops/s
어노테이션
@State
벤치마크 메서드에서 사용할 변수를 메서드 외부에서 초기화하고 파라미터로 전달받을 수 있는데, 이때의 파라미터를 "state" 변수라고 한다. 클래스에 @State 어노테이션을 기재했을 때 유효하고 어노테이션의 모드별로 파라미터의 상태를 다르게 가져갈 수 있다.
public class MyBenchmark {
@State(Scope.Thread)
public static class MyState {
public int a = 1;
public int b = 2;
public int sum ;
}
@Benchmark @BenchmarkMode(Mode.Throughput) @OutputTimeUnit(TimeUnit.MINUTES)
public void testMethod(MyState state) {
state.sum = state.a + state.b;
}
}
Thread | Thread 별로 상태 객체 생성 |
Group | Thread 그룹별로 상태 객체 생성 |
Benchmark | 모든 쓰레드가 동일 상태 객체 공유 |
@Setup, @TearDown
상태(@State) 클래스의 메서드에 기재하는 어노테이션으로, @Setup 메서드는 벤치마크가 시작되기 전에 호출되고, @TearDown 메서드는 벤치마크가 종료되고 호출된다.
public class MyBenchmark {
@State(Scope.Thread)
public static class MyState {
@Setup(Level.Trial)
public void doSetup() {
sum = 0;
System.out.println("Do Setup");
}
@TearDown(Level.Trial)
public void doTearDown() {
System.out.println("Do TearDown");
}
```
@Setup과 @TearDown이 호출되는 레벨을 지정할 수 있는데 다음과 같은 옵션이 있다.
Trial | 벤치마크를 실행할 때마다 호출되며 벤치마크의 실행은 fork를 의미 |
Iteration | 벤치마크 메서드의 iteration이 돌 때마다 호출 |
Invocation | 벤치마크 메서드가 호출될 때마다 호출 |
참고로 Setup이나 TearDown 작업은 벤치마크 성능 측정에 포함되지 않는다.
@Fork
벤치마크를 수행할 때 몇 번의 프로세스(= fork)를 생성할지 지정하는 데 사용한다. 여기서 fork는 독립적인 JVM에서 벤치마크를 실행하는 것을 의미한다. 예를 들어, @Fork(3)이라고 선언하면 각 벤치마크는 세 번 별도의 프로세스에서 실행되고 이렇게 실행된 결과를 취합해서 최종 성능을 계산한다. 일관성을 높이기 위해 사용되며 기본 값은 5로 설정되어 있다.
@Warmup
벤치마크를 실행하기 전에 웜업을 몇 번 수행할지 지정하는 데 사용된다. 웜업은 벤치마크 메서드가 실제 측정되기 전에 JVM을 최적화하고, JIT 컴파일러가 코드를 더 효율적으로 변환하도록 하는 작업이다.
@Measurement
벤치마크를 몇 번 반복해서 측정할지 지정하는 데 사용된다. 측정은 실제 벤치마크 측정이 수행되는 단위를 말한다. 예를 들어, 다음과 같이 기재하면 벤치마크 메서드를 3회 반복하고 측정을 1초 동안 수행한다.
@Measurement(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@BenchmarkMode
벤치마크 결과를 어떤 형태로 측정할지 결정하는 어노테이션으로, 다음과 같은 옵션을 제공한다.
Throughput | 1초당 얼마나 많은 작업을 수행하지는 측정 |
AverageTime | 평균 실행 시간 측정 |
SampleTime | 최대, 최소 시간등을 포함한 시간 측정 |
SingleShotTime | 메서드 단일 실행 시간 측정 (JVM 워밍업 없이 cold start에 유용하게 사용) |
All | 모든 모드를 측정한다. |
IDE에서 직접 실행한다면 다음과 같이 main 메서드를 작성하고 실행한다.
@State(Scope.Benchmark)
public class BenchmarkSet {
```
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(BenchmarkSet.class.getSimpleName())
.forks(1)
.warmupIterations(2)
.measurementIterations(2)
.measurementTime(TimeValue.seconds(1))
.timeUnit(TimeUnit.SECONDS)
.mode(Mode.AverageTime)
.build();
new Runner(opt).run();
}
}
Dead Code 제거
Dead Code란 프로그램의 실행 흐름에서 실제 사용되지 않는 코드를 의미한다. 성능 측정 도중에는 이러한 Dead Code가 JIT 컴파일러에 의해 최적화되어 벤치마크 결과가 왜곡될 수 있다. 예를 들어, JVM이 어떤한 계산 결과가 전혀 사용되지 않음을 알면 코드를 제거할 수 있는데 아래 코드가 이에 해당한다.
public class MyBenchmark {
@Benchmark
public void testMethod() {
int a = 1;
int b = 2;
int sum = a + b;
}
}
JVM은 sum에 할당된 값이 전혀 사용되지 않음을 감지하고 데드 코드로 간주해서 int sum = a + b; 코드를 제거한다. 결과적으로 a와 b의 사용처가 사라지면 자연스럽게 이 변수들도 마찬가지로 데드 코드로 간주되어 메서드에는 어떠한 코드도 남아있지 않게 되면서 벤치마크의 결과를 신뢰할 수 없게 된다.
여기엔 두 가지 해결 방안이 있는데
첫째는 계산 결과를 벤치마크 메서드 반환값으로 사용하는 것이고
둘째는 JMH에서 제공하는 Blackhole을 사용하는 것이다.
메서드 반환값으로 사용
다음과 같이 계산된 값을 반환하면 호출하는 쪽에서 값을 사용할 수 있으니 JVM이 코드를 삭제하는 불상사는 없어진다.
만약 데드 코드로 제거될 수 있는 여러 값을 만드는 경우 값을 담는 컨테이너 객체를 만들어서 반환해야 한다.
public class MyBenchmark {
@Benchmark
public int testMethod() {
int a = 1;
int b = 2;
int sum = a + b;
return sum;
}
}
Blackhole
JMH에서 제공하는 Blackhole은 계산 결과를 "소비(consume)" 하는데 사용되는 클래스로 다음과 같이 사용한다.
public class MyBenchmark {
@Benchmark
public void testMethod(Blackhole blackhole) {
int a = 1;
int b = 2;
int sum = a + b;
blackhole.consume(sum);
}
}
Blackhole을 메서드의 매개변수로 받고 blackhole.consume(result)과 같이 결과를 소비하는 형태로 사용한다. 이렇게 하면 벤치마크에서 계산한 결과를 실제로 사용하지 않더라도 JIT 최적화 등을 방지해서 벤치마크 결과가 왜곡되지 않도록 한다.