클래스 로딩

블러거·2026년 1월 28일

JVM

목록 보기
13/26

C언어는 컴파일 시점에 심벌 참조(논리적인 참조)를 직접 참조(실제 메모리 주소)로 변환하는 링킹 과정을 실행한다. 컴파일 단계에서 실제 호출 대상이나 코드 실행 전략 등이 고정된다.

반면 자바는 컴파일시 심벌 참조만 가진 클래스 파일을 만든다. JVM 런타임에 이 심벌 참조는 직접 참조로 바뀌어 실제 메모리 주소값을 가진 호출을 가지게 된다.

이 구조는 인터페이스와 구현체들을 만들어 놓고 실제 사용할 구현체를 런타임에 결정하거나, 클래스로더를 사용하여 런타임에 실행할 바이너리 스트림을 동적으로 외부에서 읽어오는 등 정적인 언어가 동적인 언어로 확장될 수 있는 기반이 된다.

반면 런타임에 하는 일이 많아지므로 살짝의 느림과 AOT 에 제약이 걸리긴 한다.

클래스 로딩

[JVM 밑바닥까지 파헤치기]

JVM 에서 클래스 로딩은 위와 같은 단계를 거친다.

단계들의 실행시간이 겹쳐 병렬로 실행되는것 처럼 보일 수 있지만, 시작 시간은 반드시 위와 같은 순서로 시작되어야 한다.

클래스 로딩 시작

자바 가상 머신 명세에는 가장 첫 단계인 로딩 이 언제 시작해야하는지는 명시가 없다.
다만, 초기화 단계는 언제 시작되어야 하는지 명시가 되어 있다. 아래의 6가지 경우 초기화 가 진행되어야 하며 이것이 로딩 부터 시작하여 클래스 로딩이 시작되는 트리거다.

  1. 다음 바이트 코드 명령어 실행시 대상의 타입이 초기화 되어있지 않을 때.
    • new : 새 객체 인스턴스 생성
    • getstatic, putstatic : 정적 필드를 읽거나 설정(final 지정된 정적 필드는 컴파일 타임에 상수 풀에 채워지므로 제외)
    • invokestatic : 정적 메서드 호출
  2. Class 클래스, 리플랙션 패키지 등 표준 클래스 라이브러리에서 제공하는 리플렉션 메서드를 사용할 때 해당 타입이 아직 초기화 되어 있지 않은 경우
  3. 클래스를 초기화할 때 상위 클래스가 초기화 되어 있지 않은 경우
  4. 가상 머신은 구동 직후 사용자가 정한 main() 을 찾아 실행함. 이 때 main 타입의 초기화 먼저 시작
  5. REF_getStatic, REF_putStatic, REF_invokeStatic, REF_newInvokeSpecial 타입 메서드 핸들을 해석해 얻은 MethodHandle 인스턴스를 호출할 때 해당하는 클래스가 초기화되어 있지 않은 경우
    • MethodHandle 은 동적 언어 지원 메서드.
    • 쉽게 말하면 MethodHandle 의 invokeExact 메서드 실행 시 초기화 된다는 것.
  6. 인터페이스에 디폴트 메서드가 있다면, 해당 인터페이스의 구현한 클래스가 초기화될 때 인터페이스도 초기화

위의 6가지 경우는 모든 가상 머신에서 클래스 로딩 및 초기화가 시작된다.
나머지 경우는 타입에 대한 수동 참조로서 클래스 로딩이 진행될지는 JVM 구현에 따라 다르다.

클래스 로딩이 될 것 같은데 안되는 예시

상위 클래스의 상수를 하위 클래스를 통해 실행하면, 하위 클래스의 초기화는 안된다.
아래의 결과는 상위 클래스 초기화! 다.
하위 클래스를 통해 호출했지만 하위 클래스의 초기화는 되지 않는다.

이 때 로딩과 링킹은 진행할지 말지 자유인데, 핫스팟 VM 의 경우 -Xlog:class+load=info ㄸ또는-XX:+TraceClassLoading 으로 보면 하위 클래스(SubClass) 의 로딩까진 진행하는 것을 볼 수 있다.

class SuperClass {
    static {
        System.out.println("상위 클래스 초기화!");
    }

    public static int value = 123;
}

class SubClass extends SuperClass {
    static {
        System.out.println("하위 클래스 초기화!");
    }
}

public class NotInitialization_1 {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

아래의 경우 SuperClass, SubClass 모두 초기화되지 않는다.
다만 [L 이 붙은 컴파일러가 만든 클래스가 초기화가 된다. (anewarray 바이트코드 명령에 의해)
이는 배열에 대한 length, clone() 속성 및 메서드를 구현하는 클래스이다.

public class NotInitialization_1 {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

아래 역시 ConstantClass 초기화! 가 불리지 않는다.
컴파일 타임에 ConstClass.HELLO_WORLD 가 최적화 되어 NotInitialization_3 의 상수 풀에 들어간다. 이는 javap 를 사용하면 볼 수 있다.

class ConstClass {
    static {
        System.out.println("ConstClass 초기화!");
    }

    public static final String HELLO_WORLD = "hello world";
}

public class NotInitialization_3 {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLO_WORLD);
    }
}

클래스 로딩 과정

로딩 -> 검증 -> 준비 -> 해석 -> 초기화

1. 로딩

  • 완전한 이름을 보고 해당 클래스를 정의하는 바이너리 바이트 스트림 가져오기
  • 바이스 스트림(정적인 저장 구조)을 메서드 영역에서 사용하는 런타임 데이터 구조로 변환
  • java.lang.Class 객체를 힙에 생성. 사용자는 이를 통해 메서드 영역 데이터 활용.

ClassLoader 의 findClass(), loadClass() 메서드가 해당 단계이다. 이 메서드를 조절하면 로딩 단계를 커스텀할 수 있다.

로딩 단계에서 검증 단계의 일부(파일 형식 검증) 이 실행될 수 있다.

2. 검증 (링킹 - 1)

바이트 스트림은 자바 코드를 컴파일한 클래스 파일 외에도 여러 경로로 만들어질 수 있다.
따라서 자바 컴파일러 외에 JVM 에서 공통적으로 검증 과정이 있어야 한다.

목적은 다음과 같다.

  • 자바 가상 머신 명세의 제약들을 다 지켰는지
  • 실행했을때 보안상의 위협이 없는지

검증은 4가지 항목에 대해 이뤄진다.

  1. 파일 형식 검증
  2. 메타데이터 검증
  3. 바이트 코드 검증
  4. 심벌 참조 검증

검증 단계는 필수는 아니다. 따라서 오래 걸리는 검증 단계를 생략함으로 코드 실행 시간을 단축 할 수 있다. (-Xverify:none) 만.. 그럴 수 있을까.

파일 형식 검증

파일 형식 검증은 로딩 단계에 같이 진행된다. 파일 형식 검증은 바이트 스트림을 대상으로 진행한다.

로딩과 파일 형식 검증이 끝나면 메서드 영역에 저장이 끝난다. 따라서 이후 메타데이터, 바이트 코드, 심벌 참조 검증은 메서드 영역에 대해 진행된다.

  • 매직 넘버 0xCAFEBABE 인지
  • 메이저, 마이너 버전이 자바 허용 범위인지
  • 상수 풀에 지원하지 않는 타입이 있는지
  • 상수 풀 인덱스 중 유효하지 않은게 있는지
  • CONSTANT_Utf8_info 타입에 UTF-8 인코딩 아닌게 있는지

등등..

메타데이터 검증

자바 가상 머신 명세의 요구 사항 검사.

  • 부모 클래스가 있는가? (모든 클래스의 상위는 Object)
  • 부모 클래스에 final 이 없는가?
  • 추상 클래스를 구현한 경우 모든 추상 메서드를 구현했는가?

등등..

바이트 코드 검증

데이터 흐름과 제어 흐름을 분석하여 프로그램의 의미가 적법하고 논리적인지 확인.
메서드 본문(클래스 파일의 Code 속성) 을 대상으로 검증한다.

  • 피연산자 스택 데이터가 int 라면 명령어에 의해 지역 변수 테이블에 저장되는 데이터도 int 여야 함.
  • 점프 명령어가 이상한 곳 가면 안됨
  • 안전하지 않은 형 변환이 있는지 확인

등등..

이 단계는 검증할게 너무 많다. 느림의 주된 원인이 될 수 있다.
클래스 파일의 stackMapTable 속성은 일부 바이트 코드 검증 단계에서 할 작업을 컴파일 단계로 넘김으로 느린 문제를 해결한다.

분기, 점프, 예외 핸들러는 여러 케이스가 한 지점으로 모이는 구문들이다. 이 때의 피연산자 스택과 지역 변수 테이블에 저장된 데이터의 타입을 컴파일 시점에 명시적으로 저장해두고, 바이트 코드 검증 시점에는 해당 타입이 유효한지만 확인하면 된다.

이게 있기 전에는 바이트 코드 검증 단계에서 피연산자 스택과 지역 변수 테이블에 저장된 데이터의 타입을 추론해야 했기에 느렸다.

심벌 참조 검증

링킹의 세번째인 해석 단계에서 심벌 참조가 직접 참조로 변환될 때 실행하는 검증.
현재 클래스가 참조하는 외부 클래스, 메서드, 필드 등에 접근할 권한이 있는지 확인.

  • 심벌 참조에서 리터럴로 기술된 클래스를 실제로 찾을 수 있는가?
  • 서술자에 적힌 메서드나 필드가 실제로 클래스에 존재하는가?
  • 접근 지정자가 접근을 허용하는가?

여기서 NoSuchFieldError, NoSuchMethodError 등 IncompatibleClassChangeError 하위를 던진다.

3. 준비 (링킹 - 2)

클래스 변수(정적 변수)를 메모리에 할당 및 초깃값 설정

또한 코드에서 지정한 초기값이 아닌 0 등 기본값으로 초기화된다.

숫자형은 0, boolean 은 false, 객체는 null 로 초기화.

기본값으로 채워지지 않는 예외의 경우가 있는데,
ConstantValue(클래스 파일 중 필드 테이블의 속성) 이 만들어지는 final static String 과 primitive type 의 경우에는 이 단계에서 ConstantValue 값이 초기화된다.

4. 해석 (링킹 - 3)

심벌 참조를 직접 참조로 변환한다.

심벌 참조를 다루는 바이트 코드 명령어 (anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, ldc2_w, multianewarray, new, putfield, putstatic) 가 사용하는 심벌 참조를 나타내는 상수 풀의 CONSTANT_Class_info, CONSTANT_Fieldref_info, CONSTANT_Methodref_info, CONSTANT_InterfaceMethodref_info, CONSTANT_MethodType_info, CONSTANT_MethodHandle_info, CONSTANT_Dynamic_info, CONSTANT_InvokeDynamic_info 를 직접 참조로 변환한다.

동적으로 결과가 바뀌는 invokedynamic 을 제외한 명령어들은 여러번 해석해도 항상 같은 결과가 나와야 한다. 따라서 한번 해석한 심벌 참조의 경우 상수 풀에 해석되었다고 표시하는 캐싱이 이뤄진다.

해석을 클래스 로딩 시점에 할 것인지, 실제 사용될 때 할 것인지는 JVM 구현에 달려있다.

이 단계는 리터럴 심벌 참조를 실제 주소인 직접 참조로 바꾸는 과정이기 때문에, 다른 클래스의 연쇄적인 클래스 로딩을 유발할 수 있다.

5. 초기화

클래스 생성자 <clinit>() 을 실행한다.
<clinit>() 는 컴파일러가 자동 생성한다.
클래스 변수를 프로그램에 작성한 대로 초기화하고, 정적 블록(static {})을 실행한다.

<clinit>() 은 부모 클래스가 있다면 먼저 실행한다. 따라서 자바에서 가장 먼저 실행되는 클래스 생성자는 Object 의 <clinit>() 이다.

반면 인터페이스(정적 변수 가능)는 실제 사용될 때 <clinit>() 가 실행된다. 즉, 구현 클래스의 <clinit>() 가 불리더라도 인터페이스의 <clinit>() 는 불릴 필요 없다.

정적 블록이나 정적 변수가 없다면 <clinit>() 가 존재하지 않을 수도 있다.

정적 블록은 멀티 스레드 문제에 민감하다. n개 스레드가 한 클래스를 동시에 로딩할 수 있다. 만약 정적 블록에 오래 걸리는 일이 있다면, n-1 개의 스레드는 블로킹 대기하게 된다.

profile
안녕하세요!

0개의 댓글