F-LAB JAVA · 2주차 · Phase 1 · 자바 변수 ↔ 메모리 영역의 매핑
이 Unit을 끝내면 다음을 답할 수 있어야 한다.
invokevirtual · invokespecial · invokestatic · invokeinterface 의 차이는?Animal a = new Dog(); a.sound(); 가 왜 Dog의 sound를 호출하는가? (메모리 추적)객체는 Heap의 데이터 컨테이너일 뿐, 행동(메서드)은 Method Area에 있다.
둘을 연결하는 다리가 Object Header의 Class Pointer, 그리고 VMT 다.
이 다리 덕분에 객체 N개를 만들어도 메서드는 1개만 존재하며, 다형성도 이 다리 위에서 동작한다.
| 메모리 요소 | 비유 |
|---|---|
| Heap의 객체 | 회사 직원 (이름·소속·전화번호 같은 개인 정보) |
| Object Header | 직원 ID 카드 (회사 매뉴얼 위치가 적힌 바코드) |
| Class Pointer | 매뉴얼 위치 바코드 |
| Method Area의 클래스 | 회사 매뉴얼 (모든 직원이 따르는 행동 규칙) |
| VMT | 매뉴얼의 목차 (각 행동의 페이지 번호) |
직원 100명을 뽑아도 매뉴얼은 1권. 직원마다 자기 ID 카드로 매뉴얼을 펴서 행동 규칙 찾음.
자회사 직원(서브클래스) 은 본사 매뉴얼을 일부 자기 회사 버전으로 덮어쓴 매뉴얼을 쓴다. → 다형성.
1. 객체와 클래스의 연결 — 왜 연결고리가 필요한가
2. Object Header 정밀 구조
3. Class Pointer — 객체 → Metadata 화살표
4. VMT (Virtual Method Table) — 다형성의 메모리 구현
5. invoke 4종 — 메서드 디스패치 메커니즘
6. new의 메모리 변화 — 정밀 추적
7. ILIC 실무 — 다형성과 메모리 비용
8. 흔한 실수 + 디버깅
9. 면접 질문 + 자기 점검
지금까지 본 것:
Method Area:
Shipment 클래스
• calculate() 바이트코드 ← 메서드는 여기, 1개만
• getBlNo() 바이트코드
Heap:
Shipment 객체 #1 ← 데이터만 있음
Shipment 객체 #2
Shipment 객체 #3
...
→ 데이터는 Heap에, 코드는 Method Area에. 물리적으로 다른 메모리.
Shipment s = new Shipment("BL-001");
s.calculate(); // ← 객체에서 메서드를 호출
s는 Heap의 객체를 가리킬 뿐인데, 어떻게 Method Area의 calculate() 까지 찾아가나?
→ 연결고리가 필요하다.
Heap:
┌──────────────────────┐
│ Shipment 객체 #1 │
│ ┌────────────────┐ │
│ │ Object Header │ │
│ │ Class ptr ────┼──┼──┐
│ └────────────────┘ │ │
│ blNo = "BL-001" │ │
│ eta = 2024-12-01 │ │
└──────────────────────┘ │
▼
Method Area: ┌─────────────────────┐
│ Shipment 클래스 │
│ 필드 선언 │
│ 메서드 시그니처 │
│ VMT │
│ 바이트코드 │
└─────────────────────┘
모든 객체는 Object Header를 가지고, 그 안에 Class Pointer가 있다.
이 Class Pointer가 객체와 클래스를 연결.
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에서 본 결론의 메모리 메커니즘.
자바의 모든 객체는 데이터 앞에 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 바이트 정렬용
└──────────────────────────────────┘
Mark Word는 8바이트에 객체의 다양한 상태를 비트 단위로 압축 저장.
저장되는 것:
Object.hashCode()의 결과 캐시)상태에 따라 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]
→ 운영 분석에서 직접 다룰 일은 적지만, 객체가 단순한 데이터가 아니라는 점이 중요.
Mark Word 다음의 4 또는 8바이트가 Class Pointer.
Object Header:
[Mark Word: 8 bytes ]
[Class Pointer: 4 or 8 bytes] ← Method Area의 클래스 metadata 주소
이 포인터가 가리키는 곳:
Klass 라는 구조체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 GB | 4 bytes | ✓ 자동 활성화 |
| > 32 GB | 8 bytes | ✗ |
옵션:
-XX:+UseCompressedOops # 기본 활성화 (Heap ≤ 32GB)
-XX:-UseCompressedOops # 비활성화
→ Heap 32GB 직전까지 키우는 게 가장 효율적. 32GB를 살짝 넘기면 포인터가 2배 커져서 오히려 사용 가능 메모리가 줄어든다.
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());
Method Area의 클래스 정보는 HotSpot 내부적으로 Klass 구조체로 표현.
Klass (Shipment 클래스):
├─ 클래스명: "com.ilic.Shipment"
├─ 부모 Klass 포인터 → BaseEntity Klass
├─ 인터페이스 Klass 포인터 목록
├─ 필드 선언 정보
├─ 메서드 정보 (이름, 시그니처, 바이트코드 위치)
├─ VMT (Virtual Method Table) ← 다음 섹션
├─ 상수 풀
└─ static 필드 위치
객체가 자기 클래스 정보를 알고 싶을 때:
객체의 Class Pointer → Klass 구조체 → 모든 클래스 정보
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 구조체에서 정보를 읽어오는 것.
== 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() 비교가 빠른 이유: 포인터 동일성 검사.
shipment instanceof BaseEntity; // true
JVM의 동작:
1. shipment의 Class Pointer → Shipment Klass
2. Shipment Klass의 부모 체인 따라감: Shipment → BaseEntity → Object
3. BaseEntity Klass와 일치하는 항목이 있으면 true
→ instanceof는 결국 Klass 체인 탐색.
→ 클래스 계층이 깊을수록 instanceof는 느려질 수 있음 (대부분 무시할 수준).
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).
각 클래스의 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만의 메서드 추가
└─────────────────────────────────────┘
핵심 규칙:
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에 따라 다른 메서드 실행.
대안과 비교:
→ 다형성 호출이 거의 일반 호출만큼 빠른 이유.
JIT 컴파일러는 한 발 더 나아가:
class Animal {
public final void breathe() { ... } // final
}
final 메서드는 오버라이드 불가 → VMT 슬롯이 절대 안 바뀜.
→ JIT가 더 적극적으로 인라이닝 가능 (정적 디스패치).
class Animal {
public static void info() { ... } // static
}
static 메서드는 VMT에 들어가지 않음. 클래스에 직접 호출. (다음 섹션의 invokestatic).
JVM 바이트코드의 메서드 호출 명령은 정확히 4종류:
| 명령 | 호출 대상 | VMT 사용? |
|---|---|---|
invokestatic | static 메서드 | ❌ (Static Zone 직접) |
invokespecial | 생성자, private, super 호출 | ❌ (정적 결정) |
invokevirtual | 일반 인스턴스 메서드 (대부분) | ✓ |
invokeinterface | 인터페이스 메서드 | ✓ (다른 방식의 테이블) |
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도 필요 없음. 가장 단순하고 빠른 호출.
Shipment s = new Shipment("BL-001"); // 생성자 호출
바이트코드:
new Shipment
dup
ldc "BL-001"
invokespecial Shipment.<init>(Ljava/lang/String;)V
언제 사용?
<init>)super.method() 호출→ 이 셋의 공통점: 컴파일 타임에 어떤 메서드가 호출될지 100% 확정.
→ VMT 없이 직접 호출.
Animal a = getAnimal();
a.sound();
바이트코드:
aload_1
invokevirtual Animal.sound()V
JVM의 동작:
1. Operand Stack top의 객체 참조 확인
2. 객체의 Class Pointer → 실제 클래스 Klass
3. VMT에서 sound() 슬롯 찾기
4. 그 슬롯이 가리키는 메서드 실행
→ 런타임 디스패치. 진짜 다형성.
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) 로 인터페이스 메서드 위치 관리.
→ 약간 느린 호출 (실제로는 미미한 차이).
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로 직접 확인.
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에 저장
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의 정체: 객체 생성 후 두 번 쓸 거라서 미리 복사.
시점 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가 객체 가리킴
여러 스레드가 동시에 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가 빠른 비밀.
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; }
}
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 명령이 서로 다른 메서드를 실행
→ 다형성의 본질
메모리 비용:
→ 100개 객체 = 데이터 100배. 메서드는 클래스 수만큼만.
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 패턴의 메모리 구현.
@Service
public class ShipmentService {
private final ShipmentRepository repository;
private final FareCalculator calculator;
private final List<TransportProvider> providers;
// ...
}
Spring이 만드는 것:
ShipmentService 인스턴스 1개 (Heap)싱글톤 Bean 100개 (보통의 Spring 앱):
Heap: 100개 객체
Method Area: 100개 클래스 정보 + VMT
+ Bean 1개당 평균 100~500 bytes 추정
→ 총합 ~50KB (무시할 수준)
→ Spring DI의 메모리 오버헤드는 사실상 0.
→ 진짜 메모리 사용은 요청별 데이터 객체(Shipment, Cargo, DTO).
// 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
→ 실제 객체 크기를 정확히 확인.
→ 대량 객체 처리 시 메모리 추정에 활용.
@Transactional
public void processBatch(List<CargoRequest> requests) {
List<Cargo> cargoes = requests.stream()
.map(this::toCargo)
.toList();
cargoRepository.saveAll(cargoes);
}
메모리 계산:
→ 큰 부담 없음. 100만 건이면 50MB 정도 → 청크 처리 검토.
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
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를 권장하기도 함 (프록시 호환).
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
String a = new String("hello");
String b = new String("hello");
a == b; // false (다른 객체)
a.equals(b); // true
==는 참조 비교 (객체 주소).
equals는 내용 비교 (오버라이드된 정의에 따라).
리터럴 풀(Unit 1.6 예고)에서는 ==가 true일 수도 있어서 더 헷갈림.
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 어노테이션 붙이면 컴파일 에러로 잡힘.
java -Xmx40g MyApp # 40GB Heap
→ 32GB 초과 → Compressed Oops 자동 비활성화.
→ 모든 포인터 8 bytes로.
→ 실효 가능 메모리 오히려 줄어들 수 있음.
권장:
java -Xmx30g MyApp # 32GB 이하 유지
# 또는 ZGC 사용 시 -XX:+UseCompressedOops
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 경로
| 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 동일성 검사. 정확한 클래스 매치 |
jmap -histo로 클래스별 인스턴스 수를 확인할 수 있다1. Object Header가 객체와 클래스를 연결한다
2. VMT가 다형성의 메모리 구현체
invokevirtual 호출 시 객체의 VMT 참조 → 런타임 디스패치3. invoke 4종이 모든 메서드 호출을 처리한다
invokestatic — VMT 무관, 가장 빠름invokespecial — 생성자/private/super, 정적 확정invokevirtual — 일반 다형성invokeinterface — 인터페이스, itable 사용이번 Unit에서 객체와 클래스의 연결을 봤다면, 다음은 String 같은 특별한 객체의 재활용 메커니즘.
"hello"와 new String("hello")의 차이String.intern()의 동작→ Phase 1의 마지막 Unit. 이후 Phase 2로 진입.
이번 Unit에서 다룬 것:
invokevirtual 명령Phase 3에서 다룰 것:
javap -v로 클래스 파일 완전 분해→ 1.5까지 와서 Phase 3 진입 준비 거의 완료.