JVM 이 플랫폼 독립이 될 수 있었던 이유는 클래스 파일 덕분이다.
JVM 은 클래스 파일을 해석하여 실행하는 머신이고, 자바는 클래스 파일을 만들 수 있는 언어 중 하나다.
과거에는 리눅스, 윈도우 등 플랫폼에 독립적으로 아무 곳에서 작성해도 실행할 수 있는 것 이었지만, 이제는 언어 독립성도 생긴다. 코틀린, 자바, 스칼라 등 언어가 달라도 바이트 코드를 만들 수 있는 각각의 컴파일러가 있어서 JVM 에서 실행할 수 있는 코드를 언어 독립적으로 만들 수 있다.
이러한 것이 클래스 파일이라는게 있기 때문이며, 클래스 파일의 형태 및 규칙은 JDK1 부터 거의 바뀌지 않고 있다.
다음은 클래스 파일을 테이블 형태로 나타낸 것. 클래스 파일도 일종의 테이블이다.
ClassFile { u4 magic; u2 minor_version; u2 major_version; u2 constant_pool_count; cp_info constant_pool[constant_pool_count-1]; u2 access_flags; u2 this_class; u2 super_class; u2 interfaces_count; u2 interfaces[interfaces_count]; u2 fields_count; field_info fields[fields_count]; u2 methods_count; method_info methods[methods_count]; u2 attributes_count; attribute_info attributes[attributes_count]; }
이번 포스팅은
package classfiletest;
public class TestClass {
private int x;
private int y;
public int sum() {
return x + y;
}
public int sub(int z) {
return x + y - z;
}
}

코드(위)를 javac 로 컴파일 한 뒤 hex 편집기 로 해석한 16진수 값(아래) 와 비교하면서 공부 할 예정이다.
16진수 값은 1Byte 단위로 그룹핑되어 있는 모습이다.
javap -v 클래스파일 내용도 참고했다.


매직 넘버
이 파일이 JVM 에서 사용하는 클래스 파일임을 나타내는 4Bytes(u4) 데이터. 이 파일이 클래스 파일인지 체크하는 용도. CAFEBABE 로 고정.
버전 번호
앞의 두 바이트(u2) = minor version
뒤의 두 바이트(u2) = major version
JDK1 은 Major Version 이 45 이다.
테스트 코드는 3D = 61 즉, JDK 17(61-45+1) 에서 컴파일 되었음을 알 수 있다.
마이너 버전은 대부분 0 으로 고정이다. JDK 1 의 일부와 12 에서 일부 기능의 베타 출시때 잠시 사용되었다.
클래스 파일에서 상수 풀

테스트 코드에서의 상수 풀 내용(length 표현 1개, 상수 23개)

상수 풀에서 쓰이는 타입의 17가지 테이블

필드 서술자, 메서드 서술자(descriptor)
- 필드 서술자 : 데이터 타입
- 메서드 서술자 : 매개 변수 목록(개수, 타입, 순서 포함), 반환 타입
상수 풀에서 서술자 상수는 다음 표와 같이 표시된다. 객체 타입의 경우 FQCN 앞에 L 을 붙인다.
배열 서술자는 차원 수 만큼 앞에
[가 붙는다. ex)int[]->[I필드 서술자의 경우 위 식별 문자 하나만 표시되나,
메서드 서술자는 N 개의 매개 변수 타입과 반환 타입 모두 나타내므로 규칙을 알아야 한다.
예시를 몇가지 적어보자면
- void inc() ->
()V- java.lang.String.toString() ->
()Ljava/lang/String- int indexOf(Char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex) ->
([CII[CIII)I
0A 00 02 00 03. tag=0x0A 면 CONSTANT_Methodref_info 이다. 데이터 구조를 보면 CONSTANT_Class_info 와 CONSTANT_NameAndType_info 의 인덱스를 각각 u2 데이터로 가지고 있다. 즉 02 인덱스 상수가 CONSTANT_Class_info, 03 인덱스의 상수가 CONSTANT_NameAndType_info 이다.CONSTANT_Class_info 인지 확인해보자. tag = 07 인 것을 확인할 수 있다. 실제 데이터가 07 00 04 이다. 04 인덱스에는 Utf8 상수 값이 들어있을 것이다.CONSTANT_Methodref_info -> CONSTANT_Class_info 로 왔으므로 인덱스 4에 있는 상수는 메서드 서술자의 완전한 이름 정보가 있을 것이다.CONSTANT_Utf8_info 이다. 실제 바이트 스트림은 01 00 10 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74 이며 실제 데이터 영역만 디코딩해보면 java/lang/Object 이다.CONSTANT_NameAndType_info 까지 따라가야 한다.바이트 코드 상수값을 항상 이렇게 해석할순 없다.
javap -verbose TestClass
javap 는 클래스 파일 디스어셈블 명령이고, -verbose 옵션은 더 자세히 나타낸다.
이 명령을 실행하면 아래와 같이 상수풀을 쉽게 볼 수 있다.

CONSTANT_Utf8_info 의 length 가 u2 타입이다.
이는 2바이트이며 나타낼 수 있는 최대값은 65535 이다.
따라서 자바 프로그램에서 변수나 메서드의 이름은 64KB 를 넘으면 컴파일 되지 않는다.


대상 클래스(인터페이스)의 접근제한자, final 등의 정보이다.
u2, 2Bytes 정보이며 비트로 정보를 나타낸다. 따라서 총 16개의 정보를 true/false 로 나타내며, 현재는 다음과 같이 9개 비트만 쓰인다.

ACC_SUPER 에 대해 보충설명 하자면, invokespeical 바이트코드 명령어는 JDK 1.0.2 버전 이전과 이후 명령어 의미가 다르다. JDK 1.0.2 이상이라면 항상 이 플래그는 true 다.
테스트 코드의 경우 public 클래스이기 때문에, 0x0001 | 0x0020 = 0x0021 임을 볼 수 있다.


들_count 를 가진 컬렉션으로 표현예시에서는 00 08 00 02 00 00 이다.
00 08 상수풀을 따라가면 classfiletest/TestClass 라는 클래스 이름이 나오고,
00 02 상수풀을 따라가면 java/lang/Object 라는 상위 클래스가 나온다. (상속을 선언하지 않았으므로 자바 기본 상위 클래스인 Object)
인터페이스 구현은 하지 않았으므로 00 00 으로 작성되었음을 알 수 있다.


// 필드 테이블 구조
field_info {
u2 access_flags;
u2 name_index; // 필드 단순 이름의 상수풀 인덱스
u2 descriptor_index; // 필드 서술자의 상수풀 인덱스
u2 attributes_count;
attribute_info attributes[attributes_count];
}
00 02 로 두개의 필드가 있다.field_info 의 첫번째 항목이다. 접근제한자 외 필드에 붙을 수 있는 것들을 표시한다.

테스트에서 선언한 두 필드 모두 private 외에는 딱히 없으므로, 두 필드 모두 00 02 이다.
다음은 필드 단순 이름과 필드 서술자의 상수 풀 인덱스이다.

00 0B 00 0C -> x:I
00 0F 00 0C -> y:I
위 상수풀과 같이 해석하면 필드의 FQCN 이 아닌 단순 이름 + 타입(I. Integer) 를 나타내는 상수 풀의 인덱스를 가진다.
만약 값이 초기화가 되어 있고 한다면 이 attribute 테이블에 명시된다.
이는 이후 속성 테이블에서 더 설명할 예정.


method_info {
u2 access_flags;
u2 name_index; // 메서드 단순 이름의 상수풀 인덱스
u2 descriptor_index; // 메서드 서술자의 상수풀 인덱스
u2 attributes_count;
attribute_info attributes[attributes_count];
}
좀 길지만, 메서드 세개(
00 03) 과 이후 세개의 메서드를 빨간색 네모로 구분했다. 생성자 1개와 선언된 메서드 2개가 그 내용이다.
<init>(), <cinit>() 이 그 예다.


attribute 는 뒤에서 자세히 설명하겠다. 여기서는 테스트 클래스의 내용만 설명해보자.
00 01 : 클래스 파일 레벨의 attributes count. 1개00 16 : attribute_name_index. 상수 풀에서 0x16(22) 를 조회하면 SourceFile 이 있다.00 00 00 02 : 속성 테이블 길이00 17 : 상수 풀의 0x17(23) 인덱스에는 TestClass.java 가 있다. 이 소스 파일의 이름이다.위에 나오지 않은 메서드의 연산 로직이라던지, 애너테이션, 런타임 디버깅에 사용할 줄번호 등은 어디에 있을까.
또 비교적 최근에 나온 sealed class, record class, module 임을 나타내는 정보는 어디 있을까.
이런 나머지 정보들이 들어가는 곳이 Attributes 다.
Attributes 는 아래 명시한 총 5가지의 사용처에서 각각 사용처를 설명하기 위한 값들이 들어간다. 예를들어 클래스 파일 attribute 로 Record Attributes 가 포함된다면 해당 클래스는 레코드 클래스임을 클래스파일에서 명시한 것이다.
JDK25 기준 클래스파일 구조에서 Attributes 는 총 30가지이다.
각각의 Attribute 는 사용처가 다르다. 예를 들어 Code Attribute 는 메서드 테이블에서만 쓰인다. 반면 애너테이션을 표현하는 RuntimeVisibleAnnotation Attribute 는 클래스 파일, 메서드 테이블, 필드 테이블, Record Attribute 의 record_component_info 에서 공통적으로 쓰일 수 있는 속성이다.
JDK25 기준 클래스파일 구조에서 Attributes 타입을 쓰는 곳은 다섯곳이다.
attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}
Attribute 는 위의 공통 구조 하에서 각각 다른 형태를 가진다.
각 Attribute 가 다른 구조이지만 위 구조는 공통적으로 포함한다.
Code_attribute {
u2 attribute_name_index; // Code 상수를 가지고 있는 상수 풀의 인덱스
u4 attribute_length; // 이후 나올 모든 Code Attribute 의 길이
u2 max_stack;
u2 max_locals;
u4 code_length;
u1 code[code_length];
u2 exception_table_length;
{ u2 start_pc;
u2 end_pc;
u2 handler_pc;
u2 catch_type;
} exception_table[exception_table_length];
u2 attributes_count;
attribute_info attributes[attributes_count];
}
코드 속성은 어쩌면 가장 중요한 속성이다. 메서드 테이블의 Attribute 로 존재하며, 메서드의 바이트 코드 명령어를 담고 있다.
javap -verbose 를 통해 code 를 보면 args_size 도 나온다.
메서드 매개 변수의 개수이며, 메서드 서술자로 계산된 값이다.
this 도 암묵적으로 들어가기 때문에 인스턴스 메서드의 경우 기본적으로 매개 변수가 없다면 1이다.
Static 메서드의 경우 this 가 없기에 매개 변수가 없다면 0이다.

Exceptions : 메서드 레벨에 throws 로 명시된 예외 정보LineNumberTable : 코드 라인넘버와 바이트 코드 오프셋의 매핑이다. 이 기능 덕분에 예외시 스택 트레이스에 라인 넘버가 나오며, 디버깅시 중단점을 적용할 수 있다. LocalVariableTable : Code 의 속성으로, 지역 변수 테이블 안의 변수와 소스 코드의 변수 사이의 관계를 나타낸다. 지역 변수 유효 범위 바이트 코드 인덱스와 변수 이름, 서술자가 있고 지역 변수 테이블에서의 슬롯 인덱스가 있다.javac -g 옵션을 줘서 컴파일해야 보인다.LocalVariableTypeTable : LocalVariableTable 와 같은 내용이나 제네릭 매개 변수용이다. 제네릭이라 Code 속성이 아닌 클래스 파일 속성이다.(제네릭은 클래스 객체 단위로 정해지니) 타입 소거 때문에 서술자는 매개 변수화 된 타입 정보를 담을 수 없으므로 따로 만들었다.SourceFile : 파일 이름이다. 이게 없으면 예외 발생시 어느 파일에서 발생했는지 보이지 않는다.ConstantValue : 필드 테이블의 속성이다. 정적 변수(클래스 변수, static) 에 본인이 가진 상수 풀 인덱스의 값을 초기화 하라고 알리는 역할<clint>() 또는 ConstantValue 속성 중 하나를 사용해서 초기화할 수 있다. 오라클 javac 는 final static 중 기본타입과 String 을 ConstantValue 를 만들어 준다.InnerClasses : 내부 클래스들과 호스트 클래스의 관계Deprecated : 내용이 boolean 인 속성. @Deprecated 에 생성됨Synthetic : 내용이 boolean 인 속성. 컴파일러가 자동으로 추가한 필드나 메서드를 표현.<clinit>(), <init>() 는 자동으로 만들어지긴 하지만 예외StackMapTable : Code 의 속성. 클래스 로딩시 파일의 타입 검증기에서 타입 검사와 적법성 검사에 사용. JDK5 까지의 타입 추론 검사기를 대체하여 성능이 좋아진 역사적인 그런..Signature : 클래스 파일, 필드 테이블, 메서드 테이블 다 가능. 제네릭 시그니처 정보를 담음. 이거 덕분에 리플렉션으로 제네릭 타입 정보를 얻을 수 있음.BootstrapMethods : 클래스 파일 속성. invokedymanic 명령어가 참조하는 부트스트랩 메서드 한정자가 담긴다.MethodParameters : 메서드 테이블의 속성이다. 메서드 파라미터의 이름이 담긴다. LocalVariableTable 이 Code 속성에 있어서 추상 메서드나 인터페이스 메서드의 매개 변수를 표현할 수 없음을 보완하기 위해 만들어졌다.-parameters 옵션을 명시적으로 줘야 한다. (기본적으로 생성되지 않는다)-parameters 컴파일 옵션을 주고 안주고 차이를 보자. 출력에 이름이 args0 이렇게 보이다가 실제 매개 변수 이름이 보일 것.RuntimeVisibleAnnotations, RuntimeInvisibleAnnotations, RuntimeVisibleParameterAnnotations, RuntimeInvisibleParameterAnnotations : 애너테이션 정보를 담는다. 리플렉션으로 애너테이션 정보를 가져올 때 사용된다.Record : 클래스 파일 속성이며, 해당 클래스가 Record Class 임을 나타낸다.PermittedSubclasses : 클래스 파일 속성이며, 해당 클래스가 Sealed Class 임을 나타낸다.[1]
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
public class TestInterface {
public static void main(String[] args) throws Exception {
Method m = TestInterface.class.getDeclaredMethod(
"transfer",
String.class, String.class, long.class
);
System.out.println("=== parameter info ===");
for (Parameter p : m.getParameters()) {
System.out.println(
"name=" + p.getName()
+ ", isNamePresent=" + p.isNamePresent()
);
}
}
static void transfer(String fromUserId,
String toUserId,
long amount) {
// no-op
}
}