자바 다형성의 비밀: 메서드 오버라이딩이 메모리에서 작동하는 방식
지난 포스트에서 상속이 메모리에서 어떻게 구현되는지 알아봤습니다. 오늘은 한 단계 더 나아가서 메서드 오버라이딩과 다형성이 실제 메모리에서 어떻게 작동하는지 깊이 있게 탐구해보겠습니다.
다형성이란 무엇인가?
다형성(Polymorphism)은 "같은 인터페이스로 다른 동작을 수행하는 능력"을 말합니다. 자바에서는 부모 클래스 타입의 참조 변수가 자식 클래스의 오버라이딩된 메서드를 호출할 수 있게 해주죠.
Animal animal = new Dog(); // 부모 타입 참조, 자식 객체
animal.sound(); // Dog의 sound() 메서드가 실행됨!
하지만 JVM은 어떻게 실행 시점에 정확한 메서드를 찾아서 호출할 수 있을까요? 🤔
가상 메서드 테이블(Virtual Method Table)
자바에서 다형성의 핵심은 가상 메서드 테이블(VTable) 또는 메서드 디스패치 테이블입니다.
클래스별 VTable 생성
class Animal {
void sound() { System.out.println("동물 소리"); }
void sleep() { System.out.println("동물이 잠듭니다"); }
}
class Dog extends Animal {
@Override
void sound() { System.out.println("멍멍!"); }
void bark() { System.out.println("짖습니다"); }
}
class Cat extends Animal {
@Override
void sound() { System.out.println("야옹!"); }
void meow() { System.out.println("울음소리"); }
}
각 클래스는 자신만의 VTable을 가집니다:
[Animal VTable]
Index 0: Animal.sound()
Index 1: Animal.sleep()
[Dog VTable]
Index 0: Dog.sound() ← 오버라이딩됨!
Index 1: Animal.sleep() ← 상속됨
Index 2: Dog.bark()
[Cat VTable]
Index 0: Cat.sound() ← 오버라이딩됨!
Index 1: Animal.sleep() ← 상속됨
Index 2: Cat.meow()
🔍 핵심: 오버라이딩된 메서드는 같은 인덱스 위치에 자식 클래스의 메서드 주소가 저장됩니다!
메모리 구조와 메서드 호출 과정
1. 객체 생성 시 메모리 할당
Animal animal1 = new Dog();
Animal animal2 = new Cat();
[Heap Memory]
┌─────────────────────┐
│ Dog 객체 │
├─────────────────────┤
│ VTable 포인터 ──────┼─→ Dog VTable
│ 인스턴스 변수들 │
└─────────────────────┘
┌─────────────────────┐
│ Cat 객체 │
├─────────────────────┤
│ VTable 포인터 ──────┼─→ Cat VTable
│ 인스턴스 변수들 │
└─────────────────────┘
모든 객체는 VTable을 가리키는 포인터를 첫 번째 멤버로 가집니다.
2. 다형적 메서드 호출 과정
animal1.sound(); // 어떤 sound()가 호출될까요?
JVM의 메서드 호출 과정:
- 객체의 VTable 포인터 확인: animal1이 가리키는 객체의 VTable을 찾음
- 메서드 인덱스 계산: sound() 메서드는 인덱스 0
- 실제 메서드 주소 조회: Dog VTable의 인덱스 0에서 Dog.sound() 주소 획득
- 메서드 실행: 해당 주소의 메서드를 실행
animal1.sound() 호출 과정:
┌─────────────┐ ┌─────────────────┐ ┌──────────────────┐
│ animal1 │─→ │ Dog 객체 │─→ │ Dog VTable │
│ (Animal타입)│ │ VTable 포인터 │ │ [0]: Dog.sound() │
└─────────────┘ └─────────────────┘ │ [1]: Animal.sleep│
└──────────────────┘
런타임 vs 컴파일타임 바인딩
정적 바인딩 (컴파일타임)
Dog dog = new Dog();
dog.sound(); // 컴파일 시점에 Dog.sound()로 결정
동적 바인딩 (런타임)
Animal animal = new Dog();
animal.sound(); // 실행 시점에 실제 객체의 VTable을 통해 결정
실제 코드로 확인해보기
public class PolymorphismDemo {
public static void main(String[] args) {
Animal[] animals = {
new Dog(),
new Cat(),
new Dog()
};
// 각각 다른 sound() 메서드가 호출됨
for (Animal animal : animals) {
animal.sound(); // 동적 바인딩!
}
}
}
출력 결과:
멍멍!
야옹!
멍멍!
같은 animal.sound() 코드가 서로 다른 메서드를 실행합니다!
메서드 오버라이딩의 메모리 최적화
1. 메서드 인라이닝
JIT 컴파일러는 자주 호출되는 가상 메서드를 최적화합니다:
// 런타임에 Dog 객체임이 확실하면
Animal animal = new Dog();
animal.sound(); // → Dog.sound()로 직접 호출하도록 최적화
2. 타입 예측 (Type Prediction)
JVM은 통계를 바탕으로 가장 가능성 높은 타입을 예측하고 최적화합니다.
인터페이스와 다형성
interface Drawable {
void draw();
}
class Circle implements Drawable {
public void draw() { System.out.println("원 그리기"); }
}
class Rectangle implements Drawable {
public void draw() { System.out.println("사각형 그리기"); }
}
인터페이스 구현도 동일한 VTable 메커니즘을 사용합니다:
Drawable shape = new Circle();
shape.draw(); // Circle.draw()가 호출됨
성능 고려사항
VTable 조회 비용
- 가상 메서드 호출: 포인터 참조 + 배열 인덱싱
- 직접 메서드 호출: 즉시 메서드 실행
- 성능 차이: 보통 1-2 CPU 사이클 정도
최적화 팁
// 성능이 중요한 경우
final class OptimizedClass { // final로 선언하여 오버라이딩 방지
public void method() { ... } // 직접 호출 가능
}
메모리 오버헤드
각 클래스당 하나의 VTable만 생성되므로:
- 공간 효율성: 모든 인스턴스가 VTable 공유
- 시간 효율성: 빠른 메서드 디스패치
- 메모리 사용량: 클래스당 VTable 크기 = (메서드 수 × 포인터 크기)
정리
자바의 다형성과 메서드 오버라이딩은 다음과 같은 메모리 메커니즘으로 구현됩니다:
- VTable: 각 클래스마다 메서드 주소 배열 생성
- VTable 포인터: 모든 객체가 자신의 VTable을 가리키는 포인터 보유
- 동적 바인딩: 런타임에 VTable을 통해 실제 실행할 메서드 결정
- 최적화: JIT 컴파일러가 성능을 위해 다양한 최적화 기법 적용
이러한 메커니즘 덕분에 자바는 타입 안전성을 보장하면서도 유연한 다형성을 제공할 수 있습니다. 컴파일 타임에는 타입 체크를, 런타임에는 올바른 메서드 실행을 보장하는 것이죠!
다음 포스트에서는 자바의 인터페이스 다중 상속과 디폴트 메서드가 메모리에서 어떻게 처리되는지 알아보겠습니다! 🚀