티스토리 뷰
이 글에서는 Java Enum의 valueOf()보다 switch ~ case의 사용이 빠른 예시와,
Enum의 switch ~ case가 바이트코드 레벨에서 어떻게 최적화되는지를 기술하였다.
Spring MVC의 코드를 살펴보던 도중 RequestMethod라는 Enum에서 HTTP 요청 메서드 문자열을 Enum으로 변환할 때 java.lan.Enum.valueOf()를 사용하지 않고 resolve()라는 메서드를 별도로 만들어 사용하는 것을 보았다.
// Spring 6.0.6+
public enum RequestMethod {
GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS, TRACE;
@Nullable
public static RequestMethod resolve(String method) {
Assert.notNull(method, "Method must not be null");
return switch (method) {
case "GET" -> GET;
case "HEAD" -> HEAD;
case "POST" -> POST;
case "PUT" -> PUT;
case "PATCH" -> PATCH;
case "DELETE" -> DELETE;
case "OPTIONS" -> OPTIONS;
case "TRACE" -> TRACE;
default -> null;
};
}
. . .
}
처음에는 '왜 굳이 valueOf()를 쓰지 않을까' 생각이 들었는데
'valueOf()가 성능적으로 switch ~ case보다 느려 사용하는 것은 아닐까?' 하는 생각이 들어 한 번 확인해 보기로 했다.
정말 간단하게 아래와 같이 1000만 번 수행했을 때의 차이를 확인해보았다.
public static void main(String[] args) {
long start1 = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
RequestMethod.resolve("GET");
}
System.out.println(System.currentTimeMillis() - start1);
// 5~7ms
long start2 = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
RequestMethod.valueOf("GET");
}
System.out.println(System.currentTimeMillis() - start2);
// 22~26ms
. . .
System.out.println(System.currentTimeMillis() - start4);
long start5 = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
RequestMethod.valueOf("TRACE");
}
System.out.println(System.currentTimeMillis() - start5);
// 22~26ms
long start6 = System.currentTimeMillis();
for (int i = 0; i < 10_000_000; i++) {
RequestMethod.resolve("TRACE");
}
System.out.println(System.currentTimeMillis() - start6);
// 9~11ms
}
1. 수행 순서와 무관하도록 여러 번 간단히 실행해 본 결과 맨 앞의 상수인 'GET'을 탐색할 때
switch ~ case를 사용하는 경우 valueOf()를 사용하는 경우보다 3~5배 빨랐으며
2. 맨 뒤의 상수인 'TRACE'를 탐색할 때
switch ~ case를 사용하는 경우 valueOf()를 사용하는 경우보다 2배 이상 빨랐다.
아마도 순차 탐색이므로 뒤쪽의 상수를 switch ~ case로 탐색하는 데 걸리는 시간이 더 걸리고 (-> 찾아보니 순차 탐색도 아니었다)
Enum에 상수 자체가 많지 않아 시간이 적게 소요되긴 하나 그래도 2배~5배 정도의 성능 차이가 나는 것에는 조금 놀랐다.
JDK에 따라 구현이 조금씩 다를 순 있지만 열거형은 내부적으로 Map으로 상수들을 가지고 있다가 valueOf() 호출 시 get()을 하면서 해시 함수를 한 번 사용하는데 이렇게 해싱을 사용하며 여러 메서드를 참조하는 과정에 생각보다 시간 소요가 꽤 있는 듯하다.
왜 하필 switch ~ case 일까?
java가 오래되어 이미 기본 연산에 대해선 최적화가 굉장히 많이 되어있지만...
깊게 알아본 적은 없어서 이 참에 굳이 왜 if ~ else도 아닌 switch ~ case일까 찾아보았다.
좋은 설명을 위에서 찾을 수 있었는데 JVM이 특별하게 switch ~ case문을 더 최적화하여 처리한다고 한다.
동일한 형태의 RequestMethod 클래스를 만들고 바이트코드를 살펴봤다.
컴파일된 .class파일을 아래와 같은 javap 명령어로 디컴파일하여 바이트코드를 확인할 수 있다.
javap -v -p -s RequestMethod.class
resolve()
public static web.org.springframework.web.bind.annotation.RequestMethod resolve(java.lang.String);
. . .
196: tableswitch { // 0 to 7
0: 244
1: 248
2: 252
3: 256
4: 260
5: 264
6: 268
7: 272
default: 276
}
244: getstatic #3 // Field GET:Lweb/org/springframework/web/bind/annotation/RequestMethod;
247: areturn
248: getstatic #7 // Field HEAD:Lweb/org/springframework/web/bind/annotation/RequestMethod;
251: areturn
252: getstatic #10 // Field POST:Lweb/org/springframework/web/bind/annotation/RequestMethod;
255: areturn
256: getstatic #13 // Field PUT:Lweb/org/springframework/web/bind/annotation/RequestMethod;
259: areturn
260: getstatic #16 // Field PATCH:Lweb/org/springframework/web/bind/annotation/RequestMethod;
263: areturn
264: getstatic #19 // Field DELETE:Lweb/org/springframework/web/bind/annotation/RequestMethod;
267: areturn
268: getstatic #22 // Field OPTIONS:Lweb/org/springframework/web/bind/annotation/RequestMethod;
271: areturn
272: getstatic #25 // Field TRACE:Lweb/org/springframework/web/bind/annotation/RequestMethod;
. . .
컴파일 이후 switch문이 해시 인덱스와 같은 형태의 table switch가 만들어진 것을 볼 수 있다.
table switch & lookup switch
table switch
https://docs.oracle.com/javase/specs/jvms/se11/html/jvms-3.html#jvms-3.10
table switch 혹은 lookup switch instruction은 int형 데이터에 대한 switch 연산에서 나타나는데,
공식 문서에 따르면 byte, char, short 또한 내부적으로는 int형 데이터로 다뤄지므로 두 instruction을 사용한다고 한다.
열거형에 대해서는 첫 상수부터 Order에 따라 번호가 부여되고 이것이 int형으로 다뤄져 table switch를 사용하게 되는 듯하다.
Oracle 공식 문서에 따르면 table switch의 경우 다음의 코드가
int chooseNear(int i) {
switch (i) {
case 0: return 0;
case 1: return 1;
case 2: return 2;
default: return -1;
}
}
아래와 같은 바이트코드로 바뀌는데,
Method int chooseNear(int)
0 iload_1 // Push local variable 1 (argument i)
1 tableswitch 0 to 2: // Valid indices are 0 through 2
0: 28 // If i is 0, continue at 28
1: 30 // If i is 1, continue at 30
2: 32 // If i is 2, continue at 32
default:34 // Otherwise, continue at 34
28 iconst_0 // i was 0; push int constant 0...
29 ireturn // ...and return it
30 iconst_1 // i was 1; push int constant 1...
31 ireturn // ...and return it
32 iconst_2 // i was 2; push int constant 2...
33 ireturn // ...and return it
34 iconst_m1 // otherwise push int constant -1...
35 ireturn // ...and return it
가장 작은 int의 case부터 큰 case까지 1씩 차이나는 점프 테이블을 만들고 (여기서는 0~2)
이것을 인덱스로 활용하여 점프 테이블의 특정 값을 바로 찾아간다
만약 argument가 1이라면 점프 테이블을 사용해 1:30을 찾고, 바로 다음 instruction인 30부터 순차적으로 실행한다.
lookup switch
enum을 사용한 switch ~ case에서는 발생하지 않을 듯한데
만약 아래와 같이 case문의 int값들이 다양하다면 1씩 차이나는 점프 테이블(-100~100)을 만들기엔 비효율적이므로
int chooseFar(int i) {
switch (i) {
case -100: return -1;
case 0: return 0;
case 100: return 1;
default: return -1;
}
}
컴파일러가 알아서 판단하여 대신 lookup switch라는 것을 만들게 되고,
Method int chooseFar(int)
0 iload_1
1 lookupswitch 3:
-100: 36
0: 38
100: 40
default: 42
36 iconst_m1
37 ireturn
38 iconst_0
39 ireturn
40 iconst_1
41 ireturn
42 iconst_m1
43 ireturn
tableswitch처럼 값이 1씩 차이 나는 것이 아니어서 뛰어넘어 바로 case문에 입력된 값을 통해 특정할 수 없으므로
case문의 세 int값인 -100, 0, 100를 선형 탐색보다는 나은 방식(이분탐색..)으로 찾아
case문에 입력된 값으로 바로 lookup 하게 된다.
간단히는 만약 case 100을 찾는다면 -100, 0, 100을 이분 탐색으로 처음엔 0을, 두 번째로는 100을 찾겠다.
혹시나 case의 순서를 섞으면 tableswitch가 아닌 lookupswitch가 나올까 했는데 값이 적어서 그런지 없는 case 4는 끼워 맞추고 정렬까지 해서 tableswitch를 알아서 만들어주었다.
컴파일이 조금 더 걸려도 런타임의 성능을 보장한다.
Enum의 valueOf()에서 시작했는데...
무튼 switch ~ case는 컴파일 시점에 O(1), 늦으면 O(log n)의 시간 복잡도로 값을 찾을 수 있도록 변형되고,
Enum의 경우에는 상수의 순서를 활용해 O(1)로 탐색이 보장된다는 것을 확인하였다.
역시 오래 인정받은 프레임워크는 볼 때마다 그 견고함에 놀라게 된다.
'Java' 카테고리의 다른 글
[Java] Lambda는 JVM에서 어떻게 다뤄지는가? (invokedynamic) (0) | 2023.11.10 |
---|---|
HikariCP 설정 바꾸기 (2) | 2023.05.21 |
왜 Mockito를 통해 테스트를 해야 할까? (1) | 2023.05.14 |
[Java] Stack보다는 ArrayDeque를 쓰자. Stack과 Vector의 문제점 (0) | 2023.04.02 |
[Java] 왜 try-with-resources를 사용할까? (0) | 2023.03.27 |
- Total
- Today
- Yesterday
- 스프링
- invokedynamic
- JPA
- Java
- GitHub Discussion
- 의존성 주입
- 생성자 주입
- Payload 암호화
- java switch case
- stubbing
- JPA JSON
- 자바
- Spring
- RandomPort
- 우테코 프리코스
- Fromtail
- 우테코 5기
- Spring Boot Monitoring
- Jenkins 예약 배포
- multiplebagsfetchexception
- MySQL 이벤트 스케줄
- MySQL
- 함수형 인터페이스
- logback-spring.xml
- Spring 테스트
- 우테코
- 람다식
- GitHub Discussion Template
- springboottest
- GitHub Discussion 템플릿
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | ||||||
2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 | 25 | 26 | 27 | 28 |