-
[JVM] 바이트코드 실행 엔진DevBook/JVM 밑바닥까지 파헤치기 2025. 4. 5. 17:52
- <자바 가상 머신 명세>는 바이트코드 실행 엔진의 개념 모델을 정의함
- 가상 머신 구현에서 실행 엔진이 바이트코드를 실행하는 방법은 해석 실행(인터프리터를 통한 실행)과 컴파일 실행(JIT 컴파일러로 네이티브 코드 생성 후 실행) 중 하나임
- 실행 엔진 하나에서 둘 다 포함할 수도, 수준이 다른 여러 JIT 컴파일러를 혼용할 수도 있음
- 여기서는 개념 모델 관점에서 가상 머신의 메서드 호출과 바이트코드 실행에 대해 설명함
런타임 스택 프레임 구조
- JVM은 메서드를 가장 기본적인 실행 단위로 사용하고, 메서드 호출과 실행을 뒷받침하는 내부 데이터 구조로 스택 프레임을 이용함
- 스택 프레임은 JVM 런타임 데이터 영역에 있는 '가상 머신 스택'의 요소이기도 함
- 스택 프레임에는 메서드의 지역 변수 테이블, 피연산자 스택, 동적 링크, 반환 주소와 같은 정보가 담김
- 메서드 호출 시작부터 실행 종료까지 과정은 스택 프레임을 가상 머신 스택으로 푸시하는 작업에 해당함
- 스택 프레임 각각에는 지역 변수 테이블, 피연산자 스택, 동적 링크, 메서드 반환 주소와 몇 가지 추가 정보가 담겨 있음
- 자바 프로그램의 소스 코드를 컴파일할 때 스택 프레임에 넣을 지역 변수 테이블의 크기와 피연산자 스택에 필요한 깊이를 계산하여 메서드 테이블의 Code 속성에 기록함
- 즉, 스택 프레임에 할당해야 하는 메모리 크기는 프로그램 실행 중에는 영향을 받지 않고, 오로지 프로그램 소스 코드와 특정 가상 머신 구현의 스택 메모리 레이아웃에 달려 있음
- 실행 엔진 관점에서 활성 스레드에서 스택 맨 위에 있는 메서드만 실행중이며, 스택 맨 위에 있는 스택 프레임만 유효함
- 이를 '현재 스택 프레임'이라 하고, 이 스택 프레임이 대변하는 메서드를 '현재 메서드'라고 함
- 실행 엔진이 실행하는 모든 바이트코드 명령어는 현재 스택 프레임에서만 작동함
참고) 가상 머신 스택과 스택 프레임의 전체 구조
1) 지역 변수 테이블
- 지역 변수 테이블은 메서드 매개 변수와 메서드 안에서 정의된 지역 변수를 저장하는 공간
- 자바 프로그램을 클래스 파일로 컴파일할 때 메서드에 할당해야 하는 지역 변수 테이블의 최대 용량은 메서드의 Code 속성 중 max_locals 항목에 기록됨
- 지역 변수 테이블의 용량 기준은 가장 작은 단위인 '변수 슬롯'
- <자바 가상 머신 명세>는 변수 슬롯이 차지하는 메모리 공간의 크기는 명시하지 않았지만 변수 슬롯 하나가 boolean, byte, char, short, int, float, 참조 타입, returnAddress를 저장할 수 있어야 한다고 규정함
- 이 데이터 타입들은 모두 32비트 이하의 물리 메모리만 차지함
- Q. 변수 슬롯 하나의 크기는 32비트라고 할 수 없는 이유?
- 64비트 가상 머신에서는 변수 슬롯을 64비트라고 구현하기도 함
- 참조 타입
- 객체 인스턴스를 가리키는 참조를 뜻함
- <자바 가상 머신 명세>는 참조의 길이나 구조를 명시하지 않았지만, 일반적으로 JVM은 다음 두 가지 정보를 처리함 (이 두 조건을 충족하지 못하면 <자바 언어 명세>에 정의된 구문 규칙을 구현할 수 없음)
- 참조가 가리키는 객체의 자바 힙 내에서의 시작 주소 또는 인덱스를 직간접적으로 알 수 있다.
- 참조가 가리키는 객체의 타입 정보를 직간접적으로 알 수 있다.
- returnAddress 타입
- 해당 타입의 용도는 jsr, jsr_w, ret 바이트코드 명령어에 다른 바이트코드 명령어의 주소를 알려주는 것
- 다만, 예전 JVM들은 예외 처리 시 다른 코드로 점프하는 데 이 명령어들을 이용했지만, 지금은 모두 예외 테이블을 사용하도록 바뀌었음
- <자바 가상 머신 명세>는 변수 슬롯이 차지하는 메모리 공간의 크기는 명시하지 않았지만 변수 슬롯 하나가 boolean, byte, char, short, int, float, 참조 타입, returnAddress를 저장할 수 있어야 한다고 규정함
- 64비트 데이터 타입의 경우 JVM은 연속된 두 개의 변수 슬롯을 고차 정렬(high-order alignment) 방식으로 연결해 할당함
- 자바 언어에서 이용하는 64비트 데이터 타입은 long과 double 뿐
- JVM은 지역 변수 테이블을 인덱스 방식으로 이용함
- 인덱스 값의 범위는 0부터 지역 변수 테이블이 담을 수 있는 변수 슬롯의 최대 개수까지
- 32비트 변수에 접근할 경우 인덱스 N은 N번째 변수 슬롯을 뜻함
- 64비트 변수라면 N번째와 N+1번째 변수 슬롯을 동시에 사용한다는 뜻
- 인접한 두 변수 슬롯이 공동으로 하나의 64비트 데이터를 저장한다면 JVM이 그중 한 슬롯에만 독립적으로 접근하지는 못함
- <자바 가상 머신 명세>는 이런 접근을 유발하는 바이트코드를 발견하면 클래스 로딩 중 검증 단계에서 예외를 던지도록 함
- 인덱스 값의 범위는 0부터 지역 변수 테이블이 담을 수 있는 변수 슬롯의 최대 개수까지
추가) 메서드 호출 시 매개 변수들도 지역 변수 테이블을 통해 전달됨
더보기- 위 그림은 인스턴스 메서드(static으로 선언되지 않은 메서드)가 호출될 때 지역 변수 테이블에 추가되는 변수들의 순서임
- 기본적으로 0번째(인덱스 0) 변수 슬롯에는 메서드가 속한 객체 인스턴스의 참조를 전달함
- 이 암묵적 매개 변수 덕분에 메서드 안에서 this 키워드를 사용할 수 있는 것
- 나머지 매개 변수들은 매개 변수 테이블의 순서대로 첫 번째 변수 슬롯부터 차례로 차지함
- 그 후 메서드 본문에서 정의한 변수들이 정의 순서와 유효 범위에 따라 할당됨
추가) 변수 슬롯 재사용
더보기- 스택 프레임이 소비하는 메모리를 절약하기 위해 변수 슬롯을 재사용하기도 함
- 메서드 본문에 정의된 변수의 유효 범위는 메서드 본문 전체가 아닐 수 있음
- 현재 바이트코드를 가리키는 프로그램 카운터의 값이 변수의 유효 범위를 벗어나면 해당 변수를 담고 있던 변수 슬롯을 다른 변수를 담는 데 재사용할 수 있음
주의) 변수 슬롯을 재사용하면 스택 프레임의 공간을 절약할 수 있지만 몇 가지 부작용이 따름
- 상황에 따라서 시스템의 GC 동작에 영향을 주기도 함
상황1. 메모리 회수되지 않는 상황 (의도한대로 동작)
- 위 코드에서는 placeholder가 차지한 메모리를 회수하지 않는 게 합당함
- System.gc()가 실행되는 시점에 placeholder 변수는 유효 범위를 벗어나지 않았기 때문임
상황2. 메모리 회수되지 않는 상황 (의도한 대로 동작X)
- 중괄호를 추가하여 placeholder의 범위를 제한함
- 논리적으로 보면 System.gc()가 실행될 때 placeholder는 유효 범위를 벗어났으므로 더 이상 접근할 수 없음
- 하지만 이번 코드에서도 여전히 메모리가 회수되지 않음
상황3. 메모리 회수되는 상황
- 위와 같이 코드를 실행하면 메모리가 올바르게 회수됨
- placeholder 재사용 여부를 결정짓는 열쇠는 '지역 변수 테이블의 변수 슬롯이 여전히 placeholder가 가리키는 배열 객체로의 참조를 담고 있는가'임
- 상황2에서는 placeholder가 유효 범위에서 벗어났지만, 그 후 지역 변수 테이블에 따로 기록한 게 없으므로 변수 슬롯이 그대로 유지되고 있었기 때문임 (상황3에서는 변수 a용으로 변수 슬롯이 재사용되었음)
- 즉, GC 루트의 일부인 지역 변수 테이블은 여전히 참조 대상과 연결되어 있어 메모리가 회수되지 않음
참고) null 할당에 의존하는 습관은 좋지 않음
- 상황3과 유사하지만, int a = 0; 대신 placeholder = null;을 추가하여 변수 슬롯의 기존 정보를 삭제하는 형태
- 상황3에서는 null 할당이 일부 특별한 상황에서 유용할 수 있음을 보여줌. 하지만 null 할당에 의존하는 습관은 좋지 않음
- 대신 범용적인 코딩 규칙을 이끌어 낼 필요가 있음
- 코딩 관점에서 이유 두 가지
- 변수 유효 범위를 적절히 지정하여 변수가 회수되는 시간을 제어하는 게 가장 우아한 해법
- 클래스 변수와 달리 지역 변수에는 클래스 로딩 중 '준비' 단계가 없음
- 지역 변수는 정의 후 초기값을 지정하기 전까지는 아예 사용할 수 없음
2) 피연산자 스택
- 피연산자 스택은 후입선출(Last-In-First-Out, LIFO) 스택
- (지역 변수 테이블과 마찬가지로) 피연산자 스택의 최대 깊이도 컴파일할 때 Code 속성의 max_stacks 항목에 기록됨
- 피연산자 스택의 각 원소에는 long과 double까지 포함한 모든 자바 데이터 타입을 담을 수 있음
- 32비트 데이터 타입이 차지하는 스택 용량은 1, 6비트 타입이 차지하는 용량은 2
- 피연산자 스택에 있는 원소의 데이터 타입은 바이트코드 명령어의 순서와 정확히 일치해야 함
- 컴파일러는 프로그램 코드를 컴파일할 때 이를 엄격하게 보장해야 하고, 클래스 검증 단게에서 데이터를 흐름을 분석해 또 한 번 검증함
- 개념 모델에서 서로 다른 메서드의 가상 머신 스택에 있는 스택 프레임들은 완전히 독립적이지만, 대부분의 가상 머신에서는 최적화 과정에서 스택 프레임들을 부분적으로 겹쳐 사용함
- 하부 스택 프레임의 피연산자 스택 일부가 상부 스택 프레임의 지역 변수 테이블과 겹쳐지는 것
- 공간이 절약되고, 메서드 호출 시 매개 변수로 전달할 데이터를 복사할 필요가 없어짐
- 가상 머신에서 해석 방식의 실행 엔진을 '스택 기반 실행 엔진'이라고 하는데, 여기서 말하는 '스택'이 피연산자 스택임
Q. 컴파일러는 피연산자 스택에 있는 원소의 데이터 타입을 어떻게 검증할 수 있을까?
더보기프로그램 코드
int x = 1; int y = 2; int a = x + y;
컴파일 후 생성된 바이트 코드
iload_1 // x -> int iload_2 // y -> int iadd // int + int
- 컴파일러는 '값'은 모르지만 '타입'은 정적 분석으로 정확히 알 수 있으므로 타입을 추적해 체크함
- 위에서 iadd는 두 개의 int 타입이 스택에 있어야만 동작하는 명령어이므로, iload로 int를 push 하는지 확인할 수 있음
3) 동적 링크
- 메서드에서 이용하는 외부 객체를 가리키는 참조는 JVM 런타임 데이터 영역 중 Method Area의 런타임 상수 풀에 담겨 있고, 각 메서드의 스택 프레임에서 런타임 상수 풀 내의 원소를 참조하는 식으로 구성됨
- 이 참조가 동적 링크를 가능하게 하는 매개가 됨
- 클래스 파일의 상수 풀에는 다수의 심벌 참조가 담겨 있음
- 이 심벌 참조 중 일부는 클래스 로딩의 해석 단계에서 직접 참조로 변환됨. 이 변환을 '정적 해석'이라고 함
- 클래스 파일을 메모리에 올리는 '클래스 로딩' 단계에서 직접 참조로 변환되는 것
- 그 외의 심벌 참조 각각은 실행 중에 직접 참조로 변환되며 이를 '동적 링크'라고 함
- 실행 중 심벌 참조가 실제 사용되는 시점에 직접 참조로 변환되는 것
- 즉, 정적 해석과 동적 링크의 차이는 해석(심벌 참조 -> 직접 참조로 변환하는 것) 시점 차이
- 이 심벌 참조 중 일부는 클래스 로딩의 해석 단계에서 직접 참조로 변환됨. 이 변환을 '정적 해석'이라고 함
- 참고
4) 반환 주소
- 시작된 메서드를 종료하는 방법은 두 가지
- 실행 엔진이 반환 바이트코드 명령어를 만나면 메서드를 종료한다.
- 반환값 유무와 반환값의 타입은 메서드 반환 명령어에 의해 결정됨
- 이 방식의 메서드 종료를 '정상적인 메서드 호출 완료(normal method invocation completion)'라고 함
- 메서드 실행 도중 예외가 발생하고 메서드 본문에서 예외 처리가 제대로 이루어지지 않으면 종료한다.
- 메서드의 예외 테이블에 적절한 예외 핸들러가 없다면 메서드가 종료됨
- 이 방식의 메서드 종료를 '갑작스러운 메서드 호출 완료(abrupt method invocation completion)'라고 함
- 실행 엔진이 반환 바이트코드 명령어를 만나면 메서드를 종료한다.
- 메서드 종료 과정은 가상 머신 스택 영역에서 현재 스택 프레임을 pop하는 것과 동일함. 종료 시 수행할 수 있는 작업은 다음과 같음
- 호출자의 지역 변수 테이블과 피연산자 스택을 복원한다.
- 반환값이 있는 경우 반환값을 호출자 스택 프레임의 피연산자 스택에 push한다.
- 프로그램 카운터(PC) 값을 조정하여 메서드 호출 명령어의 바로 다음 명령어를 가리키게 한다.
- 이것은 개념 모델에 따른 것이어서, 실제로는 JVM을 어떻게 구현했느냐에 따라 다를 수 있음
5) 기타 정보
- <자바 가상 머신 명세>는 가상 머신이 스택 프레임에 추가 정보를 포함시킬 수 있도록 함
- 주로 디버깅이나 프로파일링 관련 정보를 담는 데 활용
- 개념 모델을 이야기할 때는 일반적으로 동적 링크, 반환 주소, 추가 정보를 모두 '스택 프레임 정보'라는 하나의 범주로 묶음
메서드 호출
- 메서드 호출은 메서드 본문 코드를 실행하는 일과 다름
- 메서드 호출 단계에서 수행하는 유일한 일은 호출할 메서드의 버전을 선택하는 것 (JVM은 어떤 메서드를 호출할지 어떻게 결정할까?)
- *메서드의 버전
- 클래스 A를 상속한 클래스 B가 있고, 두 클래스 모두 sayHello() 메서드를 정의한 경우 sayHello() 메서드는 클래스 A와 B에 각각 하나씩 총 2개의 '버전'이 있다고 말한다.
- 이때 자바 코드 a.sayHello()를 실행하려면 둘 중 어떤 버전을 실행해야 하는지 '해석'해야 한다.
- *메서드의 버전
- 클래스 파일의 컴파일 과정에는 Linking 단계가 존재하지 않으므로 클래스 파일에 저장된 모든 메서드 호출은 심벌 참조일 뿐, (실제 런타임 메모리 레이아웃상의 주소를 담은) 직접 참조가 아님
- 따라서 자바는 동적 확장 측면에서 타 언어보다 뛰어나지만 메서드 호출 과정이 상대적으로 복잡해짐
- 때에 따라 클래스 로딩 시점 or 런타임에 대상 메서드의 직접 참조를 알아내야 하기 때문
- 여기서 미리 정리하면,
- 클래스 로딩의 해석 단계에서 해석이 수행되면, '정적 해석'
- 컴파일러가 프로그램 코드를 컴파일하는 시점에 호출 대상이 정해지는 경우에만 가능 (메서드 버전이 1개인 경우)
- 런타임 중 메서드가 실제 사용될 때 해석이 수행되면, '동적 링크'
- 클래스 로딩의 해석 단계에서 해석이 수행되면, '정적 해석'
- 여기서 미리 정리하면,
- 때에 따라 클래스 로딩 시점 or 런타임에 대상 메서드의 직접 참조를 알아내야 하기 때문
1) 해석 (심벌 참조 -> 직접 참조로 변환하는 것)
- 메서드 호출 대상은 모두 클래스 파일의 상수 풀에 심벌 참조로 기록되어 있음
- 클래스 로딩의 해석 단계에서 그중 일부는 직접 참조로 변환하는데, 이때 직접 참조(실제 런타임 메모리 레이아웃상의 주소)를 찾아낼 수 있는 전제는 다음과 같음
- 어떤 메서드는 호출할 버전을 프로그램이 실행되기 전에 알아낼 수 있으며 런타임에는 다른 버전으로 변경될 수 없음
- 즉, 컴파일러가 프로그램 코드를 컴파일하는 시점에 호출 대상이 정해짐
- 이처럼 호출 대상이 미리 특정되는 경우를 '정적 해석'이라 함
- 자바 언어에서 '컴파일타임에 알 수 있고 런타임에는 변경될 수 없다'라는 조건에 부합하는 메서드는 주로 정적(static) 메서드와 private 메서드임
- 두 유형의 메서드 모두 상속을 통해 다른 버전을 만들 수 없으므로 클래스 로딩 단계에서 해석하기에 적합함
- 메서드 호출 유형에 따라 사용되는 바이트코드 명령어가 다름
- 총 다섯 가지 메서드 호출 바이트코드 명령어를 제공함
- invokestatic : 정적 메서드를 호출함
- invokespecial : 인스턴스 생성자인 <init>() 메서드, private 메서드, 부모 클래스의 메서드를 호출함
- invokevirtual : 가상 메서드를 호출함
- invokeinterface : 인터페이스 메서드를 호출함. 인터페이스를 구현한 대상 객체는 런타임에 결정됨
- invokedynamic : 호출 사이트 한정자가 참조하는 메서드는 메서드 실행 전에 런타임에 동적으로 해석됨
- 앞의 4개 호출 명령어의 디스패치 로직은 JVM 차원에서 정해져 있으나, invokedynamic의 디스패치 로직은 사용자가 설정한 시작 방식에 의해 달라질 수 있음
- invokestatic, invokespecial로 호출할 수 있는 메서드는 해석 단계에서 고유한 호출 버전을 특정할 수 있음
- 여기 속하는 메서드 유형은 네 가지 : 정적 메서드, private 메서드, 인스턴스 생성자, 부모 클래스의 메서드
- 추가로 (invokevirtual 명령어로 호출하기는 하지만) final 한정자가 붙은 인스턴스 메서드도 해석 단계에 메서드 버전을 특정할 수 있음
- 오버라이딩 불가능하므로 다른 버전이 만들어질 수 없음
- 이러한 메서드 유형을 통틀어 '비가상 메서드'라고 하며, 그 외 메서드들은 '가상 메서드'라고 함
- 호출할 메서드 해석은 컴파일타임에 완전히 정해지는 정적인 작업임
- 런타임까지 기다릴 필요 없이 클래스 로딩의 해석 단계에서 관련한 심벌 참조 모두를 명시적인 직접 참조로 변환함
- 총 다섯 가지 메서드 호출 바이트코드 명령어를 제공함
예시) 메서드 정적 해결
더보기public class StaticResolution { public static void sayHello() { System.out.println("Hello, World!"); } public static void main(String[] args) { StaticResolution.sayHello(); } }
- javap 도구로 해당 클래스의 바이트코드를 살펴보면 sayHello() 메서드 호출에 invokestatic 명령어가 이용됨을 알 수 있다.
- 이때 호출되는 메서드 버전은 컴파일 타임에 상수 풀의 특정 항목으로 정해져 바이트코드 명령어의 매개 변수에 명시된다.
$ javap -verbose StaticResolution.class
2) 디스패치
- 메서드 호출의 또 다른 형태로 디스패치가 있음
- 정적/동적 여부와 단일/다중 여부를 조합하여 총 네 가지 유형이 생겨남
- 정적 단일, 정적 다중, 동적 단일, 동적 다중 디스패치
- 정적/동적 여부와 단일/다중 여부를 조합하여 총 네 가지 유형이 생겨남
- 메서드 호출 디스패치 과정은 JVM에서 '오버로딩'과 '오버라이딩'이 구현되는 방식처럼 다형성이라는 특성의 가장 기본에 해당하는 내용
- 여기서의 '구현'은 가상 머신이 올바른 대상 메서드를 결정하는 방법을 말함
정적 디스패치
- 메서드 버전 선택에 정적 타입을 참고하는 모든 디스패치 작업을 '정적 디스패치'라고 함
- 가장 일반적인 응용 예가 '메서드 오버로딩'임 (즉, 정적 디스패치는 자바 언어가 메서드 오버로딩을 구현하는 방식의 핵심)
- 디스패치는 컴파일타임에 이루어지므로 메서드 버전 선택 작업은 가상 머신에서는 이루어지지 않음
예시)
더보기package org.fenixsoft.jvm.chapter8; /** * 메서드 정적 디스패치 시연 * * @author zzm */ public class StaticDispatch { static abstract class Human { } static class Man extends Human { } static class Woman extends Human { } // 오버로딩된 메서드 1 public void sayHello(Human guy) { System.out.println("Hello, guy!"); } // 오버로딩된 메서드 2 public void sayHello(Man guy) { System.out.println("Hello, gentleman!"); } // 오버로딩된 메서드 3 public void sayHello(Woman guy) { System.out.println("Hello, lady!"); } public static void main(String[] args) { Human man = new Man(); // 정적 타입 = Human, 실제 타입 = Man Human woman = new Woman(); // 정적 타입 = Human, 실제 타입 = Woman StaticDispatch sr = new StaticDispatch(); sr.sayHello(man); // 메서드 버전 선택 필요 sr.sayHello(woman); // 메서드 버전 선택 필요 } }
- 위 코드에서 Human을 변수의 '정적 타입' 또는 겉보기 타입(apparent type)이라고 하고, Man을 변수의 '실제 타입(actual type)' 또는 런타임 타입이라고 함
- 가상 머신(정확히 말하면 컴파일러)은 호출할 sayHello()를 선택할 때 매개 변수의 실제 타입이 아닌 정적 타입을 참고함
- 정적 타입은 컴파일타임에 알려지기 때문에 javac 컴파일러는 정적 타입을 보고 어떤 오버로딩 버전을 호출할지 선택함
Q. 정적 타입은 어떻게 컴파일타임에 알 수 있을까?
// 실제 타입 변경 Human human = (new Random()).nextBoolean() ? new Man() : new Woman(); // 정적 타입 변경 sr.sayHello((Man) human); sr.sayHello((Woman) human);
- human 객체의 실제 타입은 변경될 수 있고, 실제 타입은 프로그램이 이 코드 라인을 실행할 때 알 수 있음
- 반면 human 객체의 정적 타입은 Human이고 마찬가지로 사용 중에 변경될 수 있음. 하지만 이 변경은 컴파일 타임에 알 수 있음
- sayHello() 메서드를 호출할 때 강제로 변환했기 때문에 변환 결과를 컴파일타임에 명확히 알 수 있음
동적 디스패치
- JVM이 런타임에 실제 (객체) 타입을 기준으로 메서드 버전을 선택하는 방식
- 메서드 오버라이딩한 경우 정적 타입만으로 호출할 메서드의 버전을 결정할 수 없음
Q. JVM이 실제 타입을 기준으로 메서드 버전을 어떻게 선택할까?
- <자바 가상 머신 명세>에 따르면 invokevirtual 명령어의 런타임 해석은 대략 다음 4단계로 이루어짐
- 피연산자 스택 상단 첫 번째 요소가 가리키는 객체의 실제 타입(C라고 가정)을 찾는다.
- 타입 C에서 상수의 서술자 및 단순 이름과 일치하는 메서드를 찾으면 접근 권한이 있는지 검사한다. 권한이 있다면 이 메서드의 직접 참조를 반환하고 검색을 끝낸다. 권한이 없다면 IllegalAccessError를 던진다.
- 그렇지 않으면 상속 계층을 따라 아래에서 위로 C의 상위 클래스에 대해 2번 과정을 수행한다.
- 최상위 클래스까지도 적절한 메서드를 찾지 못하면 AbstractMethodError를 던진다.
- invokevirtual 명령어 실행의 첫 번째 단계에서 런타임 수신 객체의 실제 타입을 해석한다는 점이 중요함
- invokevirtual 명령어는 상수 풀에 있는 메서드의 심벌 참조를 직접 참조로 변환하는 데서 그치지 않고, 메서드 수신 객체의 실제 타입을 보고 메서드 버전을 선택함
- 이 과정이 자바 메서드 오버라이딩의 핵심임
- 필드에는 invokevirtual 명령어를 사용하지 않기 때문에 동적 디스패치는 오직 메서드에만 적용됨
예시)
더보기프로그램 코드
package org.fenixsoft.jvm.chapter8; public class DynamicDispatch { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected void sayHello() { System.out.println("Man said hello"); } } static class Woman extends Human { @Override protected void sayHello() { System.out.println("Woman said hello"); } } public static void main(String[] args) { Human man = new Man(); // 정적 타입 = Human, 실제 타입 = Man Human woman = new Woman(); // 정적 타입 = Human, 실제 타입 = Woman man.sayHello(); // Man의 메서드 호출 woman.sayHello(); // Woman의 메서드 호출 man = new Woman(); // 실제 타입 = Woman man.sayHello(); // Woman의 메서드 호출 } }
main() 메서드의 바이트코드
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=3, args_size=1 0: new #7 // class org/example/DynamicDispatch$Man 3: dup 4: invokespecial #9 // Method org/example/DynamicDispatch$Man."<init>":()V 7: astore_1 8: new #10 // class org/example/DynamicDispatch$Woman 11: dup 12: invokespecial #12 // Method org/example/DynamicDispatch$Woman."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #13 // Method org/example/DynamicDispatch$Human.sayHello:()V 20: aload_2 21: invokevirtual #13 // Method org/example/DynamicDispatch$Human.sayHello:()V 24: new #10 // class org/example/DynamicDispatch$Woman 27: dup 28: invokespecial #12 // Method org/example/DynamicDispatch$Woman."<init>":()V 31: astore_1 32: aload_1 33: invokevirtual #13 // Method org/example/DynamicDispatch$Human.sayHello:()V 36: return
- 0~15행의 바이트코드는 준비 작업으로, 다음과 같은 일을 함
- man과 woman 변수를 할당할 메모리 공간을 만든다.
- Man과 Woman 타입의 인스턴스 생성자를 호출한다.
- 두 인스턴스에 대한 참조를 지역 변수 테이블의 첫 번째와 두 번째 변수 슬롯에 저장한다.
- Human man = new Man(); Human woman = new Woman(); 수행한 것
- 16행, 20행의 로드 명령어는 방금 생성된 두 객체의 참조를 각각 스택의 맨 위로 푸시함
- 두 객체는 실행할 sayHello() 메서드의 소유자이며 '수신 객체'라고 함
- 17행, 21행은 메서드 호출 명령
- 이때 바이트코드만 보면 두 호출 명령어가 완전히 같지만, 실행될 때 호출할는 실제 메서드는 같지 않음
- invokevirtual 명령어의 런타임 해석을 통해 후보 메서드들 중 하나를 특정하여 실행하는 것임
단일 디스패치와 다중 디스패치
- 메서드의 수신 객체와 매개 변수를 합쳐서 '메서드 볼륨'이라 함
- 디스패치의 기준이 되는 볼륨 수에 따라 단일 디스패치와 다중 디스패치로 나뉨
- 단일 디스패치 : 한 볼륨 안에서 대상 메서드 선택
- 메서드의 수신 객체 or 매개 변수 중 하나만을 기준으로 메서드 선택하는 것
- 다중 디스패치 : 둘 이상의 볼륨 안에서 대상 메서드 선택
- 단일 디스패치 : 한 볼륨 안에서 대상 메서드 선택
오늘날의 자바 언어는 '정적 다중 디스패치'와 '동적 단일 디스패치' 방식의 언어라고 할 수 있음
예시와 함께 알아보자!
더보기package org.fenixsoft.jvm.chapter8; /** * 필드는 다형성과 무관하다. * @author zzm */ public class Dispatch { static class QQ {} static class _360 {} public static class Father { public void hardChoice(QQ arg) { System.out.println("Father chose a qq"); } public void hardChoice(_360 arg) { System.out.println("Father chose a 360"); } } public static class Son extends Father { public void hardChoice(QQ arg) { System.out.println("Son chose a qq"); } public void hardChoice(_360 arg) { System.out.println("Son chose a 360"); } } public static void main(String[] args) { Father father = new Father(); Father son = new Son(); father.hardChoice(new _360()); // 결과 : Father chose a 360 son.hardChoice(new QQ()); // 결과 : Son chose a qq } }
- 가장 먼저 확인할 부분은 컴파일 단계에서 컴파일러의 선택 과정, 즉 정적 디스패치 과정임
- 대상 메서드를 선택하는 데 두 가지를 고려한다.
- 1) 변수의 정적 타입이 Father이냐 Son이냐, 2) 매개 변수의 정적 타입이 QQ이냐 360이냐
- 두 가지를 조합해 내린 결론이 두 개의 invokevirtual 명령어를 생성하는 데 이용된다. (hardChoice()는 인스턴스 메서드로 '가상 메서드'에 속하므로 해당 메서드 호출 시 invokevirtual 명령어 사용)
- 명령어의 매개 변수는 각각 상수 풀에 있는 Father::hardChoice(_360)과 Father::hardChoice(QQ) 메서드임
- 이처럼 선택에 이용된 볼륨이 두 개라서 자바의 정적 디스패치는 '다중 디스패치'이다.
- 대상 메서드를 선택하는 데 두 가지를 고려한다.
- 다음은 실행 단계에서 가상 머신의 선택 과정, 즉 동적 디스패치 과정임
- son.hardChoice(new QQ()) 라인이 실행될 때, 정확하게는 코드 라인에 해당하는 invokevirtual 명령어가 실행될 때 대상 메서드의 시그니처는 컴파일타임에 이미 hardChoice(QQ)로 결정되었다.
- 따라서 이 시점에는 매개 변수의 정적 타입과 실제 타입은 메서드 선택에 관여하지 않음
- 가상 머신의 메서드 선택에 영향을 주는 유일한 요소는 메서드 수신 객체의 실제 타입이다. (여기서는 Father인지 Son인지)
- 이때 선택 기준 볼륨이 하나뿐이므로 자바의 동적 디스패치는 '단일 디스패치'이다.
- son.hardChoice(new QQ()) 라인이 실행될 때, 정확하게는 코드 라인에 해당하는 invokevirtual 명령어가 실행될 때 대상 메서드의 시그니처는 컴파일타임에 이미 hardChoice(QQ)로 결정되었다.
가상 머신의 동적 디스패치 구현
- 지금까지는 디스패치 과정을 JVM의 개념 모델에 충실한 설명이었고, 실제로는 가상 머신 구현에 따라 조금씩 다를 수 있음
- 동적 디스패치를 위해 런타임에 실제 타입 기준으로 메서드를 선택할 때 고려할 점
- 상황)
- 동적 디스패치는 매우 자주 일어난다.
- 동적 디스패치 중 메서드 버전 선택 시에는 런타임에 수신 객체 타입의 메서드 메타데이터를 보고 적절한 대상 메서드를 찾는 작업이 이루어진다.
- 구현)
- 실행 성능을 중시하는 JVM 구현에서는 일반적으로 타입 메타데이터를 그리 자주 검색하지는 않는다.
- 해당 타입에 대한 가상 메서드 테이블(Method Area에 존재하는 vtable)을 만들어 최적화한다.
- invokeinterface용으로는 인터페이스 메서드 테이블(줄여서 itable)을 준비함
- 일반적으로 메타데이터 직접 조회 대신 가상 메서드 테이블 인덱스를 사용해 성능 향상을 꾀함
- *참고
- 여기서 '성능 향상'은 메타데이터를 직접 검색할 때가 기준
- 핫스팟 VM 구현에서는 itable과 vtable을 직접 확인하는 방식이 사실 가장 느린 디스패치에 해당하여 실행 상태를 해석할 때만 사용됨
- JIT 컴파일의 '메서드 인라인' 최적화를 대신 사용할 수 있음
- *참고
- 상황)
추가) 가상 메서드 테이블(vtable)
더보기- 가상 메서드 테이블에는 각 메서드의 실제 시작 주소가 담긴다.
- 하위 클래스에서 메서드가 오버라이딩되지 않으면 하위 클래스 가상 메서드 테이블의 주소 항목은 부모 클래스에 있는 동일한 메서드의 주소 항목과 같음 (즉, 둘 다 부모 클래스의 구현 시작점을 가리킴)
- 오버라이딩하면 하위 클래스의 구현 시작점을 가리킴
- 하위 클래스에서 메서드가 오버라이딩되지 않으면 하위 클래스 가상 메서드 테이블의 주소 항목은 부모 클래스에 있는 동일한 메서드의 주소 항목과 같음 (즉, 둘 다 부모 클래스의 구현 시작점을 가리킴)
- 구현 편의를 위해 시그니처가 같은 메서드는 부모 클래스와 자식 클래스의 가상 메서드 테이블에서 인덱스 번호가 같도록 한다.
- 형 변환 시 검색할 가상 메서드 테이블만 변경하면 되고, 필요한 시작점 주소는 다른 가상 메서드 테이블의 인덱스로 변환할 수 있음
- 가상 메서드 테이블은 일반적으로 클래스 로딩 중 링킹 단계에서 초기화됨
- 클래스 변수들의 타입 초기값(사용자 정의 초기값이 아닌 타입의 제로값)이 준비되면 클래스의 가상 메서드 테이블도 초기화됨
해석과 디스패치를 정리하자면,
더보기해석과 디스패치는 완전 별개의 개념이 아니라 서로 다른 수준에서 대상 메서드를 검사하고 선택하는 과정이다.
해석과 디스패치는 동등한 개념이 아니다. 따라서 둘을 비교할 수는 없다.
해석은 심벌 참조를 직접 참조로 변환하는 것이다.
- 이때 해석은 클래스 로딩의 해석 단계에서의 해석과 런타임 때의 해석으로 구분할 수 있다.
- 클래스 로딩의 해석 단계에서 해석을 수행하려면 컴파일타임에 메서드의 호출 버전을 알 수 있어야 한다. 컴파일시점에 메서드 호출 대상을 특정할 수 있는 경우를 '정적 해석'이라 한다.
디스패치는 여러 메서드 버전이 존재할 때 어떤 버전을 호출할지 결정하는 것이다.
- 메서드 오버로딩, 오버라이딩이 가능하기 때문에 메서드의 여러 버전이 존재할 수 있다.
- 정적/동적 디스패치가 존재한다.
- 정적 디스패치 : 정적 타입을 기준으로 호출할 메서드 버전을 결정한다. 이때 컴파일시점에 결정할 수 있으므로 '정적 해석'이 가능하다.
- 동적 디스패치 : JVM이 런타임에 실제 객체 타입을 기준으로 호출할 메서드 버전을 결정한다. 컴파일시점에 결정할 수 없고 런타임 시점에 결정할 수 있으므로 이때는 '정적 해석'이 불가능하다. 대신 '동적 링크'가 되어야 한다.
예를 들어, 정적 메서드는 컴파일타임에 호출 대상을 미리 특정(호출할 메서드의 버전 확정)할 수 있고, 따라서 클래스 로딩 중에 해석되며, 정적 메서드 역시 오버로딩될 수 있고, 오버로딩된 버전들 중에서 어떤 메서드를 호출할 지는 정적 디스패치를 통해 이루어진다.
스택 기반 바이트코드 해석 및 실행 엔진
- 가상 머신이 메서드의 바이트코드 명령어를 실행하는 방법을 알아본다.
- 많은 JVM 실행 엔진에서 자바 코드를 실행할 때 '해석 실행'과 '컴파일 실행' 중에서 선택할 수 있음
- 해석 실행 : 인터프리터가 실행하는 것
- 컴파일 실행 : JIT 컴파일러를 써서 네이티브 코드로 변환해 실행하는 것
- 여기서는 바이트코드를 해석하고 실행할 때 JVM의 실행 엔진이 어떻게 작동하는지 개념 모델을 기준으로 분석할 것임
- 가상 머신에서 실제로 사용하는 인터프리터에는 최적화가 다수 적용되어 있음
- 실제로는 각 바이트코드에 해당하는 어셈블리어코드가 동적으로 생성되어 실행되는데, 개념 모델의 실행 방식과는 차이가 큼
- 결과는 똑같음을 보장함
1) 해석 실행
- 자바 언어를 '해석 실행' 언어, 즉 인터프리터 언어로 생각할 수 있는데, JDK 1.0 이후 가상 머신에 JIT 컴파일러가 포함되면서 클래스 파일의 코드를 해석 실행할지, 컴파일 실행할지 가상 머신 스스로 판단하게 되었음
- 따라서 자바 언어 전체를 '해석 실행'이라고 설명하는 것은 무의미함
컴파일 과정
- 대부분의 프로그램 코드가 물리 머신의 목적 코드나 가상 머신이 실행할 수 있는 명령어 집합으로 변환되기 까지는 위와 같은 다양한 단계를 거침
- 오늘날 물리 머신, JVM, 기타 고수준 언어용 가상 머신은 고전적인 컴파일 원칙에 따라 소스 코드의 어휘와 구문을 분석하여 추상 구문 트리로 변환함
- 어휘 분석, 구문 분석, 최적화, 목적 코드 생성 각각은 실행 엔진과 독립적으로 구현한 후 모두를 조합하여 하나의 컴파일러를 완성할 수 있음
자바는?
- 자바 언어에서 javac 컴파일러는 프로그램 코드의 어휘 분석, 구문 분석, 추상 구문 트리 생성, 구문 트리 탐색해 일련의 바이트코드 명령어들을 생성하는 과정까지 처리함
- 작업 대부분이 가상 머신 외부에서 수행되고 인터프리터는 가상 머신 내부에 있으므로 자바 프로그램 컴파일은 준독립적인 구현에 속함
2) 스택 기반 명령어 집합과 레지스터 기반 명령어 집합
- javac 컴파일러가 출력하는 바이트코드 명령어 스트림은 기본적으로 '스택 기반 명령어 집합 아키텍처'를 따름
- 즉, 바이트코드 명령어 스트림의 명령어 대부분은 메모리 주소 대신 피연산자 스택을 이용해 동작함
참고) 스택 기반과 레지스터 기반 명령어 집합의 차이
더보기상황1) 1+1 계산을 스택 기반 명령어 집합으로 구현
iconst_1 iconst_1 iadd istore_0
- iconst_1 명령어 두 개각 연이어 상수 1을 선택에 넣은 후, iadd 명령어가 스택 맨 위의 두 값을 더해 결과를 다시 스택에 넣음
- istore_0은 스택 맨 위의 값을 (스택 프레임 내) 지역 변수 테이블의 0번째 변수 슬롯에 저장함
- 일련의 명령어 모두에 매개 변수가 없고, 대신 입력 데이터를 피연산자 스택에서 가져오고 결과도 피연산자 스택에 저장함
상황2) 1+1 계산을 레지스터 기반 명령어 집합으로 구현
mov eax, 1 add eax, 1
- mov 명령어는 eax 레지스터의 값을 1로 설정함, add 명령어는 이 값에 1을 더하고 결과를 다시 eax 레지스터에 저장함
- 이처럼 매개 변수 2개를 받는 명령어가 x86 명령어 집합의 주류임
- 명령어 각각은 독립된 매개 변수 2개를 받으며, 레지스터로부터 데이터를 읽고 레지스터에 저장함
추가) 스택 기반 명령어 집합의 장단점
더보기장점
- (하드웨어에 종속되지 않아) 이식성이 좋다.
- 레지스터는 하드웨어에서 직접 제공하기 때문에 하드웨어에 직접 의존하는 프로그램은 해당 하드웨어에 종속될 수밖에 없음
- 스택 기반 명령어 집합을 이용하면 사용자 프로그램은 하드웨어 레지스터를 직접 사용하지 않음
- 대신 가상 머신이 가장 자주 사용하는 데이터(프로그램 카운터, 스택 최상위 데이터 캐시 등)를 레지스터에 저장하여 최상의 성능을 이끌어 내도록 노력함
- 특정 하드웨어에 의존하지 않게 구현하기 쉬움
- 코드 각각이 상대적으로 더 간결하다.
- 바이트코드에서는 바이트 각각이 하나의 온전한 명령어가 됨
- 매개 변수를 받는 레지스터 기반 명령어 집합에서는 매개 변수들까지 저장해야 하기 때문
- 바이트코드에서는 바이트 각각이 하나의 온전한 명령어가 됨
- 컴파일러를 구현하기 더 쉽다.
- 필요한 공간을 모두 스택으로 처리하기 때문에 공간 할당 문제를 고민할 필요가 없음
단점
- 스택 기반 명령어 집합은 실행 속도가 상대적으로 느리다.
- *여기서 말하는 실행 속도는 '해석 실행 모드'로 동작할 때에 국한됨
- JIT 컴파일러가 만들어 내는 어셈블리 명령어 스트림은 가상 머신의 아키텍처와는 아무런 관련이 없음
- 해석 실행 모드에서 기능 완료를 위해 필요한 명령어의 총 개수가 레지스터 기반 아키텍처보다 많음
- 스택이 메모리에 구현되면 메모리 접근이 빈번하게 일어나고, 메모리를 프로세서 자체에 내장된 레지스터보다 훨씬 느리기 때문에 성능을 상당히 떨어뜨리는 요인으로 작용함
- 가상 머신은 가장 흔합 작업을 레지스터에 매핑(최상위 캐시 최적화)하여 메모리를 읽고 쓰는 빈도를 줄 일 수 있지만, 본질적인 해법은 될 수 없음
- 즉, 명령어 수가 많고 메모리를 빈번하게 읽고 써서 속도가 상대적으로 느릴 가능성이 큼
- *여기서 말하는 실행 속도는 '해석 실행 모드'로 동작할 때에 국한됨
'DevBook > JVM 밑바닥까지 파헤치기' 카테고리의 다른 글
[JVM] 컴파일과 최적화 (2) - 백엔드 컴파일과 최적화 (0) 2025.04.06 [JVM] 컴파일과 최적화 (1) - 프론트엔드 컴파일과 최적화 (0) 2025.04.06 [JVM] 가상 머신 실행 서브시스템 (2) - 클래스 로딩 메커니즘 (0) 2025.04.01 [JVM] 가상 머신 실행 서브시스템 (1) - 클래스 파일 구조 (0) 2025.03.29 [JVM] 가비지 컬렉터와 메모리 할당 전략 - 메모리 할당과 회수 전략 (0) 2025.03.27