본문 바로가기

Language/Java

[Java] invokevirtual과 invokeinterface — 바이트코드부터 vtable/itable까지

이전 포스트에서 클래스 로딩과 Klass 구조를 다뤘습니다. 이번엔 그 위에서 메서드 호출이 실제로 어떻게 이뤄지는지를 살펴보겠습니다.

 


1. invoke 명령어 종류

Java 메서드 호출은 상황에 따라 다른 바이트코드로 컴파일됩니다.

바이트코드 대상
invokevirtual 일반 인스턴스 메서드
invokeinterface 인터페이스 메서드
invokespecial 생성자, private, super 호출
invokestatic static 메서드
invokedynamic 람다, 동적 호출

 

이 중 invokevirtualinvokeinterface동적 디스패치(dynamic dispatch)가 일어나는 핵심 명령어입니다. 둘 다 런타임에 실제 객체 타입을 확인해 메서드를 찾지만, 내부 구조는 다릅니다.

 


2. 바이트코드 레벨에서 보기

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

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

class Main {
    public static void main(String[] args) {
        Animal animal = new Dog();
        animal.sound();
    }
}

 

 

javap -verbose Main으로 바이트코드를 확인하면 main 메서드 부분이 이렇게 나옵니다.

 0: new           #7                  // class Dog
 3: dup
 4: invokespecial #9                  // Method Dog."<init>":()V
 7: astore_1
 8: aload_1
 9: invokevirtual #10                 // Method Animal.sound:()V
12: return

invokevirtual #10에서 #10상수 풀(constant pool)의 심볼릭 레퍼런스 인덱스입니다. 컴파일 타임엔 Animal.sound라는 이름만 기록되고, 실제로 어느 클래스의 메서드를 실행할지는 런타임에 결정됩니다.

이걸 런타임에 어떻게 결정하는가 — 그 답이 객체 헤더와 vtable에 있습니다.

 


3. OOP와 객체 헤더 구조

런타임에 receiver가 어느 클래스의 인스턴스인지 파악하려면, 객체 자체에 그 정보가 담겨 있어야 합니다. HotSpot JVM에서 모든 Java 객체는 힙에 다음 구조로 저장됩니다.

 

HotSpot 소스에서 이 구조를 가리키는 포인터 타입을 oop(ordinary object pointer)라고 부릅니다. oop는 단순한 주소값이 아니라, 이 헤더 레이아웃을 전제로 한 타입입니다.

 

Mark Word

Mark Word는 객체의 런타임 상태를 담는 8바이트(64-bit JVM 기준) 필드입니다. 락 상태에 따라 내용이 달라집니다.

하위 2비트가 태그 역할을 해서 현재 상태를 구분합니다.

JDK 버전 참고: Biased locking은 JDK 15에서 deprecated되고 JDK 21에서 완전히 제거됐습니다. JDK 21 이상에서는 위 4가지 상태만 존재하며, JDK 15 이전에서는 Biased locking 상태(101 태그)가 추가로 있습니다.

invokevirtual 실행 자체에서 Mark Word는 직접 관여하지 않습니다. 다만 synchronized 메서드라면 진입 전에 Mark Word로 락 획득을 처리합니다.

 

Klass Word

Klass Word는 이 객체가 어떤 클래스의 인스턴스인지를 가리키는 포인터입니다. InstanceKlass*를 담고 있으며, 64-bit JVM에서는 기본적으로 Compressed Class Pointer가 활성화되어 4바이트로 압축 저장됩니다.

 

invokevirtual이 런타임에 하는 핵심 작업은 바로 이 Klass Word를 읽어서 InstanceKlass의 vtable로 진입하는 것입니다.

 


4. vtable 구조와 인덱스 할당

HotSpot은 클래스 로딩/링킹 시 각 InstanceKlassvtable(virtual method table)을 만듭니다. vtable은 InstanceKlass 구조체 바로 뒤 메모리에 인접 배치된 메서드 포인터 배열입니다.

인덱스 할당 규칙은 단순합니다. 부모 vtable을 그대로 복사한 뒤, 오버라이드 항목만 포인터를 교체합니다.

  • 새 메서드 (오버라이드 아님) → 부모 vtable 끝에 새 인덱스 추가
  • 오버라이드된 메서드 → 부모와 동일한 인덱스 유지, 포인터만 교체

이 덕분에 Animal 참조든 Dog 참조든 sound항상 인덱스 4에 있습니다. invokevirtual은 receiver 타입을 몰라도 인덱스만 알면 올바른 메서드를 찾을 수 있습니다.

 


5. invokevirtual 실행 흐름

컴파일 타임 정보와 런타임 구조가 어떻게 연결되는지 전체 흐름으로 보면 다음과 같습니다.

animal 변수가 Dog 인스턴스를 가리키고 있다면, Klass Word는 DogInstanceKlass를 가리키고, vtable[4]Dog.sound의 구현체를 가리킵니다. 컴파일 타임에 Animal.sound로 기록되어 있어도 런타임에 자동으로 Dog.sound가 호출됩니다. 이것이 Java 다형성의 구현 원리입니다.

 


6. invokeinterface와 itable

invokevirtual이 클래스 상속 계층을 기반으로 한다면, invokeinterface인터페이스 구현을 기반으로 합니다. 왜 별도 명령어와 별도 테이블이 필요한지부터 살펴보겠습니다.

 

왜 vtable을 그대로 쓸 수 없는가

vtable이 동작하는 이유는 상속 계층에서 메서드 인덱스가 항상 일정하기 때문입니다. 그런데 인터페이스에서는 이 전제가 성립하지 않습니다.

interface Flyable  { void fly(); }
interface Swimmable { void swim(); }

class Duck     implements Flyable, Swimmable { ... }
class Airplane implements Flyable            { ... }

DuckAirplane 모두 Flyable을 구현하지만, 각자의 vtable에서 fly()같은 인덱스에 있다는 보장이 없습니다. DuckObject 메서드 뒤에 fly, swim 순으로 들어갈 수도 있고, AirplaneObject 메서드 뒤 바로 fly가 올 수도 있습니다. vtable 인덱스는 클래스별 상속 계층에 따라 결정되므로, 인터페이스 타입만 보고는 어느 인덱스를 써야 할지 알 수 없습니다.

 

itable 구조

HotSpot은 이 문제를 itable(interface table)로 해결합니다. itable은 vtable 바로 뒤 메모리에 이어서 배치되며, 두 영역으로 구성됩니다.

  • offset table: 이 클래스가 구현한 인터페이스 목록입니다. 각 항목은 인터페이스 Klass*와 해당 인터페이스의 method table 시작 오프셋을 담습니다.
  • method table: 인터페이스별로 구현 메서드 포인터를 인터페이스 선언 순서대로 저장합니다.

fly()Flyable에서 0번째 메서드라면, Duck이든 Airplane이든 각자의 Flyable method table에서 [0]을 읽으면 됩니다. 인터페이스 내부 인덱스는 일관되기 때문입니다.

 

invokeinterface 실행 흐름

invokevirtual과 비교하면 ④ offset table 탐색 단계가 추가됩니다. 구현한 인터페이스 수만큼 선형 순회하므로 이론상 O(n)이지만, 인터페이스를 수십 개씩 구현하는 경우는 드물어 실제 비용은 크지 않습니다. JIT이 충분히 워밍업되면 inline cache로 이 탐색 자체가 생략됩니다.

 

바이트코드 피연산자 차이

javap -verbose로 보면 두 명령어의 피연산자 크기가 다릅니다.

invokevirtual   #10                 // 명령어(1) + 인덱스(2) = 3바이트
invokeinterface #12, 2              // 명령어(1) + 인덱스(2) + count(1) + 0x00(1) = 5바이트

count 바이트는 초기 JVM 스펙에서 receiver를 포함한 인수의 총 개수를 명시하던 필드입니다. 현재는 사용하지 않지만 하위 호환성을 위해 자리가 유지되고, javac는 항상 올바른 값을 채워넣습니다.

 


7. invokevirtual vs invokeinterface

  invokevirtual invokeinterface
대상 클래스 인스턴스 메서드 인터페이스 메서드
디스패치 테이블 vtable itable
인덱스 일관성 상속 계층 전체에서 고정 인터페이스 내부에서만 고정
런타임 탐색 vtable[k] 직접 접근 offset table 순회 후 method table[j]
바이트코드 크기 3바이트 5바이트
JIT 최적화 devirtualization inline cache로 탐색 제거

두 명령어 모두 Klass Word → 테이블 → 메서드 포인터 흐름은 동일합니다. 차이는 테이블 탐색 방식으로, vtable은 인덱스가 전역 고정이라 바로 접근하고, itable은 인터페이스를 먼저 특정한 뒤 그 안의 인덱스로 접근합니다.

 


8. 정리

     
단계 invokevirtual invokeinterface
컴파일 상수 풀에 클래스.메서드 심볼릭 레퍼런스 상수 풀에 인터페이스.메서드 심볼릭 레퍼런스
클래스 로딩 · 링킹 vtable 생성, 인덱스 할당, 오버라이드 시 포인터 교체 itable 생성, 인터페이스별 method table 구성
런타임 첫 호출 심볼릭 레퍼런스 → vtable 인덱스 k resolve 심볼릭 레퍼런스 → 인터페이스 Klass* + 메서드 인덱스 j resolve
런타임 디스패치 Klass Word → vtable[k] Klass Word → itable 탐색 → method table[j]

Java 참조 타입이 무엇이든 런타임에 올바른 메서드가 호출되는 것은 객체 헤더의 Klass Word 덕분입니다. invokevirtual은 vtable 인덱스 일관성으로, invokeinterface는 itable 탐색으로 각자의 다형성 문제를 해결합니다.

 

 

다음으로 살펴볼 주제: invokespecial — 정적 디스패치, 생성자 체인, super 호출의 의미론