JVM은 RAM에 위치하며, 실행 중에 클래스로더 서브시스템을 이용하여 클래스 파일을 RAM으로 가져옵니다. 이를 자바의 동적 클래스 로딩 기능이라고 합니다. 이 과정은 컴파일 타임이 아니라 런타임에 일어나며, 처음으로 클래스를 참조할 때 클래스 파일(.class)을 로드하고, 링크하고, 초기화 합니다.

로딩(Loading)

컴파일된 클래스(.class 파일)을 메모리에 적재하는 것이 클래스로더(class loader)의 주요 작업입니다. 보통, 클래스 로딩 과정은 메인 클래스(즉, static main() 메서드 선언이 있는 클래스)를 로드하는 것부터 시작됩니다. 이외에도 클래스 로딩은 아래의 상황에서 일어날 수 있습니다.

// 클래스에 선언된 정적 메서드를 호출할 때 
Car.invokeStaticMethod(); // static void invo..() { ... }

// 클래스나 인터페이스에 선언된 정적 필드 접근 혹은 할당
Car.wheels = 4; // static int wheels;

// 클래스의 인스턴스를 만들 때 (명시적 생성, 역직렬화, 리플렉션 등)
Car car = new Car("BMW"); // 명시적 생성
...

// 리플렉션 같은 특정 자바 SE 플랫폼 클래스 라이브러리에 있는 메서드를 호출하는 경우
Class<?> clazz = Class.forName("com.company.Car");
...

또한 부모 클래스를 먼저 로딩해야 자식 클래스를 로딩할 수 있으므로 아래와 같은 상황도 있을 수 있습니다. 아래의 결과를 보면 클래스가 실행 초기에 모두 로딩되는 게 아니라 그 클래스 사용 시점에 로딩되는 것을 볼 수 있습니다.

// (1) ClassLoadingExamples 클래스가 로드됨
// (2) 메인 메서드 진입
// (3) Car 클래스가 로드됨
// (4) Audi 클래스가 로드됨
// (5) Car 클래스의 생성자가 호출됨
// (6) Audi 클래스의 생성자가 호출됨
// (7) 메인 메서드 종료
public class ClassLoadingExamples {
    static { // (1)
        System.out.println("ClassLoadingExamples 클래스가 로드됨");
    }

    public static void main(String[] args) {
        System.out.println("메인 메서드 진입"); // (2)
        Audi audi = new Audi(); // (3) ~ (6)
        System.out.println("메인 메서드 종료"); // (7)
    }

    abstract static class Car {
        static { // (3)
            System.out.println("Car 클래스가 로드됨");
        }

        protected Car() { // (5)
            System.out.println("Car 클래스의 생성자가 호출됨");
        }
    }

    static class Audi extends Car {
        static { // (4)
            System.out.println("Audi 클래스가 로드됨");
        }

        public Audi() { // (6)
            System.out.println("Audi 클래스의 생성자가 호출됨");
        }
    }
}

클래스로더 서브시스템에는 부트스트랩 클래스로더, 확장 클래스로더, 애플리케이션 클래스로더(혹은 시스템 클래스로더)와 같이 3가지 유형의 클래스로더가 있으며, 클래스로더는 아래의 4가지 주요 원칙을 따릅니다.

가시성 원칙(Visibility Principle)

자식 클래스로더는 부모 클래스로더가 로드한 클래스를 볼 수 있지만, 부모 클래스로더는 자식 클래스로더가 로드한 클래스를 볼 수 없다는 원칙입니다.

예를 들어서, 확장 클래스로더를 통해 클래스 Vehicle.class를 로드했다고 하면 부모 클래스로더인 부트스트랩 클래스로더는 이를 볼 수 없으며, 확장 클래스로더와 그 자식 클래스로더인 애플리케이션 클래스로더만 볼 수 있습니다. 부트스트랩 클래스로더를 통해 해당 클래스를 로드하려고 하면 클래스를 찾을 수 없다는 java.lang.ClassNotFoundException 예외가 발생합니다.

public class ClassLoaderExample {
    public static void main(String args[]) {
        try {
            // 이 클래스의 클래스로더를 출력한다.
            System.out.println("ClassLoaderExample.getClass().getClassLoader(): " + ClassLoaderExample.class.getClassLoader());

            // 확장 클래스로더를 통해서 이 클래스를 다시 로드한다.
            Class.forName("ClassLoaderExample", true, ClassLoaderExample.class.getClassLoader().getParent());
        } catch (ClassNotFoundException ex) {
            ex.printStackTrace();
        }
    }
}

클래스 ClassLoaderExample의 클래스로더는 애플리케이션 클래스로더이고, 부모 클래스로더는 확장 클래스로더 입니다. 부모 클래스로더는 자식 클래스로더가 로드한 클래스를 볼 수 없으므로 예외 java.lang.ClassNotFoundException가 발생하는 것을 볼 수 있습니다.

유일성 원칙(Uniqueness Principle)

부모가 로드한 클래스를 자식 클래스로더가 다시 로드하지 않아야 하며 이미 로딩한 클래스를 다시 로드해서는 안 된다는 원칙입니다. 예를 들어서 부모인 확장 클래스로더가 이미 로드한 클래스를 자식인 애플리케이션 클래스로더가 로드해서는 안 된다는 것입니다. 이 원칙을 통해 클래스가 정확히 한 번만 로드할 수 있습니다.

위임 계층 원칙(Delegation Hierarchy Principle)

위의 가시성 원칙과 유일성 원칙을 충족하기 위해서 JVM은 클래스 로딩 요청을 받을 클래스로더를 선택하기 위해 위임 계층을 따릅니다. 여기서 가장 아래에 있는 애플리케이션 클래스로더가 자기가 받은 클래스 로딩 요청을 부모인 확장 클래스로더에 위임한 다음, 확장 클래스로더가 다시 부모인 부트스트랩 클래스로더에 이를 위임합니다.

요청한 클래스가 부트스트랩 클래스패스(jdk/jre/lib)에 있으면 해당 클래스를 로드합니다. 없으면 요청을 확장 클래스로더가 위임하게 되는데, 요청한 클래스가 확장 클래스패스(jdk/jre/lib/ext)에 있으면 해당 클래스를 로드합니다. 없으면 요청을 애플리케이션 클래스로더에 위임합니다. 마지막으로 요청한 클래스가 애플리케이션 클래스패스에 있으면 해당 클래스를 로드합니다. 만약 이번에도 실패하면 런타임 예외인 java.lang.ClassNotFoundException가 발생합니다.

클래스패스(classpath)

JVM이 프로그램을 실행할 때, 클래스 파일을 찾는 데 기준이 되는 파일 경로를 말합니다. 시스템의 모든 폴더를 JVM이 검사하도록 하는 것은 비현실적이므로 JVM에 찾아볼 파일 경로를 제공해야 합니다.

언로딩 금지 원칙(No Unloading Principle)

클래스로더는 클래스를 로드할 수는 있지만 이미 로드한 클래스를 언로드(unload) 할 수 없습니다. 언로드 하는 것 대신에 현재 클래스로더를 제거하고 새로운 클래스로더를 만들 수 있습니다.

클래스로더(Classloader)

클래스로더는 말 그대로 자바의 클래스를 로드하는 객체를 말합니다. 클래스로더를 통해서 런타임에 동적으로 클래스를 로드할 수 있으며, 보통은 패키지에 있는 클래스 파일(.class)을 사용해서 클래스를 로드합니다.

부트스트랩 클래스로더(Bootstrap Class Loader)

자바에서 기본적으로 제공하는 API 등과 같은 표준 JDK 클래스들을 부트스트랩 클래스패스(%JAVA_HOME%/jre/lib)에 있는 rt.jar에서 로드합니다. 이 클래스로더는 C/C++와 같은 네이티브 언어로 구현되며 자바에서 모든 클래스로더의 부모 역할을 합니다.

// 자바 9 이후로는 클래스로더 구현이 변경되어서 더 이상 rt.jar에서 로드하지 않는다.
// 여전히 ..\jre\lib에서 저장되지만 문서화가 되지 않아서 향후 변경될 수 있다. (예: ..\jre\lib\modules)
// ..\jre\lib\resources.jar;..\jre\lib\rt.jar;..\jre\lib\jce.jar;...
System.out.println(System.getProperty("sun.boot.class.path"));

// JVM 구현에 따라 다를 수 있지만 null은 보통 부트스트랩 클래스로더를 의미한다.
// 위에서도 언급했듯이 C/C++로 구현되기 때문에 자바에서 참조를 얻어올 수 없다.
System.out.println(ArrayList.class.getClassLoader()); // null

확장 클래스로더(Extension Class Loader)

클래스 로드 요청을 상위의 부트스트랩 클래스로더에 위임하고, 실패하면 확장 클래스패스(%JAVA_HOME%/jre/lib/ext나 환경 변수 java.ext.dirs에 지정된 경로)의 확장 디렉토리(예: 보안 확장 기능)에서 클래스를 로드합니다. 이 클래스로더는 sun.misc.Launcher$ExtClassLoader 클래스로 자바에 구현되어 있습니다.

// ..\jre\lib\ext
System.out.println(System.getProperty("java.ext.dirs"));

자바 9 이후부터는 확장 메커니즘이 제거되면서 확장 클래스로더의 이름이 플랫폼 클래스로더(Platform Class Loader)로 변경되었습니다. 클래스로더 계층은 변경 없이 그대로 유지됩니다.

// jdk.internal.loader.ClassLoaders$PlatformClassLoader@776ec8df
System.out.println(ClassLoader.getPlatformClassLoader());
System.out.println(ClassLoader.getSystemClassLoader().getParent());

애플리케이션 클래스로더(Application Class Loader)

이 클래스로더는 시스템 클래스로더(System Class Loader)라고 부르기도 합니다. CLASSPATH 환경 변수, 명령행 인수 -classpath나 -cp로 지정된 경로에서 클래스를 로드하는 역할을 합니다. 이 클래스로더는 sun.misc.Launcher$AppClassLoader 클래스로 자바에 구현되어 있습니다.

// jdk.internal.loader.ClassLoaders$AppClassLoader@63947c6b
System.out.println(ClassLoader.getSystemClassLoader());
System.out.println(Thread.currentThread().getContextClassLoader());

링킹(Linking)

이 단계에서는 로드된 클래스나 인터페이스, 그 직계 부모클래스나 인터페이스, 필요한 경우 요소 타입(배열 타입인 경우)을 검증하고, 준비하고, 해석하는 과정을 거칩니다.

  • 클래스나 인터페이스는 링크되기 전에 완전히 로드되어야 한다.
  • 클래스나 인터페이스는 초기화하기 전에 완전히 검증되고 준비되어야 한다.
  • 만약 링크하는 도중 에러가 발생하면, 해당 에러와 관련이 있는 클래스나 인터페이스로 직접적으로든 간접적으로든 링크가 필요할 수도 있는 어떤 작업을 하는 지점에서 예외가 일어날 수 있다.

링크는 아래와 같이 3단계로 이루어집니다.

검증(Verification)

클래스로더가 .class 파일의 바이트코드를 자바 언어 명세서(Java Language Specification)에 따라서 코드를 제대로 잘 작성했는지, JVM 규격에 따라 검증된 컴파일러에서 .class 파일이 생성되는지 등을 확인하여 .class 파일의 정확성을 보장합니다. 내부적으로 바이트코드 검증기(Bytecode verifier)가 이 과정을 담당합니다. 이 과정은 클래스를 로드하는 과정 중 가장 복잡한 테스트 과정이며, 가장 오랜 시간이 걸립니다. 링크로 인해 클래스를 로드하는 과정이 느려지지만 바이트코드를 실행할 때 이런 검사를 여러 번 수행할 필요가 없기 때문에 전반적으로 효율적이며 효과적입니다. 바이트코드 검증기는 검증이 실패하면 런타임 에러(java.lang.VerifyError)를 발생시킵니다. 예를 들어서, 아래와 같은 검사들을 수행합니다.

  • 심볼 테이블(symbol table)이 일관되고 올바른 형식인지 검사
  • 접근 지정자에 따른 접근 범위에서 메서드에 접근하고 있는지 검사
  • 메서드의 매개변수 수와 자료형이 올바른지 검사
  • final 메서드와 클래스가 오버라이드 되지는 않았는지 검사
  • 변수를 읽기 전에 초기화되었는지 검사
  • 변수가 올바른 타입의 값인지 검사

자바는 왜 안전한 언어인가?

공격자가 클래스 파일을 수동으로 변경하여 어떤 종류의 바이러스를 만들었다고 해봅시다. 그러면 바이트코드 검증기는 해당 클래스 파일을 검증된 컴파일러가 생성했는지에 대한 여부를 확인합니다. 만약 검증이 실패하면 런타임 에러 java.lang.VerifyError를 발생시키게 됩니다. 따라서 클래스 파일의 악의적인 혹은 유효하지 않은 변경을 미연에 방지할 수 있게 됩니다.

준비(Preparation)

이 단계에서는 메서드 테이블 같이 JVM에서 쓰이는 자료구조나 정적 기억 영역(static storage)을 위해 메모리를 할당합니다. 이때 메모리가 부족하면 java.lang.OutOfMemoryError가 발생합니다. 이 단계에서 정적 필드가 만들어지고 기본값으로 초기화됩니다. 하지만 원래의 값은 초기화 단계에서 할당되므로 아직은 초기화 블록이나 초기화 코드는 실행되지 않습니다. 여기서 기본값은 다음과 같습니다.

예를 들어서, 아래와 같은 코드가 있으면 준비 과정에서 int형 정적 변수 a에 4바이트의 메모리 공간을 할당하고 기본값인 0으로 초기화합니다. 그리고 long형 정적 변수 b에는 8바이트의 메모리 공간을 할당하고 기본값인 0으로 초기화합니다.

class Example {
    private static int a = 10;
    private static long b;

    // 정적 초기화 블록(static initialization block)
    static {
        b = 5;
    }
}

해석(Resolution)

해석은 간단히 말하면 런타임 상수 풀(run-time constant pool)에 있는 심볼릭 참조(symbolic reference)를 직접 참조(direct reference)로 대체하는 과정입니다. 다시 말해서, 추상적인 기호를 구체적인 값으로 동적으로 결정하는 과정이라고 할 수 있습니다. JVM 명령인 anewarray, checkcast, getfield, getstatic, instanceof, invokedynamic, invokeinterface, invokespecial, invokestatic, invokevirtual, ldc, ldc_w, multianewarray, new, putfield, putstatic은 런타임 상수 풀에 있는 심볼릭 참조를 사용합니다. 이러한 명령어를 실행하려면 먼저 심볼릭 참조를 해석해야 합니다.

심볼릭 참조(symbolic reference)

심볼릭 참조는 참조된 항목에 관한 이름이나 기타 정보를 제공하는 문자열로, 실제 객체(변수, 메서드, 타입 등)를 가져오는 데 사용할 수 있습니다. 예를 들어서 아래의 코드를 생각해봅시다.

if ("init".equals(opCode)) { /* ... */ }

여기서 바이트코드를 살펴보면 아래와 같을 것입니다. 여기서 #4를 보고 boolean java/lang/String.equals(Object)인지 어떻게 알 수 있을까요? 이를 가지고 런타임 상수 풀에 있는 심볼릭 참조를 통해 현재 클래스로더가 로드한 실제 클래스를 확인하고 클래스 인스턴스에 대한 참조를 반환하기 때문입니다.

3: ldc           #3   // String init
5: aload_1
6: invokevirtual #4   // Method java/lang/String.equals:(Ljava/lang/Object;)Z
9: ifeq          12

상수 풀을 살펴보면 다음과 같습니다. 구체적인 값을 동적으로 결정한다는 것이, 프로그램 실행 중에 실제 객체를 결정한다는 것입니다. JVM은 이렇게 구한 직접 참조를 기억하고 있으므로, 다시 #4와 같은 참조를 맞닥뜨리는 경우 다시 심볼릭 참조를 가지고 직접 참조를 찾는 과정을 거치지 않아도 됩니다.

Constant pool:
   #3 = String             #26       // init
   #4 = Methodref          #27.#28   // java/lang/String.equals:(Ljava/lang/Object;)Z
   ...
   #26 = Utf8               init
   #27 = Class              #31      // java/lang/String
   #28 = NameAndType        #32:#33  // equals:(Ljava/lang/Object;)Z
   ...
   #31 = Utf8               java/lang/String
   #32 = Utf8               equals
   #33 = Utf8               (Ljava/lang/Object;)Z

이 단계는 JVM 구현에 따라서 클래스를 검증할 때 한 번에 해석할 수도 있고(eager resolution), 당장 심볼릭 참조를 직접 참조로 바꿀 필요가 없다면 뒤로 밀려날 수 있습니다(lazy resolution). 따라서 준비 단계를 마치고 반드시 해석 단계가 일어나지는 않으며 이는 선택적 단계입니다.

초기화(Initialization)

이 과정에서 로드된 각 클래스나 인터페이스의 초기화 로직이 실행됩니다. 이 단계는 정적 변수는 코드에 명시된 원래 값이 할당되고, 정적 초기화 블록이 실행되는 클래스 로딩의 마지막 과정입니다. 이 작업은 클래스의 위에서 아래로, 클래스 계층 구조에서 부모에서 자식까지 한 줄씩 실행됩니다.

대략적인 흐름 살펴보기

그러면 아래와 같은 클래스가 어떤 과정을 거쳐서 로딩되는지 하나하나 살펴보도록 하겠습니다.

// Foo.java
package com.company;

public class Foo {
    int data;

    public Foo() { }

    public void doSomething() { }
}

먼저 클래스로더는 런타임에 com.company.Foo 클래스가 처음으로 참조될 때 애플리케이션 클래스로더가 이 클래스 로딩 요청을 받습니다. 클래스 로딩 과정은 자바독에 명시된 것처럼 아래와 같은 과정을 따릅니다.

  • 로드하려는 클래스가 이미 로드된 것은 아닌지 확인한다.
  • 부모 클래스 로더의 loadClass() 메서드를 호출한다. 이때 부모 클래스로더가 없으면 JVM에 내장된 클래스 로더를 대신 사용한다.
  • findClass(String) 메서드를 호출하여 클래스를 찾는다.
protected Class<?> loadClass(String name, boolean resolve)
    throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) {
        // 첫째, 클래스가 이미 로드되지 않았는지 확인한다.
        Class<?> c = findLoadedClass(name);
        if (c == null) { // 로드된 게 없으면
            ...
            try {
                // 부모 클래스 로더에게 클래스 로딩 요청을 위임한다.
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // parent가 null이면 부트스트랩 클래스로더거나 
                    // 정말 부모 클래스로더가 없음을 의미한다.
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 클래스를 찾지 못하면 부모 클래스로더가
                // ClassNotFoundException 예외를 던진다.
            }

            if (c == null) {
                // 아직도 발견되지 않았으면 클래스를 찾기 위해서
                // findClass()를 호출한다.
                long t1 = System.nanoTime();
                c = findClass(name);
                ...

여기서 애플리케이션 클래스로더는 자신이 이미 해당 클래스를 로드했는지 확인하고 로드된 것이 없으면 클래스 로딩 요청을 부모 클래스로더인 확장 클래스로더에 위임합니다. 확장 클래스로더도 마찬가지로 클래스 로드 여부를 확인하고 없으면 부모인 부트스트랩 클래스로더로 요청을 위임합니다. 만약에 부트스트랩 클래스로더도 자신이 로드한 클래스 중 해당 클래스는 없다면 부트스트랩 클래스로더부터 시작하여 애플리케이션 클래스로더까지 자신의 클래스패스에서 클래스 파일(../com.company/Foo.class)을 찾기 시작합니다. 결국 애플리케이션 클래스로더가 자신의 클래스패스(예: IntelliJ 기준으로 /out/production/project-name)에서 클래스 파일을 발견하여 이 파일로부터 바이트 배열을 읽어들이고 해당 클래스 파일이 올바른 형식을 준수하고 있는지 빠짐없이 검증하기 시작합니다. 헥스 에디터로 Foo.class 파일을 열어보면 아래의 내용을 확인할 수 있습니다.

위의 클래스 파일에 있는 명령어를 살펴보기 위해서 자바 클래스 파일 디스어셈블러(javap.exe) 혹은 바이트코드 뷰어, 아니면 컴파일러 익스플로러를 사용하면 쉽게 디스어셈블된 코드를 살펴볼 수 있습니다.

public class com.company.Foo {
  int data;

  public com.company.Foo(); // 클래스 Foo의 디폴트 생성자
    Code:
       // 여기서는 this를 피연산자 스택에 푸시한다.
       0: aload_0
       // 부모 클래스인 Object의 디폴트 생성자를 호출한다. (super())
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public void doSomething();
    Code:
       0: return
}

검증이 안전하게 끝나면 그 다음은 준비 단계에서 클래스에 필요한 메모리 공간을 할당하고 정적 필드를 기본값으로 초기화합니다. Foo 클래스에 있는 인스턴스 필드 data의 초기화는 우리가 인스턴스를 만들 때 이루어집니다. 그리고 JVM 구현에 따라서 해석 단계를 거치거나 뒤로 지연될 수 있습니다. 마지막으로 초기화 단계를 거치게 되는데 이 단계에서는 Foo 클래스의 초기화 로직(예: 정적 초기화 블록 등)이 실행되고 클래스 로딩 과정이 끝나게 됩니다.

참고