들어가며
이전 포스팅들은 스택 영역에서의 바이트코드의 상호작용을 다뤘다. 런타임 데이터 영역에는 스택 영역 뿐만 아니라 힙 영역이 존재한다. JVM 스펙에 따르면 다음 내용을 확인할 수 있다:

"자바 가상 머신은 모든 스레드가 공유하는 힙(Heap) 영역을 가지고 있습니다. 힙은 모든 클래스 인스턴스와 배열의 메모리가 할당되는 런타임 데이터 영역입니다."
따라서, 이 글에서는 객체와 힙 영역에 관련된 여러 요소들을 탐험하고 실험하는데 목적이 있다.
Object 클래스의 객체 생성 (new 키워드)
처음으로 시도해볼 실험은 가장 순수한 객체를 생성해보는 것이다. 아무런 필드도, 로직도 없는 가장 순수한 형태의 객체를 생성해 볼 수 있는 클래스는 java.lang.Object이다. Object 객체를 생성할때 과연 몇 번의 CPU 명령어를 소모할까? 어떤 과정을 거칠까?
이 질문에 답하기 위해 아래 와 같은 자바 코드를 작성하고 컴파일과 역어셈블 과정을 진행했다:
public class Main {
public void create() {
Object obj = new Object();
}
}
역 어셈블된 바이트 코드:
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
(중간 생략)
public void create();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=2, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: return
(이하 생략)
바이트코드를 살펴보면 new → dup → invokespecial 세 단계로 나눠져 동작하는것을 확인할 수 있다. 아래 섹션에서는 각각 어떤 의미이며 어떻게 힙 영역과 상호작용을 하는지 살펴보려고 한다.
1단계 - new 명령어

첫 번째 단계인 new명령어를 먼저 알아본다. 이 명령어를 통해 JVM은 Heap 영역에 인스턴스를 위한 물리적인 공간을 할당 해준다. 다만, 아직 생성자를 실행한 상태는 아니다. new 명령어가 실행된 이후는 빈 껍데기 객체라고 생각할 수 있다. 위 그림과 같이 힙에는 빈 껍데기가 생성되고, 피연산자 스택에는 이 껍데기 객체를 가르키는 참조값(Reference Value)이 Push 된다.
이와 관련된 내용을 JVM 스펙의 Notes에 명시되어있다:

초기화되지 않은 인스턴스에 초기화 메서드가 실행될때까지 인스턴스 생성은 완료되지 않는다.
명령어의 #는 무엇을 나타내는가?
명령어를 보니 뒤에 #2가 있는것을 확인할 수 있다. 명령어 뒤에 '#숫자'로 표기되면 상수 풀(Constant Pool)이라는 테이블의 인덱스를 참조하라는 의미다. 이 상수 풀은 클래스 파일에서 데이터 조회(Lookup) 테이블 역할을 한다.
위에서 살펴봤듯이, 바이트 코드에는 물리적인 메모리 주소가 아닌 '심볼릭 참조(Symbolic Reference, 단순한 번호표)'만 존재한다. 실제로 바이트코드에는 참조값만 존재한다. JVM은 이 명령어를 실행하는 런타임 시점에 상수 풀을 참조(Resolution)하여 실제(Direct) 주소을 찾아낸다.
구조와 데이터의 분리
아래는 심볼릭 참조로 바이트 코드 내에서 연결된 모습을 간단하게 정리했다.
0:new #2 -> #2 = class #4 -> #4 = Utf8 java/lang/Object
new 명령어의 #2 인덱스는 class를 가르키고 #4를 다시 가르킨다. 결과적으로 new 명령어의 참조값은 #4의 Utf8 java/lang/Object를 나타냄을 확인할 수 있다. JVM 스펙에는 해당 인덱스의 상수 풀에는 반드시 인터페이스나 클래스의 심볼릭 참조를 나타내야한다고 명시되어있다.

여기서 의문이 드는 부분이 있다. #2는 '클래스' 정보를 담고 있고, 진짜 값은 #4에 적혀있는 구조가 눈에 띈다. 왜 #2가 바로 생성하고자 하는 Object 클래스를 직접 가르키지 않는 것일까? 왜 이러한 두 단계를 거쳐 조회 해야할까?
참고 Utf8은 자바에서 문자열을 저장하는 포맷 이름이다.
이유는 JVM이 구조(Structure)와 데이터(Data)를 철저하게 분리하기 때문이다. 1단계(#2 = Class)는 껍데기(Wrapper) 역할을 하며 '클래스'라는 정체성을 알려준다. 하지만, 무엇인지 알려주지는 않고 다음 주소를 가르킨다. 2단계(#4 = Utf8)로 내용물(Raw Data)을 담고 있으며 진짜 문자열 값인 java/lang/Object를 포함한다.
분리를 하지 않았을 때를 가정해보고 어떤 문제가 생기는지 살펴보면 분리한 이유를 쉽게 이해할 수 있다. 여기서는 컴파일해서 만들어지는 클래스 파일의 크기 관점에서 접근을 해보려고 한다.
분리를 하지 않았을 때
먼저 우리가 필요한 데이터는 java/lang/Object 라는 문자열이다. 길이는 16글자로 UTF-8 (영어 1바이트 라면) 기준으로 16바이트가 필요하다. 다음과 같은 명령어로 1000개의 변수에 Object를 생성하고 할당한다고 했을 때, 총 비용을 계산해본다.
[ 바이트코드 (Hex) ]
BB 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74
[ 의미 (해석) ]
(1) (2) (3) -------------------------------------------
new Length "j a v a / l a n g / O b j e c t"
이해하기 쉽게 상단에는 Object를 생성하는 명령어를 바이트코드로, 하단에는 바이트코드를 문자로 디코딩한 형태로 작성해보았다. new 명령어는 1바이트를 차지하며 Length는 2바이트를 차지한다. 16진수로 0010은 16을 나타내며 16바이트를 차지한다. 총 1 + 2 + 16 = 19 바이트가 된다. 19바이트가 1000개 저장되기 때문에 19KB(18.55KiB)정도 된다.
분리를 했을 때
문자열은 한 번만 저장하고, 나머지는 2바이트 짜리 번호표(u2)만 붙인다고 가정(상수 풀에 저장했던 방식)한다. 상수 풀의 비용은 16바이트로 딱 한 번만 저장한다. 참조 비용은 2바이트로 1,000번 이니 2,000바이트가 필요하다. 총 비용은 2,016바이트로 약 2KB에 해당된다.
[ 바이트코드 (Hex) ]
BB 00 02
[ 의미 (해석) ]
(1) (2)
new #2
두 경우(19KB vs 2KB)를 비교하면 저장공간을 89%정도 절약할 수 있게 된다. 이 격차는 실제로 우리가 정의한 클래스를 사용한다면 커질 수 밖에 없다. classPath 가 com/example... 이런식으로 시작하기 때문이다.
2단계 - dup 명령어

dup(duplicate)은 JVM 스펙에 따르면 피연산자 스택의 Top 값을 복사해서 Push 하는 명령어다. 처음 new 키워드로 생성된 객체의 주소값(Ref)이 상단에 존재했기 때문에 동일한 주소 2개가 쌓여있음을 위 그림을 통해 이해할 수 있다.
dup은 왜 필요할까?
사실 객체 생성 단계에서는 dup이 필요하지 않다. 그러면 힙 영역에 생성된 객체의 주소는 왜 두개나 필요한걸까? 하나는 초기화(invokespecial)에서 소비(Pop)되고, 남은 하나는 변수 할당(astore)에 쓰이기 때문이다. 따라서, dup을 하지 않았다면 스택 언더플로우(Stack Underflow)가 발생하여 프로그램 에러가 발생할것이다.
3단계 - invokespecial
invokespecial은 JVM 스펙에서 다음과 같은 오퍼레이션을 하도록 명세되어있다.

읽어봐도 무슨 의미이며, 어떤 목적인지 이해하기 힘들다. 이런 경우는 비슷한 명령어들을 묶어서 비교해보면 도움이 된다. 비슷한 명령어로 invokestatic, invokespecial, invokevirtual, invokeinterface가 있고 invokedynamic까지 합하면 메서드를 호출하는 명령어는 총 5가지가 된다. 마지막 inovokedynamic은 여기서 다루기에는 복잡해서 생략하고 나머지도 간단하게 설명을 남겨본다.
메서드를 호출하는 4가지 명령어
invokestatic은 이름 그대로 static 키워드가 붙은 메서드를 호출할 때 사용된다. 4가지 명령어 중 가장 단순한 편인데, static 키워드는 결국 클래스 메서드임을 나타내기 때문에 힙영역에서 인스턴스를 조회할 필요가 없다. 따라서, JVM은 이 명령어를 마주쳤을때 컴파일 시점에 지정된 클래스를 메서드 영역에서 찾아 직접 주소로 치환해주는 해석과정을 거치면 되기 때문에 상대적으로 실행 속도가 빠를 수 있다.
invokespecial은 이름 처럼 그다지 특별하지는 않다. 먼저 invokestatic과 다르게 인스턴스 메서드를 대상으로 한다. 그 중에 생성자나 private 메서드 혹은 super()를 호출하는 용도로 사용된다. 3가지 모두 오버라이딩이 불가능한 메서드들이기 때문에 다형성을 고려하여 자식까지 동적으로 살펴볼 필요가 없다. invokestatic과 마찬가지로 정적으로 컴파일된 클래스로 찾아가 실행하면 된다. 단지 실행할때 대상 인스턴스(this)가 필요할 뿐이다.
invokevirtual은 Java의 핵심인 다형성을 지원하는 명령어이며, 가장 일반적으로 사용된다. 여기서 일반적이다는 표현은 아래 처럼 특별한 수식어(static, private)없이 가장 흔하게/빈번하게 쓰이는 패턴을 호출하는 목적이기 때문이다.
public void doSomething(){}
바이트코드 상으로는 자바 소스코드에 선언된 참조 변수 타입의 메서드를 호출한다. 다만, 다형성을 지원하기 위해 실제 힙 영역에 어떤 객체가 초기화되어있는지 체크를 하는 과정(동적 바인딩)이 필요하기 때문에 앞서 본 두 가지 호출 방법에 비해 거쳐가야하는 비용이 추가적으로 든다.
마지막으로 invokeinterface는 이름에서 유추할 수 있듯이 인터페이스 타입의 변수 메서드를 호출하는 경우에 사용된다. 다형성 관점에서 invokevirtual을 사용할 수 있지 않을까? 하는 궁금증이 생길 수 있다. 특별히 구분해둔 이유는 상속과 다르게 인터페이스는 다중 구현이기 때문이다. 구현하는 클래스마다 메서드의 위치가 제각각일 수 있기 때문에 찾아가는 과정이 좀더 복잡하다.
인스턴스 초기화
invokespecial 명령어의 실행 결과를 아래 그림으로 나타내보았다. 먼저 #1의 상수 풀을 따라가면 메서드 시그니처를 알 수 있게 된다. 여기서는 Method java/lang/Object."<init>":()V으로 java/lang/Object 클래스 내부에 존재하는 메서드임을 경로로 표시한다. 참고로 자바 소스코드에서는 점(.)으로 경로를 표시하지만 바이트코드 내부(Internal Name)에서는 슬래시(/)로 경로를 나타낸다.
init은 생성자를 의미하며 예약어이다. 만약 우리가 정의한 메서드, 예를 들어, toString()을 호출한다면 <init> 대신 toString이 위치할 것이다. 그리고 () 괄호 내에는 입력 파라미터의 타입이 순서대로 위치하며, V는 반환값인 void를 의미한다.

invokespecial 명령어를 실행하면 결과적으로 스택 Top을 Pop해서 힙영역에 위치하는 해당 인스턴스를 초기화하는 작업을 진행한다. new 만 실행된 인스턴스는 모든 필드가 기본값(0, false, null)로 채워져있는 상태이다. 여기서 invokespecial이 실행되면서 우리가 작성한 생성자가 돌아가는데, 구체적으로 다음 세 가지 작업을 수행하게 된다.
- super() 호출
- 필드 초기화
- 메서드 바디 수행
위 내용을 직접 확인해보기 위해 아래와 같은 예시 코드를 작성해서 컴파일 및 역어셈블을 하였다.
public class SimpleObject {
private int value = 100;
public SimpleObject() {
System.out.println("Hello Heap!");
}
}
public heap.SimpleObject();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: aload_0
5: bipush 100
7: putfield #7 // Field value:I
10: getstatic #13 // Field java/lang/System.out:Ljava/io/PrintStream;
13: ldc #19 // String Hello Heap!
15: invokevirtual #21 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
18: return
참고로 역어셈블 과정을 거치면 어떤 참조를 하고 있는지 이해하기 쉽게 자동으로 주석(//)을 달아준다.
이 클래스의 부모클래스는 없기 때문에 Object의 생성자(super())를 먼저 호출한다. 다음으로 value 참조 변수의 int값을 100으로 초기화해주는 작업을 진행한다. 마지막으로 출력메서드인 println을 호출하는 것을 보면 메서드의 바디 부분을 실행하는것을 확인할 수 있다.
결론 및 인사이트
먼저, new 키워드를 통해 객체가 초기화되는 과정을 살펴보았다. 자바 소스코드에서는 한줄이었지만, 바이트코드 레벨에서는 메모리할당(new), 복제(dup), 초기화(invokespecial)로 3단계로 나눠져 프로세스됨을 알 수 있었다. 다음은 그 과정을 간략하게 표로 정리해봤다.
| 순서 | 명령어 | 역할 | 스택 변화 (Bottom → Top) |
| 1 | new | 힙 메모리 할당 & 주소 Push | [ Ref ] |
| 2 | dup | 주소값 복제 | [ Ref, Ref ] |
| 3 | invokespecial | 생성자 호출(<init>) | [ Ref ] |
| 4 | astore_1 | 변수에 주소 저장 | [ ] |
3단계인 invokespecial을 살펴보면서 메서드를 찾기위한 비용이 호출 방식에 따라 다름을 이해할 수 있었고, 다형성이 어떻게 구현되는지 바이트코드 레벨에서 이해할 수 있었다.
관련 포스팅:
- JVM 해부학 개론 #3 - new는 어떻게 동작하는가? 힙 영역과 바이트코드 (현재 글)
- JVM 해부학 개론 #2 - 제어 흐름 (if-else, for, while, switch) 바이트코드 이해하기
- JVM 해부학 개론 #1 - 자바의 스택 머신은 어떻게 동작하는가?
참고 자료
- Oracle JVM Spec - 6. The Java Virtual Machine Instruction Set | Oracle
- Oracle JVM Spec - 4. The class File format | Oracle
- 플라이웨이트 패턴 | 리팩토링 구루