어느 구현을 실제로 부를지 정하는 절차. 바이트코드 명령이 방식을 결정한다.
- invokestatic — static 메서드 — 컴파일 타임에 확정
- invokespecial — 생성자 · private · super 호출 — 확정
- invokevirtual — 일반 인스턴스 메서드 — 런타임 실제 타입으로 결정
- invokeinterface — 인터페이스 메서드 — 런타임 결정, 탐색이 조금 더 복잡
- invokedynamic — 호출 지점을 런타임에 연결 — 람다 · 문자열 결합
오버로딩과 오버라이딩이 갈리는 지점
void f(Object o) { print("Object"); }
void f(String s) { print("String"); }
Object x = "hi";
f(x); // "Object" — 오버로딩은 컴파일 타임 정적 타입으로 고른다
Parent p = new Child();
p.hello(); // Child 의 구현 — 오버라이딩은 런타임 실제 타입
-
오버로딩 — '어느 시그니처인가' → 정적 타입 (컴파일 타임)
-
오버라이딩 '어느 구현인가' — → 실제 타입 (런타임)
-
둘을 섞으면 직관을 배신한다 — 오버로딩은 되도록 시그니처를 확실히 구분한다
vtable 과 인라인 캐시
각 클래스는 메서드 테이블(vtable) 을 갖는다 invokevirtual 은 객체 헤더 → 클래스 → vtable 슬롯을 따라간다
그런데 실제로는 대부분 한 종류만 온다
→ JIT 이 호출 지점에서 관찰한다
단형(monomorphic) 구현이 하나뿐 → 인라인해 버린다 (사실상 공짜)
이형(bimorphic) 둘 → 분기 후 인라인
다형(megamorphic) 셋 이상 → 인라인 포기, 진짜 vtable 조회
"인터페이스가 느리다" 가 대체로 사실이 아닌 이유이자, 구현체를 무분별하게 늘리면 실제로 느려질 수 있는 이유다
private·static 이 빠른 이유
오버라이딩될 수 없으므로 대상이 확정된다 — 조회도 추측도 필요 없고 인라인도 쉽다.
인라인이 최적화의 관문인 이유
인라인 자체의 이득(호출 비용 제거) 은 사실 작다 진짜 이득은 그다음이다
- 인라인 → 두 메서드가 한 덩어리가 된다
- 상수 전파 · 죽은 코드 제거 · 이스케이프 분석이 가능해진다
- 객체가 아예 안 만들어지기도 한다 (스칼라 치환)
그래서 인라인이 막히면 뒤따르는 최적화가 통째로 사라진다 막히는 이유
- 메서드가 너무 크다 (-XX:MaxInlineSize · FreqInlineSize)
- 호출 지점이 다형(megamorphic)
- 호출 대상이 런타임까지 확정 안 됨
CHA 와 역최적화
클래스 계층 분석(CHA): 현재 로딩된 클래스 중 구현이 하나뿐이면 JIT 은 가상 호출을 확정 호출로 바꿔 인라인한다
나중에 두 번째 구현이 로딩되면?
- 그 가정이 깨진다 → 컴파일된 코드를 폐기하고 인터프리터로 되돌린다
- (deoptimization) 후 다시 컴파일
인터페이스에 구현이 하나뿐인 코드가 빠른 이유이자, 런타임에 구현체가 늘어나면 성능이 변할 수 있는 이유다
면접 함정
- ❌ "오버로딩도 런타임에 정해진다" → 컴파일 타임이다.
- ❌ "가상 호출은 항상 비싸다" → 단형 호출은 JIT이 인라인해 없앤다.