JVM 클래스 동적 로딩

헙크·2023년 5월 20일
1

1. 개요

JVM은 클래스를 동적 로딩한다고 합니다. 간단하게 말하자면 애플리케이션 실행 시점에 모든 클래스 파일을 메모리에 올려두는 것이 아니라, 필요할 때 하나씩 메모리에 올리는 방식을 취한다는 것입니다.

그런데 개인적으로 이를 체감하지 못하고 이론적으로만 공부해왔던 것 같습니다. 물론 이런 궁금증을 해소하기 위해 알아보고 이번 글을 쓰는 것은 아니였습니다 ㅎㅎㅎ. 자바의 리플렉션이란 기술에 대해서 학습하다가 JVM 메모리 구조에 대해서 깊게 공부해볼 기회가 있었고, 이를 통해 클래스를 어느 시점에 동적 로딩하는지에 대해서 궁금증을 해소할 수 있었습니다.

이번 글에서는 JVM이 어떻게 클래스를 동적 로딩하는지, 이를 실질적으로 어떻게 우리 눈으로 확인할 수 있는지를 풀어보고자 합니다.

2. 동적 클래스 로딩

2.1. 동적 클래스 로딩이란?

동적 클래스 로딩이란 JVM에서 실행에 필요한 모든 클래스 파일을 메모리에 올려놓지 않고, 필요한 시점에 동적으로 메모리에 올리는 기술을 말합니다. 따라서 가능한 적은 클래스 파일을 메모리에 올려 실행할 수 있으므로 조금 더 효율적이라 할 수 있습니다.

2.2. Trouble Shooting

JVM은 동적 클래스 로딩을 사용한다고 하는데, 그렇다면 언제 어느 클래스를 어떻게 메모리에 올리는지 궁금했습니다. 이 라인에서는 클래스가 메모리에 올라와 있을까?? 라는 의문이 들었고 직접 메모리를 확인해보고 싶다는 생각이 들었던 것입니다.

이를 설명하기 위해서는 JVM의 인터프리터가 클래스 파일을 읽는 과정과, 클래스 로더에 대해서 알아볼 필요가 있습니다.

3. JVM 메모리 구조

이번 글에서는 메모리 구조 그 자체에 대해서 설명하기 보다, 클래스의 동적 로딩에 대해서 설명하고자 하기 때문에 각 영역이 무엇인지 모두 설명하진 않을 것입니다.

3.1. 인터프리터란?

컴파일러가 우리의 자바 소스코드를 컴파일하면 클래스 파일(바이너리 파일)이 만들어집니다. 이는 JVM이 바로 읽을 수 없습니다. JVM에서는 이를 읽을 수 있도록 인터프리터를 통해서 다시 한 번 JVM이 이해 할 수 있는 형태인 기계어로 해석해 실행합니다. 인터프리터는 클래스 파일에 있는 바이트 코드를 한 줄 한 줄 읽어 내려간다는 특징이 있습니다.

💡 참고
자바에서는 우리가 어느 OS(플랫폼)을 사용하든, 한 번만 컴파일하고 배포하면 각 OS에 존재하는 JVM에서 바로 읽을 수 있다는 것을 특징으로 말하곤 합니다. 그 이유는 컴파일한 클래스 파일을 OS에 종속적인 JVM이 다시 한 번 실행시에 인터프리터를 통해 자신일 이해할 수 있는 기계어로 번역하기 때문입니다. 이로써 자바는 플랫폼에 독립적이고, JVM은 플랫폼에 종속적이라 할 수 있습니다.

3.2. 클래스 로더란?

클래스 로더란 컴파일된 클래스 파일을 JVM의 메모리 영역 중 Runtime Data Areas에 필요한 파일을 올려주는 역할을 담당합니다. 위에서 설명했다시피, JVM은 모든 클래스 파일을 메모리에 올려놓지 않기 때문에 필요한 클래스 파일을 로드하기 위해서는 이 클래스 로더를 통해 클래스 파일을 클래스 패스를 뒤져 찾아와서 메서드 영역에 올려놓습니다.

클래스 로더가 클래스 파일을 메모리에 로딩하기 위해서는 Load-Link-Initialization의 프로세스를 거치는데, 이는 밑에서 자세히 설명하겠습니다. 일단 넘어가시죠!!

💡 클래스 패스란?
클래스 패스란 JVM이 클래스를 찾을 수 있는 경로를 말합니다. 어떤 클래스를 로딩할 것을 요청한다면 해당 경로를 뒤져가며 클래스를 찾아올 것이고, 만약 우리가 필요로하는 클래스가 클래스 패스에 존재하지 않는다면 JVM은 ClassNotFoundException을 발생시킬 것입니다.

Java를 처음 배우기 시작하실 때, 윈도우에서 다들 한 번씩 환경변수를 설정하느라 고생하신 경험이 있으실 것 같습니다 ㅎㅎㅎ

💡 메서드 영역이란?
메서드 영역에는 클래스 수준의 메타 정보가 저장됩니다. 어떤 클래스의 이름, 풀 패키지 경로, 가지고 있는 필드, 메서드, 생성자, 배열, static 변수 등 어떤 골격으로 구성되어 있는지가 저장되는 것입니다. 실제 인스턴스 변수의 값은 저장되지 않습니다. (static 변수 값은 이곳에 저장됩니다)

3.3. 인터프리터와 클래스 로더, 클래스 동적 로딩의 관계

위에서 인터프리터클래스 로더에 대해서 설명했습니다. 이 사이에서 클래스 동적 로딩이 일어나게 되는데, 이에 대해서 조금 자세하게 설명해보겠습니다.

우선 인터프리터가 우리의 코드를 한 줄씩 읽다가, 메모리에 올라와있지 않는 클래스를 읽어야 할 때면 클래스 로더에게 해당 클래스를 메모리에 올려줄 것을 요청하게 됩니다. 따라서 클래스 로더는 자신의 클래스 패스를 뒤져 해당 클래스를 찾아 메서드 영역에 올려줍니다. 이런 동작 과정을 클래스 동적 로딩이라고 볼 수 있겠네요!

정리하자면, 인터프리터가 한 줄씩 실행하다가 메서드 영역 필요한 클래스가 없으면 그 시점에 클래스 로더를 통해 클래스를 메서드 영역에 올려놓는 것이 클래스 동적 로딩입니다.

3. 예제로 알아보자

이제까지 인터프리터클래스 로더에 대한 설명을 기반으로 클래스 동적 로딩에 대해서 설명하였습니다. 그렇다면 이런 전체적인 동작 과정과 어느 시점에 클래스가 동적 로딩되는지 코드상에서 설명해보겠습니다.

3.1. 예제코드

class MyClass {
	// Main 클래스와 다른 파일에 있다고 가정		
}

class Main {
	public static void main(String[] args) { // (1)
		MyClass myclass = null; // (2)
		myclass = new MyClass(); // (3)
	}
}

예제와 함께 설명해보겠습니다. 만약 우리가 위처럼 코드를 작성하고 이를 실행한다고 가정해보겠습니다.

우선 자바에서 이 프로그램을 실행시키기 위해서는 터미널에서 java Main 명령어를 사용해야 합니다. (물론 인텔리제이에서 run 버튼을 누르면 내부적으로 java Main 명령을 통해 실행시키겠죠!) 그렇게 하면 JVM은 메모리에 Main 클래스를 올려놓고, 프로그램의 시작점인 public static void main(String[] args)을 찾아 실행을 시작합니다.

인터프리터는 한 줄씩 코드를 읽는다고 했었죠. 따라서 (1)로 진입해 프로그램을 시작하면 곧바로 (2)를 실행시킬 것입니다. 이때 MyClass가 JVM 메서드 영역에 올라와있을까요?! 아닙니다. 해당 줄을 읽을 때까지는 MyClass가 실질적으로 필요하지 않기 때문에 메모리에 올라와있지 않습니다.

(3)을 시작할 때에 인터프리터는 new MyClass()를 만나게 됩니다. 인터프리터는 이를 실행시키기 위해 JVM 메서드 영역에 접근해 해당 클래스의 정보가 있는지 확인합니다. 당연히 MyClass에 대한 정보는 올라와있지 않고, 이제서야 클래스 로더에게 해당 클래스 정보를 메모리에 올려줄 것을 요청합니다. 클래스 로더는 클래스 패스를 뒤져 해당 파일을 메서드 영역에 올려줍니다. 결국 (3)을 실행하는 시점에 동적 클래스 로딩이 일어나고 이제 MyClass는 메모리에 올라왔다고 볼 수 있습니다.

3.2. Trouble Shooting

제가 말씀드린 내용에 대해서 의심하실 수도 있을 것 같습니다. 그렇다면 제 말을 증명해보겠습니다. 저는 이를 증명하기 위해 Class라는 클래스가 힙 영역에 생성되는 시점으로 파악할 수 있다는 말씀을 드리고 싶습니다.

그렇다면 클래스 로더와 Class에 대해서도 한 번 짚고 넘어가보겠습니다.

4. 클래스 로더와 Class

4.1. 클래스 로더 동작 과정 심화

클래스 로더가 클래스를 필요한 시점에 로딩한다고 위에서 대략적으로 설명했습니다. 하지만 클래스 로더의 동작 방식은 조금 복잡한데, 이에 대해서 조금 더 자세하게 알아보겠습니다.

클래스 로더에서 클래스를 동적 로딩할 때에는 다음과 같은 프로세스를 거칩니다.

  1. Load : 로드 단게에서는 클래스를 JVM 메모리에 로드하기 위한 기초적인 작업을 진행합니다.

    • 클래스 로더가 .class 파일을 읽고 그 내용에 따라 적절한 바이너리 데이터를 만들고 메서드 영역에 저장합니다. 메서드 영역에는 클래스 수준의 메타 정보가 저장됩니다. 풀패키지 경로, 클래스, 인터페이스, Enum, 메서드와 변수 등의 정보가 저장된다고 생각하시면 될 것 같습니다.
    • 로딩이 끝나면 해당 클래스 타입의 Class 객체를 생성해 힙 영역에 저장합니다. 바이트 코드로부터 클래스 로더가 직접 힙 영역에 해당 클래스 타입에 맞는 Class 객체를 생성해줍니다.

    💡 Class란?
    이 클래스는 보통 리플렉션 기술을 사용하여 클래스의 메타 정보에 접근할 때 사용됩니다. Class는 메서드 영역에 저장되어 있는 메타 정보를 참조하고 있고, 리플렉션을 사용할 때에 Class를 사용해 클래스의 메타 정보를 런타임에 검사하고 조작할 수 있습니다.

    리플렉션은 이번 주제의 핵심이 아니므로 자세한 설명은 하지 않겠습니다. 하지만 Class라는 객체가 클래스 로더에 의해 메모리에 로딩되는 시점에 힙 영역에 올라온다는 사실은 주의깊게 봐주세요!

  2. Link : 링크 단계는 Verify → Prepare → Resolve 세 단계로 나눠져 있습니다.

    1. Verify(검증) : .class 파일 형식이 유효한지 체크 (바이트 코드가 유효한지 체크)
    2. Prepare(준비) : 클래스 변수와 기본값에 필요한 메모리를 할당
    3. Resolve(분석) : 심볼릭 메모리 레퍼런스를 메소드 영역에 있는 실제 레퍼런스로 교체 (Optional)
  3. Initialization

    • static 변수의 값을 할당합니다.
    • 추가적으로 클래스에 static 블록이 있다면 이를 실행시킵니다.

4.2. Class가 힙에 올라오는 시점

위의 동작 과정을 살펴보면서 눈여겨봐야 할 것은, 클래스 로더가 Load 단계에서 메모리 영역에 클래스를 로드할 때 해당 클래스 타입의 Class 객체를 힙 영역에 생성한다는 점이었습니다.

그렇다면 클래스가 메모리에 올라오는 시점은 해당 클래스 타입의 Class 객체가 힙 영역에 올라오는 시점과 일치한다고 볼 수 있습니다.

따라서 힙 영역을 확인하여 Class가 언제 올라오는지 확인하면 클래스의 로딩 시점을 알 수 있겠네요!

4.3. 인텔리제이 디버깅의 Memory 탭

인텔리제이는 디버깅 도구를 제공하는데, 이때 스택 영역 뿐만 아니라 힙 영역의 정보도 확인할 수 있습니다. 이에 대한 내용은 JetBrain의 공식 문서에서 확인하였습니다.

이제 이 디버깅 도구를 활용하는 방법을 차례대로 알아보겠습니다. 먼저 저는 아래와 같이 코드를 작성하고 5, 7번에 break point를 걸었다고 가정하겠습니다.

이제 디버깅을 실행하면 5번 라인에서 걸릴 것입니다. 이때 아래의 탭이 나타납니다.

Threads & Variable에 우클릭을 누르면 아래와 같이 탭이 나오는데, Layout 탭에서 Memory를 클릭해 Memory 정보를 볼 수 있도록 설정하겠습니다.

설정을 정상적으로 하면 아래와 같이 Memory 탭이 생깁니다. 이를 통해 Memory 영역에 어떤 정보들이 올라왔는지 확인할 수 있습니다.

제 예상과는 달리 수많은 객체가 이미 메모리에 올라와 있더군요. 아마 기본적으로 프로그램을 실행하는 데에 필요한 것들인 것 같습니다.

자 이제 5번줄을 실행시켜보겠습니다.
(5번줄을 실행했으니, 7번줄로 디버깅 라인이 내려간 것은 당연합니다)

그렇다면 이 상황에서 MyClass가 메모리에 로딩 되었는지 확인해보겠습니다. 위에서 제가 말한 것 처럼, 만약 메모리에 로딩 되었다면 해당 클래스 타입의 Class 객체가 힙 영역에 올라왔을 것이고 java.lang.Class 탭에서 이를 확인할 수 있을 것입니다.

java.lang.Class 탭을 더블클릭하면 아래처럼 모달창이 뜨는데, 이곳에서 MyClass를 검색해보면 아무것도 없는 것을 알 수 있습니다. 즉, 5번 라인을 실행했음에도 불구하고 MyClass는 아직 메모리에 올라와있지 않은 것을 알 수 있습니다.

이제 7번을 실행해보죠.

디버거의 Memory 탭에서 java.lang.Class를 더블클릭한 후, MyClass를 검색하면 아래와 같이 MyClass 타입의 Class 객체가 올라온 것을 확인할 수 있습니다.

정리하자면.. 프로그래밍 시작 시점에는 MyClass가 메모리에 로드되지 않았다가, 7번 라인을 실행하는 시점에서야 메서드 영역에 MyClass 정보가 없다는 것을 인터프리터가 깨닫게 됩니다. 그제서야 클래스 로더에게 MyClass를 로드시킬 것을 요청하게 되고, 클래스 로더메서드 영역에 클래스 수준의 메타 정보를 로드시키고 마지막에 Class 객체를 힙 영역에 올려놓게 됩니다.

5. 마무리

이렇게 해서 메서드 영역의 정보를 직접적으로 참조할 수는 없지만, 클래스 로더힙 영역에 Class를 로딩 시점에 올려준다는 것을 활용해 눈으로 직접 확인해보았습니다.

개인적으로 언제 클래스가 로드되는지 메서드 영역을 직접 확인하고 싶어서, 많은 라이브러리와 툴들을 찾아보았지만 번번히 실패했습니다. 그러다가 클래스 로더가 로딩 시점에 Class 객체를 힙 영역에 올려준다는 사실을 알았고, 정말 운이 좋게 인텔리제이의 디버깅 툴에서 힙 영역을 보여주는 탭이 있었다는 것을 알게 되어서 오늘의 포스팅을 성공적으로 마무리할 수 있었습니다.

이번 글에서는 어떻게 클래스가 동적 로딩되는지 전체적인 동작 과정을 살펴보았고, 이를 눈으로 확인할 수 있는 방법을 알아보았습니다. JVM 메모리 구조에 대해서 조금 헷갈리시는 분들은 아래의 포스팅을 확인하시면 전체적인 감을 잡으실 수 있을 것 같습니다. 조만간 제가 테코톡에서 진행했던 리플렉션에 대해서도 포스팅을 올려보겠습니다.

이것저것 쓰다보니 글이 길어졌는데.. 읽어주셨다면 너무 감사합니다 ㅠㅡㅠ

6. 참고 자료

  1. JVM 내부 구조 & 메모리 영역 💯 총정리
  2. [10분 테코톡] 어썸오의 JVM Memory Layout

4개의 댓글

comment-user-thumbnail
2023년 5월 20일

퍼가요~

1개의 답글
comment-user-thumbnail
2023년 5월 20일

와 진짜 너무 좋은글 감사합니다 :+1

1개의 답글