안동민 개발노트

본문 시작

선택 심화: 클래스 로딩과 초기화

필수 입문 과정을 마친 학습자가 클래스 로딩·연결·초기화와 JVM 진단 로그를 선택적으로 관찰합니다.

이 장은 선택 심화 과정입니다.

일반적인 Java 애플리케이션을 작성하는 데 꼭 필요한 문법을 더 배우는 장이 아니라, 이미 실행되는 프로그램의 내부 동작과 장애 원인을 JVM 도구로 추적하는 장입니다.

핵심 자습 경로를 먼저 끝낸 뒤 운영 진단이나 JVM 구현에 관심이 있을 때 진행하세요.

이 장을 건너뛰어도 앞에서 완성한 프로그램과 이후 학습에는 영향이 없습니다.

JVM에서 클래스를 “사용한다”는 말에는 로딩, 연결, 초기화가 섞여 있습니다.

로딩은 바이너리 표현으로 Class 객체를 만들고, 연결에는 검증·준비·심볼 해석이 포함됩니다. 검증과 준비는 초기화 전에 끝나지만 해석은 사용 시점까지 늦춰질 수 있습니다. 상수 변수를 제외한 정적 필드 초기화 식과 static 블록은 초기화 과정에서 소스 순서대로 실행됩니다.

진단에서는 어느 단계에서 실패했는지 분리해야 합니다.


정적 초기화 예외

아래 클래스는 처음으로 활성 사용될 때 초기화되며 값이 0인 구성을 거부합니다.

이 예제의 IllegalStateException은 초기화 규칙상 ExceptionInInitializerError로 감싸집니다. 모든 초기화 실패를 같은 방식으로 포장하는 것은 아니며, 이미 Error인 실패는 그대로 전파됩니다.

lab/StaticInitializationFailure.java
public final class StaticInitializationFailure {
    private static final class Configuration {
        private static final int PORT = loadPort();

        private static int loadPort() {
            int port = 0;
            if (port <= 0) {
                throw new IllegalStateException("invalid port=" + port);
            }
            return port;
        }
    }

    public static void main(String[] args) {
        System.out.println(Configuration.PORT);
    }
}

한 번 초기화에 실패한 클래스는 같은 로더에서 오류 상태가 되어 이후 활성 사용 시 NoClassDefFoundError를 낼 수 있습니다.

근본 원인은 첫 ExceptionInInitializerError.getCause()에 있으므로 최초 로그를 보존합니다.

static 초기화 블록에서는 네트워크나 데이터베이스처럼 복구 가능한 I/O를 수행하지 말고 애플리케이션 생명 주기에서 명시적으로 시작합니다.


Class.forName 로딩·초기화

Class.forName(name, false, loader)는 클래스를 적재하지만 초기화하지 않습니다.

true로 초기화를 요청하거나 상수 변수가 아닌 정적 필드를 처음 사용하면 그 필드를 선언한 타입이 초기화됩니다. 컴파일 시점 상수 읽기는 초기화 트리거가 아닙니다.

src/ClassLoadingInitializationBoundary.java
public final class ClassLoadingInitializationBoundary {
    private static final class Target {
        private static final String VALUE = initialize();

        private static String initialize() {
            System.out.println("target-initialized");
            return "ready";
        }
    }

    public static void main(String[] args) throws Exception {
        String name = ClassLoadingInitializationBoundary.class.getName() + "$Target";
        ClassLoader loader = ClassLoadingInitializationBoundary.class.getClassLoader();
        Class<?> loaded = Class.forName(name, false, loader);
        System.out.println("loaded=" + loaded.getName());
        System.out.println("before-active-use");
        Class.forName(name, true, loader);
        System.out.println("value=" + Target.VALUE);
    }
}
클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다

ClassLoadingInitializationBoundary의 새 JVM main 순서를 읽습니다.

클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다
main의 동작Target 초기화이어지는 출력
Class.forName(name, false, loader)요청하지 않음loaded=…$Target
초기화 전 위치 표시아직 시작하지 않음before-active-use
Class.forName(name, true, loader)initialize() 실행target-initialized
Target.VALUE 읽기이미 완료됨 · 반복하지 않음value=ready
Class.forName(name, false, loader)
Target 초기화: 요청하지 않음
이어지는 출력: loaded=…$Target
초기화 전 위치 표시
Target 초기화: 아직 시작하지 않음
이어지는 출력: before-active-use
Class.forName(name, true, loader)
Target 초기화: initialize() 실행
이어지는 출력: target-initialized
Target.VALUE 읽기
Target 초기화: 이미 완료됨 · 반복하지 않음
이어지는 출력: value=ready

원문의 호출 순서와 초기화 규칙을 따른 풀이입니다. 클래스 로그를 새로 수집한 관측은 아닙니다.

ClassLoader.loadClass()도 보통 초기화를 시작하지 않습니다.

리플렉션으로 static 필드 값을 읽는 것은 활성 사용이 될 수 있습니다.


통합 로깅의 클래스 이벤트

다음 명령은 클래스의 적재 출처와 초기화 이벤트를 기록합니다.

javac --release 25 ClassLoadingInitializationBoundary.java
java -Xlog:class+load=info,class+init=debug ClassLoadingInitializationBoundary

이 명령을 실행해 수집한 로그에서는 중첩 클래스 Target의 적재 기록과 초기화 기록을 구분해 읽습니다. 여기에는 새로 수집한 클래스 로그나 시각 값을 제시하지 않습니다.

JDK 클래스 이벤트가 매우 많으므로 파일로 출력하고 태그 필터를 사용합니다.

java -Xlog:class+load=info,class+init=debug:file=class.log:time,level,tags Application

로그 문법과 태그는 java -Xlog:help로 현재 런타임에서 확인합니다.

클래스 경로 미적중은 로딩 문제, 검증 오류는 연결 문제, 초기화 블록 예외는 초기화 문제로 분류합니다.


정적 필드의 초기화 순서

아래 예제에서는 뒤에서 설정할 필드를 앞의 필드 초기화 식이 먼저 읽습니다.

컴파일은 되지만 순서 의존성이 숨겨집니다.

src/StaticInitializationOrderTrace.java
public final class StaticInitializationOrderTrace {
    private static final class Rates {
        private static final int FIRST = trace("FIRST", Rates.SECOND);
        private static final int SECOND = trace("SECOND", 25);

        private static int trace(String name, int value) {
            System.out.println(name + " sees " + value);
            return value;
        }
    }

    public static void main(String[] args) {
        System.out.println("first=" + Rates.FIRST);
        System.out.println("second=" + Rates.SECOND);
    }
}
SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다

StaticInitializationOrderTrace의 준비와 필드 초기화 단계를 구분합니다.

SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다
단계FIRSTSECOND
필드 준비기본값 0기본값 0
FIRST의 trace 실행SECOND에서 읽은 0 대입아직 기본값 0
SECOND의 trace 실행0 유지25 대입
main에서 두 필드 읽기025
필드 준비
FIRST: 기본값 0
SECOND: 기본값 0
FIRST의 trace 실행
FIRST: SECOND에서 읽은 0 대입
SECOND: 아직 기본값 0
SECOND의 trace 실행
FIRST: 0 유지
SECOND: 25 대입
main에서 두 필드 읽기
FIRST: 0
SECOND: 25

Rates.SECOND처럼 타입 이름으로 한정한 참조이므로 이 원문은 컴파일됩니다. 선언 뒤 필드를 단순 이름으로 읽는 경우의 전방 참조 제한과 구분합니다.

static 필드끼리 순환 참조하지 말고 하나의 불변 구성 객체를 명시적 팩터리에서 완성합니다.


클래스 이름·로더의 식별자 쌍

같은 바이너리 이름도 다른 로더가 정의하면 JVM에서 다른 타입입니다.

애플리케이션 서버, 플러그인 계층, 실행 중 재적재 환경에서 ClassCastException 메시지에 같은 클래스 이름이 두 번 보여도 정의 로더를 확인합니다.

src/ClassLoaderIdentityInventory.java
public final class ClassLoaderIdentityInventory {
    public static void main(String[] args) {
        Class<?> current = ClassLoaderIdentityInventory.class;
        ClassLoader application = current.getClassLoader();
        ClassLoader platform = ClassLoader.getPlatformClassLoader();

        System.out.println("class=" + current.getName());
        System.out.println("application-loader=" + application);
        System.out.println("platform-loader=" + platform);
        System.out.println("String-loader=" + String.class.getClassLoader());
        System.out.println("module=" + current.getModule());
    }
}

부트스트랩 로더는 Java 객체로 노출되지 않아 String.class.getClassLoader()가 null입니다.

스레드 문맥 클래스 로더는 ServiceLoader나 프레임워크의 자원 탐색에 쓰일 수 있으므로 클래스를 정의한 로더와 혼동하지 않습니다.


초기화 문제 진단 순서

  1. 최초 예외 객체와 원인 연쇄를 확보합니다.
  2. 실패한 클래스와 정의 로더, 모듈, 코드 출처를 기록합니다.
  3. -Xlog:class+load,class+init로 적재·초기화 시점을 확인합니다.
  4. static 초기화 블록의 I/O, 순환 필드 의존성, 환경 변수 읽기를 찾습니다.
  5. 명시적인 애플리케이션 시작 단계와 의존성을 주입할 수 있는 팩터리로 옮겨 재시도와 shutdown을 설계합니다.
Class.forName을 호출하면 항상 static 블록이 실행되나요?

인수 하나를 받는 오버로드는 클래스를 초기화합니다.

인수 세 개를 받는 오버로드에 초기화 여부로 false를 주면 적재만 요청합니다.

이미 초기화된 클래스는 다시 초기화되지 않으며 로더가 다르면 별도의 클래스로 취급됩니다.


연습 문제

등록 정보를 담은 Map을 첫 호출 때만 만들고 동일한 인스턴스를 반환하세요.

보관 클래스 초기화에 대한 JVM의 보장을 사용합니다.

해설 보기
exercise/LazyHolderRegistrySolution.java
import java.util.Map;

public final class LazyHolderRegistrySolution {
    private static final class Holder {
        private static final Map<String, Integer> REGISTRY = create();

        private static Map<String, Integer> create() {
            System.out.println("registry-created");
            return Map.of("java", 25, "spring", 6);
        }
    }

    static Map<String, Integer> registry() {
        return Holder.REGISTRY;
    }

    public static void main(String[] args) {
        System.out.println("before");
        System.out.println(registry());
        System.out.println(registry() == registry());
    }
}

원문의 출력 표식은 registry-created입니다. 생성이 성공하면 이 표식은 한 번만 나오고 참조 동일성 비교는 true가 됩니다. Map 항목의 출력 순서는 고정하지 않습니다.

생성 실패는 보관 클래스의 초기화 실패가 되므로 복구 가능한 자원에는 다른 생명 주기를 사용합니다.