본문 바로가기
Old Posts/Java

[Java] 자바 클래스로더(Class Loader)

by A6K 2021. 7. 13.

자바언어로 작성된 클래스 파일은 JVM 위에서 동작한다. 직접 운영체제 위에서 동작하지 않기 때문에 높은 이식성을 가질 수 있게되었다. 자바의 클래스로더(ClassLoader)는 컴파일된 클래스 파일을 JVM위로 올리는(Load) 동작을 수행한다.

자바 클래스로더(Java ClassLoader)

자바는 클래스 파일을 동적으로 읽어온다. JVM이 동작하다가 클래스 파일을 참조하는 순간 동적으로 읽어서 메모리에 로드되면서 JVM에 링크 된다. 자바 클래스로더(Java ClassLoader)는 클래스들을 동적으로 메모리에 로딩하는 역할을 담당한다.

클래스 로더 로딩과정

컴파일된 .class 파일은 '로딩(Loading)', '링킹(Linking)', '초기화(Initializing)' 단계를 거쳐 JVM에서 사용할 수 있게 된다.

Loading

우선 클래스 로더는 .class 파일을 읽고, 그 내용에 따라 적절한 바이너리 데이터를 만들고 메소드 영역에 저장하는 동작을 수행한다. 이 과정에서 .class 파일이 JVM 스펙에 맞는지 확인하고, Java Version을 확인한다.

Linking

읽어들인 클래스 데이터는 3단계의 링킹과정을 거치게 된다.

Verify

읽은 클래스의 바이너리 데이터가 유효한 것인지 확인해야한다. .class 파일 형식이 유효한지 여러가지 체크를 한 다음 믿을 수 있는 .class 파일 데이터인 경우에 진행한다.

이 과정은 다소 오버헤드가 발생하기 때문에 클래스 패스에 믿을 수 있는 클래스 파일만 있는 경우라면 성능 향상을 위해 진행하지 않도록 설정할 수 있다. (-Xverify:none 옵션으로 검증을 하지 않을 수 있다.)

Prepare

클래스의 static 변수와 기본값에 필요한 메모리 공간을 준비한다.

Resolution

선택적으로 진행되는 과정으로 사용하는 환경에 따라 동작 유무가 정해진다. 이 과정에서 심볼릭 메모리 레퍼런스를 메소드 영역에 있는 실제 힙 메모리 영역에 있는 인스턴스에 대한 레퍼런스로 교체해준다.

Constant Pool의 심볼릭 레퍼런스를 다이렉트 레퍼런스, 즉 실제 메모리 주소 값으로 변경해주는 작업을 한다.

Initialization

링크 단계의 Prepare 단계에서 확보한 메모리 영역에 클래스의 static 값들을 할당한다. 그리고 SuperClass 초기화와 해당 클래스의 초기화를 진행한다.

계층적 구조

클래스 로더는 계층 구조로 이뤄져 있다. 개발자가 필요에 따라 커스텀 클래스로더를 구현할 수도 있지만 기본적으로는 3가지 클래스로더가 제공된다.

Bootstrap ClassLoader

최상위 우선순위를 갖는 클래스 로더

jre/lib/rt.jar 를 로드함

네이티브 코드로 구현되어 있음.

Extension ClassLoader

jre/lib/ext 에 포함된 클래스 파일을 로드함

java.ext.dirs 환경변수로 지정된 폴더에 있는 클래스 파일을 로딩한다.

Java로 구현되어 있으며 sun.misc.Launcher 클래스 안에 정적 클래스로 구현되어 있으며 URLClassLoader를 상속하고 있다.

Application ClassLoader

애플리케이션의 클래스 패스에서 클래스를 읽어 로드함

-classpath 혹은 -cp 옵션으로 준 경로나 JAR 파일 안에 있는 Manifest 파일의 Class-Path 속성 값으로 지정된 폴더에 있는 클래스를 로딩한다.

Java로 구현되어 있으며 sun.misc.Launcher 클래스 안에 static 클래스로 구현되어 있으며, URLClassLoader를 상속하고 있다.

개발자가 애플리케이션 구동을 위해 직접 작성한 대부분의 클래스는 이 애플리케이션 클래스로더에 의해 로딩된다.

클래스로더의 원칙

Delegation

클래스 로딩 작업을 상위 클래스 로더에 위임

[그림으로 설명]

main 메소드가 포함된 ClassLoaderRunner 클래스에서 개발자가 직접 작성한 Internal 클래스를 로딩하는 과정을 그림으로 표현

  1. ClassLoaderRunner는 자기 자신을 로딩한 애플리케이션 클래스로더에게 Internal 클래스 로딩을 요청한다.
  2. 애플리케이션 클래스 로더는 익스텐션 클래스 로더에게 위임함
  3. 익스텐션 클래스 로더는 부트 스트랩 클래스로더에게 위임함
  4. 부트 스트랩 클래스로더는 rt.jar에서 찾고 있으면 반환
  5. 없으면 익스텐션 클래스로더가 jre/lib/ext, java.etx.dirs 환경변수에서 찾고 있으면 반환
  6. 없으면 애플리케이션 클래스 로더가 클래스 패스에서 찾음
  7. 없으면 ClassNotFoundException이 발생

Visibility

하위 클래스로더는 상위 클러스로더가 로드한 클래스의 내용을 볼 수 있지만 상위 클래스 로더는 하위 클래스로더가 로드한 내용을 볼 수 없음

부트스트랩 클래스로더가 로드한 String.class 를 볼 수 없다면 애플리케이션은 String.class 를 사용할 수 없을 것이다. 상위 클래스 일 수록 좀 더 보편적으로 사용하는 클래스들이 있기 때문이다.

반대로 상위가 하위를 볼 수 있다면 계층적으로 구분해놓은 이유가 없어진다. 계층적으로 구성하는 이유를 쓰기 위해서 이런 원칙이 도입되었다.

Uniqueness

하위 클래스로더는 상위 클래스 로더가 로딩한 클래스를 다시 로딩하지 않게 해서 로딩된 클래스의 유일성을 보장한다.

유일성을 식별하는 기준은 클래스의 바이너리 이름(BinaryName)이다. java.lang.String 이나 java.net.URLClassLoader$3$1 이런 이름들이다.

클래스로더를 여러개 사용하는 이유

클래스 로더를 계층적으로 구성해서 사용하는 이유

  • 모듈화가 가능함
  • 클래스의 충돌을 피할 수 있음
  • 효율적으로 사용할 수 있음
    • 사용하지 않는 클래스는 언로드해서 메모리 사용량을 줄일 수 있음
      • 클래스 로더에 의해 로드된 클래스는 임의로 언로드할 수 없다. 다만 클래스 로더 자체를 제거하면서 언로드해야한다.
    • 동적으로 클래스나 리소스를 추가할 수 있음
    • 운영중에 수정된 클래스를 동적으로 리로딩하기 용이함

예제 1 - 클래스 로더 순서 확인

클래스로더가 로드하는 과정을 확인

$ java -verbose:class HelloWorld
[Opened /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Object from /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.io.Serializable from /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.Comparable from /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/rt.jar]
[Loaded java.lang.CharSequence from /Library/Java/JavaVirtualMachines/jdk1.8.0_171.jdk/Contents/Home/jre/lib/rt.jar]
...

예제 2 - 커스텀 클래스로더

커스텀 클래스로더를 만들어보자.

import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;

public class MyClassLoader extends ClassLoader {

    private final String rootDir;

    public MyClassLoader(ClassLoader parent, String rootDir) throws FileNotFoundException {

        super(parent);

        File f = new File(rootDir);
        if (f.isDirectory())
            this.rootDir = rootDir;
        else
            throw new FileNotFoundException("'" + rootDir + "' isn't a directory");
    }

    @Override
    public Class findClass(String name) throws ClassNotFoundException {
        String classFilePath = rootDir + File.separator + name.replace(".", File.separator) + ".class";

        try {
            FileInputStream fis = new FileInputStream(classFilePath);
            byte[] buffer = new byte[fis.available()];
            fis.read(buffer);

            return defineClass(name, buffer, 0, buffer.length);
        } catch (IOException e) {
            throw new ClassNotFoundException();
        }
    }
}

커스텀 클래스로더를 정의하기 위해서는 ClassLoader 클래스를 상속해야한다.

이 때, 생성자 부분의 super(parent) 부분을 유의하자. 여기에 인자로 넣어주는 parent 클래스로더를 부모 클래스로 생각하고 위에서 봤던 Delegation 이 적용된다. 이 클래스는 생성자로 준 디렉토리에서 클래스 파일을 찾아 로드해 사용하는 클래스 로더다. 응용하면 DB에서 클래스 바이너리를 찾아서 로드하는 클래스로더나 웹에서 다운로드하는 클래스 로더도 만들어볼 수 있다.

커스텀 클래스로더를 정의할 때, findClass() 메소드를 제정의하면 된다. 이 때, 클래스의 바이너리가 위치한 디렉토리에서 파일을 찾아 읽은 후 defineClass() 메소드를 이용해서 클래스를 만들어주게 된다. findClass()에서 바이너리를 찾아 defineClass()로 클래스 객체를 만들어 리턴하면 된다.

이 클래스로더를 테스트할 테스트 프로그램을 작성해보자

import java.io.FileNotFoundException;

public class Example {

    public static void main(String[] args)
        throws FileNotFoundException, ClassNotFoundException, IllegalAccessException, InstantiationException {
        MyClassLoader myClassLoader = new MyClassLoader(Example.class.getClassLoader(), "/tmp/testPath");

        Class clazz = myClassLoader.loadClass("MyClass");

        Object myClass = clazz.newInstance();
        System.out.println(myClass.toString());
    }
}

클래스로더를 생성할 때, 현재 클래스의 클래스로더를 인자로 줬다. 따라서 MyClassLoader는 Example 클래스를 로드한 아마도 애플리케이션 클래스로더를 부모로 갖게 된다. MyClassLoader는 '/tmp/testPath' 디렉토리에 있는 클래스파일을 읽어서 클래스 로드를 시도할 것이다.

이 프로그램을 그냥 실행하면 다음 에러가 발생한다.

Exception in thread "main" java.lang.ClassNotFoundException
    at MyClassLoader.findClass(MyClassLoader.java:32)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    at Example.main(Example.java:9)

클래스파일이 /tmp/testPath 디렉토리에 없기 때문이다. (ClassPath에 있어도 로드하지 않는다) 그러면 MyClass를 정의해보자. (이 때, ClassPath에 MyClass.class 가 정의되어 있지 않아야 한다)

public class MyClass {

    public MyClass() {
        System.out.println("Load MyClass");
    }

    @Override
  public String toString() {
    return "This is MyClass";
  }
}

이 파일을 /tmp/testPath 디렉토리 아래에 생성하고 javac MyClass.java 명령으로 컴파일한다.

$ pwd
/tmp/testPath
$ javac MyClass.java
$ ls
MyClass.class   MyClass.java

이제 Exmaple 파일에 있는 main() 메소드를 실행해보자.

Load MyClass
This is MyClass

이런 결과를 얻게 된다. 클래스 패스에는 MyClass 클래스가 없지만 커스텀 클래스를 이용해서 클래스 파일을 로드하고 사용해봤다.

한가지 더 흥미로운 테스트를 해보자. MyClass 클래스를 하나 더 정의해서 클래스 패스에 위치시켜보자. (IDE의 경우 Example 클래스와 같은 디렉토리에 정의해보자)

public class MyClass {

    public MyClass() {
        System.out.println("Load new MyClass");
    }

    @Override
  public String toString() {
    return "This is new MyClass";
  }
}

그리고 다시 Example 클래스를 실행해보면,

Load new MyClass
This is new MyClass

새로 클래스 패스에 정의한 클래스가 참조되었다.

이는 클래스로더의 Delegation 원칙으로 설명할 수 있다. MyClassLoader는 애플리케이션 클래스로더에게 클래스 로딩을 위임한다. 애플리케이션 클래스로더는 상위 클래스로더에게 위임하고, 결국 클래스 패스에서 MyClass 클래스를 찾는다. 없다면 MyClassLoader가 findClass() 메소드에 의해서 클래스를 찾겠지만 클래스패스에 있다면 그 클래스를 우선 사용하게 된다.

따라서 위와 같이 클래스 패스에 있는 클래스가 로딩된다.

댓글