2주차 Unit 1.5 — Heap과 객체-Metadata 연결

Psj·2026년 5월 15일

F-lab

목록 보기
54/239

Unit 1.5 — Heap과 객체-Metadata 연결

F-LAB JAVA · 2주차 · Phase 1 · 자바 변수 ↔ 메모리 영역의 매핑


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • Heap에 있는 객체는 자기 클래스의 메서드를 어떻게 찾는가?
  • Object Header의 정확한 구성은? (Mark Word + Class Pointer)
  • VMT (Virtual Method Table) 이 다형성을 메모리 수준에서 어떻게 구현하는가?
  • invokevirtual · invokespecial · invokestatic · invokeinterface 의 차이는?
  • Animal a = new Dog(); a.sound(); 가 왜 Dog의 sound를 호출하는가? (메모리 추적)
  • Compressed Oops 가 무엇이며 왜 필요한가?
  • 객체 1개의 메모리 사용량을 어떻게 계산하는가?

🎯 핵심 한 문장

객체는 Heap의 데이터 컨테이너일 뿐, 행동(메서드)은 Method Area에 있다.
둘을 연결하는 다리가 Object Header의 Class Pointer, 그리고 VMT 다.
이 다리 덕분에 객체 N개를 만들어도 메서드는 1개만 존재하며, 다형성도 이 다리 위에서 동작한다.

비유 — 직원 ID 카드와 회사 매뉴얼

메모리 요소비유
Heap의 객체회사 직원 (이름·소속·전화번호 같은 개인 정보)
Object Header직원 ID 카드 (회사 매뉴얼 위치가 적힌 바코드)
Class Pointer매뉴얼 위치 바코드
Method Area의 클래스회사 매뉴얼 (모든 직원이 따르는 행동 규칙)
VMT매뉴얼의 목차 (각 행동의 페이지 번호)

직원 100명을 뽑아도 매뉴얼은 1권. 직원마다 자기 ID 카드로 매뉴얼을 펴서 행동 규칙 찾음.
자회사 직원(서브클래스) 은 본사 매뉴얼을 일부 자기 회사 버전으로 덮어쓴 매뉴얼을 쓴다. → 다형성.


🧭 9개 섹션 로드맵

1. 객체와 클래스의 연결 — 왜 연결고리가 필요한가
2. Object Header 정밀 구조
3. Class Pointer — 객체 → Metadata 화살표
4. VMT (Virtual Method Table) — 다형성의 메모리 구현
5. invoke 4종 — 메서드 디스패치 메커니즘
6. new의 메모리 변화 — 정밀 추적
7. ILIC 실무 — 다형성과 메모리 비용
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검

1️⃣ 객체와 클래스의 연결 — 왜 연결고리가 필요한가

1.1 문제 — 객체와 메서드가 다른 곳에 산다

지금까지 본 것:

Method Area:
  Shipment 클래스
    • calculate() 바이트코드  ← 메서드는 여기, 1개만
    • getBlNo() 바이트코드

Heap:
  Shipment 객체 #1   ← 데이터만 있음
  Shipment 객체 #2
  Shipment 객체 #3
  ...

→ 데이터는 Heap에, 코드는 Method Area에. 물리적으로 다른 메모리.

1.2 그럼 이런 코드가 어떻게 동작하나?

Shipment s = new Shipment("BL-001");
s.calculate();          // ← 객체에서 메서드를 호출

s는 Heap의 객체를 가리킬 뿐인데, 어떻게 Method Area의 calculate() 까지 찾아가나?

연결고리가 필요하다.

1.3 답 — Object Header의 Class Pointer

Heap:
  ┌──────────────────────┐
  │ Shipment 객체 #1      │
  │  ┌────────────────┐  │
  │  │ Object Header  │  │
  │  │  Class ptr ────┼──┼──┐
  │  └────────────────┘  │  │
  │  blNo  = "BL-001"    │  │
  │  eta   = 2024-12-01  │  │
  └──────────────────────┘  │
                            ▼
Method Area:                ┌─────────────────────┐
                            │ Shipment 클래스       │
                            │   필드 선언            │
                            │   메서드 시그니처      │
                            │   VMT                │
                            │   바이트코드          │
                            └─────────────────────┘

모든 객체는 Object Header를 가지고, 그 안에 Class Pointer가 있다.
이 Class Pointer가 객체와 클래스를 연결.

1.4 객체 100개 만들면?

for (int i = 0; i < 100; i++) {
    new Shipment("BL-" + i);
}
Heap:
  Shipment #1 → Class ptr ─┐
  Shipment #2 → Class ptr ─┤
  Shipment #3 → Class ptr ─┤  ← 모두 같은 곳을 가리킴
  ...                       │
  Shipment #100 → Class ptr ┤
                            ▼
Method Area:
  Shipment 클래스 (단 1개)
    • calculate() 바이트코드

메서드 바이트코드는 1개. 100번 복제되지 않는다.
→ Unit 1.1, 1.3에서 본 결론의 메모리 메커니즘.


2️⃣ Object Header 정밀 구조

2.1 모든 객체의 머리 — Object Header

자바의 모든 객체는 데이터 앞에 12~16바이트의 헤더를 가진다.

객체 메모리 레이아웃 (HotSpot JVM 기준):
┌──────────────────────────────────┐
│ Object Header                    │  ← 12~16 바이트
│   ├─ Mark Word     (8 bytes)     │
│   └─ Class Pointer (4 or 8 bytes)│
├──────────────────────────────────┤
│ Instance Data                    │  ← 인스턴스 변수들
│   blNo, eta, freight, ...        │
├──────────────────────────────────┤
│ Padding                          │  ← 8 바이트 정렬용
└──────────────────────────────────┘

2.2 Mark Word — 객체의 상태 정보

Mark Word는 8바이트에 객체의 다양한 상태를 비트 단위로 압축 저장.

저장되는 것:

  • Hash code (Object.hashCode()의 결과 캐시)
  • GC 정보 (어느 세대에 있는가, 마킹됐는가, age)
  • Lock 상태 (synchronized 정보)
  • Biased lock 정보 (Java 15 이전)

상태에 따라 Mark Word의 비트 사용이 달라짐:

일반 상태:
  [unused:25 | hashcode:31 | unused:1 | age:4 | biased:1 | lock:2]

Lock 잡힌 상태:
  [thread pointer:62 | lock:2]

GC 마킹 중:
  [forwarding pointer:62 | lock:2]

→ 운영 분석에서 직접 다룰 일은 적지만, 객체가 단순한 데이터가 아니라는 점이 중요.

2.3 Class Pointer — 클래스 메타데이터로 가는 다리

Mark Word 다음의 4 또는 8바이트가 Class Pointer.

Object Header:
  [Mark Word: 8 bytes ]
  [Class Pointer: 4 or 8 bytes]  ← Method Area의 클래스 metadata 주소

이 포인터가 가리키는 곳:

  • HotSpot JVM 내부에서는 Klass 라는 구조체
  • Method Area의 Class Metadata Zone에 위치
  • 클래스의 모든 정보(필드 선언, 메서드, VMT, 상수 풀)에 접근하는 진입점

2.4 Compressed Oops — 32비트 포인터 트릭

Oops = Ordinary Object Pointers. 객체 참조.

64비트 JVM에서 모든 포인터를 64비트로 저장하면 메모리 낭비.
→ Heap이 32GB 이하면 포인터를 32비트로 압축 (Compressed Oops).

64비트 정통:  pointer = 8 bytes
Compressed:   pointer = 4 bytes (Heap 시작 주소 + 4바이트 오프셋)

비교:

Heap 크기포인터 크기Compressed?
≤ 32 GB4 bytes✓ 자동 활성화
> 32 GB8 bytes

옵션:

-XX:+UseCompressedOops    # 기본 활성화 (Heap ≤ 32GB)
-XX:-UseCompressedOops    # 비활성화

Heap 32GB 직전까지 키우는 게 가장 효율적. 32GB를 살짝 넘기면 포인터가 2배 커져서 오히려 사용 가능 메모리가 줄어든다.

2.5 객체 1개의 메모리 계산

public class Shipment {
    private Long id;            // 4 or 8 bytes (참조)
    private String blNo;        // 4 or 8 bytes (참조)
    private int weight;         // 4 bytes
    private LocalDate eta;      // 4 or 8 bytes (참조)
}

Compressed Oops 가정 (Heap ≤ 32GB):

Header:         16 bytes
  Mark Word:     8
  Class Pointer: 4 (compressed)
  + padding:     4

Instance Data:  16 bytes
  id (ref):      4
  blNo (ref):    4
  weight:        4
  eta (ref):     4

총합:           32 bytes (8-byte 정렬)

→ Shipment 객체 1개 = 32바이트 (실제 데이터 객체들 제외).
→ 100만 개면 32MB. 1억 개면 3.2GB.

객체 크기 측정 도구:

// Java Object Layout (JOL) 라이브러리
System.out.println(ClassLayout.parseInstance(shipment).toPrintable());

3️⃣ Class Pointer — 객체 → Metadata 화살표

3.1 Class Pointer가 가리키는 것 — Klass 구조

Method Area의 클래스 정보는 HotSpot 내부적으로 Klass 구조체로 표현.

Klass (Shipment 클래스):
  ├─ 클래스명: "com.ilic.Shipment"
  ├─ 부모 Klass 포인터 → BaseEntity Klass
  ├─ 인터페이스 Klass 포인터 목록
  ├─ 필드 선언 정보
  ├─ 메서드 정보 (이름, 시그니처, 바이트코드 위치)
  ├─ VMT (Virtual Method Table)        ← 다음 섹션
  ├─ 상수 풀
  └─ static 필드 위치

객체가 자기 클래스 정보를 알고 싶을 때:

객체의 Class Pointer → Klass 구조체 → 모든 클래스 정보

3.2 Reflection의 진실

Shipment s = new Shipment("BL-001");
Class<?> clazz = s.getClass();    // Class Pointer 따라가서 Klass 정보 반환

getClass()Object Header의 Class Pointer를 따라가서 Class 객체를 반환.

clazz.getName();              // "com.ilic.Shipment"   (Klass에서 가져옴)
clazz.getDeclaredFields();    // 필드 선언 (Klass에서)
clazz.getDeclaredMethods();   // 메서드 정보 (Klass에서)

→ Reflection의 모든 호출이 결국 Klass 구조체에서 정보를 읽어오는 것.

3.3 == vs equals() — Class Pointer의 비교

Shipment a = new Shipment("BL-001");
Shipment b = new Shipment("BL-001");

a == b;             // false  (참조 비교 — 객체 주소가 다름)
a.equals(b);        // true (필드 값 비교, equals 오버라이드 시)

a.getClass() == b.getClass();   // true (Class Pointer가 같음)

getClass() == getClass() 비교가 빠른 이유: 포인터 동일성 검사.

3.4 instanceof의 동작

shipment instanceof BaseEntity;    // true

JVM의 동작:
1. shipment의 Class Pointer → Shipment Klass
2. Shipment Klass의 부모 체인 따라감: Shipment → BaseEntity → Object
3. BaseEntity Klass와 일치하는 항목이 있으면 true

→ instanceof는 결국 Klass 체인 탐색.
→ 클래스 계층이 깊을수록 instanceof는 느려질 수 있음 (대부분 무시할 수준).


4️⃣ VMT (Virtual Method Table) — 다형성의 메모리 구현

4.1 문제 — 다형성은 어떻게 동작하는가

1주차 Unit 2.4에서 본 코드:

class Animal {
    public void sound() { System.out.println("..."); }
}

class Dog extends Animal {
    @Override
    public void sound() { System.out.println("멍"); }
}

Animal a = new Dog();
a.sound();    // "멍"   ← 변수 타입은 Animal인데 Dog의 sound 실행

JVM은 어떻게 "이 객체에서는 Dog의 sound를 실행해야 한다"는 걸 아는가?

→ 답: VMT (Virtual Method Table).

4.2 VMT의 구조

각 클래스의 Klass 구조에는 메서드 포인터 테이블이 있다.

Animal 클래스의 VMT:
  ┌─────────────────────────────────────┐
  │ [0] toString()  → Object.toString    │  (상속받은 채로)
  │ [1] hashCode()  → Object.hashCode    │
  │ [2] equals()    → Object.equals      │
  │ [3] sound()     → Animal.sound       │
  │ [4] eat()       → Animal.eat         │
  └─────────────────────────────────────┘

Dog 클래스의 VMT:
  ┌─────────────────────────────────────┐
  │ [0] toString()  → Object.toString    │
  │ [1] hashCode()  → Object.hashCode    │
  │ [2] equals()    → Object.equals      │
  │ [3] sound()     → Dog.sound          │  ← 오버라이드! 슬롯 [3] 덮어씀
  │ [4] eat()       → Animal.eat         │
  │ [5] bark()      → Dog.bark           │  ← Dog만의 메서드 추가
  └─────────────────────────────────────┘

핵심 규칙:

  • 부모와 같은 메서드는 같은 슬롯 번호 사용
  • 오버라이드 시 그 슬롯이 자식 버전으로 덮어씌워짐
  • 자식만의 새 메서드는 뒤에 추가

4.3 다형성의 실행 — 메모리 추적

Animal a = new Dog();
a.sound();

JVM의 동작 단계:

Step 1: a가 가리키는 객체 찾기
  Stack: a = 0x7f4a2c01 (참조)
  Heap: Dog 객체 @0x7f4a2c01

Step 2: 객체의 Class Pointer 따라가기
  Dog 객체의 Class ptr → Dog Klass

Step 3: 메서드 호출 — invokevirtual sound()
  컴파일 시점에 sound()는 Animal의 VMT 슬롯 [3]으로 컴파일됨

Step 4: Dog Klass의 VMT[3]을 찾음
  VMT[3] = Dog.sound

Step 5: Dog.sound 실행
  → "멍" 출력

핵심: 컴파일러는 슬롯 번호만 결정하고, 실제 어느 클래스의 메서드인지는 런타임의 객체가 결정.

Animal a = new Dog();    // a의 객체는 Dog
a.sound();               // VMT[3] = Dog.sound

Animal b = new Cat();    // b의 객체는 Cat
b.sound();               // VMT[3] = Cat.sound

Animal c = new Animal(); // c의 객체는 Animal
c.sound();               // VMT[3] = Animal.sound

같은 슬롯 번호로 호출하지만, 객체가 가진 VMT에 따라 다른 메서드 실행.

4.4 VMT의 효율성

대안과 비교:

  • VMT 없이: 메서드 이름으로 매번 검색 → O(N) 또는 해시
  • VMT 사용: 슬롯 번호로 배열 접근 → O(1)

→ 다형성 호출이 거의 일반 호출만큼 빠른 이유.

JIT 컴파일러는 한 발 더 나아가:

  • 같은 위치에서 항상 같은 타입이 호출되면 → 인라이닝
  • 거의 항상 같은 타입이면 → 인라인 캐시

4.5 final 메서드와 VMT

class Animal {
    public final void breathe() { ... }   // final
}

final 메서드는 오버라이드 불가 → VMT 슬롯이 절대 안 바뀜.
→ JIT가 더 적극적으로 인라이닝 가능 (정적 디스패치).

class Animal {
    public static void info() { ... }     // static
}

static 메서드는 VMT에 들어가지 않음. 클래스에 직접 호출. (다음 섹션의 invokestatic).


5️⃣ invoke 4종 — 메서드 디스패치 메커니즘

5.1 4가지 invoke 명령

JVM 바이트코드의 메서드 호출 명령은 정확히 4종류:

명령호출 대상VMT 사용?
invokestaticstatic 메서드❌ (Static Zone 직접)
invokespecial생성자, private, super 호출❌ (정적 결정)
invokevirtual일반 인스턴스 메서드 (대부분)
invokeinterface인터페이스 메서드✓ (다른 방식의 테이블)

5.2 invokestatic — 객체 무관

int max = Math.max(10, 20);

바이트코드:

bipush 10
bipush 20
invokestatic Math.max(II)I

JVM의 동작:
1. Method Area의 Math 클래스 → Static Zone
2. max(II)I 시그니처의 바이트코드 직접 호출

객체도 VMT도 필요 없음. 가장 단순하고 빠른 호출.

5.3 invokespecial — 정적 결정

Shipment s = new Shipment("BL-001");   // 생성자 호출

바이트코드:

new Shipment
dup
ldc "BL-001"
invokespecial Shipment.<init>(Ljava/lang/String;)V

언제 사용?

  • 생성자 (<init>)
  • super.method() 호출
  • private 메서드 호출

→ 이 셋의 공통점: 컴파일 타임에 어떤 메서드가 호출될지 100% 확정.
→ VMT 없이 직접 호출.

5.4 invokevirtual — 다형성의 주역

Animal a = getAnimal();
a.sound();

바이트코드:

aload_1
invokevirtual Animal.sound()V

JVM의 동작:
1. Operand Stack top의 객체 참조 확인
2. 객체의 Class Pointer → 실제 클래스 Klass
3. VMT에서 sound() 슬롯 찾기
4. 그 슬롯이 가리키는 메서드 실행

런타임 디스패치. 진짜 다형성.

5.5 invokeinterface — 인터페이스 호출

List<String> list = new ArrayList<>();
list.add("x");

바이트코드:

aload_1
ldc "x"
invokeinterface List.add(Ljava/lang/Object;)Z

왜 별도 명령인가?

클래스의 VMT는 상속 체인 기반이라 슬롯 번호가 예측 가능.
하지만 클래스가 여러 인터페이스를 구현하면:

class MyList implements List, Serializable, Cloneable {}

add가 어느 인터페이스의 몇 번 슬롯인지 일관성 없음.
별도 테이블(itable) 로 인터페이스 메서드 위치 관리.
→ 약간 느린 호출 (실제로는 미미한 차이).

5.6 4종 비교 — 코드 한 번에

public class Demo {

    public void main() {
        Math.max(10, 20);              // invokestatic

        Animal a = new Dog();           // invokespecial (생성자)
        a.sound();                      // invokevirtual

        List<String> l = new ArrayList<>();  // invokespecial
        l.add("x");                     // invokeinterface

        privateHelper();                // invokespecial (private)
        super.toString();               // invokespecial (super)
    }

    private void privateHelper() {}
}

→ 한 메서드 안에 4종 모두 등장 가능.
javap -c로 직접 확인.


6️⃣ new의 메모리 변화 — 정밀 추적

6.1 한 줄의 코드, 6개 작업

Shipment s = new Shipment("BL-001");

JVM이 실제로 하는 일:

Step 1: Class Pointer 확인
  - Shipment Klass가 로딩됐는가?
  - 안 됐으면 → 클래스 로딩 (Method Area 적재)
  - 됐으면 → 다음 단계

Step 2: Heap에 메모리 할당
  - 객체 크기 계산 (Header + Instance Data + Padding)
  - Young Generation의 Eden에 공간 확보
  - TLAB (Thread Local Allocation Buffer)에서 할당 (스레드별 최적화)

Step 3: 메모리 초기화
  - 할당된 메모리를 0으로 초기화
  - 모든 필드가 자동으로 기본값 (null, 0, false)

Step 4: Object Header 설정
  - Mark Word 초기 상태 (hashcode 미계산, lock 없음)
  - Class Pointer = Shipment Klass 주소

Step 5: 생성자 호출 (invokespecial <init>)
  - this 매개변수에 새 객체의 참조 전달
  - blNo = "BL-001" 등 필드 초기화
  - 부모 생성자 super() 자동 호출 (필요 시)

Step 6: 객체 참조 반환
  - new 표현식의 결과로 객체 주소가 Operand Stack에 push
  - astore_1 등으로 변수 s에 저장

6.2 바이트코드로 보기

Shipment s = new Shipment("BL-001");

바이트코드:

0: new           #2     // class com/ilic/Shipment
3: dup
4: ldc           #3     // String "BL-001"
6: invokespecial #4     // Method <init>(Ljava/lang/String;)V
9: astore_1

한 줄씩 분석:

0: new #2
  - 상수 풀의 #2 항목 = Shipment 클래스 심볼
  - JVM이 Heap에 Shipment 객체 메모리 할당 + Header 설정
  - Operand Stack에 객체 참조 push
  - 단, 아직 생성자는 호출 안 됨 (객체는 미초기화 상태)

3: dup
  - Operand Stack top을 복제
  - 왜? 다음 단계에서 invokespecial이 참조를 "소비"하므로
  - 그 다음 astore_1이 변수에 저장하려면 또 하나가 필요
  
  Stack 변화:
    new 후:  [obj_ref]
    dup 후:  [obj_ref, obj_ref]

4: ldc #3
  - 상수 풀의 #3 = "BL-001" 푸시
  Stack: [obj_ref, obj_ref, "BL-001"]

6: invokespecial #4   (생성자 호출)
  - 인자: obj_ref + "BL-001"
  - 생성자 실행 (this = obj_ref)
  - Stack: [obj_ref]   ← dup 덕에 1개 남음

9: astore_1
  - LVA[1]에 obj_ref 저장
  - Stack: []
  - 변수 s가 이제 객체를 가리킴

→ Unit 1.4에서 본 Operand Stack의 push/pop과 정확히 일치.
dup의 정체: 객체 생성 후 두 번 쓸 거라서 미리 복사.

6.3 메모리 그림 — 시점별

시점 1: new 직후 (생성자 호출 전)

  Heap:
    ┌──────────────────────────┐
    │ Shipment @0x7f4a2c01      │
    │   Mark Word: 초기 상태     │
    │   Class ptr → Shipment Klass│
    │   blNo: null              │  ← 미초기화
    │   eta: null               │
    └──────────────────────────┘

  Stack (Operand):
    [0x7f4a2c01, 0x7f4a2c01]    ← dup 결과

시점 2: 생성자 호출 중

  Stack:
    ┌──────────────────────────┐
    │ <init> 프레임              │
    │   LVA[0] this = 0x7f4a2c01│
    │   LVA[1] blNo  = "BL-001" │
    └──────────────────────────┘

  Heap:
    Shipment 객체 (this를 통해 필드 초기화)

시점 3: 생성자 종료, astore_1 실행

  Heap:
    ┌──────────────────────────┐
    │ Shipment @0x7f4a2c01      │
    │   blNo = "BL-001"         │  ← 초기화됨
    └──────────────────────────┘

  Stack (calling frame):
    LVA[1] s = 0x7f4a2c01       ← 변수 s가 객체 가리킴

6.4 TLAB — 스레드별 할당 최적화

여러 스레드가 동시에 new를 호출하면 → Heap의 같은 영역에 동시 할당 시도 → 경쟁.

해결책: TLAB (Thread Local Allocation Buffer).

Heap의 Eden:
  ┌─────────────────────────────────────────┐
  │  Thread A의 TLAB │  Thread B의 TLAB │ ... │
  └─────────────────────────────────────────┘

각 스레드는 자기 TLAB 안에서 객체를 빠르게 할당 → 동기화 비용 거의 0.
TLAB이 가득 차면 새 TLAB 할당받음.

-XX:+UseTLAB              # 기본 활성화
-XX:TLABSize=512k         # 크기 조정

→ 멀티스레드 환경에서 new가 빠른 비밀.


7️⃣ ILIC 실무 — 다형성과 메모리 비용

7.1 도메인 다형성 — Cargo 종류

public abstract class Cargo {
    protected Long id;
    protected String cargoNo;
    protected BigDecimal weight;

    public abstract BigDecimal calculateFee();   // 다형 메서드
    public abstract boolean isHazardous();
}

public class DryCargo extends Cargo {
    @Override
    public BigDecimal calculateFee() {
        return weight.multiply(new BigDecimal("100"));
    }
    @Override
    public boolean isHazardous() { return false; }
}

public class ReeferCargo extends Cargo {
    private double targetTemperature;

    @Override
    public BigDecimal calculateFee() {
        BigDecimal base = weight.multiply(new BigDecimal("150"));
        BigDecimal coolingFee = new BigDecimal("50000");
        return base.add(coolingFee);
    }
    @Override
    public boolean isHazardous() { return false; }
}

public class HazmatCargo extends Cargo {
    private String unNumber;

    @Override
    public BigDecimal calculateFee() {
        return weight.multiply(new BigDecimal("300"));
    }
    @Override
    public boolean isHazardous() { return true; }
}

7.2 다형성 호출의 메모리 추적

public BigDecimal totalFee(List<Cargo> cargoes) {
    BigDecimal sum = BigDecimal.ZERO;
    for (Cargo c : cargoes) {
        sum = sum.add(c.calculateFee());    // invokevirtual
    }
    return sum;
}

100개의 다양한 Cargo가 섞여 있을 때:

for 루프 매 회 호출 시:
  1. c가 가리키는 객체의 Class Pointer 확인
     - DryCargo? → Dry Klass의 VMT[N] = DryCargo.calculateFee
     - ReeferCargo? → Reefer Klass의 VMT[N] = ReeferCargo.calculateFee
     - HazmatCargo? → Hazmat Klass의 VMT[N] = HazmatCargo.calculateFee
  2. 해당 메서드 실행

→ 같은 invokevirtual 명령이 서로 다른 메서드를 실행
→ 다형성의 본질

메모리 비용:

  • 객체별 데이터(weight, cargoNo 등): 인스턴스마다 별도
  • 메서드 바이트코드: 클래스별로 1개씩만
  • VMT: 클래스별 1개

→ 100개 객체 = 데이터 100배. 메서드는 클래스 수만큼만.

7.3 인터페이스 기반 설계 — invokeinterface

public interface TransportProvider {
    Shipment ship(Cargo cargo, Route route);
    BigDecimal quotePrice(Cargo cargo);
}

public class SeaTransportProvider implements TransportProvider {
    @Override
    public Shipment ship(Cargo cargo, Route route) { ... }
    @Override
    public BigDecimal quotePrice(Cargo cargo) { ... }
}

public class AirTransportProvider implements TransportProvider {
    @Override
    public Shipment ship(Cargo cargo, Route route) { ... }
    @Override
    public BigDecimal quotePrice(Cargo cargo) { ... }
}

// 사용
public Shipment dispatch(Cargo c, Route r, TransportProvider provider) {
    return provider.ship(c, r);   // invokeinterface
}

invokevirtual이 아닌 invokeinterface. 미세하게 느리지만 무시할 수준.
Strategy 패턴의 메모리 구현.

7.4 Spring Bean의 메모리 비용

@Service
public class ShipmentService {
    private final ShipmentRepository repository;
    private final FareCalculator calculator;
    private final List<TransportProvider> providers;
    // ...
}

Spring이 만드는 것:

  • ShipmentService 인스턴스 1개 (Heap)
  • 클래스 정보는 Method Area에 1개
  • 필드는 모두 참조 (4 or 8 bytes 각각)
  • 의존성들도 다른 Bean으로 Heap에 1개씩
싱글톤 Bean 100개 (보통의 Spring 앱):
  Heap: 100개 객체
  Method Area: 100개 클래스 정보 + VMT
  
  + Bean 1개당 평균 100~500 bytes 추정
  → 총합 ~50KB (무시할 수준)

→ Spring DI의 메모리 오버헤드는 사실상 0.
→ 진짜 메모리 사용은 요청별 데이터 객체(Shipment, Cargo, DTO).

7.5 객체 크기 측정 — JOL 라이브러리

// build.gradle
implementation 'org.openjdk.jol:jol-core:0.17'

// 사용
import org.openjdk.jol.info.ClassLayout;

Shipment s = new Shipment("BL-001");
System.out.println(ClassLayout.parseInstance(s).toPrintable());

출력 예:

com.ilic.Shipment object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header: mark)                     0x0000000000000001
      4     4        (object header: class)                    0x00100c30
      8     8   long Shipment.id                               0
     16     4   long Shipment.blNo                             (object)
     20     4   long Shipment.weight                           0
     24     4        (object alignment gap)
Instance size: 28 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

→ 실제 객체 크기를 정확히 확인.
→ 대량 객체 처리 시 메모리 추정에 활용.

7.6 운영 시나리오 — 1만 건 화물 처리

@Transactional
public void processBatch(List<CargoRequest> requests) {
    List<Cargo> cargoes = requests.stream()
        .map(this::toCargo)
        .toList();
    
    cargoRepository.saveAll(cargoes);
}

메모리 계산:

  • Cargo 객체 1개 ≈ 50 bytes (대략)
  • 1만 건 → 약 500KB
  • 추가로 List, Iterator 등 → 100KB 전후
  • 안전하게 1MB 정도 예상

→ 큰 부담 없음. 100만 건이면 50MB 정도 → 청크 처리 검토.


8️⃣ 흔한 실수 + 디버깅

실수 1 — getClass()의 의미 오해

Animal a = new Dog();
a.getClass();        // Dog.class    ← 실제 객체의 클래스
a instanceof Animal; // true         ← 부모 클래스 체크

getClass()실제 객체의 Class Pointer가 가리키는 클래스 반환.
변수 타입(Animal)이 아니다.

// 잘못된 패턴
if (a.getClass() == Animal.class) { ... }    // ❌ Dog면 false

// 올바른 패턴
if (a instanceof Animal) { ... }              // ✓ 부모도 OK

실수 2 — equals와 hashCode의 Class Pointer

public class Shipment {
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Shipment s = (Shipment) o;
        return Objects.equals(id, s.id);
    }
}

getClass() != o.getClass() 체크: 클래스 포인터 동일성.
→ 부모-자식 간에 false (Hibernate 프록시 등 트릭 없을 때).

JPA Entity에서는 instanceof를 권장하기도 함 (프록시 호환).

실수 3 — 객체 크기 과소평가

class Tiny {
    private byte b;     // 1 byte
}

"Tiny는 1 byte짜리"? 아님.

실제: Header 16 + Data 1 + Padding 7 = 24 bytes

→ Header 오버헤드가 데이터보다 크다.
객체 수가 많아질수록 Header 비중 폭증.

해결: 정말 작은 데이터는 객체로 만들지 말고 primitive 배열로.

// ❌ 객체 1억 개 = 약 2.4GB
byte[] data = new byte[100_000_000];

// ✅ 같은 데이터, 1억 byte = 100MB

실수 4 — 동등 비교에 == 사용

String a = new String("hello");
String b = new String("hello");

a == b;          // false  (다른 객체)
a.equals(b);     // true

==참조 비교 (객체 주소).
equals내용 비교 (오버라이드된 정의에 따라).

리터럴 풀(Unit 1.6 예고)에서는 ==가 true일 수도 있어서 더 헷갈림.

실수 5 — VMT 오해 — static 메서드를 override 시도

class Parent {
    public static void greet() { System.out.println("P"); }
}
class Child extends Parent {
    public static void greet() { System.out.println("C"); }    // override 아님!
}

Parent p = new Child();
p.greet();       // "P"  ← Parent 메서드 실행
((Child)p).greet();  // "C"

static 메서드는 VMT에 없음invokestatic은 컴파일 시점에 클래스 결정.
→ 다형성 없음. "메서드 숨김(hiding)" 이라 부름.

@Override 어노테이션 붙이면 컴파일 에러로 잡힘.

실수 6 — Compressed Oops 한계 무시

java -Xmx40g MyApp    # 40GB Heap

→ 32GB 초과 → Compressed Oops 자동 비활성화.
→ 모든 포인터 8 bytes로.
→ 실효 가능 메모리 오히려 줄어들 수 있음.

권장:

java -Xmx30g MyApp    # 32GB 이하 유지
# 또는 ZGC 사용 시 -XX:+UseCompressedOops

실수 7 — 익명 클래스의 메모리 비용

button.setOnClickListener(new OnClickListener() {
    @Override
    public void onClick(View v) { ... }
});

익명 클래스도 별도 Klass 구조 + VMT + Constant Pool.
다수 생성 시 Method Area에 클래스 정보가 누적.

→ Java 8+의 람다는 더 가벼움 (invokedynamic + 캐싱).

button.setOnClickListener(v -> { ... });    // ✓ 람다

디버깅 도구 정리

# 객체 카운트 (살아있는 객체)
jmap -histo:live <PID>

# 출력 예:
#  num     #instances         #bytes  class name
#    1:        125000        4000000  com.ilic.Shipment
#    2:        250000        2000000  com.ilic.Cargo
#    3:       1000000       16000000  java.lang.String
#    ...

# 클래스별 인스턴스 추적
jcmd <PID> GC.class_histogram

# Heap dump 분석 (Eclipse MAT)
# - 인스턴스 카운트
# - Retained Size (이 객체를 GC하면 풀려날 메모리)
# - GC Roots 경로

9️⃣ 면접 질문 + 자기 점검

9.1 면접 단골 질문 매핑

Q핵심 답변
객체는 자기 클래스 메서드를 어떻게 찾는가?Object Header의 Class Pointer → Method Area의 Klass → VMT
Object Header의 구성?Mark Word (8B) + Class Pointer (4 or 8B)
객체 100개 만들면 메서드 몇 개?1개. 클래스에 속함. 객체는 Class Pointer로 공유
Mark Word에 저장되는 정보?hashcode, GC age, lock 상태 등
VMT가 다형성을 구현하는 방식?같은 슬롯 번호를 자식 메서드가 덮어씀. 런타임에 객체의 VMT 참조
invoke 4종 차이?static(객체 무관), special(정적 확정), virtual(VMT), interface(itable)
Compressed Oops의 효과?32GB 이하 Heap에서 포인터 4바이트. 메모리 절약
32GB 넘으면?자동 비활성화 → 포인터 8바이트 → 실효 메모리 감소
TLAB은 무엇?스레드별 Heap 할당 버퍼. 동기화 없이 객체 생성
getClass() == Foo.class의 의미?Class Pointer 동일성 검사. 정확한 클래스 매치

9.2 자기 점검 체크리스트

기본 이해

  • Object Header가 Mark Word + Class Pointer로 구성됨을 안다
  • Class Pointer가 객체와 Method Area를 연결한다는 점을 안다
  • VMT가 다형성을 구현하는 메커니즘을 설명할 수 있다
  • invoke 4종의 차이를 안다
  • Compressed Oops의 효과와 한계를 안다

실전 적용

  • JOL로 객체 크기를 측정할 수 있다
  • 도메인 다형성을 invokevirtual로 구현할 수 있다
  • static vs instance 메서드 디스패치 차이를 안다
  • jmap -histo로 클래스별 인스턴스 수를 확인할 수 있다
  • 다수 객체 처리 시 메모리 추정을 할 수 있다

면접 대비 — 5분 답변

  • 객체-클래스 연결 메커니즘 (Class Pointer → Klass → VMT)
  • 다형성의 메모리 구현 (VMT 슬롯 오버라이드)
  • invoke 4종과 그 효율성
  • Compressed Oops와 32GB 경계
  • new 키워드의 6단계 동작

🎯 핵심 요약 — 3줄 정리

1. Object Header가 객체와 클래스를 연결한다

  • Mark Word (8B): GC, lock, hashcode 정보
  • Class Pointer (4 or 8B): Method Area의 Klass 가리킴
  • 객체 N개 → 모두 같은 Klass 가리킴 → 메서드는 1개

2. VMT가 다형성의 메모리 구현체

  • 각 클래스가 자기 VMT를 가짐
  • 자식이 같은 슬롯을 덮어씀 = 오버라이드
  • invokevirtual 호출 시 객체의 VMT 참조 → 런타임 디스패치

3. invoke 4종이 모든 메서드 호출을 처리한다

  • invokestatic — VMT 무관, 가장 빠름
  • invokespecial — 생성자/private/super, 정적 확정
  • invokevirtual — 일반 다형성
  • invokeinterface — 인터페이스, itable 사용

📚 다음으로...

Unit 1.6 — Literal Pool Area

이번 Unit에서 객체와 클래스의 연결을 봤다면, 다음은 String 같은 특별한 객체의 재활용 메커니즘.

  • String Constant Pool의 정확한 위치 (Heap? Method Area?)
  • 리터럴 "hello"new String("hello")의 차이
  • String.intern()의 동작
  • Integer Cache (-128 ~ 127)
  • 메모리 효율을 위한 JVM의 다른 풀(pool) 들

→ Phase 1의 마지막 Unit. 이후 Phase 2로 진입.

2주차 진행 상황

  • ✅ Unit 1.1 자바 변수의 3종류
  • ✅ Unit 1.2 변수별 저장 위치
  • ✅ Unit 1.3 Method Area의 3개 존 ★
  • ✅ Unit 1.4 Stack Area의 동작
  • ✅ Unit 1.5 Heap과 객체-Metadata 연결 — 이 문서
  • ⏭ Unit 1.6 Literal Pool Area (Phase 1 마지막)
  • ⏭ Phase 2 (메서드 실행 메커니즘)
  • ⏭ Phase 3 (바이트코드와 상수 풀) ★ 정점

Phase 3 진입까지 — 점점 가까워지는 정점

이번 Unit에서 다룬 것:

  • 객체 → Class Pointer → Klass → VMT
  • 메서드 호출 = invokevirtual 명령
  • 상수 풀의 #N 인덱스 표기 등장

Phase 3에서 다룰 것:

  • 상수 풀의 모든 종류 (Class, String, MethodRef, ...)
  • 심볼 참조 → 실제 참조 해소(Resolution) 과정
  • javap -v로 클래스 파일 완전 분해

→ 1.5까지 와서 Phase 3 진입 준비 거의 완료.

profile
Software Developer

0개의 댓글