개요
테스트 소스는 깃헙에 있습니다.
JMH(Java Microbenchmarking Harness)는 Java 애플리케이션에서 작은 코드 조각, 즉 "마이크로벤치마크"의 성능을 정확하고 신뢰성 있게 측정할 수 있습니다.
예를 들어 문자열을 +로 결합하는 방식과 StringBuilder의 속도 차이 등을 벤치마크 해볼 수 있습니다.
그 외에도 synchronized, Lambda와 반복문. hashCode() & equals(), 배열 처리 방식(병렬, 순차), 기본 타입과 객체 타입 간 변환 성능 차이 등 작은 코드 조각에 대한 성능을 확인해볼 수 있습니다.
요즘에는 컴퓨터 성능이 다 좋아서 웬만하면 신경을 쓰지 않아도 된다고 주장하는 경우도 있습니다.
하지만 클라우드 환경에 대한 성능을 최소한으로 사용해서 비용을 아껴보는 전략이 될 수도 있고 대규모 시스템에서 자주 쓰는 메소드에 대한 최적화(Warmup 대상인 코드를 파악하는 경우 얼마나 빠른지 벤치마크)를 해볼 수도 있습니다.
우선 여기서는 여러가지 예제를 통해 어떠한 테스트를 해볼 수 있는지 성능도 확인해보고 잊고 있던 개념도 다시 복습해보겠습니다.
Gogo~
실행 방법과 원리
jmh gradle 설정을 하고 터미널에서 아래와 같이 입력하면 됩니다.
./gradlew clean jmh
테스트 실행 횟수는 gradle 파일에서 관리합니다.
jmh {
warmupIterations = 2 // (warmup 횟수)
iterations = 3 // (성능 테스트 횟수)
fork = 1 // (JVM 프로세스 개수)
}
우선 JMH가 수행하는 Warmup을 알아야합니다.
JVM은 처음에 서버에 있는 바이트 코드 내에 메소드를 인터파일러로 컴파일하다가 해당 메소드가 자주 호출될 것이라고 판단하면, Hot Spot Code로 인식하고 JIT Compiler로 컴파일을 시작하여 최적화 한 뒤에 네이티브 코드로 변환하게 됩니다.
이 과정을 미리 강제로 하는 것을 Warmup이라고 합니다.
즉, 성능 테스트를 위해서는 Warmup을 미리 처리한 뒤에 실행은 JVM이 네이티브 코드로 한다고 볼 수 있습니다.
하지만 모든 코드를 최적화해서 컴파일 하는 것은 아닙니다.
일반적으로 반복적이고, 최적화 가능한 특정 패턴을 가진 코드만을 대상으로 Warmup을 수행합니다.
그럼 아래 내용에서 하나하나 알아보겠습니다.
성능 테스트
문자열 결합 1(+와 StringBuilder 비교)
@Benchmark
public String stringConcatenationWithPlus() {
return "Hello" + " " + "World";
}
@Benchmark
public String stringConcatenationWithStringBuilder() {
return new StringBuilder()
.append("Hello")
.append(" ")
.append("World")
.toString();
}
순서대로 첫번째 두번째 메소드에 대한 벤치마크 결과입니다.
보통 서버에서는 +로 문자열을 결합하는 것을 지양한다고 알고있는 경우도 있지만 이런 경우는 오히려 +로 결합하는 것이 더 빠른 것을 볼 수 있습니다. +로 문자열을 결합하는 것을 Warmup하고 실행했을 때는 0.7초로 줄어든 것을 확인할 수 있습니다. 즉, 최적화 시에 "Hello" + "World"와 같은 문자열 결합은 자주 사용되는 패턴으로 인식되어 미리 "Hello World"로 계산되어 미리 최적화될 수 있습니다.
반면에 StringBuilder는 새로운 객체를 만들어야하고 속도에도 변화가 없는 것을 볼 수 있는데 StringBuilder 내부에서 동작하는 코드를 컴파일 타임 최적화하기 어렵기 때문에 StringBuilder는 최적화 코드가 아니란 것을 알 수 있습니다.
문자열 결합 2(랜덤으로 String 생성하기)
위에서는 자주 사용되고 반복적인 패턴을 미리 최적화해서 빨라졌다는 것을 알 수 있었습니다.
그렇다면 만약에 값이 변한다면 어떠한 결과가 나올까요?
private static final Random random = new Random();
private String generateRandomString(int length) {
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append((char) ('a' + random.nextInt(26))); // a-z 문자 랜덤
}
return sb.toString();
}
@Benchmark
public String stringConcatenationWithPlus() {
String str1 = generateRandomString(5);
String str2 = generateRandomString(5);
return str1 + " " + str2;
}
@Benchmark
public String stringConcatenationWithStringBuilder() {
String str1 = generateRandomString(5);
String str2 = generateRandomString(5);
return new StringBuilder()
.append(str1)
.append(" ")
.append(str2)
.toString();
}
예상한대로 최적화가 되지 않아서 속도가 오히려 더 느려진 것을 확인할 수 있습니다.
문자열 결합 3(StringBuilder가 더 좋은 경우)
확실히 StringBuilder가 이제 결합이 많이 일어나는 경우에 더 좋다는 것을 알 수 있습니다.
그럼 어느정도 결합이 발생해야 좋은지 결합을 강제로 100번 해서 테스트해보겠습니다.
private static final String str = "a"; // 1자 문자열
@Benchmark
public String stringConcatenationWithPlus() {
String result = "";
for (int i = 0; i < 100; i++) {
result += str; // 문자열 결합을 100번 반복
}
return result;
}
@Benchmark
public String stringConcatenationWithStringBuilder() {
StringBuilder result = new StringBuilder();
for (int i = 0; i < 100; i++) {
result.append(str); // StringBuilder를 사용하여 100번 결합
}
return result.toString();
}
100번만 해도 +의 결과가 굉장히 안좋을 확인할 수 있겠습니다.
ArrayList vs LinkedList
이제 두개의 List의 성능 차이를 비교해보겠습니다.
아래와 같이 기본적인 세팅을 해두고 벤치마크 해보겠습니다.
private static final int SIZE = 100;
private List<Integer> arrayList;
private List<Integer> linkedList;
@Setup(Level.Iteration)
public void setup() {
arrayList = new ArrayList<>(SIZE);
linkedList = new LinkedList<>();
}
@TearDown(Level.Iteration)
public void teardown() {
arrayList.clear();
linkedList.clear();
}
우선 ArrayList에 데이터를 삽입해보겠습니다.
@Benchmark
public void insertAtBeginningArrayList() {
for (int i = 0; i < 100; i++) {
arrayList.add(0, i);
}
}
@Benchmark
public void insertAtEndArrayList() {
for (int i = 0; i < 100; i++) {
arrayList.add(i);
}
}
첫번째 방식은 0번째 인덱스에 100회에 걸쳐서 요소를 삽입합니다.
이런 경우에 기존 0번째 들어있던 요소들을 모두 뒤로 밀어야 하기 때문에 성능이 나쁘게 나옵니다.
하지만 두번째는 단순히 배열의 끝에 요소를 추가하는 작업은 일반적으로 O(1) 시간 복잡도를 가집니다
@Benchmark
public void insertAtBeginningLinkedList() {
for (int i = 0; i < 100; i++) {
linkedList.add(0, i);
}
}
@Benchmark
public void insertAtEndLinkedList() {
for (int i = 0; i < SIZE; i++) {
linkedList.add(i);
}
}
이제 LinkedList에 삽입 해보겠습니다.
LinkedList는 이중 연결 리스트로 구현되어 있습니다.
리스트의 앞에 요소를 추가할 때는, 단순히 새로운 노드를 생성하고, 기존 첫 번째 노드의 포인터를 새로운 노드로 연결하면 됩니다. 이 작업은 O(1) 시간에 처리됩니다.
하지만 새 노드 생성과 링크 연결이 필요하기 때문에 ArrayList보다는 속도가 약간 늦게 나오는 것을 확인할 수 있습니다.
@Benchmark
public int randomAccessArrayList() {
// 리스트 채우기
for (int i = 0; i < SIZE; i++) {
arrayList.add(i);
}
// 1000번의 랜덤 접근
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += arrayList.get(i * (SIZE / 10));
}
return sum;
}
@Benchmark
public int randomAccessLinkedList() {
// 리스트 채우기
for (int i = 0; i < SIZE; i++) {
linkedList.add(i);
}
// 1000번의 랜덤 접근
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += linkedList.get(i * (SIZE / 10));
}
return sum;
}
ArrayList는 인덱스를 사용해 O(1) 시간 복잡도로 접근합니다.
LinkedList는 노드를 순회하며 접근하므로 O(n) 시간이 소요되므로 속도가 좀 더 느린 것을 확인할 수 있습니다.
결론을 요약하자면 아래와 같습니다.
- 데이터를 자주 읽고 끝에만 삽입한다면 ArrayList가 유리
- 데이터를 중간이나 앞에 자주 삽입/삭제한다면 LinkedList가 유리
- 데이터를 인덱스로 자주 검색한다면 ArrayList가 압도적으로 유리
테스트 데이터는 신빙성이 떨어질 수 있으므로(Warmup의 결과 등) 필요한 경우 직접해보면서 벤치마크를 해보는 것이 좋을 듯 합니다.
'☕ Java' 카테고리의 다른 글
[Spring] DTO는 어디서 변환할까? (feat. DDD) (0) | 2024.09.19 |
---|---|
[Java] Apache Commons Library에서의 올바른 압축 해제 방법 (0) | 2024.03.15 |
[Kotlin] Companion Object (0) | 2024.02.02 |
Java Stream 사용법 ( 당신의 Stream은 안녕하십니까? ) (0) | 2023.08.26 |
커스텀 어노테이션과 리플렉션 (0) | 2023.07.30 |