들어가며
이전 포스팅에서는 컴파일된 증감연산자(i++, ++i)의 정적인 바이트코드를 분석했다. 해당 실험에서는 load, store 등의 명령어를 피연산자 스택을 통해 어떻게 연산이 이뤄지는지, iinc 명령어의 최적화 방식을 이해할 수 있었다.
이번에는 단순한 연산을 벗어나, 프로그램의 실행 순서를 바꾸고 제어하는 제어 흐름(Control Flow)을 다뤄보려고 한다. 코드의 흐름을 나누는 분기문(if-else), 특정 구간을 반복하는 반복문(for-loop, while), 조건에 따라 특정 점프하는 스위치문(switch)에 해당된다.
각각 바이트코드 레벨에서 어떻게 흐름을 제어하는지 실험을 통해 살펴본다.
실험
java 코드를 작성하고 javac ControlFlowTest.java 와 javap -c -p -v ControlFlowTest > result.txt 명령어를 이용해서 컴파일과 역어셈블을 하였다. 3개의 섹션을 나눠서 각각 분기문, 반복문, 스위치문을 실험 및 분석하였다.
1. 분기문 (if-else)
// [실험 1] If-Else: 분기와 점프
public int ifElse(int a, int b) {
if (a < b) {
return a + b;
} else {
return a - b;
}
}
public int ifElse(int, int);
Code:
0: iload_1 // a 로딩
1: iload_2 // b 로딩
2: if_icmpge 9 // "a >= b"라면 9번 라인(else 블록)으로 점프! (조건 역전)
// --- if 블록 (a < b) ---
5: iload_1
6: iload_2
7: iadd
8: ireturn // 메서드 종료 (else 블록 실행 안 함)
// --- else 블록 ---
9: iload_1
10: iload_2
11: isub
12: ireturn
오프셋 2, 11에서 if_icmpge, isub 명령어를 확인할 수 있다. 이 명령어들은 이전 포스팅에서 다루지 않아서 관련 명령어들을 모아서 정리를 해보았다. isub는 빼기(-) 명령어임을 추측할 수 있고 if_icmpge는 다음과 같이 이해해 볼 수 있다:
- if : 만약 ~라면 (분기)
- i : integer (정수형 데이터)
- cmp : compare (비교)
- ge : greater than or equal to (≥)
유사한 규칙으로 다음 표에 정리된 명령어들도 참고해 보면 좋을 것 같다:
| 명령어 | 의미 |
| if_icmpeq | equal |
| if_icmpne | not equal |
| if_icmplt | less than |
| if_icmpgt | greater than |
| if_icmple | less than or equal |
자바 코드와 달라진 바이트 코드 (조건 역전)
자바 코드에서는 if(a < b) 문이 첫 번째로 왔지만, 바이트 코드에서는 if (a >= b)이 첫 번째로 왔음을 확인할 수 있다. 피연산자 스택과 상호작용하는 과정을 아래 그림으로 나타냈다:

a와 b가 순서대로 피연산자 스택에 담기고 last in first out으로 b, a 순서대로 pop 된다. 그리고 먼저 나온 b 피연산자는 오른쪽에 위치하고, 나중에 나온 a 피연산자는 왼쪽으로 온다. 결과적으로 만약 a가 b보다 크거나 같으면 (a >= b) 9번 라인으로 점프해라!라고 명령하는 것이다.
하지만 이 바이트코드의 조건 순서는 자바코드와 반대로 되어있다.
Fall-through와 Jump의 비용차이


왜 이런 결과를 낳았는지는 CPU라는 물리적 요소(하드웨어)와 주변 요소의 상호 동작 방식을 이해할 필요가 있다. 먼저, 전제가 있다. 사용자가 작성한 자바 코드에서 if가 true인 경로가 happy path 즉, 통계적으로 자주 발생할 수 있는 경로라고 간주한다. 위 코드에서 if 문을 만났을 때 (a < b)의 조건을 만족할 가능성이 (a >= b) 보다 더 높다고 간주하는 것이다.
여기서 이해하면 도움되는 두 가지 용어가 있다. Fall-through는 조건문의 true로 자연스럽게 위에서 아래로 코드 흐름이 흘러가는 것이다. 반면에 Jump는 goto문처럼 특정 코드라인으로 이동해야 함을 나타낸다. 명령어를 cpu에서 실행하기 위해서는 L1/L2캐시라는 작업대에 올려둘 필요가 있다. 다만 이 작업대는 용량이 작기 때문에 RAM이라는 책장에 명령어가 담긴 책들을 꽂아두고, 필요에 따라 책들을 가져와서 실행하는 방식이다.
일반적으로 하나의 청크 단위는 64바이트로 실리콘 맥은 128바이트이다. 이는 캐시와 메모리 사이의 대역폭이나 작업 처리 단위 등을 통계적으로 오랜 세월 분석한 결과를 바탕으로 최적점을 찾아낸 것이다.
이를 바탕으로 생각을 해본다. if문 시점에서 명령어 덩어리를 RAM으로부터 가져와 실행을 하려고 한다. CPU입장에서는 그 명령어 덩어리 안에 순차 실행할 수 있는 명령어들이 모두 들어있는 게 비용적으로 유리하다. 만약 if가 false가 나와서 else문으로 jump를 해야 한다면, 그 덩어리 안에 명령어가 들어있을 확률은 낮은 것이다. 이 상황들을 우리는 캐시 히트와 캐시 미스로 설명할 수도 있다.
이러한 이유 때문에 자바 코드 관점에서 true인 코드 경로를 자연스럽게 Fall-through 할 수 있도록 컴파일러(javac)는 조건을 역전시킨다. 기계는 한없이 사람을 믿어준다.
컴파일된 정적 바이트코드의 경우는 위와 같은 결과를 낳는다. 그렇다면 동적으로 RAM으로부터 명령어를 캐시로 전송할 때는 어떨까? 이에 관한 디스커션이 스택오버플로우 "Why is processing a sorted array faster than processing an unsorted array?"에서 훌륭한 비유(기차에 비유함)를 통해 설명한다. 관심이 있다면 읽어보는 것을 추천한다. 위 글에서는 분기 예측(Branch Prediction)으로 실행 중에 데이터 패턴을 학습하여 최적화된 경로로 변경을 한다고 설명한다.
2. 반복문 (for vs while)
다음으로는 반복(Iteration) 문의 두 가지 for와 while문의 바이트코드를 확인 및 비교해보려고 한다. 두 케이스 모두 sum이라는 int 변수에 i 값이 0부터 9가 될 때까지 1씩 커지면서 덧셈을 반복해 주는 로직이다. 여기서 우리가 가져야 할 질문은 무엇일까?
for문과 while문의 자바 코드의 형태가 다르다. 그럼 바이트코드의 구조도 다를까? 이 질문에 답하기 위해 실험을 진행해 본다.
// [실험 2] For vs While: 구조적 동일성 증명
public int forLoop() {
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
return sum;
}
public int whileLoop() {
int sum = 0;
int i = 0;
while (i < 10) {
sum += i;
i++;
}
return sum;
}
A. For Loop
아래 컴파일된 바이트코드를 확인해 보면 문법 관점에서 흥미로운 부분은 4번 라인부터 시작된다.
변수 테이블의 i를 스택에 올린다. 그리고 10이라는 상수를 스택에 올리고 if_icmpge로 i >= 10 비교연산을 한다. 맞으면 20번 라인으로 이동하고 아니면 그대로 흐르게(Fall-through) 된다.
public int forLoop();
Code:
0: iconst_0
1: istore_1 // sum = 0
2: iconst_0
3: istore_2 // i = 0 (초기화)
// --- 루프 시작 (비교) ---
4: iload_2 // i 로딩
5: bipush 10
7: if_icmpge 20 // i >= 10 이면 20번(종료)으로 점프
// --- 루프 본문 ---
10: iload_1 // sum
11: iload_2 // i
12: iadd // sum + i
13: istore_1 // sum 갱신
// --- 증감식 (Step) ---
14: iinc 2, 1 // i++ (iinc 최적화 사용됨!)
17: goto 4 // 다시 4번(비교)으로 무조건 점프
// --- 루프 종료 ---
20: iload_1
21: ireturn
루프 본문에서는 Read-Modify-Write 사이클을 탄다. 그다음 단계인 증감식에서는 이전 포스팅에서 강조했던 iinc를 사용해서 스택에 올려서 수정하는 것이 아닌 변수 테이블에 직접 접근해서 값을 업데이트하는 최적화된 연산을 수행한다. i값에 1을 더하고 17 라인에서 4라인으로 goto 하게 되어 비교하는 단계를 반복한다.
이 섹션에서 시작하면서 했던 질문인 바이트 코드 수준에서 for문과 while문의 구조적 차이가 있는지? 에 대한 답변은 다음 while loop의 컴파일된 바이트코드 결과를 통해 알 수 있다.
B. While Loop
public int whileLoop();
Code:
0: iconst_0
1: istore_1
2: iconst_0
3: istore_2
4: iload_2
5: bipush 10
7: if_icmpge 20
10: iload_1
11: iload_2
12: iadd
13: istore_1
14: iinc 2, 1
17: goto 4
20: iload_1
21: ireturn
실험 결과 for와 while은 바이트코드 레벨에서 완벽하게 동일했다. 개발 공부하다 보면 for와 while문의 자바 코드 형태가 다르기 때문에 내부적으로 어떤 차이가 있을지? 성능에서 차이가 있을지? 궁금했다. 이 실험을 통해 두 반복문에는 그러한 차이가 발생할 수 없음을 알 수 있게 해 주었다.
더 논의해 볼 만한 사항은 "향상된 for문", "i가 지역변수가 아닌 멤버 변수(인스턴스 필드 또는 클래스 변수)일 때는 어떨까?" 등등 여러 가지 있을 수 있다. 다만, 해당 내용들은 Heap 영역에 대한 공부를 하고 다루는 게 좋을 것 같아서 여기서 마무리한다.
3. 스위치 (table vs look up)
스위치의 바이트코드를 살펴보면서 흥미로운 동작방식을 알 수 있었다. 먼저, 각각의 자바 코드의 차이점에 대해 설명해 본다.
switchDense 메서드에는 case로 오는 val들이 1, 2, 3, 4, 5로 각각의 값들이 서로 인접한 형태를 갖는다. 반면에, switchSparse는 1, 100, 1000으로 값들 간의 간격이 벌어져있도록 구성했다.
이 둘의 바이트코드 차이는 어떻게 될까?
// [실험 3] Switch: Table vs Lookup (O(1) vs O(log n))
public int switchDense(int val) {
// 케이스가 조밀함 (1, 2, 3, 4, 5) -> tableswitch 예상
switch (val) {
case 1: return 10;
case 2: return 20;
case 3: return 30;
case 4: return 40;
case 5: return 50;
default: return 0;
}
}
public int switchSparse(int val) {
// 케이스가 듬성듬성함 (1, 100, 1000) -> lookupswitch 예상
switch (val) {
case 1: return 10;
case 100: return 20;
case 1000: return 30;
default: return 0;
}
}
A. Dense
먼저 case의 값들이 서로 인접한 경우를 살펴본다. 입력 파라미터로 들어온 int 타입의 값을 스택에 올리고, 이를 1, 2, 3, 4, 5 값들 중에서 일치하는 값을 찾는다. 이때 사용되는 방식은 tableswitch을 사용한다. 테이블 스위치는 다음에 나오는 룩업스위치(lookupswitch) 방식과 비교하여 이해하면 좋다.
테이블 스위치는 조회하고자 하는 키들이 현재 예시처럼 (1, 2, 3, 4, 5) 밀집되어 있는 경우에 유리하다. 왜냐하면, 배열의 인덱스를 이용한 방식이기 때문이다. 아래 바이트 코드를 예시로 배열을 생성한다면 [36, 39, 42, 45, 48]을 담고 있는 배열을 생성하게 된다. 만약 메서드로 3이라는 값이 입력된다면 3에서 1(최솟값을 빼서 offset을 해준다)을 빼준 배열의 인덱스인 2로 즉시 조회하게 된다.
조회 방식은 랜덤액세스이기 때문에 시간 복잡도는 O(1)이며 매우 빠르다. 여기서 문제점은 무엇이고 언제 테이블 스위치 방식을 선택하지 않게 될까? 공간복잡도 차원에서 접근해야 한다.
만약 1, 2, 3, 4, 5, 가 아닌 1 -> 32, 1000 -> 45와 같이 두 가지 케이스였다면 어땠을까? 이 경우 테이블 스위치 방식을 사용하면 조회속도는 빠르지만 생성해야 하는 배열의 형태는 [32, ... 999인덱스 뒤에 ... 45] 이렇게 998개에 해당되는 배열의 위치가 낭비되게 된다. 이런 경우 공간복잡도는 O(Max - Min + 1)로 조회해야하는 키가 듬성듬성 존재한다면 상대적으로 공간낭비가 심해진다.
테이블 스위치 방식에서 각 배열 요소에는 오프셋 해야하는 위치가 저장된다.
이때 등장하는 것이 '룩업 스위치' 방식이다.
public int switchDense(int);
Code:
0: iload_1
1: tableswitch { // 1 to 5 (범위 기반 점프)
1: 36
2: 39
3: 42
4: 45
5: 48
default: 51
}
(생략)
36: bipush 10
38: ireturn
39: bipush 20
41: ireturn
42: bipush 30
44: ireturn
45: bipush 40
47: ireturn
48: bipush 50
50: ireturn
51: iconst_0
52: ireturn
// ... (이하 생략)
B. Sparse
룩업스위치 방식은 [<1, 36>, <100, 39>, <1000, 42>]와 같은 형태로 리스트를 만든다. 각 리스트의 요소는 <key, value> 쌍 형태를 갖는다. 이때 조회를 하는 방식은 무엇일까?
첫 번째로 생각해 볼 수 있는 방법은 순회 탐색이다. 메서드를 실행할 때마다 입력된 값을 찾기 위해 처음부터 끝까지 그 값과 같은지 비교를 하게 된다. 이는 O(N)의 복잡도를 갖는 방법인데, 더 나은 시간 복잡도를 갖도록 하는 방법은 무엇일까? 이진 탐색을 사용하면 O(logN)의 복잡도로 탐색을 할 수 있다. 그러면 이러한 질문을 할 것이다. "그런 경우라면 정렬을 해야 하는 게 아닌가요?" 오라클 JVM 스펙 문서를 살펴보면 이런 내용이 적혀있다.

즉, 컴파일시점에 정렬이 되고 이를 실행하는 주체(JVM)는 이진탐색하기만 하면 된다. 공간복잡도는 자연스럽게 O(N)으로 case의 개수 가 N이 된다.
public int switchSparse(int);
Code:
0: iload_1
1: lookupswitch { // 3 (키-값 검색 방식)
1: 36
100: 39
1000: 42
default: 45
}
(생략)
36: bipush 10
38: ireturn
39: bipush 20
41: ireturn
42: bipush 30
44: ireturn
45: iconst_0
46: ireturn
// ... (이하 생략)
컴파일 시점에 정렬이 되는지 확인하기 위해 아래와 같은 코드를 동일하게 컴파일, 역어셈블을 하였다. 결과는 역시 동일하게 정렬된 바이트코드를 확인할 수 있었다. 실무에서 case문에 정렬되지 않은 순서로 작성을 해도 컴파일 시점에 정렬이 이뤄지기 때문에 실행시점에서의 성능 저하는 고민할 필요 없이 추상화되어 있다.
public int switchSparseV2(int val) {
switch (val) {
case 1000: return 30;
case 1: return 10;
case 100: return 20;
default: return 0;
}
}
결론 및 인사이트
이번 포스팅에서는 자바의 제어 흐름이 바이트 코드 레벨에서 어떻게 동작하는지 살펴봤다. 실험을 통해 얻은 핵심 인사이트는 다음과 같다.
- if 문 조건 역전 : CPU의 캐싱과 파이프라인 효율을 높이기 위한 컴파일러의 최적화 전략
- for와 while의 문법 : JVM 입장에서는 두 문법이 완전히 동일한 논리 구조를 갖으며 성능 차이는 존재하지 않음
- switch 문의 동작 방식 : 데이터의 밀도에 따라 O(1)의 배열 방식과 O(log N)의 이진 탐색 방식 중 하나를 선택
다음 포스팅에서는 스택(Stack)을 벗어나, 자바 메모리의 또 다른 거대한 축인 힙(Heap) 영역을 객체 생성과 바이트 코드 관점에서 이해해보려고 한다.