본문 바로가기

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

필수 입문 과정을 마친 학습자가 클래스 로딩·연결·초기화와 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()에 있으므로 최초 로그를 보존합니다.

같은 클래스와 정의 로더의 다음 접근에서 나온 오류는 초기화에 다시 도전한 결과가 아닙니다. 첫 시도의 구성 값 0과 원인 예외를 조사해야 하며, 두 번째 오류 메시지만 보존하면 그 구성을 거부한 이유를 놓칠 수 있습니다. JLS 12.4.2는 실패한 초기화 상태와 이후 접근을 구분합니다.

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);
    }
}

클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다

forName(false)는 초기화 없이 Class를 얻고 forName(true)에서 target-initialized를 출력한 뒤 VALUE 읽기는 초기화를 반복하지 않습니다. 로딩과 첫 초기화의 실제 전환입니다.

클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다

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

새 JVM의 main · 로딩과 초기화 요청의 경계

클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다

false로 Class 객체를 얻을 때는 Target을 초기화하지 않습니다. 뒤의 true 호출이 처음 초기화를 수행하고, 이어지는 VALUE 읽기는 초기화를 반복하지 않습니다.

클래스 객체 획득 뒤 처음 초기화를 요청한다원 main 호출 순서의 실선이다. false 호출과 before-active-use 출력까지 Target 초기화가 없다. true 호출이 initialize를 실행한다. VALUE 읽기는 완료된 초기화를 반복하지 않는다. 초기화/적재 로그를 새로 수집한 시간선은 아니다.forName · falseClass 객체 획득초기화 요청 없음before-active-use아직 초기화 전forName · trueinitialize() 실행target-initializedTarget.VALUE 읽기value=ready초기화 반복 없음
클래스 객체 획득 뒤 처음 초기화를 요청한다원 main 호출 순서의 실선이다. false 호출과 before-active-use 출력까지 Target 초기화가 없다. true 호출이 initialize를 실행한다. VALUE 읽기는 완료된 초기화를 반복하지 않는다. 초기화/적재 로그를 새로 수집한 시간선은 아니다.forName · falseClass 객체 획득초기화 요청 없음before-active-use아직 초기화 전forName · trueinitialize() 실행target-initializedTarget.VALUE 읽기value=ready초기화 반복 없음
  1. Class.forName(name, false, loader)

    Target 초기화
    요청하지 않음
    이어지는 출력
    loaded=…$Target
  2. 초기화 전 위치 표시

    Target 초기화
    아직 시작하지 않음
    이어지는 출력
    before-active-use
  3. Class.forName(name, true, loader)

    Target 초기화
    initialize() 실행
    이어지는 출력
    target-initialized
  4. Target.VALUE 읽기

    Target 초기화
    이미 완료됨 · 반복하지 않음
    이어지는 출력
    value=ready

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

이 예의 조건

  • 원 main 순서이며 새 적재·초기화 로그 수집이 아닙니다.
관계도의 보충 설명
원 도표의 설명과 주석
구분내용
전체 읽기 정보클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다 클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다 ClassLoadingInitializationBoundary의 새 JVM main 순서를 읽습니다. 새 JVM의 main · 로딩과 초기화 요청의 경계 클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다 false로 Class 객체를 얻을 때는 Target을 초기화하지 않습니다. 뒤의 true 호출이 처음 초기화를 수행하고, 이어지는 VALUE 읽기는 초기화를 반복하지 않습니다. 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 원문의 호출 순서와 초기화 규칙을 따른 풀이입니다. 클래스 로그를 새로 수집한 관측은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기화 없는 획득

forName(false)가 Class 객체를 얻습니다.

  • 초기화 요청: 없음
  • 다음 출력: before-active-use
  • Class 객체 획득과 초기화 요청을 구별합니다.
첫 초기화 요청

forName(true)가 initialize를 실행합니다.

  • 출력: target-initialized
  • Target: 초기화 완료
  • 첫 능동적 초기화 요청이 실행을 촉발합니다.
완료된 값 읽기

Target.VALUE를 읽습니다.

  • 값: ready
  • 초기화: 반복 없음
  • 완료된 초기화는 뒤의 필드 읽기에서 다시 실행되지 않습니다.

클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다

forName(false)는 초기화 없이 Class를 얻고 forName(true)에서 target-initialized를 출력한 뒤 VALUE 읽기는 초기화를 반복하지 않습니다. 로딩과 첫 초기화의 실제 전환입니다.

클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다

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

새 JVM의 main · 로딩과 초기화 요청의 경계

클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다

false로 Class 객체를 얻을 때는 Target을 초기화하지 않습니다. 뒤의 true 호출이 처음 초기화를 수행하고, 이어지는 VALUE 읽기는 초기화를 반복하지 않습니다.

클래스 객체 획득 뒤 처음 초기화를 요청한다원 main 호출 순서의 실선이다. false 호출과 before-active-use 출력까지 Target 초기화가 없다. true 호출이 initialize를 실행한다. VALUE 읽기는 완료된 초기화를 반복하지 않는다. 초기화/적재 로그를 새로 수집한 시간선은 아니다.forName · falseClass 객체 획득초기화 요청 없음before-active-use아직 초기화 전forName · trueinitialize() 실행target-initializedTarget.VALUE 읽기value=ready초기화 반복 없음
클래스 객체 획득 뒤 처음 초기화를 요청한다원 main 호출 순서의 실선이다. false 호출과 before-active-use 출력까지 Target 초기화가 없다. true 호출이 initialize를 실행한다. VALUE 읽기는 완료된 초기화를 반복하지 않는다. 초기화/적재 로그를 새로 수집한 시간선은 아니다.forName · falseClass 객체 획득초기화 요청 없음before-active-use아직 초기화 전forName · trueinitialize() 실행target-initializedTarget.VALUE 읽기value=ready초기화 반복 없음
  1. Class.forName(name, false, loader)

    Target 초기화
    요청하지 않음
    이어지는 출력
    loaded=…$Target
  2. 초기화 전 위치 표시

    Target 초기화
    아직 시작하지 않음
    이어지는 출력
    before-active-use
  3. Class.forName(name, true, loader)

    Target 초기화
    initialize() 실행
    이어지는 출력
    target-initialized
  4. Target.VALUE 읽기

    Target 초기화
    이미 완료됨 · 반복하지 않음
    이어지는 출력
    value=ready

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

이 예의 조건

  • 원 main 순서이며 새 적재·초기화 로그 수집이 아닙니다.

관계도의 보충 설명

원 도표의 설명과 주석
구분내용
전체 읽기 정보클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다 클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다 ClassLoadingInitializationBoundary의 새 JVM main 순서를 읽습니다. 새 JVM의 main · 로딩과 초기화 요청의 경계 클래스 객체를 얻는 호출과 초기화를 요청하는 호출을 구분한다 false로 Class 객체를 얻을 때는 Target을 초기화하지 않습니다. 뒤의 true 호출이 처음 초기화를 수행하고, 이어지는 VALUE 읽기는 초기화를 반복하지 않습니다. 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 원문의 호출 순서와 초기화 규칙을 따른 풀이입니다. 클래스 로그를 새로 수집한 관측은 아닙니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
초기화 없는 획득

forName(false)가 Class 객체를 얻습니다.

  • 초기화 요청: 없음
  • 다음 출력: before-active-use
  • Class 객체 획득과 초기화 요청을 구별합니다.
첫 초기화 요청

forName(true)가 initialize를 실행합니다.

  • 출력: target-initialized
  • Target: 초기화 완료
  • 첫 능동적 초기화 요청이 실행을 촉발합니다.
완료된 값 읽기

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가 기본값을 읽는다

두 static 필드의 기본값 0에서 FIRST가 SECOND의 0을 복사하고 이후 SECOND에만 25가 대입됩니다. 텍스트 순서와 과거 값 복사의 상태 변화가 핵심입니다.

SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다

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

준비의 기본값 → 소스 순서의 명시적 대입

SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다

준비 때 두 int 필드는 0입니다. FIRST 초기화 식이 SECOND의 0을 먼저 읽은 뒤, SECOND에 25가 대입되므로 최종 값은 각각 0과 25가 됩니다.

소스 순서의 대입은 저장한 앞 필드를 재계산하지 않는다실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재0을 복사, SECOND가25로 바뀜, main이0/25를 읽는다. SECOND 이후대입에서 FIRST로 자동갱신 간선은 없다.준비 · 기본값FIRST=0SECOND=0FIRST 식 실행SECOND의 0 복사FIRST=0SECOND 식 실행25 대입FIRST는 0 유지main에서 읽기FIRST=0SECOND=25
소스 순서의 대입은 저장한 앞 필드를 재계산하지 않는다실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재0을 복사, SECOND가25로 바뀜, main이0/25를 읽는다. SECOND 이후대입에서 FIRST로 자동갱신 간선은 없다.준비 · 기본값FIRST=0SECOND=0FIRST 식 실행SECOND의 0 복사FIRST=0SECOND 식 실행25 대입FIRST는 0 유지main에서 읽기FIRST=0SECOND=25
  1. 필드 준비

    FIRST
    기본값 0
    SECOND
    기본값 0
  2. FIRST의 trace 실행

    FIRST
    SECOND에서 읽은 0 대입
    SECOND
    아직 기본값 0
  3. SECOND의 trace 실행

    FIRST
    0 유지
    SECOND
    25 대입
  4. main에서 두 필드 읽기

    FIRST
    0
    SECOND
    25

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

이 예의 조건

  • 원 static 필드 초기화의 소스 순서이다.
관계도의 보충 설명
원 도표의 전체 관계·라벨·조건
구분내용
2실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재 0을 복사, SECOND가 25로 바뀜, main이 0/25를 읽는다. SECOND의 이후 대입에서 FIRST로 자동 갱신하는 간선은 없다.
원 도표의 설명과 주석
구분내용
설명실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재 0을 복사, SECOND가 25로 바뀜, main이 0/25를 읽는다. SECOND의 이후 대입에서 FIRST로 자동 갱신하는 간선은 없다.
전체 읽기 정보SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다 SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다 StaticInitializationOrderTrace의 준비와 필드 초기화 단계를 구분합니다. 준비의 기본값 → 소스 순서의 명시적 대입 SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다 준비 때 두 int 필드는 0입니다. FIRST 초기화 식이 SECOND의 0을 먼저 읽은 뒤, SECOND에 25가 대입되므로 최종 값은 각각 0과 25가 됩니다. 필드 준비 FIRST 기본값 0 SECOND 기본값 0 FIRST의 trace 실행 FIRST SECOND에서 읽은 0 대입 SECOND 아직 기본값 0 SECOND의 trace 실행 FIRST 0 유지 SECOND 25 대입 main에서 두 필드 읽기 FIRST 0 SECOND 25 Rates.SECOND처럼 타입 이름으로 한정한 참조이므로 이 원문은 컴파일됩니다. 선언 뒤 필드를 단순 이름으로 읽는 경우의 전방 참조 제한과 구분합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
기본값 준비

두 필드를 0으로 둡니다.

  • FIRST: 0
  • SECOND: 0
  • 명시적 초기화식 전 기본값이 존재한다.
FIRST 초기화

SECOND의 현재 0을 복사합니다.

  • FIRST: 0
  • SECOND: 0
  • 나중 값 25를 예측하거나 계속 참조하지 않는다.
SECOND 초기화

SECOND에 25를 대입합니다.

  • FIRST: 0 유지
  • SECOND: 25
  • 이 대입이 이전 FIRST 복사를 갱신하지 않는다.
main 읽기

완성된 두 필드 값을 관찰합니다.

  • 출력: first=0, second=25
  • 명시적 초기화 순서가 최종값 차이를 만든다.

SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다

두 static 필드의 기본값 0에서 FIRST가 SECOND의 0을 복사하고 이후 SECOND에만 25가 대입됩니다. 텍스트 순서와 과거 값 복사의 상태 변화가 핵심입니다.

SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다

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

준비의 기본값 → 소스 순서의 명시적 대입

SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다

준비 때 두 int 필드는 0입니다. FIRST 초기화 식이 SECOND의 0을 먼저 읽은 뒤, SECOND에 25가 대입되므로 최종 값은 각각 0과 25가 됩니다.

소스 순서의 대입은 저장한 앞 필드를 재계산하지 않는다실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재0을 복사, SECOND가25로 바뀜, main이0/25를 읽는다. SECOND 이후대입에서 FIRST로 자동갱신 간선은 없다.준비 · 기본값FIRST=0SECOND=0FIRST 식 실행SECOND의 0 복사FIRST=0SECOND 식 실행25 대입FIRST는 0 유지main에서 읽기FIRST=0SECOND=25
소스 순서의 대입은 저장한 앞 필드를 재계산하지 않는다실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재0을 복사, SECOND가25로 바뀜, main이0/25를 읽는다. SECOND 이후대입에서 FIRST로 자동갱신 간선은 없다.준비 · 기본값FIRST=0SECOND=0FIRST 식 실행SECOND의 0 복사FIRST=0SECOND 식 실행25 대입FIRST는 0 유지main에서 읽기FIRST=0SECOND=25
  1. 필드 준비

    FIRST
    기본값 0
    SECOND
    기본값 0
  2. FIRST의 trace 실행

    FIRST
    SECOND에서 읽은 0 대입
    SECOND
    아직 기본값 0
  3. SECOND의 trace 실행

    FIRST
    0 유지
    SECOND
    25 대입
  4. main에서 두 필드 읽기

    FIRST
    0
    SECOND
    25

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

이 예의 조건

  • 원 static 필드 초기화의 소스 순서이다.

관계도의 보충 설명

원 도표의 전체 관계·라벨·조건
구분내용
2실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재 0을 복사, SECOND가 25로 바뀜, main이 0/25를 읽는다. SECOND의 이후 대입에서 FIRST로 자동 갱신하는 간선은 없다.
원 도표의 설명과 주석
구분내용
설명실선은 준비 뒤의 필드 초기화/읽기 순서다. 준비의 FIRST/SECOND 0, FIRST가 SECOND의 현재 0을 복사, SECOND가 25로 바뀜, main이 0/25를 읽는다. SECOND의 이후 대입에서 FIRST로 자동 갱신하는 간선은 없다.
전체 읽기 정보SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다 SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다 StaticInitializationOrderTrace의 준비와 필드 초기화 단계를 구분합니다. 준비의 기본값 → 소스 순서의 명시적 대입 SECOND의 명시적 대입 전에 FIRST가 기본값을 읽는다 준비 때 두 int 필드는 0입니다. FIRST 초기화 식이 SECOND의 0을 먼저 읽은 뒤, SECOND에 25가 대입되므로 최종 값은 각각 0과 25가 됩니다. 필드 준비 FIRST 기본값 0 SECOND 기본값 0 FIRST의 trace 실행 FIRST SECOND에서 읽은 0 대입 SECOND 아직 기본값 0 SECOND의 trace 실행 FIRST 0 유지 SECOND 25 대입 main에서 두 필드 읽기 FIRST 0 SECOND 25 Rates.SECOND처럼 타입 이름으로 한정한 참조이므로 이 원문은 컴파일됩니다. 선언 뒤 필드를 단순 이름으로 읽는 경우의 전방 참조 제한과 구분합니다.
예의 값과 변화
관찰·상황값·보충 정보변화·이유
기본값 준비

두 필드를 0으로 둡니다.

  • FIRST: 0
  • SECOND: 0
  • 명시적 초기화식 전 기본값이 존재한다.
FIRST 초기화

SECOND의 현재 0을 복사합니다.

  • FIRST: 0
  • SECOND: 0
  • 나중 값 25를 예측하거나 계속 참조하지 않는다.
SECOND 초기화

SECOND에 25를 대입합니다.

  • FIRST: 0 유지
  • SECOND: 25
  • 이 대입이 이전 FIRST 복사를 갱신하지 않는다.
main 읽기

완성된 두 필드 값을 관찰합니다.

  • 출력: first=0, second=25
  • 명시적 초기화 순서가 최종값 차이를 만든다.

예시

준비 단계에서 두 필드가 0인 뒤, FIRST의 식은 그 시점의 SECOND 값 0을 복사합니다. 나중에 SECOND가 25가 되어도 앞서 저장된 FIRST를 다시 계산하지 않으므로 최종 쌍은 0·25입니다. 이를 변수 사이의 지속적인 연결이나 자동 갱신 규칙으로 해석하면 순서 문제를 놓칩니다.

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의 보장을 사용합니다.

등록 값은 java에 25, spring에 6입니다. 첫 호출과 두 번째 호출을 비교할 때 생성 표식의 횟수, 두 참조의 동일성, 각 키의 값을 먼저 예상합니다. 항목을 출력하는 순서는 판정 조건에 넣지 않습니다. 생성 시 예외가 발생한 경우도 정상 생성 후 두 번 읽는 경우와 구별해 설명합니다.

해설 보기
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 항목의 출력 순서는 고정하지 않습니다.

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