본문 바로가기

CS study/java

[troubleshooting][ClassLoader]동적 클래스로더와 계층 관계

개요와 문제 상황

Instrument API를 사용한 개발 도중, 기본 클래스로더가 아닌, 내가 만든 ClassLoader를 사용하여 동적 클래스를 로드하려고 시도하였다.

 

이 과정에서 확인차 ForName 등으로 Class 정보를 가져왔는데, 새롭게 얻은 지식을 정리하고자 한다.

 

premain

    public static void premain(String agentArgs, Instrumentation instrumentation)  {

        Banner.send(agentArgs); //로그 찍기
        ConfigRead configRead = new ConfigRead(); //Config Read
        //CallThread.run(); JMX 쓰레드 생성 및 호출

        //새로운 클래스를 생성하는 로직
        try {
            MakeClassClassLoader makeClassClassLoader = new MakeClassClassLoader();
            Class<?> newClass = makeClassClassLoader.defineClass("testSCH", true, true);
//            newClass.newInstance(); 9++ Deprecated
            newClass.getDeclaredConstructor().newInstance(); //생성자 호출을 통한 새 인스턴스 생성
            //새로 변조한(생성한) 클래스의 인스턴스 생성
        } catch (InstantiationException | IllegalAccessException e) {
            log.warn("[PREMAIN] {}", e.getMessage());
        } catch (InvocationTargetException | NoSuchMethodException e) {
            log.warn("[PREMAIN] Name Err(getDeclaredConstructor) {}", e.getMessage());
            throw new RuntimeException(e);
        }


        instrumentation.addTransformer(new TreeAPITransformer());
    }

CustomClassLoader

@Slf4j
public class MakeClassClassLoader extends ClassLoader {

    public Class<?> defineClass(String name, boolean isPrint, boolean autoCompute) {
        //ClassWriter 생성
        ClassWriter classWriter;
        if(autoCompute) {
            classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS | ClassWriter.COMPUTE_FRAMES);
        } else {
            classWriter = new ClassWriter(0); //스택과 로컬 변수(visitMaxs) 직접 계산
        }

        //ClassNode를 통한 변조 진행
        try {
            ClassNode cn = VisitorAdapter.setClassNode(name); // name에 따른 ClassNode 생성
            cn.accept(classWriter); //ClassNode의 정보를 ClassWriter에 적용하여 바이트코드로 변환(accept를 통해 writer가 확인)
            if(isPrint) {
                CodePrinter.printClass(classWriter.toByteArray(), name); //출력
                log.warn("[Print]{} 코드 출력 완료", name);
            }
        } catch (IOException e) {
            log.warn("doMethod ERR[IOException : PRINT] : {}", e.getMessage());
        } catch (Exception e) {
            log.warn("doMethod ERR : {}", e.getMessage());
        }
        byte[] bytecodes = classWriter.toByteArray(); //변경 사항(생성) 저장
        return super.defineClass(name, bytecodes, 0, bytecodes.length);
    }
}

 

.InvocationTargetException .NoSuchMethodException

15:33:07.684 [main] WARN org.agent.MyAgent -- [PREMAIN] Name Err(getDeclaredConstructor) test.<init>()
FATAL ERROR in native method: processing of -javaagent failed, processJavaStart failed
Exception in thread "main" java.lang.reflect.InvocationTargetException
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:118)
	at java.base/java.lang.reflect.Method.invoke(Method.java:580)
	at java.instrument/sun.instrument.InstrumentationImpl.loadClassAndStartAgent(InstrumentationImpl.java:560)
	at java.instrument/sun.instrument.InstrumentationImpl.loadClassAndCallPremain(InstrumentationImpl.java:572)
Caused by: java.lang.RuntimeException: java.lang.NoSuchMethodException: test.<init>()
	at org.agent.MyAgent.premain(MyAgent.java:42)
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:103)
	... 3 more
Caused by: java.lang.NoSuchMethodException: test.<init>()
	at java.base/java.lang.Class.getConstructor0(Class.java:3761)
	at java.base/java.lang.Class.getDeclaredConstructor(Class.java:2930)
	at org.agent.MyAgent.premain(MyAgent.java:35)
	... 4 more
*** java.lang.instrument ASSERTION FAILED ***: "!errorOutstanding" with message Outstanding error when calling method in invokeJavaAgentMainMethod at s\open\src\java.instrument\share\native\libinstrument\JPLISAgent.c line: 627
*** java.lang.instrument ASSERTION FAILED ***: "success" with message invokeJavaAgentMainMethod failed at s\open\src\java.instrument\share\native\libinstrument\JPLISAgent.c line: 466
*** java.lang.instrument ASSERTION FAILED ***: "result" with message agent load/premain call failed at s\open\src\java.instrument\share\native\libinstrument\JPLISAgent.c line: 429

 

생성되는 Class의 위치를 찾을 수 없는 것 같다.

정확히 말하자면, 다음 구문에서 에러가 발생한다.

newClass.getDeclaredConstructor().newInstance(); //생성자 호출을 통한 새 인스턴스 생성

 

 

문제 원인 분석

    public static void premain(String agentArgs, Instrumentation instrumentation)  {

        Banner.send(agentArgs); //로그 찍기
        ConfigRead configRead = new ConfigRead(); //Config Read
        //CallThread.run(); JMX 쓰레드 생성 및 호출

        //새로운 클래스를 생성하는 로직
        try {
            MakeClassClassLoader makeClassClassLoader = new MakeClassClassLoader();
            Class<?> newClass = makeClassClassLoader.defineClass("testSCH", true, true);
//            newClass.newInstance(); 9++ Deprecated
            newClass.getDeclaredConstructor().newInstance(); //생성자 호출을 통한 새 인스턴스 생성
            //새로 변조한(생성한) 클래스의 인스턴스 생성
        } catch (InstantiationException | IllegalAccessException e) {
            log.warn("[PREMAIN] {}", e.getMessage());
        } catch (InvocationTargetException | NoSuchMethodException e) {
            log.warn("[PREMAIN] Name Err(getDeclaredConstructor) {}", e.getMessage());
            throw new RuntimeException(e);
        }


        instrumentation.addTransformer(new TreeAPITransformer());
    }

 

1. Class<?> newClass = makeClassClassLoader.defineClass("testSCH", true, true); 이제 여기서 내가 새로운 Class 파일(바이트코드 파일)을 만들어서 newClass에 실을 것이다.

 

2. newClass.getDeclaredConstructor().newInstance(); //생성자 호출을 통한 새 인스턴스 생성 그리고 이 바이트코드를 기반으로 새로운 인스턴스를 생성한다.

 

추측 : Method Area 누락

 

하지만 이 과정에서 문제가 있다고 추측했다.

 

이 과정에서 'getDeclaredConstructor'는 runtime Area의 method 영역을 참조하여 리플렉션을 통해 객체를 생성한다. 

-> 그 이야기는 먼저 'JVM 안의 Method Area에 이 코드가 존재해야 한다'는 전제를 가진다. 

 

-> 하지만 현재 'premain' 단계에서는 동적으로 생성된 이 class의 코드가 runtime에 적재가 되지 않은 상태일텐데, 리플렉션을 사용할 수 있을까? 불가능할 것이라고 생각했다.

 

하지만 Class.forName() 메소드를 사용하여 클래스를 명시적으로 로드하거나, 사용자 정의 클래스 로더의 defineClass 메소드를 호출하여 클래스를 로드하는 과정에서 Method Area에 적재가 된다고 한다. 즉, 현재 Method Area에 적재를 하는 기능이 .defineClass에 존재한다는 것이다.

 

이것이 제대로 되지 않는다면 일단 Method Area에 Class 정보가 존재하는지 확인해야 한다.

Class.forName() 검사

Class.forName()을 통해 클래스를 동적 로드시켜 이것이 존재하는지 확인할 수 있을 것이다.

이를 위해 기본 코드를 조금 변경해보겠다.

 

        //새로운 클래스를 생성하는 로직
        try {
            MakeClassClassLoader makeClassClassLoader = new MakeClassClassLoader();
            Class<?> newClass = makeClassClassLoader.defineClass("test", true, true);

            //테스트
            try {
                Class<?> testClass = Class.forName("test");
                System.out.println("클래스 존재");
            } catch (Exception e) {
                System.out.println("클래스 없음");
            }
            
////            newClass.newInstance(); 9++ Deprecated
//            newClass.getDeclaredConstructor().newInstance(); //생성자 호출을 통한 새 인스턴스 생성
//            //새로 변조한(생성한) 클래스의 인스턴스 생성
//        } catch (InstantiationException | IllegalAccessException e) {
//            log.warn("[PREMAIN] {}", e.getMessage());
//        } catch (InvocationTargetException | NoSuchMethodException e) {
//            log.warn("[PREMAIN] Name Err(getDeclaredConstructor) {}", e.getMessage());
//            throw new RuntimeException(e);
//        }

 

 

원인을 찾은 것 같다. 

 

문제의 원인? : ClassLoader 

Class.forName("test") 호출이 실패하는 원인은 MakeClassClassLoader를 통해 정의된 test 클래스가 기본 클래스 로더에 의해 관리되지 않기 때문이다. Java에서 클래스 로더는 계층 구조를 이루며, 각 클래스 로더는 자신이 로드한 클래스에 대해서만 관리한다.

 

MakeClassClassLoader와 같은 사용자 정의 클래스 로더로 클래스를 로드할 경우, 그 클래스는 해당 클래스 로더의 네임스페이스 내에서만 존재한다.

 

즉, Class.forName("test")를 호출했을 때 "클래스 없음"이라는 결과를 얻은 것은, Class.forName이 기본 클래스 로더를 사용하기 때문이다.

하지만 test 클래스는 MakeClassClassLoader에 의해 로드(이자 생성)되었으므로, 기본 클래스 로더의 네임스페이스에는 존재하지 않는다.

 

다음과 같이 메서드에 내가 만든 동적 클래스로더를 지정해보자.

        //새로운 클래스를 생성하는 로직
        try {
            MakeClassClassLoader makeClassClassLoader = new MakeClassClassLoader();
            Class<?> newClass = makeClassClassLoader.defineClass("test", true, true);

            //테스트
            try {
                Class<?> testClass = Class.forName("test", true, makeClassClassLoader);
                System.out.println("클래스 존재");
            } catch (Exception e) {
                System.out.println("클래스 없음");
            }
        } catch (Exception e) {};
////            newClass.newInstance(); 9++ Deprecated
//            newClass.getDeclaredConstructor().newInstance(); //생성자 호출을 통한 새 인스턴스 생성
//            //새로 변조한(생성한) 클래스의 인스턴스 생성
//        } catch (InstantiationException | IllegalAccessException e) {
//            log.warn("[PREMAIN] {}", e.getMessage());
//        } catch (InvocationTargetException | NoSuchMethodException e) {
//            log.warn("[PREMAIN] Name Err(getDeclaredConstructor) {}", e.getMessage());
//            throw new RuntimeException(e);
//        }

 

 

클래스가 존재함을 확인할 수 있었다.

 

결론

ClassLoader의 네임스페이스와 계층 관련 이슈를 확인했다. 이 과정에서 추가적으로 학습한 내용을 정리해보려고 한다.

 

 

리플렉션과 메서드 영역

 

JVM에서 리플렉션은 실제로 메소드 영역(Method Area)에 저장된 메타데이터를 참조하여 실행된다.

클래스 로더(ClassLoader)는 컴파일된 바이트코드(.class 파일들)를 읽어서 JVM의 런타임 데이터 영역인 메소드 영역에 적재한다.

 

클래스 로더의 계층 구조

JVM의 클래스 로더는 계층 구조를 가진다는 점이 중요한 이슈였다.

 

 

클래스 로더들 사이의 계층 구조는 클래스의 가시성과 격리를 관리하는 데 도움이 된다.

각각의 클래스 로더는 특정한 영역 내에서 클래스를 로드하며, 부모-자식 관계에 있는 클래스 로더들은 로드된 클래스 정보를 공유할 수 있다.

 

정확히 말하면, 자식 클래스 로더는 부모 클래스 로더가 로드한 클래스를 볼 수 있지만, 부모 클래스 로더는 자식 클래스 로더가 로드한 클래스를 직접 볼 수 없다.

 

 

CustomClassLoader 같은 사용자 정의 클래스 로더를 생성하고 사용할 때, 이 클래스 로더는 자신이 직접 로드한 클래스에 대해서만 관리한다.

 

이 클래스 로더로 로드된 클래스는 다른 클래스 로더와의 명시적인 공유 없이는 접근할 수 없다. 

따라서, 부모 클래스 로더에서 자식 클래스 로더가 로드한 클래스를 "볼" 수 없는 것은 설계에 의한 것이다. 

 

그렇기에, forName 등에서 특정 ClassLoader를 명시해야만 클래스 정보를 확인할 수 있었던 것이다.

 

메서드 영역은 JVM 내에서 전부 공유되는 것이 아니었나?

중요한 점은 모든 클래스 로더가 메소드 영역 자체를 공유한다는 것이다. 이것은 엄연한 사실이다.

메소드 영역은 JVM 내에서 공유되는 영역으로, 모든 클래스 로더가 여기에 클래스를 적재하며, 여기에 저장된 정보는 JVM 내의 모든 클래스 로더에 의해 접근될 수 있다.

 

문제가 되는 상황은 클래스를 로드할 때 발생한다.

 

클래스 로드 : 계층 구조의 검색

 

JVM은 클래스를 찾을 때 클래스 로더의 계층 구조를 따라서 검색한다.

이 과정에서 요청된 클래스를 찾지 못하면 ClassNotFoundException이 발생한다. 특정 ClassLoader 인스턴스로 클래스를 로드한 경우, 그 클래스는 해당 클래스 로더의 "네임스페이스" 내에 존재하게 된다. 따라서 다른 클래스 로더가 로드한 클래스를 참조하려면, 클래스의 로드 과정에서 같은 클래스 로더를 사용하거나, 클래스 로더 간의 부모-자식 관계를 통해 접근 가능해야 한다.

 

Class.forName 같은 메소드를 사용할 때 특정 클래스 로더를 지정하지 않으면, 현재 스레드의 컨텍스트 클래스 로더 또는 호출자의 클래스 로더(기본 클래스 로더)가 사용된다.

 

만약 CustomClassLoader로 클래스를 로드했다면, 해당 클래스에 접근하기 위해서는 동일한 CustomClassLoader 인스턴스를 사용하여 Class.forName을 호출해야 한다, 아니면 지금처럼 특정 ClassLoader를 지정하는 방법도 있다.

그렇지 않으면 메소드 영역 내에서 해당 클래스를 찾지 못할 수 있다.

 

요약

요약하자면, 모든 클래스 로더는 JVM 내의 메소드 영역을 공유하지만, 로드 과정에서 각 클래스 로더는 고유한 네임스페이스를 가지고 있어서, 특정 클래스 로더로 로드된 클래스에 접근하기 위해서는 해당 클래스 로더를 명시적으로 사용해야 한다.

 

이 과정에서 자식 클래스 로더는 부모 클래스 로더가 로드한 클래스를 볼 수 있지만, 부모 클래스 로더는 자식 클래스 로더가 로드한 클래스를 직접 볼 수 없다.