[Java] JVM 실행 과정 및 메모리 구조

1. JVM이란?

JVM(Java Virtual Machine)은 메모리 관리 및 자바 바이트코드를 해석하고 실행하는 가상 머신입니다.

바이트코드란? 바이트코드(Bytecode)는 컴퓨터가 직접 실행하는 대신, 가상 머신에서 실행할 수 있는 이진(binary) 형태의 코드입니다.

바이트코드의 특징

대부분의 컴파일 언어는 특정 운영체제에 최적화된 네이티브 코드로 컴파일됩니다. 이렇게 컴파일된 파일은 다른 운영체제에서 실행하기 어렵습니다. 다른 운영체제에서 실행하려면 해당 운영체제에 최적화된 컴파일을 해야 합니다.(운영체제에 종속적)

JVM 구조 01

바이트코드는 컴퓨터가 직접 실행할 수 있는 네이티브 코드가 아닌, 가상 머신이 실행할 수 있는 코드로 컴파일된 파일입니다. 이로 인해 운영체제에 바이트코드를 실행할 수 있는 가상 머신만 있다면, 바이트코드를 실행할 수 있습니다.(운영체제에 독립적, 가상 머신은 특정 운영체제에 종속적)

JVM 구조 02

2. JVM의 위치

JVM 구조 03

JVM은 JRE(Java Runtime Environment) 안에 위치합니다.

3. JVM의 역할

  • 클래스 파일 해석 및 실행
  • 메모리 관리

4. JVM의 구조

JVM 구조 04

ClassLoader(클래스로더): 클래스 파일을 검색하고 메서드 영역에 로딩하는 역할을 합니다. 클래스로더는, JVM 실행 중에 동적으로 필요한 클래스들을 메서드 영역에 로딩합니다.

Runtime Data Area: JVM이 프로그램을 수행하는 동안 사용하는 메모리 공간입니다. 이 영역은 여러 세부적인 영역들로 나누어져 있으며, 각 영역은 다양한 목적으로 사용됩니다.(각 영역의 자세한 설명은 아래 5. JVM의 메모리구조에서 설명)

Execution Engine: 자바 바이트코드를 실행하는 역할을 담당합니다. Excution Engine은 다양한 방식으로 바이트코드를 처리하며, 주로 두 가지 방식을 사용합니다.

  • 인터프리터(Interpreter): 인터프리터는 바이트코드를 한 줄씩 읽고 해석하여 실행합니다.
  • JIT 컴파일러(Just-In-Time Compiler): 인터프리터가 반복 또는 빈번하게 사용되는 코드를 감지하면, 해당 부분을 JIT 컴파일러에게 넘깁니다. JIT 컴파일러는 이를 네이티브 코드로 변환하고, 변환된 코드를 메서드 영역의 Code Cache 영역에 저장합니다. 그 후 동일한 코드가 호출될 때, 인터프리터가 컴파일된 네이티브 코드를 호출하여 실행합니다. 네이티브 코드는 컴퓨터가 직접 실행항 수 있는 이진 형태의 코드로, 하드웨어에 최적화되어 있어 인터프리터보다 훨씬 빠르게 실행됩니다.

초기에는 Execution Engine이 인터프리터를 사용하여 프로그램을 빠르게 실행하고, 프로그램이 실행되면서 JIT 컴파일러의 비중이 증가합니다.

Garbage Collection: 프로그램 실행 중에 Heap 영역에 더 이상 필요하지 않은 객체들을 자동으로 식별하고 제거하는 역할을 합니다.

가비지 컬렉션 자세히 알아보기: [Java] 가비지 컬렉션(Garbage Collection) 실행 과정

5. JVM의 메모리 구조

JVM의 메모리 구조는 크게 다섯 가지 영역으로 나뉩니다. 각 영역은 특정한 목적으로 사용되며, 프로그램의 실행 동안 메모리를 효율적으로 관리하기 위해 나누어져 있습니다.

JVM 구조 05

[1] 메서드 영역(Method Area)/ 자바 8 이후-> 메타스페이스(Metaspace):

  • 클래스 파일이 저장되는 영역입니다.
  • 모든 스레드에서 공유되는 메모리 영역입니다.
  • 메서드 영역은 JVM이 시작될 때 생성되고 프로그램이 종료될 때까지 유지됩니다.

[2] 힙(Heap)

  • JVM의 실행 단계에서 동적으로 생성된 객체 저장되는 곳입니다.
  • 모든 스레드에서 공유되는 메모리 영역입니다.

[3] 스택(Stack)

  • 각 스레드마다 자신만의 스택 영역이 존재합니다.
  • 지역 변수, 매개 변수, 반환 값, 반환 주소 등이 저장됩니다.
  • 스택 프레임은 JVM의 실행 단계에서 메서드 호출 시마다 생성되며, 후입선출(LIFO, Last-In-First-Out) 구조로, 새로운 스택 프레임이 스택의 맨 위에 추가되고, 현재 실행 중인 메서드가 끝나면 해당 프레임이 스택의 맨 위에서 제거됩니다.

[4] PC Register(Program Counter Register):

  • 각 스레드마다 자신만의 PC Register 영역이 존재합니다.
  • PC Register는 JVM 내부에서 각 스레드마다 독립적으로 관리되며, 현재 수행 중인 명령어의 주소를 가리키는 포인터 역할을 합니다. PC Register가 가리키는 명령어를 CPU가 실행하면, 이후 PC Register는 다음에 실행할 명령어의 위치를 가리킵니다. 이를 통해 프로그램의 명령어 실행 흐름을 제어합니다.

[5] 네이티브 메서드 스택(Native Method Stack):

  • 각 스레드마다 자신만의 네이티브 메서드 스택 영역이 존재합니다.
  • 주로 C나 C++과 같은 다른 언어로 작성된 네이티브 코드를 실행하기 위한 메모리 공간입니다.
  • 자바 코드에서 네이티브 메서드를 호출하면, 해당 메서드의 정보는 자바 스택 영역이 아니라 네이티브 메서드 스택에 저장됩니다.

6. JVM의 실행 과정

JVM 구조 06

이미지에 표시된 숫자는 JVM의 실행 과정입니다. JVM의 실행 과정은 크게 로딩, 링킹(Linking), 초기화, 실행, 종료로 나눌 수 있습니다.

[1] 자바 컴파일러가 자바 소스 코드를 컴파일합니다.

[2] 자바 컴파일러가 클래스 파일을 생성합니다.

[3] JVM이 실행될 때, JVM은 내부의 구성 요소를 초기화합니다.(자바 애플리케이션을 실행하기 위한 기반마련) 초기화가 완료되면, 클래스로더가 시작됩니다.

[4] 클래스로더는 검색할 클래스 파일이 메서드 영역에 이미 로딩되어 있는지 확인합니다. 없다면, 클래스로더는 클래스 경로(classpath) 또는 다른 로딩 경로에서 클래스 파일을 검색합니다.

[5-1] 클래스로더는 검색한 클래스 파일을 메서드 영역에 로딩합니다.(로딩 단계)

[5-2] (링킹 단계)가 진행됩니다.

링킹 단계는 검증(Verification), 준비(Preparation), 해결(Resolution)의 세 단계를 포함합니다.

  • 검증(Verification): 클래스로더가 로딩된 클래스가 올바른 형식과 구조를 갖추고 있는지 확인합니다. 검증에 실패할 시, JVM은 해당 클래스를 사용하려고 할 때마다 java.lang.VerifyError와 같은 예외를 발생시킵니다.
  • 준비(Preparation): 클래스의 정적 변수들이 해당 데이터 타입의 기본값으로 초기화됩니다. 해당 변수에 명시적인 값이 없을 때만 기본값으로 초기화합니다.
  • 해결(Resolution): 클래스로더가 런타임 상수 풀의 심볼릭 레퍼런스를 실제 메모리 주소로 바인딩합니다. 심볼릭 레퍼런스는 다른 클래스 및 인터페이스를 참조하는 경우에 사용되며, 클래스로더는 참조되는 클래스를 찾아서 로딩하고 초기화 단계까지 진행합니다. 그 후에는 기존 클래스의 단계를 이어서 진행합니다.

아래는 링킹 단계의 간단한 예시입니다.

클래스 A 로딩

클래스 A 바인딩 중, 심볼릭 레퍼런스가 클래스 B 참조

클래스 B 로딩

클래스 B 바인딩 중, 심볼릭 레퍼런스가 클래스 C 참조

클래스 C 로딩

클래스 C 초기화 단계까지 진행

클래스 B 초기화 단계까지 진행

클래스 A 이어서 진행

(즉, 심볼릭 레퍼런스를 실제 메모리 주소로 바인딩할 때, 심볼릭 레퍼런스가 다른 클래스나 인터페이스를 참조하고 있다면 클래스로더는 [4], [5]의 동작을 수행)

상수 풀이란? 클래스 파일 내에 존재하는 하나의 섹션입니다. 클래스의 구조를 정의하는 상수들의 집합으로, 참조되는 클래스의 이름, 문자열 및 숫자 상수의 초기 값, JVM 실행에 필요한 기타 데이터가 포함됩니다. 참고로 런타임 상수 풀은 JVM의 Metaspace에 로딩된 상수 풀입니다.

상수 풀 자세히 알아보기: [Java] 상수 풀(Constant Pool), 문자열 풀(String Pool)

심볼릭 레퍼런스란? 심볼릭 레퍼런스(Symbolic Reference)는 어떤 객체를 참조하는 데 사용되는 이름이나 기호입니다. 이는 구체적인 메모리 주소나 값이 아니라, 해당 객체의 심볼적인 표현이나 이름을 나타냅니다.

바인딩이란? 바인딩이란 두 요소를 서로 연결하는 행위나 과정을 나타내는 용어입니다.

[5-3] (초기화 단계)가 진행됩니다.

클래스의 정적 변수가 static 블록을 통해 초기화됩니다.(static 블록이 없다면, 준비 단계에서 할당된 기본값 또는 개발자가 명시적으로 지정한 값이 유지됩니다.)

[6] 클래스의 초기화 단계가 완료되면, Execution Engine이 해당 클래스의 main 메서드를 찾아 프로그램을 실행합니다. Execution Engine은 프로그램 실행 도중 발생하는 이벤트에 대응하여 Runtime Data Area를 조작합니다.(스택 관리, 예외 처리 등)(실행 단계)

[7] Execution Engine이 동적으로 생성된 다른 클래스가 필요할 때 클래스로더에게 필요한 클래스의 로딩 요청을 보냅니다.(클래스로더는 [4], [5]의 동작을 수행, 그 후 다시 기존의 단계 이어서 진행)

[8] 가비지 컬렉션은 여러 조건(메모리 부족, 명시적인 요청 등)에 의해 트리거 되며, Heap 영역에서 더 이상 참조되지 않는 객체들을 정리합니다.


JVM의 구체적인 내부 동작 원리를 모르더라도 Java 언어를 사용하여 개발을 할 수 있습니다. 하지만 JVM의 구조와 동작 원리를 이해하는 것은 성능 최적화, 메모리 관리, 스레드 처리 등과 같은 측면에서 유용할 수 있습니다.