티스토리 뷰

반응형

이 글에서는 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배 정도의 성능 차이가 나는 것에는 조금 놀랐다.

 

 

java.lang.Enum의 valueOf()

JDK에 따라 구현이 조금씩 다를 순 있지만 열거형은 내부적으로 Map으로 상수들을 가지고 있다가 valueOf() 호출 시 get()을 하면서 해시 함수를 한 번 사용하는데 이렇게 해싱을 사용하며 여러 메서드를 참조하는 과정에 생각보다 시간 소요가 꽤 있는 듯하다.

 

왜 하필 switch ~ case 일까?

 

java가 오래되어 이미 기본 연산에 대해선 최적화가 굉장히 많이 되어있지만...

깊게 알아본 적은 없어서 이 참에 굳이 왜 if ~ else도 아닌 switch ~ case일까 찾아보았다.

 

https://stackoverflow.com/questions/2086529/what-is-the-relative-performance-difference-of-if-else-versus-switch-statement-i

 

What is the relative performance difference of if/else versus switch statement in Java?

Worrying about my web application's performances, I am wondering which of "if/else" or switch statement is better regarding performance?

stackoverflow.com

 

좋은 설명을 위에서 찾을 수 있었는데 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

 

Chapter 3. Compiling for the Java Virtual Machine

The Java Virtual Machine machine is designed to support the Java programming language. Oracle's JDK software contains a compiler from source code written in the Java programming language to the instruction set of the Java Virtual Machine, and a run-time sy

docs.oracle.com

 

 

 

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)로 탐색이 보장된다는 것을 확인하였다.

 

역시 오래 인정받은 프레임워크는 볼 때마다 그 견고함에 놀라게 된다.

반응형