선택 심화: 클래스 로딩과 초기화
필수 입문 과정을 마친 학습자가 클래스 로딩·연결·초기화와 JVM 진단 로그를 선택적으로 관찰합니다.
이 장은 선택 심화 과정입니다. 일반적인 Java 애플리케이션을 작성하는 데 꼭 필요한 문법을 더 배우는 장이 아니라, 이미 실행되는 프로그램의 내부 동작과 장애 원인을 JVM 도구로 추적하는 장입니다. 핵심 자습 경로를 먼저 끝낸 뒤 운영 진단이나 JVM 구현에 관심이 있을 때 진행하세요. 이 장을 건너뛰어도 앞에서 완성한 프로그램과 이후 학습에는 영향이 없습니다.
JVM에서 클래스를 “사용한다”는 말에는 로딩, 연결, 초기화가 섞여 있습니다.
로딩은 바이너리 표현으로 Class 객체를 만들고, 연결은 검증·준비·해석을 수행하며, 초기화는 static 필드 초기화 블록과 static 블록을 소스에 적힌 순서대로 실행합니다.
진단에서는 어느 단계에서 실패했는지 분리해야 합니다.
정적 초기화 예외
아래 클래스는 처음으로 활성 사용될 때 초기화되며 값이 0인 구성을 거부합니다.
초기화 블록의 예외는 실제 ExceptionInInitializerError로 감싸집니다.
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로 호출하거나 static 필드를 읽으면 초기화가 일어납니다.
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, before-active-use, target-initialized, 값입니다.
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로 현재 런타임에서 확인합니다.
클래스 경로 미적중은 로딩 문제, 검증 오류는 연결 문제, 초기화 블록 예외는 초기화 문제로 분류합니다.
정적 필드의 초기화 순서
아래 예제에서는 뒤에서 설정할 필드를 앞의 초기화 블록이 먼저 읽으므로 기본값 0을 봅니다. 컴파일은 되지만 순서 의존성이 숨겨집니다.
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);
}
}FIRST sees 0, SECOND sees 25가 출력됩니다.
static 필드끼리 순환 참조하지 말고 하나의 불변 구성 객체를 명시적 팩터리에서 완성합니다.
클래스 이름·로더의 식별자 쌍
같은 바이너리 이름도 다른 로더가 정의하면 JVM에서 다른 타입입니다.
애플리케이션 서버, 플러그인 계층, 실행 중 재적재 환경에서 ClassCastException 메시지에 같은 클래스 이름이 두 번 보여도 정의 로더를 확인합니다.
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나 프레임워크의 자원 탐색에 쓰일 수 있으므로 클래스를 정의한 로더와 혼동하지 않습니다.
초기화 문제 진단 순서
- 최초 예외 객체와 원인 연쇄를 확보합니다.
- 실패한 클래스와 정의 로더, 모듈, 코드 출처를 기록합니다.
-Xlog:class+load,class+init로 적재·초기화 시점을 확인합니다.static초기화 블록의 I/O, 순환 필드 의존성, 환경 변수 읽기를 찾습니다.- 명시적인 애플리케이션 시작 단계와 의존성을 주입할 수 있는 팩터리로 옮겨 재시도와
shutdown을 설계합니다.
Class.forName을 호출하면 항상 static 블록이 실행되나요?
인수 하나를 받는 오버로드는 클래스를 초기화합니다.
인수 세 개를 받는 오버로드에 초기화 여부로 false를 주면 적재만 요청합니다.
이미 초기화된 클래스는 다시 초기화되지 않으며 로더가 다르면 별도의 클래스로 취급됩니다.
연습 문제
비용이 큰 목록을 첫 호출 때만 만들고 동일한 인스턴스를 반환하세요. 보관 클래스 초기화에 대한 JVM의 보장을 사용합니다.
해설 보기
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());
}
}종료 기준은 holder-created가 한 번만 출력되고 참조 동일성 비교가 true인 것입니다.
생성 실패는 보관 클래스의 초기화 실패가 되므로 복구 가능한 자원에는 다른 생명 주기를 사용합니다.