들어가며
이 글은 거창하게 바이트코드를 분석하려는 의도로 시작하지 않았다. 단지 JVM을 학습하는 과정에서 제시된 간단한 실습 예제를 직접 실험해보고 있었다. 작성된 자바 파일을 javac와 javap 명령어로 컴파일하고 역어셈블(Disassemble)하는 과정은 생각보다 많은 질문을 던져주었다.
"우리가 작성한 소스코드는 실제로 JVM 위에서 어떻게 돌아갈까?"
"단순한 i++ 연산은 CPU와 메모리 관점에서 어떤 비용을 지불하는가?"
이러한 질문을 가지고 실험을 하면서, 컴파일러의 최적화와 JVM의 스택 머신 아키텍처 특징을 바이트코드와 그 연산 플로우에서 확인할 수 있었다. 이 글에서는 간단한 증감 연산자(i++) 실험을 통해 소스코드가 바이트코드로 변환될 때 발생하는 차이와 그 원리를 정리해본다.
자바 컴파일러와 바이트코드

우리는 javac 명령어로 자바 소스코드를 컴파일할 수 있다. 그 결과 생성된 class 파일에는 '바이트코드'가 담겨있다. 왜 이름이 바이트코드일까? JVM의 명령어(Opcode)가 딱 1바이트만 차지하기 때문이다. 따라서 0부터 255까지 총 256개의 명령어만 가질 수 있다는 제약이 존재한다. 하지만 그 덕분에 컴파일된 클래스 파일을 획기적으로 줄일 수 있고 상대적으로 작아진 용량 덕분에 네트워크 전송에 유리하다.
참고) 256개의 명령어만 갖는 한계점과 극복을 어떻게 하는지에 대한 내용은 여기서 다루지 않는다.
하지만 여기서 의문이 생긴다. 명령어 하나가 고작 1바이트라면, '더하라'는 명령어의 피연산자들을 담을 공간은 어디에 있는가? 이러한 질문은 JVM의 아키텍처 설계방식을 통해 답변받을 수 있었다.
아키텍처 설계: 레지스터(CPU) vs 스택(JVM)
실행엔진은 레지스터 기반, 스택 기반 두 가지로 나눌 수 있다. 아래 그림에서 볼 수 있듯이 레지스터 기반은 연산에 사용되는 피연산자가 어디에 있는지 명시적으로 말해준다. 그 값은 이름에서도 알 수 있듯이 레지스터라고 불리는 공간에 저장된다.

레지스터 방식의 문제점은 무엇일까? 레지스터 그 자체가 문제가 될 수 있다. 다음 표에 ISA(Instruction Set Architecture, 명령어 집합 아키텍처)와 이에 상응하는 레지스터 개수를 정리해두었다.
| ISA (Instruction Set Architecture) | 범용 레지스터 (개) | 대표적인 CPU |
| x86-32 | 8 | 구형 인텔/AMD |
| x86-64 | 16 | 최신 인텔/AMD |
| ARM64 | 31 | 애플 M시리즈, 모바일 |
| RISC-V | 32 | 오픈소스 아키텍처 |
ISA는 CPU가 인식하는 명령어, 레지스터의 구조를 추상화한 설계도로 이해할 수 있다. CPU가 사용하는 언어와도 같다고도 하는데, 문제는 CPU 아키텍처 마다 레지스터의 개수와 규칙이 완전히 다르다는 점이다. 표에서 볼 수 있듯이, 아키텍처마다 사용할 수 있는 레지스터의 수가 제각각이다.
JVM이 특정 레지스터 개수에 의존하여 설계된다는 것 자체가 곧 하드웨어의 물리적 특성에 종속됨을 의미한다. 자바를 처음 배울 때 자주 들었던 '플랫폼 독립성'이라는 개념이 바로 여기서 시작되었다는 것을 알 수 있다. 우리가 관심 있어하는 JVM은 스택 기반 실행 엔진을 채택하고 있다. 스택은 생각보다 굉장히 간단한 자료구조라서 어떠한 하드웨어를 갖추더라도 그 위에 추상화 및 구현하여 사용할 수 있다.
레지스터(R1, R2)가 없다면 연산에 사용되는 피연산자들은 스택에 쌓고(push) 꺼내서(pop) 계산된 뒤 다시 스택에 넣는 방식으로 처리된다. 즉, 변수 저장소(Local Variable)에 있는 값들은 반드시 작업대인 스택 위로 꺼내와야 비로소 연산이 가능하다. 이론으로만 들으면 굉장히 추상적이다. 프로그래밍에서 가장 기초적인 연산인 증감 연산자 (i++, ++i)가 이 스택 머신 위에서 어떤 바이트코드로 변환되어 움직이는지, 실제 실험을 통해 확인해본다.
[실험 #1] i++ 와 ++i의 바이트코드 차이점
변수 i, j를 선언하고 후위 연산과 전위 연산을 수행하는 코드를 작성했다. javac Bytecode.java 명령어로 class 파일로 컴파일하고 뒤이어 javap -v -p Bytecode.class 명령어로 class 파일을 역 어셈블하여 JVM이 코드를 어떻게 실행하는지 분석하였다.
참고)
-v는 verbose를 나타내며 Constant Pool, Bytecode Instructions 등 상세한 메타데이터를 출력하도록 했다. -p는 private으로 javap 명령어는 기본적으로 public, protected, package-private 멤버만 보여주기 때문에 private으로 선언된 필드와 메서드까지 포함된 모든 멤버를 보여주도록 했다.
컴파일한 코드는 아래와 같다:
public class BytecodeInt {
public static void main(String[] args) {
int i = 0;
int j = 0;
i++; // 후위 연산
++j; // 전위 연산
}
}
java -v -p Bytecode.class의 실행 결과에서 이 글에서 주목하고자 하는 일부 스니펫만 아래에 가져왔다:
// public static void main(String[] args)
Code:
stack=1, locals=3, args_size=1 // locals=3 : 변수 3개가 필요
0: iconst_0 // 상수 0을 스택에 push
1: istore_1 // 스택에서 pop해서 1번 변수에 저장
2: iconst_0 // 상수 0을 스택에 push
3: istore_2 // 스택에서 pop해서 1번 변수에 저장
// 핵심 : i++과 ++j는 동일하게 최적화된다!
4: iinc 1, 1 // 1번 변수(=i)를 1 증가 (스택을 경유하지 않음)
7: iinc 2, 1 // 2번 변수(=j)를 1 증가 (스택을 경유하지 않음)
10: return // 메서드 종료
실행결과를 다루기 앞서 바이트코드를 이해하는데 도움이 되는 몇 가지 포인트들을 아래에 정리해 봤다. 우리가 지금 들여다보고 있는 공간이 어디인지 명확히 짚고 넘어가 본다.
아래 그림은 JVM이 메서드를 실행할 때 생성하는 스택 프레임(Stack Frame)의 구조를 도식화한 것이다. 이 글에서 진행한 실험은 바로 이 '작업실' 안에서 벌어지는 일이다.

locals
Code 속성에 존재하는 locals은 해당 메서드에서 몇 개의 변수가 필요한지를 나타낸다. 위 코드에서는 3개의 변수 args, i, j가 차례대로 0, 1, 2로 번호가 매겨진다. 이를 바탕으로 아래와 같이 표로 정리했다.
| 변수명 (선언된 순서) | 인덱스 |
| args | 0 |
| i | 1 |
| j | 2 |
명령어 iconst, istore, iinc
먼저 iconst은 integer constant을 나타낸다. 흥미롭게도 위 코드에서 변수 타입을 int에서 double로 바꿨을 때 dconst로 변경됨을 확인했다. 사실 JVM 바이트코드는 "타입 접두사 + 명령어" 구조를 가진다. 즉, 앞글자만 봐도 이 명령어가 어떤 데이터 타입을 다루는지 알 수 있다는 뜻이다.
| 접두사 (Prefix) | 데이터 타입 (Type) | 예시 명령어 |
| i | int (정수) | iload, istore, iadd |
| d | double (실수) | dload, dstore, dadd |
| l | long (긴 정수) | lload, lstore, ladd |
| f | float (실수) | fload, fstore, fadd |
| a | address (참조/객체) | aload, astore |
그렇다면 iconst_0나 istore_1 뒤에 붙은 언더바 숫자(_n)는 정확히 무엇을 의미할까? 비슷해 보이지만 명령어에 따라 그 의미가 달라진다.
- iconst_n의 숫자는 우리가 사용할 상수 값(Value) 그 자체를 의미한다 (-1부터 5까지 제공)
- istore_n의 숫자는 값을 저장할 변수의 인덱스(index)를 의미한다 (0부터 3까지 제공)
그렇다면 왜 굳이 이런 전용 명령어를 만들었을까? 정답은 '바이트 코드의 크기'와 'CPU 사이클'을 줄이기 위해서다.
예를 들어 숫자 0을 스택에 올릴 때, 일반적인 명령어 bipush 0을 사용하면 "명령어 코드(1byte) + 값(1byte)"로 총 2byte가 필요하다. 반면 최적화된 iconst_0을 사용하면 값(0)이 명령어 자체에 내장되어 있어. 단 1byte로 처리가 끝난다.
bipush의 bi는 byte integer push의 약자로, -128 ~ 127 사이의 정수를 다룰 때 사용된다. (이 범위를 넘어가면 sipush, ldc 등을 사용)
여기까지가 실행 결과를 이해하기 위해 필요한 최소한의 지식들이었다. 그럼 이제 본격적으로 결과를 분석해본다.
실행 결과 이해하기

먼저 프로그램 카운터(PC)가 0이며 오프셋 주소가 0에 해당하는 명령어인 iconst_0을 실행한다. integer 상수 0을 꺼내서 피연산자 스택에 넣는다. 이어서 오프셋 주소 1 명령어는 istore_1을 실행한다. 이는 피연산자 스택에서 0을 pop 해서 지역 변수 테이블 1에 저장(store)해야 한다.
참고로 메서드 호출 시 지역 변수 테이블이 채워지는 순서는 다음 그림과 같다. static이냐, 인스턴스 메서드냐에 따라 this 가 0 인덱스에 올지 말지가 결정된다. main메서드는 static이기 때문에 this가 없다. 따라서, 입력 매개 변수인 args가 0을 차지하고 차례대로 메서드 본문에 존재하는 i, j가 차례대로 등록된다.

다시 이어서 설명해 본다. 오프셋 주소 2, 3에 해당하는 명령어도 0과 1 명령어와 유사한 동작을 수행한다. 흥미로운 사실을 4번 주소와 7번 주소의 명령어를 수행할 때 확인할 수 있었다. 아래 그림은 3번 주소에서 4번 주소로 넘어갈 때 수행되는 결과를 나타내었다. iinc는 지역 변수 테이블 1번 인덱스의 값을 직접 업데이트한다.
참고로 iinc 명령어는 3 byte를 차지하기 때문에 4번에서 7번 오프셋으로 넘어간다. 연산자(1byte)와 피 연산자 두 개 (각각 1byte)로 총 3byte이다.

JVM은 설계상 스택 머신(Stack Machine) 임을 앞에서 강조했고, 이 아키텍처에는 아주 엄격한 법칙이 하나 있다.
"모든 연산자(더하기, 빼기 등)는 변수 저장소(Local Variables)를 직접 건드릴 수 없다. 오직 스택(Operand Stack)에 있는 값만 건드릴 수 있다."
사실 대부분의 연산은 '읽고', '변경하고', '쓰고'의 한 사이클을 도는 이른바 RMW(Read, Modify, Write)의 사이클을 따른다. 이는 CPU 레벨부터 데이터베이스 트랜잭션까지 어디서나 볼 수 있는 공통된 흐름이다. 예를 들어, 유저 엔티티를 조회해서 이름을 변경하고 다시 데이터베이스에 업데이트하는 과정을 한 번 생각해볼 수 있다. 하지만 이 방법에는 치명적인 단점이 있다. 바로 데이터를 옮기는데 드는 비용이다. CPU레벨이라는 아주 미시적인 연산 수준에서 단순한 +1을 위해 굳이 변수를 스택까지 꺼냈다가 다시 집어넣을 필요가 있느냐는것이다.
프로그래밍에서 가장 빈번하게 연산은 i++연산이며 for루프에서 카운터로 사용된다. i++연산에도 이렇게 비효율적으로 처리한다면 전체 시스템 성능에 영향을 줄 수 있다. 그래서 JVM은 원칙을 깨고 예외를 허용하여, 스택을 거치지 않고 변수 저장소에서 값을 바로 고쳐버리는 것이다.
여기서 두 가지 새로운 의문이 생겼다:
- integer가 아닌 다른 타입 예를 들어, double에서는 이 예외가 적용되는가?
- 실험 #1에서는 i++와 ++i는 바이트코드상 차이가 없었다. 그렇다면 변수에 값을 대입하는 상황에서도 여전히 동일한가?
이 의문을 해소하기 위해 #2, #3의 연속된 실험을 진행해 보았다.
[실험 #2] 스택이라는 '작업대' 위에서 항상 연산되어야 한다
실험#1과 동일한 소스코드를 사용하여 타입만 int에서 double로 변경하여 동일하게 javac, javap 명령어를 사용하여 결과를 확인했다.
public class BytecodeDouble {
public static void main(String[] args) {
double i = 0;
double j = 0;
i++; // 대입 후 증가
++j; // 증가 후 대입
}
}
Code:
stack=4, locals=5, args_size=1
0: dconst_0 // 상수 0을 스택에 push
1: dstore_1 // 스택에서 pop하여 1번 변수에 저장
2: dconst_0 // 상수 0을 스택에 push
3: dstore_3 // 스택에서 pop하여 3번 변수에 저장
4: dload_1 // 1번 변수(0)을 스택에 push
5: dconst_1 // 상수 1을 스택에 push
6: dadd // 스택에서 두 번 pop하여 값을 더하고 다시 push
// 참고) 첫번째 pop은 오른쪽 항, 두번재 pop은 왼쪽 항
7: dstore_1 // 스택을 pop해서 1번 변수에 저장
8: dload_3 // 3번 변수(0)을 스택에 push
9: dconst_1 // 상수 1을 스택에 push
10: dadd // 스택에서 두 번 pop하여 값을 더하고 다시 push
11: dstore_3 // 스택을 pop해서 3번 변수에 저장
12: return // 메서드 종료
결과를 해석하기 전에 한 가지 주목할 점 : "i는 지역 변수 테이블의 인덱스 1과 2를 차지하고, j는 3과 4를 차지한다."
JVM 명세서에 따르면 "a Value of type long or type double occupies two consecutive local variables." (long이나 double 타입의 값은 연속된 두 개의 지역 변수 공간을 차지한다.) 즉, double 변수는 뚱뚱해서 혼자서 두 개(slot)를 차지한다는 뜻이다.
그다음 문장은 "Such a value may only be addressed using the index." (그런 값은 오직 '더 작은 인덱스'로만 접근할 수 있다.)이다. 이것이 바로 바이트코드에 dload_2 같은 명령어가 없고 dload_1과 dload_3만 존재하는 이유이다. 1번을 부르면 2번까지 알아서 챙겨주는 규칙이다.
이를 반영한 '지역 변수 테이블'을 보면 다음과 같다:
| 변수명 (선언된 순서) | 인덱스 |
| args | 0 |
| i | 1 |
| 2 | |
| j | 3 |
| 4 |
처음 지역 변수를 테이블에 할당하는 작업은 생략하고 중요하게 봐야할 지점을 아래 처럼 도식화했다. 먼저, 스택에 지역 변수 1과 상수 1을 push해준다. 위에서 언급한것 처럼 테이블에서도, 스택에서도 하나의 double 변수는 두 개의 슬롯을 차지하게 된다.

다음으로 dadd 명령어는 스택에서 두 개의 요소를 pop해서 꺼내온다. 이때 덧셈이나 곱셈은 순서에 상관이 없지만, 뺄셈이나 나눗셈에서 중요하다. 스택 맨 위의 요소가 오른쪽 그 아래가 왼쪽에 위치하게 된다. 이 경우 뺄셈인 경우 a - b와 b - a의 결과가 다르기 때문에 순서가 중요하다.
다시 본론으로 돌아와서 두 개의 요소를 pop하여 덧셈(0 + 1) 을 하고 얻은 결과값인 1을 다시 스택에 push한다. 오프셋 주소 7에서 dstore 명령어가 지역 변수 테이블 인덱스 1번에 스택에서 pop한 1을 쓰기한다.

이렇게 실험#2에서는 타입을 double로 변경하였을때는 스택이라는 작업대 위에서 작업을 하는 바이트 코드 모습을 확인할 수 있었다. 다음으로는 ++1과 1++의 바이트코드 상의 차이를 확인해보기 위한 실험을 진행한다.
[실험 #3] 할당(Assignment)과 함께라면 다른 결과를 낳는다
자바 코드는 다음과 같다. 다른 점은 result1과 result2에 후위, 전위 연산 결과를 선언과 동시에 초기화를 한다는 점이다. 동일하게 class 파일로 컴파일하고 javap 명령어로 출력 결과를 가져왔다.
public class BytecodeIntInit {
public static void main(String[] args) {
int i = 0;
int j = 0;
int result1 = i++; // 대입 후 증가
int result2 = ++j; // 증가 후 대입
}
}
// public static void main(String[] args)
Code:
stack=1, locals=5, args_size=1 // locals=5 : 변수 5개가 필요
0: iconst_0 // 상수 0을 스택에 push
1: istore_1 // 스택에서 pop해서 1번 변수(i)에 저장
2: iconst_0 // 상수 0을 스택에 push
3: istore_2 // 스택에서 pop해서 2번 변수(j)에 저장
// [상황 1] int result1 = i++;
// "현재 값을 먼저 챙긴다"
4: iload_1 // 1번 변수(i) 스택에 push
5: iinc 1, 1 // 1번 변수(i) 1만큼 증가
8: istore_3 // 스택에서 pop해서 3번 변수(result1)에 저장
// [상황 2] int result2 = ++j;
// "증가를 먼저 한다"
9: iinc 2, 1 // 2번 변수(j) 1만큼 증가
12: iload_2 // 2번 변수(j)를 스택에 push
13: istore 4 // 4번 변수(result2)에 스택을 pop해서 저장한다
15: return // 메서드 종료
| 변수명 (선언된 순서) | 인덱스 |
| args | 0 |
| i | 1 |
| j | 2 |
| result1 | 3 |
| result2 | 4 |
출력된 바이트코드에 주석으로 상황1과 상황2를 남겨 i++ 연산과 ++i 연산의 차이점을 확인할 수 있었다.
상황 1을 자바코드 관점에서 보면 result1에 i를 먼저 전달하고, i를 1 증가시킨다. 이러한 점이 그대로 바이트코드에서도 드러났다. 값을 올리기 전에 스냅샷을 스택 위로 올린다. 그리고 지역 변수 테이블의 i를 증가시키고, 스냅샷을 result에 반영한다.
- 후위 연산 (i++): iload → iinc (복사 먼저, 수정 나중에)
- 전위 연산 (++i): iinc → iload (수정 먼저, 복사 나중에)
할당이라는 결과값 사용처가 없는 상태(#1)에서는 그 과정이 중요하지 하지 않았지만, result 변수에 할당을 하는 상황에서는 그 과정이 중요해졌다. 그러한 이유때문에 숨겨진 순서의 차이가 바이트 코드에서 드러나게 된것이다.
결론 및 인사이트
이 글에서는 정적인 바이트코드와 JVM의 스택 머신 구조를 살펴보았다. 두 가지 실무적 인사이트를 정리해본다.
동시성 이슈의 '원자성(Atomicity)' 판단
실험 #1에서의 iinc는 성능최적화를 위한 예외케이스였지만, double 연산이나 대입 연산 등 대부분의 바이트코드는 RMW(Read-Modify-Write) 사이클을 따르고 있다.
만약 iinc를 사용할 수 없는 인스턴스 변수(멤버 변수)였다면, 바이트코드는 getfield - iadd - putfield의 형태가 되어 앞서 살펴본 실험 #2의 double 연산이나 RMW 사이클과 유사한 구조를 갖게 된다. 이 경우는 연산간에 시간적 틈(Gap)이 발생하므로 동시성 이슈에 노출된다.
우리는 이 글에서 분석한 일련의 과정을 통해 동시성 이슈가 발생할 수 밖에 없는 구조적 원인을 눈으로 확인할 수 있었다.
보이지 않는 비용
요즘 하드웨어가 좋아서 데이터 타입이나 연산 비용을 간과하곤 한다. 하지만 바이트코드 레벨에서는 고작 double 변수 두 개를 더하는데, JVM은 슬롯을 2배로 늘리고 스택 깊이를 4칸까지 확보해야 했다.
또한, 단순한 변수 할당 과정에서도 수많은 load와 store가 발생함을 실험을 통해서 확인할 수 있었다. 우리가 작성하는 코드 한 줄이 JVM 내부에서는 여러 명령어 실행과 메모리 이동을 발생시킨다. 이러한 숨겨진 비용을 인지하는 것은, 고성능 애플리케이션을 설계할때 중요한 인사이트로 작용할 수 있다.
참고 자료
- JVM bytecode | Wikipedia
- Oracle JVM Spec - 2.6 Frames & Operand Stacks | Oracle
- JVM Internals | James D. Bloom (2013)
- The Java Virtual Machine - Ch.5 of Inside the Java Virtual Machine | Bill Venners
- JVM 밑바닥까지 파헤치기 | 저우즈밍
- 8.2 런타임 스택 프레임 구조
- 6.3 클래스 파일 구조
- 12.3 자바 메모리 모델
- JVM Internal | 박세훈 (2011, Naver D2)
- Instruction Set Architecture | Wikipedia