-
[JVM] 가상 머신 실행 서브시스템 (1) - 클래스 파일 구조DevBook/JVM 밑바닥까지 파헤치기 2025. 3. 29. 23:28
JVM이 제공하는 언어 독립성
- JVM은 플랫폼 독립성을 넘어 언어 독립성을 제공함
- JVM이 다양한 플랫폼을 지원하고, 모든 VM이 동일한 프로그램 저장 형식(바이트코드)을 지원한다는 사실은 플랫폼 독립성의 핵심임
- JVM은 자바를 포함해 어떠한 프로그래밍 언어에도 종속되지 않음
- '클래스 파일'이라는 특정한 바이너리 파일 형식에만 의존할 뿐임
- <자바 가상 머신 명세>는 클래스 파일이 여러 가지 필수 구문을 갖추고 특정 구조를 따르도록 제약하고 있음
- 다른 언어에서도 JVM은 하드웨어 독립적인 범용 실행 플랫폼으로 활용할 수 있고, 클래스 파일은 프로그램을 전달하는 매체로 이용할 수 있음
- 자바 언어의 다양한 구문, 키워드, 상수, 변수, 연산 기호는 결국 바이트코드 명령어 조합으로 표현됨
클래스 파일의 구조
- 클래스 파일은 바이트(byte)를 하나의 단위로 하는 이진 스트림 집합체
- 각 데이터 항목이 정해진 순서에 맞게, 구분 기호 없이 조밀하게 나열됨
- 클래스 파일 전체가 낭비되는 공간 없이 프로그램 실행에 필요한 데이터로 채워짐
- 1byte가 넘는 데이터 항목은 byte 단위로 분할되며, 이때 큰 단위의 byte가 먼저 저장되는 빅 엔디언 방식으로 표현함
- *빅 엔디언 방식 : 가장 큰 단위의 바이트가 가장 낮은 주소에, 가장 작은 단위의 바이트가 가장 높은 주소에 저장되는 방식
- <자바 가상 머신 명세>에 따르면 클래스 파일에 데이터를 저장하는 데는 의사 구조(pseudo structure)를 이용함
- '부호 없는 숫자'와 '테이블'이라는 두 가지 데이터 타입만 존재함
- 부호 없는 숫자(unsigned number)
- 기본 데이터 타입을 표현함
- u1, u2, u4, u8은 각각 1바이트, 2바이트, 4바이트, 8바이트를 뜻함
- 숫자, 인덱스 참조, 수량 값을 기술하거나 UTF-8로 인코딩된 문자열 값을 구성할 수 있음
- 테이블
- 여러 개의 부호 없는 숫자나 또 다른 테이블로 구성된 복합 데이터 타입을 표현함
- 구분이 쉽도록 테이블 이름은 관례적으로 '_info'로 끝남
- 계층적으로 구성된 복합 구조의 데이터를 설명하는 데 사용됨
- 클래스 파일 전체는 본질적으로 테이블이며 구조는 다음과 같음
- 같은 타입의 데이터 여러 개를 표현할 때 그 개수가 정해져 있지 않다면 개수를 알려주는 타입이 바로 앞에 등장함 (*_count 형태의 항목들이 여기 속함)
- 이처럼 {개수 + 개수만큼의 데이터 타입} 형태를 해당 타입의 '컬렉션'이라고 함
1) 매직 넘버와 클래스 파일의 버전
- 매직 넘버
- 모든 클래스 파일의 처음 4바이트는 매직 넘버로 시작함
- 가상 머신이 허용하는 클래스 파일인지 여부를 빠르게 확인하는 용도로만 쓰임
- 고정된 0xCAFEBABE 값 사용
- 클래스 파일의 버전 번호
- 매직 넘버 다음의 4바이트
- 5~6번째 바이트는 마이너 버전, 7~8번째 바이트는 메이저 버전 뜻함
- 자바 버전 번호는 45부터 시작함
- JDK 1.1 이후 주요 JDK 릴리스의 메이저 버전은 1씩 증가하며, 상위 버전 JDK는 하위 버전을 인식할 수 있음
- 하위 버전 JDK에서 상위 버전의 클래스 파일을 실행할 수는 없음
- <자바 가상 머신 명세>의 '클래스 파일 검증' 절에서 '파일 형식이 변경되지 않았더라도 상위 버전의 클래스 파일을 실행하면 안된다'라고 규정했기 때문
- 매직 넘버 다음의 4바이트
예시)
더보기package org.fenixsoft.clazz; public class TestClass { private int m; public int inc() { return m + 1; } }
- JDK 1.2부터는 마이너 버전을 사용하지 않아서 모두 값이 0으로 고정되어 있음
2) 상수 풀
- 클래스 파일 구조에서 다른 클래스와 가장 많이 연관된 부분
- 클래스 파일에서 가장 먼저 등장하는 테이블 타입 데이터 항목
- 상수 풀에 들어있는 상수의 수는 고정적이지 않으므로 상수 풀 항목들에 앞서 항목 개수를 알려주는 u2 타입 데이터가 필요함
- 자바 언어의 관례상 이 개수를 셀 때는 0이 아닌 '1부터 시작'함 (아래 예시 참고)
예시)
더보기package org.fenixsoft.clazz; public class TestClass { private int m; public int inc() { return m + 1; } }
- TestClass 클래스의 상수 풀 크기(오프셋 주소: 0x00000008)는 16진수로 0x0013, 10진수로 19에 해당함
- 상수 풀에는 상수가 18개 존재하며 인덱스 범위는 1 ~ 18까지
- Q. 0번째 상수를 비운 이유?
- 상수 풀 인덱스를 가리키는 데이터에서 '상수 풀 항목을 참조하지 않음'을 표현해야 하는 특수한 경우에 인덱스를 0으로 설정하도록 한 것
- 클래스 파일 구조에서 오직 상수 풀만이 개수를 1부터 셈 (그 외 컬렉션의 원소 개수는 0부터 셈)
상수 풀에 담기는 상수 유형 2가지
- 리터럴
- 자바 언어 수준에서 이야기하는 상수(final로 선언된 문자열이나 상수)와 비슷한 개념
- 심벌 참조
- 컴파일과 관련된 개념, 다음 유형의 상수들이 포함됨
- 모듈에서 익스포트하거나 임포트하는 패키지
- 클래스와 인터페이스의 완전한 이름(fully qualified name)
- 필드 이름과 서술자(descriptor)
- 메서드 이름과 서술자
- 메서드 핸들과 메서드 타입(method handle, method type, invoke dynamic)
- 동적으로 계산되는 호출 사이트와 동적으로 계산되는 상수(dynamically computed call site, dynamically computed constant)
- 참고) 가상 머신이 클래스 파일을 읽어 들이며 수행하는 동적 링크
- 자바 코드를 javac로 컴파일할 때는 링크 단계가 없음
- 자바에서 링크는 가상 머신이 클래스 파일을 로드할 때 동적으로 이루어짐
- 필드와 메서드가 메모리에서 어떤 구조로 표현되는가에 관한 정보는 클래스 파일에 저장되지 않는다는 뜻
- 따라서 가상 머신이 필드와 메서드의 심벌 참조를 런타임에 변환하지 않으면 각 항목의 실제 주소를 알 수 없음
- 가상 머신은 클래스 파일을 로드할 때 상수 풀에서 해당 심벌 참조들을 가져옴
- 그런 다음 클래스가 생성되거나 구동할 때 해석하여 실제 메모리 주소로 변환함
- 컴파일과 관련된 개념, 다음 유형의 상수들이 포함됨
특징
- 상수 풀 안의 상수 각각이 모두 테이블임
- JDK 21 기준으로 총 17가지 상수 타입이 존재함
- 17가지 타입의 테이블들은 공통적으로 u1 타입(1 byte)의 플래그 비트로 시작하며, 그 값은 현재 상수가 속한 상수 타입을 나타냄
- 상수 풀이 가장 복잡한 데이터인 이유는 17가지 상수 타입 각각의 데이터 구조가 완전히 독립적이기 때문
참고) 상수 풀의 항목 타입
3) 접근 플래그
- 상수 풀 다음의 2바이트는 현재 클래스(또는 인터페이스)의 접근 정보를 식별하는 access_flags
- 현재 클래스 파일이 표현하는 대상이 클래스인지, 인터페이스인지, public인지, abstract인지, 클래스인 경우 final인지 등의 정보가 담김
- 크기는 2바이트이므로 플래그 비트를 최대 16개 사용할 수 있음
- 현재는 위의 표처럼 9개만 정의되어 있음
- 정의되지 않은 플래그 비트의 값은 모두 0이어야 함
예시)
더보기package org.fenixsoft.clazz; public class TestClass { private int m; public int inc() { return m + 1; } }
- 일반 자바 클래스이며, public 클래스임
- JDK 1.2 이상을 사용했으므로 ACC_PUBLIC과 ACC_SUPER 플래그는 true여야 하고, 나머지 7개 플래그는 모두 false여야 함
- 두 개의 플래그를 비트 OR 연산으로 합침
- 0x0001 | 0x0020 = 0x0021
4) 클래스 인덱스, 부모 클래스 인덱스, 인터페이스 인덱스
- 클래스 파일의 상속 관계를 규정함
클래스 인덱스와 부모 클래스 인덱스
- u2 타입
- 각각 현재 클래스와 부모 클래스의 완전한 이름을 결정하는 데 쓰임
- 자바는 다중 상속을 허용하지 않으므로 부모 클래스 인덱스는 하나뿐임
- 단, 모든 자바 클래스의 부모인 java.lang.Object는 부모 클래스 없음
- java.lang.Object를 제외한 모든 자바 클래스의 부모 클래스 인덱스 값은 0이 아님
- 인덱스 값은 CONSTANT_Class_info 타입의 클래스 서술자 상수를 가리킴
- 클래스의 완전한 이름 문자열은 CONSTANT_Class_info 타입에 담긴 상수의 값을 인덱스로 하는 CONSTANT_Utf8_info 타입으로 정의됨
인터페이스 인덱스 컬렉션
- u2 타입 데이터들의 묶음
- 현재 클래스가 구현한 인터페이스들을 기술함
- 컬렉션 내 인터페이스의 순서는 자바 코드에서 implements 키워드 뒤에 나열한 순서를 따름
- 첫 항목은 u2 타입이며, 값은 인덱스 테이블의 크기를 뜻함
- 현재 클래스가 구현한 인터페이스의 수(interfaces_count)
예시)
더보기package org.fenixsoft.clazz; public class TestClass { private int m; public int inc() { return m + 1; } }
- 클래스 인덱스는 8, 부모 클래스 인덱스는 2, 인터페이스 인덱스 컬렉션의 크기는 0이라는 뜻
5) 필드 테이블(field_info)
- 인터페이스나 클래스 안에 선언된 변수들을 설명하는 데 쓰임
- 자바에서 필드란 클래스 변수와 인스턴스 변수를 뜻함 (메서드 안에 선언된 지역 변수는 제외)
- 자바에서 필드가 담고 있는 정보
- 필드에 접근할 수 있는 범위 제한(public, private, protected)
- 인스턴스 변수와 클래스 변수의 구분(static)
- 불변 여부(final)
- 휘발성(volatile, CPU 캐시가 아닌 메인 메모리를 직접 읽거나 쓰게 함)
- 직렬화 시 포함 여부(transient)
- 데이터 타입(기본 타입, 객체, 배열)
- 필드 이름
- *필드의 이름과 타입은 크기가 일정하지 않으므로 상수 풀에 정의된 상수를 참조해 설명해야 함
필드 테이블의 최종 형태
- 필드의 access_flags 항목이 가질 수 있는 값은 클래스의 access_flags와 매우 비슷함
- name_index와 descriptor_index는 상수 풀에서 인덱스로, 각각 '필드의 단순 이름'과 '필드 및 메서드 서술자' 참조를 가리킴
- 단순 이름 : 메서드나 필드의 이름을 참조할 때 이용하며 타입과 매개 변수 정보가 생략된 형태
- ex) inc() 메서드와 m 필드의 단순 이름은 'inc'와 'm'
- 서술자 : 필드의 경우 데이터 타입까지, 메서드의 경우 매개 변수 목록(개수, 타입, 순서 포함)과 반환값까지 기술하는 것
- 단순 이름 : 메서드나 필드의 이름을 참조할 때 이용하며 타입과 매개 변수 정보가 생략된 형태
- 필드 테이블에 반드시 포함되어야 하는 데이터 항목은 descriptor_index까지
- 부모 클래스나 부모 인터페이스로부터 상속받은 필드는 필드 테이블 컬렉션에 나열하지 않음
참고) 필드의 access_flags 항목
예시)
더보기package org.fenixsoft.clazz; public class TestClass { private int m; public int inc() { return m + 1; } }
- 필드 테이블 컬렉션이 0x000000B9부터 시작함
- 첫 번째 데이터는 필드 개수를 뜻하는 fields_count이며 타입은 u2, 이 클래스의 필드 테이블에는 데이터가 1개 있음
6) 메서드 테이블(method_info)
메서드 테이블 구조
- 필드 테이블 구조와 거의 동일하고, 접근 플래그(access_flags)와 속성 테이블 컬렉션(attributes)에서 선택할 수 있는 값만 다름
- '메서드 정의'는 접근 플래그, 이름 인덱스, 서술자 인덱스만으로 명확하게 표현됨
- 필드 테이블 컬렉션과 마찬가지로 부모 클래스의 메서드를 오버라이딩하지 않았다면, 부모 클래스의 메서드 정보는 자식 클래스의 메서드 테이블 컬렉션에 나타나지 않음
- Q. 메서드 본문의 코드는 어디에 있을까?
- 메서드 본문의 자바 코드는 javac 컴파일러에 의해 바이트코드 명령어로 변환된 후, 메서드 속성 테이블 컬렉션의 'Code' 속성에 따로 저장됨
- 속성 테이블은 클래스 파일 형식에서 확장성이 가장 큰 데이터 항목임
참고) 메서드 접근 플래그
더보기- 필드에는 없던 synchronized, native, strictfp, abstract 키워드에 대응하는 플래그가 추가됨
예시)
더보기package org.fenixsoft.clazz; public class TestClass { private int m; public int inc() { return m + 1; } }
- 메서드 테이블 컬렉션의 시작 주소는 0x000000C3
- 첫 번째 u2 타입 데이터의 값, 즉 테이블의 항목 수는 0x0002 (메서드가 두 개라는 뜻)
- 소스 코드에 정의된 inc()와 컴파일러가 추가한 인스턴스 생성자인 <init>
7) 속성 테이블(attribute_info)
- 클래스 파일, 필드 테이블, 메서드 테이블, Code 속성, 레코드 구성 요소(record_component_info)는 모두 특정 시나리오에서 특정한 정보를 설명하기 위해 고유한 속성 테이블을 포함할 수 있음
- <자바 가상 머신 명세>는 기존 속성 이름과 중복되지 않는 한, 자체 제작한 컴파일러가 새로운 속성 정보를 속성 테이블에 추가할 수 있도록 허용함
참고) 가상 머신 명세에 사전 정의된 속성
속성 테이블 구조
- 속성 이름은 모두 CONSTANT_Utf8 타입 상수를 참조해 표현, 속성값의 길이는 u4 타입으로 나타냄
- 속성 테이블이 만족해야 하는 공통 구조는 위와 같음 (속성값 자체의 구조는 사용자 정의할 수 있음)
Code 속성
- 자바 프로그램의 메서드 본문 코드는 자바 컴파일러에 의해 최종적으로 바이트코드 명령어로 변환된 후 Code 속성에 저장됨
- Code 속성은 메서드 테이블(method_info)의 속성 컬렉션에 자리하지만, 모든 메서드 테이블에 포함되는 것은 아님
- 인터페이스나 추상 클래스의 추상 메서드에는 Code 속성이 없음
- 클래스 파일에서 가장 중요한 속성으로, 클래스 파일 전체는 '코드를 설명하는 Code 속성'과 '메타데이터를 설명하는 나머지 데이터 항목'으로 구분할 수 있음
참고) Code 속성 테이블 구조
더보기- attribute_name_index
- CONSTANT_Utf8_info 타입 상수를 가리키는 인덱스
- 이 상수의 값은 이 속성의 이름을 뜻하는 'Code'로 고정되어 있음
- attribute_length
- 이 속성의 값이 차지하는 길이
- attribute_name_index와 attribute_length까지가 6바이트를 차지하므로 이 값의 크기는 속성 테이블 전체의 길이에서 6바이트를 뺀 만큼이 됨
- max_stack
- 피연산자 스택의 최대 깊이
- 메서드가 실행되는 동안 피연산자 스택은 이 깊이를 절대 넘을 수 없음
- 가상 머신은 깊이가 이 값만큼인 피연산자 스택을 스택 프레임에 할당함
- max_locals
- 지역 변수 테이블에 필요한 저장소 공간
- 메서드 매개 변수, 명시적 예외 핸들러 매개 변수(try-catch 문의 catch 블록에 정의된 예외), 메서드 본문에 정의된 지역 변수는 모두 지역 변수 테이블에 저장됨
- max_locals의 단위는 변수 슬롯 (변수 슬롯의 개수가 저장됨)
- 변수 슬록은 가상 머신이 지역 변수용으로 메모리를 할당하는 가장 작은 단위
- byte, char, float, int, short, boolean, returnAddress와 같이 32비트를 넘지 않는 데이터 타입들은 변수 하나가 하나의 변수 슬롯을 차지함
- 64비트 데이터 타입인 double, long은 변수 슬롯 두 개를 차지함
- 참고)
- 피연산자 스택과 지역 변수 테이블은 메서드 스택 프레임의 메모리 사용량에 직접 영향을 줌
- 피연산자 스택의 깊이와 변수 슬롯의 개수를 필요 이상으로 크게 잡으면 메모리가 낭비됨
- JVM은 사용을 마친 변수 슬롯을 재사용함
- 실행하는 코드가 지역 변수의 유효 범위를 벗어나면 그 변수가 차지하던 슬롯을 다른 지역 변수가 사용할 수 있음
- 자바 컴파일러는 변수의 유효 범위를 참고해 변수별로 적합한 변수 슬롯을 할당한 후, '동시에 존재하는 지역 변수들'이 차지하는 슬롯의 최대 개수만큼을 max_locals의 크기로 잡음
- 피연산자 스택과 지역 변수 테이블은 메서드 스택 프레임의 메모리 사용량에 직접 영향을 줌
- 지역 변수 테이블에 필요한 저장소 공간
- code_length와 code
- 자바 소스 코드가 컴파일되어 생성된 바이트코드 명령어들을 저장하는 데 이용됨
- code_length는 바이트코드의 길이이고, code는 바이트코드 명령어들이 순서대로 저장되는 바이트 스트림
- 가상 머신은 code에서 바이트코드를 하나씩 읽어 어떤 명령어인지 파악함
- 명령어 각각의 크기는 1바이트라서 code의 타입은 u1
- u1 데이터 타입의 값 범위는 0x00~0xFF(십진수로는 0 ~ 255)로, 최대 256가지 명령어를 표현할 수 있다는 뜻
- 현재 <자바 가상 머신 명세>에는 약 200가지 명령어가 정의되어 있음
- 주의)
- code_length의 타입은 u4라서 이론적인 최대값은 2^32임
- 하지만 <자바 가상 머신 명세>에는 이 값이 65535를 넘을 수 없다고 기록되어 있음
- 사실상 u2 타입에 해당하며, 이 제한을 넘어서면 자바 컴파일러가 컴파일을 거부함 (현실적으로 자바 코드가 이 제한을 초과할 일은 없음)
- 예외 테이블(exception_table)
- 해당 메서드와 관련한 명시적 예외 처리 테이블 집합이 나옴
- 필수는 아님
- 바이트코드의 start_pc 줄과 end_pc 줄 사이에서 catch_type 또는 그 하위 예외가 발생하면 handler_pc 줄로 이동하라는 뜻
- catch_type은 CONSTANT_Class_info 타입 상수를 가리키는 인덱스
- catch_type의 값이 0이면 어떤 비정상 상황이 발생하든 handler_pc로 이동해야 한다는 뜻
'DevBook > JVM 밑바닥까지 파헤치기' 카테고리의 다른 글
[JVM] 바이트코드 실행 엔진 (0) 2025.04.05 [JVM] 가상 머신 실행 서브시스템 (2) - 클래스 로딩 메커니즘 (0) 2025.04.01 [JVM] 가비지 컬렉터와 메모리 할당 전략 - 메모리 할당과 회수 전략 (0) 2025.03.27 [JVM] 가비지 컬렉터와 메모리 할당 전략 (2) - 클래식 가비지 컬렉터 > G1 GC (0) 2025.03.18 [JVM] 가비지 컬렉터와 메모리 할당 전략 (2) - 클래식 가비지 컬렉터 (0) 2025.03.18 - JVM은 플랫폼 독립성을 넘어 언어 독립성을 제공함