지난 시간에는 Java의 실행과정에 대해 자세하게 알아봤다면 이번 포스팅에서는 JVM의 구성요소인 Runtime Data Area가 어떻게 구성되어 있고, JVM은 이를 어떻게 관리하는지 자세하게 알아보겠습니다.
Java는 메모리 관리에 자유로운 언어이지만, 개발자 자신이 다루는 변수, 객체들이 어떻게 저장되고 참조되는지에 대해 정확하게 이해하고 있어야 합니다. 불필요한 메모리 사용을 줄이고 최적화와 성능 향상을 위해서 메모리 구조에 대한 지식은 개발자에게 필수입니다.
1. Runtime Data Area(런타임 데이터 영역)
Java 바이트 코드를 실행하는 JVM은 메모리 관리를 위해 Runtime Data Area를 사용합니다. JVM은 Runtime Data Area를 여러 영역으로 나누어 각 데이터의 용도에 맞게 저장하고 참조합니다. Runtime Data Area는 Method Area, Heap Area, Stack Area, PC Register, Native Method Stacks으로 구성되어 있습니다.
2. Method Area(메소드 영역)
메소드 영역은 모든 클래스, 인터페이스에 대한 정보(이름, 상수, 변수, static 필드 등)가 저장됩니다. 또한, 메소드 영역은 모든 스레드가 공유하는 공간입니다.
- 클래스 정보(Class Information) : 클래스의 구조, 필드 및 메소드의 정의, 접근 제어 정보 등 클래스에 대한 정보를 저장합니다. JVM의 클래스 로더에 의해 로드된 클래스의 Metadata가 적재됩니다.
- 스태틱 변수(Static Variables) : 클래스 수준에서 선언된 Static 변수들이 Method Area에 저장됩니다. Static 변수는 해당 클래스의 모든 인스턴스들이 공유하는 변수입니다. Static 변수 또한 클래스 로더에 의해 초기화되고 Method Area에 적재됩니다.
- 메소드 코드(Method Code) : 클래스에 선언된 메소드들의 바이트 코드가 저장됩니다. 스택에서 메소드를 호출할 때 Method Area의 바이트 코드를 참조합니다.
- 런타임 상수 풀(Runtime Constant Pool) : 클래스의 상수 값과 해당 상수 값에 대한 참조 정보를 테이블 형태의 구조로 저장하는 데이터 영역입니다.
3. Stack Area(스택 영역)
스택 영역은 메소드 호출에 관한 정보들이 저장되는 영역입니다. 각 스레드마다 별도의 스택 영역을 가지고 있고, 스택 영역은 스택 프레임 단위로 관리됩니다. 메소드가 호출되면 JVM이 새로운 스택 프레임을 생성하고, 메소드 호출이 완료되면 해당 스택 프레임이 소멸됩니다.
3.1 Stack Frame
스택 프레임은 스레드 내부에서 호출된 하나의 메소드의 정보를 저장하는 데이터 단위입니다. 매개변수, 지역변수, 복귀주소, Operand Stack 등과 같은 데이터들이 스택 프레임에 저장됩니다.
- 지역변수(Local Variables) : 메소드 내부에서 선언된 지역변수와 함수 호출 시 전달되는 매개변수가 저장됩니다. Java는 매개변수 전달 시 새로운 변수를 생성하고 값을 복사합니다.(Call by Value)
- 피연산자 스택(Operand Stack) : 메소드 내 계산을 위한 작업 공간입니다. 메소드 내부에서 연산자를 사용하여 계산할 때 Operand Stack을 활용하여 중간 연산 결과를 저장합니다.
- 메소드 실행 정보(Method Invocation) : 호출된 메소드의 실행정보가 저장되는 영역입니다. 해당 메소드 종료 시 복귀할 메모리 주소와 예외 처리에 대한 정보가 포함되어 있습니다.
3.2 Stack 자료구조란?
스택 영역은 Stack 자료구조와 같은 방법으로 프레임을 적재합니다. Stack 자료구조는 Last-In First-Out으로 동작하면서 데이터를 저장 및 관리하는 자료구조입니다.
Stack은 접시를 쌓는 것과 같습니다. 가장 마지막에 올려놓은 접시만 꺼낼 수 있는 것처럼 스택도 가장 마지막에 넣은 데이터에만 액세스 할 수 있습니다. Stack 자료구조가 동작하는 과정은 다음과 같습니다.
- Top() : 스택 가장 위에 있는 데이터
- Push() : Top위에 새로운 데이터 추가
- Pop() : 현재 Top을 반환 후 데이터 제거
3.3 메소드 호출 시 Stack의 동작 과정
지금부터 메모리에서 Stack 영역이 왜 LIFO의 자료구조로 설계되었는지 코드로 알아보겠습니다.
메소드 종료 시 메세지를 출력하는 메소드 A, B, C가 선언되어 있습니다. B는 A를 호출하고, C는 B를 호출할때 각 메소드가 스택 영역에서 어떤 순서로 생성되고 소멸되는지 알아보겠습니다.
Java 프로그램의 진입점은 main 메소드입니다. 따라서 초기 Stack 영역에는 main 메소드의 스택 프레임이 있습니다. 위의 그림은 시간의 흐름에 따른 Stack 프레임의 변화를 나타낸 것입니다.
Stack 영역에서 Top에 해당하는 스택 프레임은 현재 실행되고 있는 메소드를 의미하며 해당 메소드가 종료되면 Stack 영역에서 소멸됩니다. 해당 결과를 토대로 종료 메세지가 출력되는 순서는 A - B - C - A입니다. 실제 실행 결과를 확인해 보면 정확한 것을 볼 수 있습니다.
4. Heap Area(힙 영역)
Heap은 동적 할당으로 생성된 메모리가 저장되는 공간입니다. 개발자가 new 연산을 통해 배열이나 객체를 생성하면 해당 데이터는 힙(Heap) 영역에 저장됩니다. 힙 영역은 JVM 시작 시 생성되며 모든 스레드들이 공유하는 공간입니다. Java의 힙영역은 가비지 콜렉터(Garbage Collector)에 의해 자동적으로 관리되기 때문에 개발자는 힙 영역에 직접 접근하는 일은 없습니다.
Heap영역에 생성된 동적 메모리는 GC에 의해 자동으로 관리됩니다. GC는 더 이상 참조되지 않는 객체를 식별하기 위해 세대(Generation)라는 개념을 사용하여 객체를 분류합니다.
- Young Generation :
- Eden Space : 객체가 처음 생성되는 공간입니다.
- Survivor Space : Eden Space에서 참조되는 객체들 중 일부가 살아남은 객체들이 복사되는 공간입니다.
- Old Generation(Tenured)
- Tenured : Young Generation에서 일정 시간 동안 살아남은 객체들이 이동되는 공간입니다.
- Permanent Generation : 클래스의 메타데이터, Constant Pool을 저장하는 공간이었지만, Java 8 이후론 사라졌습니다.
4.1 참조변수(Stack)와 인스턴스(Heap)의 참조관계
힙(Heap)에는 참조변수의 데이터가 저장되고 스택(Stack)에는 기본변수가 저장되어 있습니다. 위의 코드 예시를 통해 참조변수와 기본변수가 메모리에 어떻게 적재되고 소멸되는지 그림으로 설명드리겠습니다.
아래의 메모리 상황은 7 line이 실행되기 직전 상황이라고 가정했을 때의 메모리 상황입니다.
4.2 String Constant Pool
문자열은 참조변수기 때문에 Heap에 저장됩니다. 하지만 어떻게 String 객체가 생성되는지에 따라 메모리 적재방식이 달라집니다.
a, b, c는 모두 String 객체고 같은 문자열을 가지고 있지만, 객체의 생성 방식에 차이가 있습니다. a와 c는 "hello"라는 문자열 리터럴로 생성했고, b는 new 연산자를 통해 생성했습니다.
문자열 리터럴로 생성한 String 객체는 String Constant Pool(문자열 상수 풀)이란 특별한 영역에 따로 저장됩니다.
문자열 리터럴(String literal)은 String Constant Pool이라는 곳에 저장됩니다. String Constant Pool은 Heap 내부에 위치하며 문자열 리터럴이 중복되지 않도록 관리합니다. 또한, String Constant Pool은 Heap에 위치하고 있지만, GC의 대상이 되지 않기 때문에 프로그램이 종료될 때까지 메모리가 유지됩니다.
5. PC(Program Counter) Registers
PC레지스터는 현재 실행 중인 JVM 명령어의 주소를 저장하는 영역입니다. PC 레지스터는 스레드마다 별도로 유지되며, 명령어의 실행 순서를 제어하는 역할을 합니다.
PC 레지스터는 명령어의 주소를 보관하고 있으며 CPU가 다음에 어떤 명령어를 실행해야 하는지 알려줍니다. CPU는 현재 실행 중인 명령어를 실행한 후, PC 레지스터의 값에 따라 다음에 실행할 명령어의 주소를 결정합니다. CPU의 일부분으로서 매우 빠른 접근 속도를 가지며, 프로그램의 제어 흐름을 관리하는 중요한 역할을 수행합니다.
6. Native Method Stacks
Native Method Stack은 Java 프로그램에서 네이트 코드(다른 언어로 작성된 코드)를 실행하는 데 사용되는 Stack 영역입니다. Java 언어 자체로 처리할 수 없는 경우, 네이티브 메소드를 호출하여 해당 기능을 수행할 수 있습니다. 일반적으로 C/C++과 같은 언어로 작성된 라이브러리를 호출합니다.
Native Method Stack도 스레드마다 별도로 할당되며 네이티브 메소드의 호출 스택 프레임을 관리합니다. Java 언어로 작성된 메소드는 Java 스택 프레임에 저장되고 실행되지만, 네이티브 메소드는 네이티브 메소드 스택 프레임에 저장되고 실행됩니다. 따라서 네이티브 메소드가 호출되면, JVM은 현재의 Java 스택 프레임을 저장하고 네이티브 메소드 스택으로 전환하여 네이티브 코드를 실행합니다. 네이티브 메소드의 실행이 완료되면, JVM은 네이티브 메소드 스택 프레임을 제거하고 이전의 Java 스택으로 다시 전환합니다.