들어가며

        int[] arr = {1,2,3,4,5};
        int sum = 0;
        
        // for문
        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }

        // 향상된 for문
        for (int i : arr) {
            sum += i;
        }

 

향상된 for문은 JAVA 5에서 추가되었습니다. 이전 for문 보다 가독성이 좋아졌고, 더욱 간결해졌습니다.

이번 글에서는 for문과 향상된 for문에 어떤 차이가 있는지 알아보도록 하겠습니다.

 

 

 

for문

        for (int i = 0; i < arr.length; i++) {
            sum += arr[i];
        }

이 부분에 대해서 bytecode를 확인해보겠습니다.

 

L3
FRAME APPEND [[I I I]
    ILOAD 3        // i 값 로드
    ALOAD 1        // 배열 arr 로드
    ARRAYLENGTH    // 배열 길이를 가져옴
    IF_ICMPGE L4   // i가 배열 길이와 같거나 크면 L4로 점프
L5
    ILOAD 2        // sum 값 로드
    ALOAD 1        // 배열 arr 로드
    ILOAD 3        // i 값 로드
    IALOAD        // 배열 arr[i] 값 로드
    IADD          // sum + arr[i] 계산
    ISTORE 2      // 결과를 sum에 저장
L6
    IINC 3 1      // i++
    GOTO L3       // 다시 조건 검사로 이동

 

바이트코드를 해석해본다면 인덱스 i를 매번 증가시키고, 배열의 해당 인덱스에 접근해서 값을 더하는 방식입니다.

중요한건 인덱스를 통해 값을 가져오는 방식이라는 것입니다. 인덱스 개념이 없는 HashMap 과 같은 데이터에서는 사용할 수 없습니다. 배열구조를 가진 데이터에서 데이터를 로드할 수 있습니다.

 

 

 

향상된 for문

 

 

L3
FRAME FULL [[Ljava/lang/String; [I I [I I I] []
    ILOAD 5        // 인덱스 로드
    ILOAD 4        // 배열 길이 로드
    IF_ICMPGE L4   // 인덱스가 배열 길이와 같거나 크면 L4로 점프
    ALOAD 3        // 배열 arr 로드
    ILOAD 5        // 인덱스 로드
    IALOAD        // 배열 arr[인덱스] 값 로드
    ISTORE 6       // 로컬 변수 6에 저장 (배열 요소)
L5
    ILOAD 2        // sum 값 로드
    ILOAD 6        // 로컬 변수 6(배열 요소) 로드
    IADD          // sum + 배열 요소 계산
    ISTORE 2      // 결과를 sum에 저장
L6
    IINC 5 1      // 인덱스 증가
    GOTO L3       // 다시 조건 검사로 이동

 

여기에서 눈 여겨볼 부분은 로컬 변수 6입니다. 일반 for문은 인덱스를 통해 데이터를 바로 sum에 저장하는 방식이었습니다.

하지만 향상된 for문은 로컬변수6에 arr[인덱스] 를 저장하고 로컬변수6을 sum에 저장하는 방식입니다.

 

왜 이런 번거로운 과정이 추가된걸까요?

 

 

Iterator 안전성

향상된 for문이 좋은점은 배열 뿐만이 아니고 Iterator 에서 동작할 수 있다는 것입니다. Iterable 은 Collection 인터페이스에서 상속 받고 있기때문에 컬렉션(List, Set, Map 등) 에서도 향상된 for문을 동작할 수 있다는 것이죠. 이를 위해 Iterator나 내부적인 인덱스 처리로 컬렉션을 순회하는데, 컬렉션의 상태가 중간에 변경될 가능성을 방지하는 안전한 구조가 필요하기 때문입니다.

 

        List<Integer> arr = List.of(1,2,3,4,5);
        int sum = 0;
        
        // 이 코드는
        for (int i : arr) {
            sum += i;
        }
        
        // Iterator 와 동일하다
        Iterator<Integer> iterator = arr.iterator();
        while (iterator.hasNext()) {
            sum += iterator.next();
        }

 

List의 향상된 for문

   L3
   FRAME APPEND [java/util/List I java/util/Iterator]
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
    IFEQ L4
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    ISTORE 4
   L5
    LINENUMBER 15 L5
    ILOAD 2
    ILOAD 4
    IADD
    ISTORE 2
   L6
    LINENUMBER 16 L6
    GOTO L3

 

 

List의 Iterator

   L3
    LINENUMBER 13 L3
   FRAME APPEND [java/util/List I java/util/Iterator]
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.hasNext ()Z (itf)
    IFEQ L4
   L5
    LINENUMBER 14 L5
    ILOAD 2
    ALOAD 3
    INVOKEINTERFACE java/util/Iterator.next ()Ljava/lang/Object; (itf)
    CHECKCAST java/lang/Integer
    INVOKEVIRTUAL java/lang/Integer.intValue ()I
    IADD
    ISTORE 2
    GOTO L3

 

향상된 for문은 컴파일 시 iterator를 사용합니다.

 

 

재사용과 성능최적화

        for (int i : arr) {
            System.out.println(i);
            sum += i;
        }

for 문 안에서 i의 값을 여러번 사용된다면 어떻게 될까요?

 

   L3
   FRAME FULL [[Ljava/lang/String; [I I [I I I] []
    ILOAD 5
    ILOAD 4
    IF_ICMPGE L4
    ALOAD 3
    ILOAD 5
    IALOAD
    ISTORE 6
   L5
    LINENUMBER 11 L5
    GETSTATIC java/lang/System.out : Ljava/io/PrintStream;
    ILOAD 6
    INVOKEVIRTUAL java/io/PrintStream.println (I)V
   L6
    LINENUMBER 12 L6
    ILOAD 2
    ILOAD 6
    IADD
    ISTORE 2
   L7
    LINENUMBER 10 L7
    IINC 5 1
    GOTO L3

 

L3에서 ISTORE 6 에 i 값을 저장하고

L5와 L6에서 ILOAD 6 을 통해 로컬변수 6을 로드해 재사용하고 있는 것을 확인할 수 있었습니다. 로컬변수6 데이터를 저장하므로써 단 한번 배열에 접근하는 것으로 다음의 데이터를 모두 처리할 수 있게되었습니다.

 

 

 

읽기전용

향상된 for문에서 배열이나 컬렉션의 요소를 순회할 때, 해당 요소는 로컬변수에 저장되기때문에 읽기 전용상태로 처리됩니다. 즉 복사본을 사용하기때문에 안전한 처리가 가능해집니다.

 

        int[] arr = {1,2,3,4,5};

        for (int i : arr) {
            i = 100;
        }

 

i 는 로컬변수에서 로드된 데이터이기 때문에 i에 직접 할당할 수 없습니다.

다만 참조타입인 경우 얕은복사로 인해 내부데이터의 변경은 가능합니다.

 

    public class Item {

        public int value;

        public Item(int value) {
            this.value = value;
        }
    }

    public static void main(String[] args) {
        List<Item> items = List.of(new Item(1), new Item(2));

        for (Item item : items) {
            item.value = 100;
        }

        for (Item item : items) {
            System.out.println(item.value); // 100
        }
    }

 

 

일관성

향상된 for문은 모든 배열과 Iterable 객체에서 동작하게 설계되었습니다.따라서 배열이 아닌 다른 컬렉션 객체를 다룰 때도 일관성 있게 순회하기 위해, 배열의 요소를 먼저 임시 변수에 저장하고 그 후에 작업을 수행하는 것이 통일된 방식이라고 할 수 있겠습니다.

 

 

 

 

마무리

향상된 for문은 시작, 끝 인덱스를 지정할 수 없습니다. 무조건 전체 순회만 가능하다는 것이죠.

그리고 순회 중에는 데이터를 삭제가 불가능합니다. 시작할 때 전체 길이를 저장하는데 중간에 전체길이가 달라지면 예외가 발생합니다.

 

일반 for문, 향상된 for문간에는 성능차이가 있을까요? 우리는 위에 bytecode를 직접 확인해봤습니다. 향상된 for문에서 추가적으로 로컬변수를 사용하는 차이가 있지만 이는 무시할 정도이고, 로컬변수 도입으로 인한 재사용성으로 인한 최적화도 무시할 정도라고 생각이 됩니다. 따라서 성능차이는 없다고 생각합니다.

 

 

결론은 향상된 for문은 Iterable 객체를 손쉽게 쉽게 사용하기 위해 만들어졌다! 그리고 컴파일 시에 로컬변수를 사용된다!

개요

if-else 와 switch문은 성능 상 약간의 차이가 있습니다. 오늘은 그 구조를 이해해보고 언제 사용해야 좋을지 바이트코드를 알아보도록 하겠습니다.

 

 

if

int i = 1;

if (i == 1) {
    method1();
} else if (i == 3) {
    method2();
} else {
    method3();
}

 

int i의 값이 1일때 method1()을 실행하고, i의 값이 3일때 method2()를 실행하고, 나머지는 method3()을 실행하는 아주 간단한 구조를 가진 if 문입니다. 이 코드를 bytecode로 변환해보겠습니다.

public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    ICONST_1
    IF_ICMPNE L2
   L3
    LINENUMBER 10 L3
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L4
   L2
    LINENUMBER 11 L2
   FRAME APPEND [I]
    ILOAD 1
    ICONST_3
    IF_ICMPNE L5
   L6
    LINENUMBER 12 L6
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L4
   L5
    LINENUMBER 14 L5
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L4
    LINENUMBER 17 L4
   FRAME SAME
    RETURN
   L7
    LOCALVARIABLE args [Ljava/lang/String; L0 L7 0
    LOCALVARIABLE i I L1 L7 1
    MAXSTACK = 2
    MAXLOCALS = 2

 

구분을 좀 나누면 이렇게 됩니다.

구조를 잘 보시면

  1. L1에서 조건이 일치하면 L3으로 이동하여 method1을 실행하고, 일치하지 않으면 IF_ICMPNE L2 로 이동합니다.
  2. L2에서 조건이 일치하면 L6으로 이동하여 method2를 실행하고, 일치하지 않으면 IF_ICMPNE L5 로 이동합니다.
  3. else 에서 method3을 실행합니다.

if 문은 결과가 true 가 나오기전까지 순서대로 비교연산을 수행해야합니다. 만약 비교문이 10개라면 최대 10번 비교연산이 수행될 수 있단는 것입니다.

 

 

 

switch

 

int i = 1;

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}
  public static main([Ljava/lang/String;)V
   L0
    LINENUMBER 7 L0
    ICONST_1
    ISTORE 1
   L1
    LINENUMBER 9 L1
    ILOAD 1
    LOOKUPSWITCH
      1: L2
      3: L3
      default: L4
   L2
    LINENUMBER 10 L2
   FRAME APPEND [I]
    INVOKESTATIC functional/Main.method1 ()V
    GOTO L5
   L3
    LINENUMBER 11 L3
   FRAME SAME
    INVOKESTATIC functional/Main.method2 ()V
    GOTO L5
   L4
    LINENUMBER 12 L4
   FRAME SAME
    INVOKESTATIC functional/Main.method3 ()V
   L5
    LINENUMBER 15 L5
   FRAME SAME
    RETURN
   L6
    LOCALVARIABLE args [Ljava/lang/String; L0 L6 0
    LOCALVARIABLE i I L1 L6 1
    MAXSTACK = 1
    MAXLOCALS = 2

 

 

switch 문을 바이트코드로 변환했을 때, 이 부분에서 switch 문이 발생된걸 확인할 수 있습니다. 위에 if-else 문과 구조가 다른데요 눈여겨 볼 것은 LOOKUPSWITCH 입니다.

LOOKUPSWITCH 는 조건마다 비교연산을 하는게 아니고 표현식 값을 가지고 탐색합니다.

if-else 보다 비교연산 없이 탐색하기 때문에 효율적이지만, jump table 생성 비용의 오버헤드가 있습니다.

 

LOOPUPSWITCH, TABLESWITCH 에 대해서  조금 더 Alaboza

더보기

TABLESWITCH

int i = 1;

int j = switch (i) {
    case 0 ->  0;
    case 1 ->  1;
    case 2 ->  2;
    case 5 ->  5;
    case 10 ->  10;
    default -> -1;
};

 이 switch문을 바이트 코드로 바꾸면 어떻게 될까?

 

   L1
    LINENUMBER 9 L1
    ILOAD 1
    TABLESWITCH
      0: L2
      1: L3
      2: L4
      3: L5
      4: L5
      5: L6
      6: L5
      7: L5
      8: L5
      9: L5
      10: L7
      default: L5

우리는 0, 1, 2, 5, 10 에대한 case를 작성했는데 바이트 코드를 보면 TABLESWITCH 안에 0 - 10까지의 데이터가 들어가있습니다. 0, 1, 2, 5, 10을 제외한 나머지 숫자들은 switch 종료 라인인 L5 를 가리키고 있습니다.

 

 TABLESWITCH는 전체 범위를 인덱싱 하기 때문인데요. 이 과정에서 사용되지 않은 빈 공간까지 차지하게 되어버렸습니다.

 

LOOKUPSWITCH와 TABLESWITCH는 컴파일과정에서 효율성을 따져서 자동으로 이 방식이 선택됩니다.

만약 인덱싱과정이 사용된다면 더더욱 switch문을 써야겠죠?

 

 

그래서 뭐가 더 좋을까?

코드를 작성할 때, 성능도 중요하지만 가독성에 대한 것도 고려해야합니다. JAVA 17부터 switch 문이 업데이트되면서 기존의 if-else 보다 가독성이 좋아졌습니다.

 

// switch 문
switch (i) {
    case 1:
        method1();
        break;
    case 3:
        method2();
        break;
    default:
        method3();
        break;
}

// JAVA 17 에서의 switch 문

switch (i) {
    case 1 -> method1();
    case 3 -> method2();
    default -> method3();
}

switch (i) { // 다수 조건식
    case 1,2 -> method1();
    case 3 -> method2();
    default -> method3();
}


// JAVA 21 에서의 switch 문 
switch (animal) { // 타입 캐스팅
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    default -> "Unknown";
};

switch (animal) { // Null 처리
    case Cat c -> c.sound();
    case Dog d -> d.sound();
    case Cow c -> c.sound();
    case null -> "Null";
    default -> "Unknown";
};

 

 

따라서, 가독성에 중점을 두거나 조건이 많을때는 switch 문을 사용하는것도 고려해볼만 합니다.

+ Recent posts