본문 바로가기

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

JVM이 클래스를 불러와 초기화하는 과정과 static 초기화가 실행되는 시점을 실행 결과와 JVM 로그로 확인하고, 초기화 실패와 교착을 진단하는 방법을 배웁니다.

38장은 선택 심화 과정입니다. 새 문법을 배우는 장이 아니라, 이미 돌아가는 프로그램 안에서 JVM이 하는 일을 도구로 들여다보고 장애의 원인을 좁혀 가는 방법을 다룹니다. 이 장을 건너뛰어도 앞에서 만든 프로그램이나 다른 장을 공부하는 데는 지장이 없습니다. 기본 과정을 마친 뒤 운영 중인 서비스를 진단해야 하거나 JVM 내부가 궁금해졌을 때 읽으세요.

static 변수를 배울 때 static 필드는 객체를 하나도 만들기 전에 준비된다고 했습니다. 그렇다면 정확히 언제 준비될까요? 프로그램이 시작할 때 모든 클래스의 static 블록이 한꺼번에 실행될 것 같지만 실제로는 그렇지 않습니다. 이 차이를 모르면 'static 블록에 넣은 로그가 왜 안 찍히지?', '클래스 파일이 분명히 있는데 왜 NoClassDefFoundError가 나지?' 같은 상황에서 원인을 찾기 어렵습니다. 이 글에서는 JVM이 클래스를 처음 쓰기까지 거치는 단계를 나눠 보고, 각 단계가 언제 일어나는지를 실행 결과와 JVM 로그로 확인합니다.

예제는 모두 javac -d out 파일이름.java로 컴파일하고 java -cp out 클래스이름으로 실행합니다. 본문의 출력은 JDK 21(HotSpot)에서 얻은 것입니다. 로딩과 초기화의 규칙은 Java 언어 명세(JLS)와 Java 가상 머신 명세(JVMS)가 정하므로 JDK 버전이 바뀌어도 같습니다. 하지만 로그 형식과 오류 메시지의 세부 문구는 HotSpot 구현이 정한 것이라 버전마다 조금씩 다를 수 있습니다.

클래스를 쓰기 전에 JVM이 하는 세 가지 일

javac가 만든 클래스 파일은 프로그램이 시작할 때 한꺼번에 메모리에 올라가지 않습니다. JVM은 실행 도중 어떤 클래스가 필요해지는 시점에 그 클래스를 준비하는데, JVMS 5장은 이 과정을 로딩(loading), 링킹(linking), 초기화(initialization)의 세 단계로 나눕니다. 링킹은 다시 검증, 준비, 해석으로 나뉩니다.

단계하는 일이 단계에서 실패하면
로딩클래스 로더가 이름으로 클래스 파일을 찾아 읽고, JVM이 그 내용으로 클래스와 이를 나타내는 Class 객체를 만듭니다.파일을 찾지 못하면 ClassNotFoundException이나 NoClassDefFoundError
링킹: 검증(verification)바이트코드가 클래스 파일 형식과 타입 규칙을 지키는지 검사합니다.VerifyError
링킹: 준비(preparation)static 필드를 만들고 기본값(0, false, null)을 넣습니다. 이때 코드는 하나도 실행되지 않습니다.드묾(로더 사이의 타입 불일치가 드러나면 LinkageError)
링킹: 해석(resolution)클래스 파일 안에 이름으로만 적힌 참조(심볼릭 참조)를 실제 클래스, 메서드, 필드로 바꿉니다.NoSuchMethodError, NoSuchFieldError 등
초기화static 필드 초기화 식과 static 블록을 소스에 적힌 순서대로 실행합니다.ExceptionInInitializerError, 그 뒤로는 NoClassDefFoundError

단계를 나눠 생각해야 하는 이유는 JVM이 이 단계들을 서로 다른 시점에 하기 때문입니다. 클래스를 불러오기만 하고 아직 초기화하지 않은 상태가 얼마든지 있을 수 있고, 같은 NoClassDefFoundError라도 로딩에서 실패했는지 초기화에서 실패했는지에 따라 고칠 곳이 완전히 다릅니다. 명세가 시점에 대해 정해 둔 조건은 많지 않습니다. 클래스는 초기화되기 전에 반드시 검증과 준비를 마쳐야 하고, 링킹 중에 발견한 오류는 그 클래스가 필요한 동작을 프로그램이 실제로 할 때 던져야 합니다. 해석은 시점을 구현에 맡겨서, 클래스를 검증할 때 한꺼번에 해도 되고 참조를 처음 실행할 때 하나씩 해도 됩니다(JLS 12.3).

static 초기화가 실행되는 순간

다음 프로그램은 클래스를 '쓰는' 여러 방법을 차례로 시도하면서 그때마다 초기화가 일어나는지 보여 줍니다. Parent와 Child의 static 필드는 초기화 식에서 log를 부르므로, 클래스가 초기화되는 순간 >> … 초기화 줄이 찍힙니다. 단계마다 설명을 먼저 출력한 다음 해당 동작을 하도록 문장을 나눠 두었습니다.

src/InitializationTriggers.java
public final class InitializationTriggers {
    static class Parent {
        static int parentValue = log("Parent", 1);
    }

    static class Child extends Parent {
        static final int LIMIT = 7;
        static int childValue = log("Child", 2);

        static void hello() {
            System.out.println("  Child.hello() 실행");
        }
    }

    static int log(String name, int value) {
        System.out.println("  >> " + name + " 초기화");
        return value;
    }

    public static void main(String[] args) {
        System.out.println("1. 상수 Child.LIMIT 읽기");
        int limit = Child.LIMIT;

        System.out.println("2. Child 배열 만들기");
        Child[] children = new Child[limit];

        System.out.println("3. 클래스 리터럴 Child.class 얻기");
        Class<?> type = Child.class;

        System.out.println("4. Child.parentValue 읽기");
        int inherited = Child.parentValue;

        System.out.println("5. Child.hello() 호출");
        Child.hello();

        System.out.println("6. Child.hello() 다시 호출");
        Child.hello();
        System.out.println("끝: " + children.length + ", " + type.getSimpleName() + ", " + inherited);
    }
}

들여 쓴 >> 줄이 어느 번호 뒤에 나오는지 보면, 몇 번째 동작이 초기화를 일으켰는지 알 수 있습니다. 마지막 줄은 앞의 동작들이 얻은 값을 모아 한 번에 찍습니다.

1. 상수 Child.LIMIT 읽기
2. Child 배열 만들기
3. 클래스 리터럴 Child.class 얻기
4. Child.parentValue 읽기
  >> Parent 초기화
5. Child.hello() 호출
  >> Child 초기화
  Child.hello() 실행
6. Child.hello() 다시 호출
  Child.hello() 실행
끝: 7, Child, 1

1번부터 3번까지는 코드에 Child를 썼는데도 초기화가 일어나지 않았습니다. 1번의 LIMIT는 상수 변수(constant variable)입니다. 상수 변수란 기본형이나 String 타입의 final 변수 가운데 리터럴처럼 컴파일 시점에 값이 정해지는 식으로 초기화한 것을 말합니다. javac는 상수 변수를 읽는 코드를 값 7 자체로 바꿔 컴파일하므로, 실행할 때는 Child를 건드릴 일이 아예 없습니다. 2번처럼 배열을 만들거나 3번처럼 클래스 리터럴로 Class 객체를 얻는 것도 초기화를 일으키지 않습니다.

4번은 Child.parentValue라고 썼지만 초기화된 것은 Parent뿐입니다. parentValue를 실제로 선언한 클래스가 Parent이기 때문입니다. 5번에서 static 메서드 hello()를 부르자 비로소 Child가 초기화됩니다. 클래스를 초기화하기 전에는 부모 클래스를 먼저 초기화해야 하지만, Parent는 4번에서 이미 초기화됐으므로 다시 하지 않습니다. 6번에서 한 번 더 불러도 초기화 줄이 나오지 않는 것처럼, 초기화는 클래스마다 한 번만 일어납니다.

JLS 12.4.1은 이 규칙을 다음과 같이 정합니다. 클래스 T는 다음 가운데 하나가 처음 일어나기 직전에 초기화됩니다.

  • T의 인스턴스를 만들 때
  • T가 선언한 static 메서드를 호출할 때
  • T가 선언한 static 필드에 값을 대입할 때
  • T가 선언한 static 필드를 읽을 때. 단, 그 필드가 상수 변수라면 초기화하지 않습니다.

여기에 몇 가지 규칙이 덧붙습니다. 클래스를 초기화할 때는 아직 초기화되지 않은 부모 클래스와, default 메서드를 선언한 상위 인터페이스를 먼저 초기화합니다. static 필드를 하위 클래스 이름으로 참조해도 초기화되는 것은 그 필드를 선언한 클래스뿐입니다. Class와 java.lang.reflect 패키지의 일부 리플렉션 메서드도 초기화를 일으키고, main 메서드가 있는 시작 클래스는 JVM이 시작하면서 초기화합니다. 그리고 명세는 '그 밖의 경우에는 초기화하지 않는다'고 못 박습니다. 그래서 static 블록이 실행되는 시점은 프로그램이 시작할 때가 아니라, 그 클래스를 위의 방법으로 처음 쓰는 순간입니다.

더 깊이: 상수는 사용하는 쪽 클래스 파일에 복사된다

상수 변수를 읽어도 초기화가 일어나지 않는 것은 컴파일러가 값을 사용하는 쪽 클래스 파일에 복사해 넣기 때문입니다(JLS 13.1). 이 동작은 라이브러리를 따로 배포할 때 뜻밖의 결과를 낳습니다. 라이브러리의 static final int LIMIT = 7;을 10으로 바꾸고 라이브러리만 다시 컴파일하면, 그 상수를 쓰던 다른 클래스 파일에는 여전히 7이 박혀 있습니다. 사용하는 쪽까지 다시 컴파일해야 새 값이 반영됩니다(JLS 13.4.9). 앞으로 바뀔 수 있는 값을 상수 변수로 공개하지 말고 메서드로 돌려주라는 조언이 여기서 나옵니다.

Class.forName으로 로딩과 초기화를 따로 일으키기

이름(문자열)으로 클래스를 다룰 때는 Class.forName을 씁니다. 인수가 세 개인 Class.forName(이름, 초기화여부, 로더)는 두 번째 인수로 초기화까지 할지 고를 수 있어서, 로딩과 초기화가 따로 일어나는 모습을 보기에 좋습니다. 아래 프로그램은 중첩 클래스 Target을 먼저 초기화 없이 불러온 다음, 초기화를 요청합니다.

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

loaded= 줄은 첫 forName이 돌려준 Class 객체의 이름이고, before-active-use는 두 번째 forName을 부르기 직전에 찍힙니다. target-initialized가 이 두 줄의 앞에 나오는지 뒤에 나오는지 보세요.

loaded=ClassLoadingInitializationBoundary$Target
before-active-use
target-initialized
value=ready

Class.forName(name, false, loader)는 Target을 로딩해 Class 객체를 돌려주지만 초기화는 하지 않습니다. 그래서 loaded=…와 before-active-use가 찍힐 때까지 initialize()는 실행되지 않습니다. 이름 끝의 $Target은 중첩 클래스의 바이너리 이름으로, javac가 바깥 클래스 이름 뒤에 $를 붙여 만든 것입니다. 두 번째 호출에서 true를 주자 그 자리에서 초기화가 일어나 target-initialized가 출력됩니다. 마지막 줄에서 Target.VALUE를 읽을 때는 초기화가 이미 끝났으므로 값만 읽습니다. VALUE도 static final이지만 메서드 호출로 초기화하므로 상수 변수가 아니라는 점에 주의하세요.

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

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
  • 초기화: 반복 없음
  • 완료된 초기화는 뒤의 필드 읽기에서 다시 실행되지 않습니다.

인수가 하나인 Class.forName("클래스이름")은 Class.forName(이름, true, 호출한 클래스의 로더)와 같아서 언제나 초기화까지 합니다. 예전 JDBC 코드에서 Class.forName("com.mysql.jdbc.Driver")처럼 드라이버 클래스 이름만 적던 이유가 이것입니다. 드라이버 클래스의 static 블록이 자신을 DriverManager에 등록하므로 초기화만 일으키면 됐습니다. JDBC 4.0부터는 ServiceLoader가 드라이버를 찾아 주므로 이 호출이 필요 없습니다.

반대로 ClassLoader.loadClass("클래스이름")은 로딩만 하고 초기화하지 않습니다. 하지만 이렇게 얻은 클래스의 static 필드를 리플렉션으로 읽으면(Field.get) 그 순간 초기화가 일어납니다. 앞의 규칙에서 말한 '일부 리플렉션 메서드'가 이런 경우입니다.

static 필드는 소스에 적힌 순서대로 초기화된다

초기화가 시작되면 static 필드 초기화 식과 static 블록이 소스에 적힌 순서대로, 마치 하나의 블록처럼 실행됩니다(JLS 12.4.2). 이 순서 때문에 생기는 함정을 보겠습니다. 아래 Rates에서 FIRST의 초기화 식은 그 아래에 선언된 SECOND를 읽습니다.

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

trace는 각 필드의 초기화 식이 실행되는 순간 받은 값을 찍습니다. 앞의 두 줄은 초기화 도중에, 뒤의 두 줄은 초기화가 끝난 뒤 main에서 찍힙니다.

FIRST sees 0
SECOND sees 25
first=0
second=25

FIRST가 읽은 SECOND는 25가 아니라 0이었습니다. 준비 단계에서 두 필드에 기본값 0이 들어간 상태로 초기화가 시작되고, 초기화 식은 위에서부터 차례로 실행되기 때문입니다. 단계별로 따라가면 다음과 같습니다.

시점FIRSTSECOND출력
준비 단계가 끝난 뒤00없음
FIRST의 초기화 식 실행00FIRST sees 0
SECOND의 초기화 식 실행025SECOND sees 25

FIRST는 그 순간의 값 0을 복사해 두었을 뿐이므로, 나중에 SECOND가 25가 되어도 다시 계산되지 않습니다. 그래서 main에서 읽은 최종 값은 0과 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
  • 명시적 초기화 순서가 최종값 차이를 만든다.

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 앞에 클래스 이름을 붙였기 때문입니다. 단순한 이름으로 선언보다 앞에서 값을 읽으면 javac가 거부합니다.

src/ForwardReference.java
public final class ForwardReference {
    private static final int FIRST = SECOND + 1;
    private static final int SECOND = Integer.parseInt("25");
}
ForwardReference.java:2: error: illegal forward reference
    private static final int FIRST = SECOND + 1;
                                     ^
1 error

이 전방 참조 검사(JLS 8.3.3)는 초기화 식이나 static 블록 안에서 필드를 단순한 이름으로 읽는 경우만 잡습니다. Rates.SECOND처럼 클래스 이름을 붙이거나 메서드 안에서 읽으면 컴파일러는 막지 않고, 실행하면 기본값이 보입니다.

SECOND가 상수 변수라면 결과가 또 달라집니다. SECOND의 초기화 식을 trace("SECOND", 25) 대신 25로 바꿔 실행하면 다음과 같이 나옵니다.

FIRST sees 25
first=25
second=25

상수 변수는 다른 초기화 식들이 실행되기 전에 먼저 값이 정해지고(JLS 12.4.2의 6단계), Rates.SECOND를 읽는 코드도 javac가 값 25로 바꿔 컴파일하기 때문입니다. SECOND sees 25 줄이 사라진 것은 trace를 더 이상 부르지 않아서입니다. 이처럼 같은 모양의 코드가 필드 하나의 초기화 식에 따라 다른 값을 내므로, static 필드끼리 서로를 참조하게 만들지 않는 것이 가장 안전합니다. 여러 값이 서로 의존한다면 불변 설정 객체 하나를 팩터리 메서드 한 곳에서 순서대로 계산해 만드세요.

초기화에 실패한 클래스는 다시 시도되지 않는다

static 초기화 식에서 예외가 나면 어떻게 될까요? 아래 Configuration은 포트 번호를 정하는 loadPort()에서 값이 0이면 IllegalStateException을 던집니다. main은 Configuration.PORT를 두 번 읽어 보는데, 각 시도에서 무슨 일이 일어나는지 보려고 일부러 Error를 잡았습니다. 실무 코드에서는 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) {
        for (int attempt = 1; attempt <= 2; attempt++) {
            try {
                System.out.println(Configuration.PORT);
            } catch (Error error) { // 동작을 보여 주려는 예제일 뿐, 실무에서는 Error를 잡지 않습니다
                System.out.println(attempt + "번째: " + error);
                System.out.println("  원인: " + error.getCause());
            }
        }
    }
}

loadPort()의 port는 언제나 0이므로 첫 시도는 반드시 실패합니다. 두 번째 시도에서도 같은 종류의 오류가 나오는지 보세요.

1번째: java.lang.ExceptionInInitializerError
  원인: java.lang.IllegalStateException: invalid port=0
2번째: java.lang.NoClassDefFoundError: Could not initialize class StaticInitializationFailure$Configuration
  원인: java.lang.ExceptionInInitializerError: Exception java.lang.IllegalStateException: invalid port=0 [in thread "main"]

첫 번째 시도에서 PORT를 읽자 Configuration의 초기화가 시작되고, loadPort()가 던진 IllegalStateException이 ExceptionInInitializerError로 감싸여 나왔습니다. JLS 12.4.2에 따르면 초기화 도중 던져진 예외가 Error가 아니면 이렇게 감싸고, OutOfMemoryError처럼 이미 Error인 예외는 그대로 전달합니다. 이와 함께 JVM은 이 클래스를 '오류 상태(erroneous)'로 표시합니다.

두 번째 시도에서는 loadPort()가 아예 실행되지 않았습니다. 오류 상태인 클래스는 초기화를 다시 시도하지 않고 곧바로 NoClassDefFoundError를 던지기 때문입니다. 'Could not initialize class'라는 메시지와 첫 실패를 원인으로 붙여 준 마지막 줄은 HotSpot이 덧붙이는 정보입니다. JDK 21은 이렇게 첫 예외를 알려 주지만 예전 JDK에서는 이 원인이 붙지 않았으므로, 어느 버전에서든 로그에서 가장 처음 나온 ExceptionInInitializerError와 그 원인을 찾는 습관을 들이는 것이 좋습니다. 서버라면 첫 실패는 한 번만 기록되고 그 뒤로는 같은 NoClassDefFoundError가 요청마다 쌓이므로, 마지막 오류만 봐서는 원인을 알 수 없습니다.

오류 상태는 그 클래스를 정의한 클래스 로더가 살아 있는 동안 풀리지 않습니다. 설정 파일을 고쳐도 같은 JVM 안에서는 Configuration을 다시 초기화할 수 없고, 프로그램을 다시 시작해야 합니다. 그래서 네트워크나 데이터베이스 연결처럼 일시적으로 실패할 수 있는 작업은 static 초기화에 넣지 말고, 애플리케이션이 시작할 때 명시적으로 호출하는 메서드로 옮기는 편이 좋습니다. 그래야 실패를 잡아 다시 시도하거나 깔끔하게 종료할 수 있습니다.

같은 NoClassDefFoundError라도 로딩 단계에서 실패하면 모습이 다릅니다. 다음 프로그램을 컴파일한 뒤 중첩 클래스 Helper의 클래스 파일만 지우고 실행해 보겠습니다.

src/MissingClass.java
public final class MissingClass {
    static final class Helper {
        static String name() {
            return "helper";
        }
    }

    public static void main(String[] args) {
        System.out.println("start");
        System.out.println(Helper.name());
    }
}
javac -d out MissingClass.java
rm 'out/MissingClass$Helper.class'
java -cp out MissingClass

rm 명령으로 Helper의 클래스 파일만 지운 뒤 실행하므로, MissingClass 자체는 정상적으로 불러와집니다. 셸이 $Helper를 변수로 읽지 않도록 파일 이름은 작은따옴표로 감쌌습니다.

start
Exception in thread "main" java.lang.NoClassDefFoundError: MissingClass$Helper
	at MissingClass.main(MissingClass.java:10)
Caused by: java.lang.ClassNotFoundException: MissingClass$Helper
	at java.base/jdk.internal.loader.BuiltinClassLoader.loadClass(BuiltinClassLoader.java:641)
	at java.base/jdk.internal.loader.ClassLoaders$AppClassLoader.loadClass(ClassLoaders.java:188)
	at java.base/java.lang.ClassLoader.loadClass(ClassLoader.java:526)
	... 1 more

메시지에 'Could not initialize'가 없고 원인이 ClassNotFoundException이면 클래스 파일 자체를 찾지 못한 것이므로, 클래스 경로나 배포한 파일 구성을 확인해야 합니다. start가 먼저 찍힌 것도 눈여겨볼 만합니다. JVM은 Helper가 필요해진 10번째 줄에 도달해서야 그 클래스를 찾았고, 그 전까지 프로그램은 아무 문제 없이 실행됐습니다.

클래스 로더는 부모에게 먼저 묻는다

지금까지 로딩이라고 부른 일은 클래스 로더(class loader)가 합니다. JDK 9부터 JVM에는 다음 세 가지 기본 클래스 로더가 있습니다.

  • 부트스트랩 로더(bootstrap class loader): JVM에 내장된 로더로, java.lang.String이 들어 있는 java.base 같은 핵심 모듈의 클래스를 불러옵니다. 자바 객체로 존재하지 않기 때문에 코드에서는 null로 보입니다.
  • 플랫폼 로더(platform class loader): 부트스트랩 로더가 맡지 않는 일부 Java SE·JDK 모듈의 클래스를 불러옵니다. 예를 들어 java.sql 모듈의 클래스가 여기에 속합니다.
  • 애플리케이션 로더(application class loader): 시스템 로더라고도 하며, 클래스 경로와 모듈 경로에 있는 우리 코드를 불러옵니다.

아래 프로그램은 이 로더들을 직접 출력합니다.

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

        System.out.println("application의 부모=" + application.getParent());
        System.out.println("platform의 부모=" + platform.getParent());
    }
}

실행 결과는 다음과 같습니다. @ 뒤의 16진수는 객체마다 붙는 해시 값이라 JDK 버전이나 실행 환경에 따라 다르게 나올 수 있습니다.

class=ClassLoaderIdentityInventory
application-loader=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
platform-loader=jdk.internal.loader.ClassLoaders$PlatformClassLoader@677327b6
String-loader=null
module=unnamed module @14ae5a5
application의 부모=jdk.internal.loader.ClassLoaders$PlatformClassLoader@677327b6
platform의 부모=null

우리 클래스는 애플리케이션 로더(AppClassLoader)가 불러왔고, String의 로더는 부트스트랩 로더라서 null로 나왔습니다. 클래스 경로에서 불러온 클래스는 이름 없는 모듈(unnamed module)에 속합니다. 마지막 두 줄은 로더들이 부모 관계로 이어져 있음을 보여 줍니다. 애플리케이션 로더의 부모는 플랫폼 로더이고, 플랫폼 로더의 부모는 null, 즉 부트스트랩 로더입니다.

이 부모 관계가 위임 모델(delegation model)의 바탕입니다. 대부분의 로더는 클래스를 찾아 달라는 요청을 받으면 자기가 찾아보기 전에 먼저 부모에게 넘기고, 부모와 그 위의 로더들이 모두 찾지 못했을 때만 자기 경로를 뒤집니다. 애플리케이션 로더에 java.lang.String을 요청하면 자기 클래스 경로를 뒤지기 전에 요청을 위쪽 로더에 넘기고, 결국 부트스트랩 로더가 찾아 줍니다. 다만 JDK 9부터 기본 로더들은 패키지가 어느 모듈에 속하는지 먼저 보고 그 모듈을 맡은 로더에게 곧장 넘기므로, java.base 모듈의 String 요청은 플랫폼 로더를 거치지 않고 바로 부트스트랩 로더로 갑니다. 이렇게 하면 클래스 경로에 java.lang.String이라는 이름의 클래스를 넣어도 핵심 클래스가 바꿔치기되지 않고, 여러 로더가 공통 클래스를 따로따로 불러오는 일도 막을 수 있습니다.

이름이 같아도 로더가 다르면 다른 클래스

JVM은 클래스를 이름만으로 구별하지 않습니다. JVMS 5.3에 따르면 만들어진 클래스는 바이너리 이름과, 그 클래스를 실제로 만든 로더인 정의 로더(defining loader)의 쌍으로 구별됩니다. 그래서 같은 클래스 파일이라도 서로 다른 로더가 각자 정의하면 JVM 안에는 이름만 같은 두 타입이 생깁니다.

아래 프로그램은 같은 클래스 경로를 보는 URLClassLoader 두 개로 이를 확인합니다. delegating은 부모를 지정하지 않아 기본값인 애플리케이션 로더를 부모로 쓰고, isolated는 플랫폼 로더를 부모로 둡니다. 두 로더에 같은 이름의 Plugin을 요청한 뒤, isolated가 돌려준 클래스로 만든 객체를 우리 코드의 Plugin 타입으로 형변환해 봅니다.

src/SameNameDifferentLoader.java
import java.net.URL;
import java.net.URLClassLoader;

public final class SameNameDifferentLoader {
    public static final class Plugin {
    }

    public static void main(String[] args) throws Exception {
        URL[] classPath = {SameNameDifferentLoader.class.getProtectionDomain().getCodeSource().getLocation()};
        String name = Plugin.class.getName();

        try (URLClassLoader delegating = new URLClassLoader(classPath);
                URLClassLoader isolated = new URLClassLoader(classPath, ClassLoader.getPlatformClassLoader())) {
            Class<?> viaParent = delegating.loadClass(name);
            Class<?> ownCopy = isolated.loadClass(name);

            System.out.println("delegating: 같은 클래스? " + (viaParent == Plugin.class) + ", 정의한 로더=" + viaParent.getClassLoader());
            System.out.println("isolated:   같은 클래스? " + (ownCopy == Plugin.class) + ", 정의한 로더=" + ownCopy.getClassLoader());

            Object instance = ownCopy.getDeclaredConstructor().newInstance();
            Plugin plugin = (Plugin) instance;
            System.out.println(plugin);
        }
    }
}

로더 이름 뒤의 @639fee48 같은 값은 로더 객체의 해시 코드라서 실행마다 다를 수 있습니다. 형변환에서 난 예외는 잡지 않으므로, 실패하면 프로그램이 스택 트레이스를 남기고 끝납니다.

delegating: 같은 클래스? true, 정의한 로더=jdk.internal.loader.ClassLoaders$AppClassLoader@639fee48
isolated:   같은 클래스? false, 정의한 로더=java.net.URLClassLoader@6d06d69c
Exception in thread "main" java.lang.ClassCastException: class SameNameDifferentLoader$Plugin cannot be cast to class SameNameDifferentLoader$Plugin (SameNameDifferentLoader$Plugin is in unnamed module of loader java.net.URLClassLoader @6d06d69c; SameNameDifferentLoader$Plugin is in unnamed module of loader 'app')
	at SameNameDifferentLoader.main(SameNameDifferentLoader.java:21)

delegating.loadClass는 위임 규칙대로 부모인 애플리케이션 로더에 먼저 물었고, 애플리케이션 로더는 이미 Plugin을 불러 둔 상태였습니다. 그래서 같은 Class 객체가 돌아오고, 정의한 로더도 애플리케이션 로더로 나옵니다. delegating은 로딩 요청을 받았을 뿐 클래스를 직접 만들지는 않은 것입니다. 반면 isolated의 부모인 플랫폼 로더는 우리 클래스를 찾을 수 없으므로, isolated가 같은 .class 파일을 직접 읽어 새 클래스를 정의했습니다. 이 클래스로 만든 객체를 애플리케이션 로더의 Plugin으로 형변환하자 ClassCastException이 났습니다. 메시지에 같은 이름이 두 번 나오고 괄호 안에 서로 다른 로더가 적혀 있는 것이 이런 상황의 특징입니다.

애플리케이션 서버, 플러그인 시스템, 코드를 고치면 바로 다시 불러오는 개발 도구는 일부러 로더를 여러 개 만들어 이런 격리를 이용합니다. 그러다 같은 라이브러리가 두 로더에서 따로 불려 오면 위와 같은 오류를 만나게 되므로, 이름이 같은 클래스끼리 형변환이 실패하면 가장 먼저 정의 로더를 확인하세요. 스레드마다 지정하는 문맥 클래스 로더(Thread.getContextClassLoader())도 있는데, 이것은 ServiceLoader나 프레임워크가 클래스와 자원을 찾을 때 쓰는 로더로, 클래스를 정의한 로더와는 별개입니다.

두 클래스가 서로를 기다리는 초기화 교착

초기화는 여러 스레드가 동시에 같은 클래스를 써도 한 번만 일어나야 합니다. 그래서 JVM은 클래스마다 초기화용 잠금을 두고, 다른 스레드가 초기화하고 있는 클래스를 쓰려는 스레드는 그 초기화가 끝날 때까지 기다리게 합니다(JLS 12.4.2의 2단계). 초기화를 진행하는 스레드 자신이 도중에 그 클래스를 다시 쓰는 경우에는 기다리지 않고 그대로 진행합니다(3단계). 앞의 Target에서 static 메서드 initialize()를 초기화 도중에 호출할 수 있었던 것도 이 규칙 덕분입니다. 그런데 이 대기 때문에, 두 클래스의 static 초기화가 서로를 참조하면 교착(deadlock)이 생길 수 있습니다.

아래 프로그램은 스레드 init-A가 클래스 A를, 스레드 init-B가 클래스 B를 동시에 초기화하게 합니다. A의 static 블록은 B.VALUE를, B의 static 블록은 A.VALUE를 읽습니다. 실제 서비스에서는 타이밍이 맞을 때만 가끔 생기는 문제지만, 여기서는 CyclicBarrier로 두 스레드가 각자 초기화를 시작한 다음에야 상대 클래스를 읽게 맞춰서 매번 재현되게 했습니다. 두 스레드는 데몬 스레드라서 main이 끝나면 프로그램도 끝납니다.

src/InitializationDeadlock.java
import java.util.concurrent.CyclicBarrier;

public final class InitializationDeadlock {
    private static final CyclicBarrier BOTH_STARTED = new CyclicBarrier(2);

    static final class A {
        static final int VALUE;

        static {
            waitForOther();
            VALUE = B.VALUE + 1; // B의 초기화가 끝나기를 기다린다
        }
    }

    static final class B {
        static final int VALUE;

        static {
            waitForOther();
            VALUE = A.VALUE + 1; // A의 초기화가 끝나기를 기다린다
        }
    }

    private static void waitForOther() {
        try {
            BOTH_STARTED.await(); // 두 스레드가 각자 A, B 초기화를 시작한 상태를 만든다
        } catch (Exception exception) {
            throw new IllegalStateException(exception);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread first = new Thread(() -> System.out.println("A=" + A.VALUE), "init-A");
        Thread second = new Thread(() -> System.out.println("B=" + B.VALUE), "init-B");
        first.setDaemon(true);
        second.setDaemon(true);
        first.start();
        second.start();

        first.join(1_000);
        second.join(1_000);
        System.out.println(first.getName() + " alive=" + first.isAlive() + ", state=" + first.getState());
        System.out.println(second.getName() + " alive=" + second.isAlive() + ", state=" + second.getState());
    }
}

main은 두 스레드를 join(1_000)으로 1초씩만 기다린 뒤 상태를 찍습니다. 스레드가 정상적으로 끝났다면 두 줄 모두 alive=false가 나와야 합니다.

init-A alive=true, state=RUNNABLE
init-B alive=true, state=RUNNABLE

A=나 B=로 시작하는 줄은 끝내 출력되지 않았습니다. 두 스레드에서 일어난 일을 순서대로 보면 다음과 같습니다.

순서init-Ainit-B
1A 초기화를 시작하고 진행 중으로 표시B 초기화를 시작하고 진행 중으로 표시
2장벽에서 상대 스레드를 기다림장벽에서 상대 스레드를 기다림
3B.VALUE를 읽으려 함. B는 다른 스레드가 초기화 중이므로 대기A.VALUE를 읽으려 함. A는 다른 스레드가 초기화 중이므로 대기
4B의 초기화가 끝나기를 계속 기다림A의 초기화가 끝나기를 계속 기다림

두 스레드는 1초씩 기다린 뒤에도 살아 있습니다. 눈여겨볼 것은 상태입니다. synchronized 락을 기다리는 스레드라면 BLOCKED로 나오겠지만 여기서는 RUNNABLE입니다. JDK 21의 HotSpot은 클래스 초기화를 기다리는 스레드를 자바 락 대기 상태로 보고하지 않기 때문에, Thread.getState()만 보면 멀쩡히 일하는 스레드처럼 보입니다. 이때 jstack <pid>로 스레드 덤프를 뜨면 다음 부분이 보입니다(덤프 일부).

"init-A" #18 [16254] daemon prio=5 os_prio=0 cpu=1.59ms elapsed=1.13s tid=0x00007f1120649770 nid=16254 in Object.wait()  [0x00007f10aebfe000]
   java.lang.Thread.State: RUNNABLE
	at InitializationDeadlock$A.<clinit>(InitializationDeadlock.java:11)
	- waiting on the Class initialization monitor for InitializationDeadlock$B

"init-B" #19 [16255] daemon prio=5 os_prio=0 cpu=0.61ms elapsed=1.13s tid=0x00007f112064a9c0 nid=16255 in Object.wait()  [0x00007f10aeafe000]
   java.lang.Thread.State: RUNNABLE
	at InitializationDeadlock$B.<clinit>(InitializationDeadlock.java:20)
	- waiting on the Class initialization monitor for InitializationDeadlock$A

<clinit>은 javac가 static 필드 초기화 식과 static 블록을 모아 만든 클래스 초기화 메서드의 이름입니다. 스택 맨 위에 <clinit>이 있고 바로 아래에 'waiting on the Class initialization monitor for …'가 보이면, 그 스레드는 다른 스레드가 초기화하고 있는 클래스를 기다리는 중입니다. jstack은 synchronized 블록끼리 교착에 빠지면 덤프 끝에 'Found one Java-level deadlock'이라는 안내를 따로 출력하지만, 이 덤프에는 그런 안내가 없었습니다. 그러니 스택을 직접 읽어 <clinit> 프레임끼리 서로를 기다리는지 확인해야 합니다.

고치는 방법은 순환을 끊는 것입니다. static 초기화에서 다른 클래스의 static 상태가 필요하다면 의존 방향이 한쪽으로만 흐르게 정리하고, 서로 필요한 값은 한 클래스에 모으거나 처음 쓸 때 만드는 방식으로 바꿉니다. static 초기화 안에서 스레드를 띄우고 그 스레드가 끝나기를 기다리는 코드도 같은 이유로 피해야 합니다.

더 깊이: static 초기화 안의 병렬 스트림

스레드를 직접 만들지 않아도 같은 교착이 생길 수 있습니다. 다음 클래스는 static 필드를 병렬 스트림으로 계산합니다.

src/ParallelInStaticInit.java
import java.util.stream.IntStream;

public final class ParallelInStaticInit {
    static final int TOTAL = IntStream.range(0, 1_000).parallel().map(i -> i * 2).sum();

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

이 프로그램은 아무것도 출력하지 않고 멈춥니다. JDK 21에서 6초 동안 기다렸다가 강제로 종료해 확인했습니다. 람다 i -> i * 2는 javac가 같은 클래스 안의 private static 메서드로 컴파일하므로, 병렬 스트림의 작업자 스레드가 이 람다를 실행하려면 ParallelInStaticInit의 초기화가 끝나야 합니다. 그런데 초기화를 진행하는 main 스레드는 작업자 스레드들의 결과를 기다리고 있으니 서로 기다리게 됩니다. .parallel()을 빼면 main 스레드가 람다를 직접 실행하고, 이것은 같은 스레드의 재귀 요청이므로 TOTAL=999000이 정상적으로 출력됩니다.

JVM 로그로 로딩과 초기화 시점 보기

지금까지는 출력문을 넣어 초기화 시점을 확인했습니다. 코드를 고칠 수 없는 운영 환경에서는 JVM의 로그 기능을 씁니다. HotSpot의 통합 로깅(unified logging, -Xlog)에서 class+load 태그는 클래스를 불러올 때마다, class+init 태그는 검증과 초기화가 일어날 때마다 줄을 남깁니다. 앞의 InitializationTriggers를 class+load 로그와 함께 실행해 보겠습니다.

java -Xlog:class+load=info -cp out InitializationTriggers

출력에는 JDK 클래스를 불러오는 줄이 수백 개 섞여 나옵니다. 이 환경에서는 class+load 줄이 모두 562개였습니다. 그 줄들을 빼고 예제 클래스에 해당하는 줄만 남기면 다음과 같습니다. 경로는 /…/out/으로 줄였고, 맨 앞의 시각(JVM이 시작한 뒤 지난 시간)은 실행마다 다릅니다.

[0.059s][info][class,load] InitializationTriggers source: file:/…/out/
1. 상수 Child.LIMIT 읽기
2. Child 배열 만들기
[0.064s][info][class,load] InitializationTriggers$Parent source: file:/…/out/
[0.064s][info][class,load] InitializationTriggers$Child source: file:/…/out/
3. 클래스 리터럴 Child.class 얻기
4. Child.parentValue 읽기
  >> Parent 초기화
5. Child.hello() 호출
  >> Child 초기화
  Child.hello() 실행
6. Child.hello() 다시 호출
  Child.hello() 실행
끝: 7, Child, 1

Parent와 Child는 2번에서 배열을 만들 때 이미 로딩됐지만, 초기화는 각각 4번과 5번에서야 일어났습니다. 로딩과 초기화가 별개의 사건이라는 것이 로그에 그대로 드러납니다. Child를 만들려면 부모 클래스가 먼저 있어야 하므로 Parent가 바로 앞 줄에 함께 로딩된 것도 볼 수 있습니다. 생략한 JDK 클래스 줄에는 대부분 source: shared objects file이 붙어 있는데, HotSpot이 JDK 클래스를 미리 가공해 둔 CDS(Class Data Sharing) 아카이브에서 불러왔다는 뜻입니다. 오래전부터 쓰던 -verbose:class 옵션을 주어도 HotSpot은 이와 같은 class+load 형식으로 출력합니다.

class+init 태그를 더하면 검증과 초기화 시점도 볼 수 있습니다. ClassLoadingInitializationBoundary를 실행하고 예제 클래스에 해당하는 줄만 추리면 다음과 같습니다.

java -Xlog:class+load=info,class+init=info -cp out ClassLoadingInitializationBoundary
[0.034s][info][class,load] ClassLoadingInitializationBoundary source: file:/…/out/
[0.035s][info][class,init] Start class verification for: ClassLoadingInitializationBoundary
[0.035s][info][class,init] End class verification for: ClassLoadingInitializationBoundary
[0.035s][info][class,init] 312 Initializing 'ClassLoadingInitializationBoundary'(no method) (0x00007f59e8000800) by thread "main"
[0.044s][info][class,load] ClassLoadingInitializationBoundary$Target source: file:/…/out/
loaded=ClassLoadingInitializationBoundary$Target
before-active-use
[0.045s][info][class,init] Start class verification for: ClassLoadingInitializationBoundary$Target
[0.045s][info][class,init] End class verification for: ClassLoadingInitializationBoundary$Target
[0.045s][info][class,init] 395 Initializing 'ClassLoadingInitializationBoundary$Target' (0x00007f59e8000a08) by thread "main"
target-initialized
value=ready

$Target은 forName(…, false, …)에서 로딩됐지만, 검증은 before-active-use가 찍힌 뒤 초기화 직전에야 이루어졌습니다. 명세는 초기화 전에 검증을 마치라고만 요구하고 시점은 구현에 맡기는데, 이 실행에서 HotSpot은 링킹을 초기화 직전까지 미뤘습니다. Initializing 줄의 숫자는 HotSpot이 매기는 초기화 순번이고, (no method)는 실행할 static 초기화 코드, 즉 <clinit>이 없는 클래스라는 표시입니다. 괄호 안의 16진수는 클래스 정보가 놓인 메모리 주소이고, 순번과 함께 실행할 때마다 달라질 수 있습니다. 이 형식은 JDK 21의 것이며 버전에 따라 달라질 수 있습니다. 수준을 class+init=debug로 높이면 어느 스레드가 초기화를 시작했는지, 같은 스레드의 재귀 요청이 있었는지까지 기록됩니다.

로그가 많을 때는 파일로 보내 두고 필요한 줄만 찾아보는 편이 편합니다. -Xlog: 뒤에 무엇을 기록할지, 어디로 보낼지, 각 줄에 무엇을 붙일지(decorator)를 콜론으로 이어서 지정합니다.

java -Xlog:class+load=info,class+init=info:file=class.log:time,level,tags -cp out ClassLoadingInitializationBoundary

이렇게 실행하면 화면에는 프로그램 출력 네 줄만 나오고, 로그는 class.log에 [2026-10-27T09:36:39.591+0900][info][class,load] …처럼 실제 시각이 붙어 기록됩니다. 쓸 수 있는 태그와 문법은 java -Xlog:help로 지금 쓰는 JDK에서 확인하세요.

초기화 문제를 진단하는 순서

지금까지 본 내용을 장애 대응 순서로 묶으면 다음과 같습니다.

  1. 로그에서 가장 처음 나온 ExceptionInInitializerError와 그 원인(getCause())을 찾습니다. 뒤따르는 NoClassDefFoundError들은 결과일 뿐입니다.
  2. NoClassDefFoundError의 메시지를 구분합니다. 'Could not initialize class'가 있으면 앞선 초기화 실패의 결과이고, 클래스 이름만 있고 원인이 ClassNotFoundException이면 로딩 실패이므로 클래스 경로와 배포 파일을 확인합니다.
  3. 문제의 클래스를 어느 로더가 어디서 불러왔는지 -Xlog:class+load의 source:와 getClassLoader()로 확인합니다. 같은 이름의 클래스가 두 곳에서 불려 오지 않았는지도 봅니다.
  4. 프로그램이 멈췄다면 스레드 덤프에서 <clinit> 프레임과 'Class initialization monitor' 대기를 찾습니다. 스레드 상태가 RUNNABLE이어도 안심하면 안 됩니다.
  5. static 초기화 코드에서 I/O, 환경 변수나 설정 읽기, 다른 클래스와의 순환 의존을 찾아 애플리케이션 시작 단계의 명시적인 메서드로 옮깁니다.
Class.forName을 호출하면 항상 static 블록이 실행되나요?

아닙니다. 인수가 하나인 Class.forName(이름)은 초기화까지 하지만, 인수가 세 개인 버전에 두 번째 인수로 false를 주면 로딩만 합니다. 또 이미 초기화된 클래스는 true를 줘도 다시 초기화하지 않습니다. 다만 다른 클래스 로더로 같은 이름의 클래스를 불러오면 JVM에게는 별개의 클래스이므로, 그 클래스는 따로 초기화됩니다.

정리

  • JVM은 클래스가 처음 필요할 때 로딩하고, 링킹(검증·준비·해석)을 거쳐 초기화합니다. 로딩과 초기화는 서로 다른 시점에 일어날 수 있습니다.
  • static 초기화는 인스턴스 생성, static 메서드 호출, 상수 변수가 아닌 static 필드의 읽기와 쓰기 가운데 하나가 처음 일어나기 직전에 클래스마다 한 번 실행됩니다. 상수 변수 읽기, 배열 생성, 클래스 리터럴은 초기화를 일으키지 않습니다.
  • static 필드 초기화 식과 static 블록은 소스 순서대로 실행되며, 아직 초기화되지 않은 필드를 읽으면 기본값이 보입니다.
  • 초기화에 실패한 클래스는 오류 상태가 되어 그 뒤로는 NoClassDefFoundError만 던집니다. 원인은 첫 ExceptionInInitializerError에 있습니다.
  • 클래스는 이름과 정의 로더의 쌍으로 구별되고, 로더는 보통 부모에게 먼저 위임합니다.
  • 두 클래스의 static 초기화가 서로를 기다리면 교착이 생깁니다. JDK 21 HotSpot의 스레드 덤프에서는 RUNNABLE 상태와 'Class initialization monitor' 대기로 나타납니다.

연습 문제

  1. registry()를 처음 호출할 때만 등록 정보 Map(java는 25, spring은 6)을 만들고, 그 뒤로는 같은 인스턴스를 돌려주는 프로그램을 작성하세요. synchronized나 volatile 없이 클래스 초기화 규칙만 이용합니다. main에서 before를 출력하고, registry()의 내용을 출력한 뒤, registry() == registry()의 결과를 출력할 때 무엇이 나올지 먼저 예상해 보세요. 맵을 만드는 도중 예외가 나면 어떻게 되는지도 설명하세요.
  2. 다음 프로그램의 출력을 예상하세요.
src/ConstantQuiz.java
public final class ConstantQuiz {
    static final class FixedName {
        static final String NAME = "app";

        static {
            System.out.println("FixedName 초기화");
        }
    }

    static final class PropertyName {
        static final String NAME = System.getProperty("app.name", "app");

        static {
            System.out.println("PropertyName 초기화");
        }
    }

    public static void main(String[] args) {
        System.out.println(FixedName.NAME);
        System.out.println(PropertyName.NAME);
    }
}
  1. 운영 중인 서버에서 요청을 처리하는 스레드 두 개가 멈췄습니다. 스레드 덤프를 보니 두 스레드 모두 상태는 RUNNABLE입니다. 한 스레드의 스택 맨 위에는 Pricing.<clinit>과 'waiting on the Class initialization monitor for Discount'가 있고, 다른 스레드의 스택 맨 위에는 Discount.<clinit>과 'waiting on the Class initialization monitor for Pricing'이 있습니다. 무슨 일이 일어났고, 어떻게 고쳐야 할까요?

힌트

  1. 중첩 클래스는 바깥 클래스와 따로, 처음 쓸 때 초기화됩니다.
  2. 두 NAME 가운데 상수 변수는 어느 쪽인지 따져 보세요.
  3. 다른 스레드가 초기화하고 있는 클래스를 쓰려는 스레드는 어떻게 되는지 떠올려 보세요.

해설

  1. 맵을 static 중첩 클래스의 static 필드에 두고, registry()는 그 필드를 돌려주기만 하면 됩니다. 이런 형태를 초기화 지연 홀더(initialization-on-demand holder)라고 부릅니다.
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());
    }
}

main은 registry()를 부르기 전에 before를 먼저 찍습니다. 마지막 줄은 두 번의 호출이 같은 객체를 돌려주는지 ==로 비교한 결과입니다.

before
registry-created
{java=25, spring=6}
true

before가 registry-created보다 먼저 나온 것은 Holder가 registry() 안에서 Holder.REGISTRY를 처음 읽는 순간에 초기화되기 때문입니다. 그 뒤의 호출은 이미 초기화된 필드를 읽기만 하므로 registry-created는 한 번만 나오고, 같은 객체를 돌려주니 == 비교도 true입니다. 여러 스레드가 동시에 처음 호출해도 JVM이 초기화를 한 스레드에서 한 번만 실행하고 나머지를 기다리게 하므로, 따로 동기화할 필요가 없습니다. Map.of로 만든 맵은 순회 순서가 정해져 있지 않으므로 셋째 줄은 {spring=6, java=25}로 나올 수도 있습니다. create()에서 예외가 나면 Holder의 초기화가 실패한 것이므로 첫 호출은 ExceptionInInitializerError, 그 뒤의 호출은 모두 NoClassDefFoundError를 던지고, 같은 JVM에서는 다시 만들 방법이 없습니다. 그래서 이 방식은 실패하지 않는 계산에만 쓰고, 실패한 뒤 다시 시도해야 하는 자원에는 쓰지 않습니다.

  1. 출력은 다음과 같습니다.
app
PropertyName 초기화
app

FixedName.NAME은 리터럴로 초기화한 String 타입의 final 필드라서 상수 변수입니다. javac가 "app"을 main에 직접 복사해 넣으므로 FixedName은 초기화되지 않고, FixedName 초기화는 끝내 출력되지 않습니다. 반면 PropertyName.NAME은 메서드 호출 결과로 초기화하므로 값이 같아도 상수 변수가 아닙니다. 그래서 이 필드를 읽기 직전에 PropertyName이 초기화되어 static 블록의 출력이 먼저 나옵니다.

  1. 두 스레드가 Pricing과 Discount를 거의 동시에 처음 쓰면서 초기화 교착에 빠졌습니다. 한 스레드는 Pricing의 초기화를 시작한 상태에서 Discount가 필요해졌는데, Discount는 다른 스레드가 초기화하는 중이라 기다립니다. 다른 스레드도 같은 식으로 Pricing을 기다리므로 둘 다 영원히 진행하지 못합니다. 스레드 상태가 RUNNABLE인 것은 HotSpot이 이 대기를 락 대기로 보고하지 않기 때문입니다. 이렇게 멈춘 JVM은 다시 시작해야 하고, 코드에서는 두 클래스의 static 초기화가 서로를 참조하지 않게 고칩니다. 공통으로 필요한 값을 별도 클래스로 옮겨 의존이 한 방향으로만 흐르게 하거나, 한쪽 값을 처음 쓸 때 계산하도록 바꾸면 됩니다.

다음 글

javap로 바이트코드 읽기에서는 javac가 만든 클래스 파일을 javap로 열어 봅니다. 이 글에서 잠깐 본 람다용 메서드처럼 소스에는 없지만 컴파일러가 만들어 넣은 메서드와, 그 안의 바이트코드 명령어를 직접 읽습니다.