
JVM(Java Virtual Machine)이 운영체제로부터 데이터를 저장할 메모리 공간을 할당받아 자바 애플리케이션을 실행한다. 이 글은 자바 애플리케이션을 실행할 때인 런타임시 데이텅 영역에 대해 정리했다.


JVM의 PC 레지스터(Program Counter Register)는 스레드가 생성될 때 함께 생성된다. 스레드마다 PC 레지스터는 하나씩 존재하고 각각의 레지스터는 서로 영향을 주지 않는 독립된 영역에 저장되는 스레드 프라이빗 메모리이다.
멀티스레드 환경에서도 스레드들은 각각 하나의 pc 레지스터를 사용한다.
🌟 cpu가 여러개의 스레드를 번갈아 가면서 처리하다가(컨텍스트 스위칭) 각각의 스레드 실행 위치를 정확히 복원하기 위해
PC 레지스터에는 해당 스레드가 현재 실행 중인 JVM 바이트코드 명령어의 위치가 저장된다. JVM은 이 값을 통해 어떤 바이트코드를 실행하고 있는지 추적할 수 있다.
🌟 JVM에서 실행되는 바이트코드는 개발자가 작성한 Java 소스 코드를 javac가 컴파일해 생성한 중간 코드이며, PC 레지스터는 이 바이트코드 명령어의 실행 위치를 추적한다. (index와 비슷한 느낌...)
인터프리터(바이트코드를 실행하는 JVM의 가상 CPU) 는 Java 바이트코드(.class 파일)를 CPU 아키텍처에 맞는 동작으로 한 줄씩 해석하고 실행하는 JVM 내부의 핵심 구성 요소로 주로 PC 레지스터에서 실행할 명령어를 참고한다.
그럼 인터프린터와 pc레지스터와의 동작을 간단하게 살펴보면 인터프린터는 레지스터가 가르치는 바이트코드를 찾아 실행하고 인터프리터(또는 JIT 컴파일된 코드) 가 명령어 실행 흐름에 따라 PC 레지스터 값을 다음 명령어 위치로 갱신하거나, 분기·점프 명령에 따라 다른 위치로 변경한다. 이 과정이 반복되면서 JVM의 바이트코드 실행 흐름이 유지된다.
이 프로그램 카운터 레지스터 영역은 자바 가상 머신에서 유일하게 OutOfMemoryError 조건이 명시되지 않은 유일한 영역이다.
단, 네이티브 메서드를 실행하는 동안에는 PC 레지스터의 값은 Undefined이다. => 3의 네이티브 메서드 스택에서 살펴볼 예정

자바 가상 머신 스택 역시 PC레지스터와 같이 스레드 프라이빗한 성격을 가지고 있고 연결된 스레드와 생성 삭제 시기가 일치한다. 가상 머신 스택은 자바 메서드를 실행하는 스레드의 메모리 모델을 설명해 준다.
각 스레드에서 메서드가 실행될 때 JVM실행 엔진은 지역 변수 테이블(Local Variable Table), 피연산자 스택(Operand Stack), 동적 링크 정보 등을 포함한 스택 프레임(Frame) 을 생성하여 가상 머신 스택에 push한다. 메서드 실행이 종료되면 해당 프레임은 pop되며 이전 프레임으로 실행 흐름이 복원된다.

지역 변수 테이블은 각 스택 프레임 내부에 존재하며, 메서드의 매개변수와 지역 변수(기본 타입 값, 객체 참조, return address 등)를 슬롯(slot) 기반 구조로 저장한다.
피연산자 스택(Operand Stack)은 메서드의 연산을 위한 스택 자료구조로 이루어져있는 공간이다.
스택 프레임에 포함된 Constant Pool Reference는 동적 링크(Dynamic Linking)를 수행하기 위해 필요한 정보를 가지고 있다. 실행 중인 메서드가 참조하는 클래스의 Runtime Constant Pool을 가리키며, 이를 통해 메서드 호출이나 필드 접근 시 동적 링크가 수행된다.
Runtime Constant Pool: 클래스가 JVM에 로딩될 때 생성되는 상수 정보 저장소
StackOverflowError: Thread가 요청한 Stack 깊이가 VM이 허용하는 깊이보다 클 때
OutOfMemoryError: Stack 용량을 동적으로 확장하려는 시점에 여유 메모리가 충분하지 않을 때

네이티브 메서드 스택은 가상 머신 스택과 이름이 비슷한 만큼 스레드 프라이빗한 성격과 비슷한 역할을 가지고 있다. 차이점이라고 한다면 가상 머신 스택은 메서드를 실행할 때 사용되었다면 네이티브 메서드 스택은 네이티브 메서드를 실행할 때 사용된다.
그렇다면 네이티브 메서드란 무엇일까? 네이티브 메서드는 C, C++ 등 자바 이외의 언어로 작성된 메서드로, JVM의 바이트코드 실행 영역이 아닌 네이티브 코드 영역에서 실행된다.
자바에서는 이러한 메서드를 native 키워드로 선언하며, JVM은 JNI(Java Native Interface) 를 통해 자바 코드와 네이티브 코드 사이의 호출을 연결한다. 이때 네이티브 코드 실행에 사용되는 스택이 바로 네이티브 메서드 스택이다.
사진의 네이티브 라이브러리 인터페이스의 대표적인 예시가 JNI이다.
네이티브 메서드는 JVM이 직접 실행하는 것이 아니라, JVM 프로세스에 로드된 네이티브 라이브러리 코드가 CPU에 의해 직접 실행된다. JVM은 JNI를 통해 해당 호출을 중개한다.
JVM Stack과 마찬가지로 StackOverflowError, OutOfMemoryError를 던질 수 있다.
지금까지 정리한 PC 레지스터, 가상 머신 스택, 네이티브 메서드 스택은 모든 스레드 프라이빗한 성격을 가지고 있다. 이는 멀티스레드 환경에서도 각 스레드의 실행 흐름과 상태가 서로 간섭 없이 독립적으로 유지되기 위함이다.
이러한 구조를 Thread를 중심에 그림으로 정리하면 다음과 같다.


자바 힙은 자바 애플리케이션이 사용할 수 있는 가장 큰 메모리로 자바의 거의 모든 객체 인스턴스와 배열이 힙에 할당된다. 자바 힙은 가비지 컬렉터가 관리하는 메모리 영역으로 어떤 문헌에서는 GC힙 이라고도 한다. 메모리 회수 관점에서 대다수 현대적인 가비지 컬렉터는 세대별 컬렉션 이론을 기초로 설계되었다고 하니 힙에 대해 더 자세한 이해를 하고 싶다면 찾아봐도 좋을 것 같다.
자바 힙은 이전에 정리했던 영역과는 달리 모든 스레드가 메모리를 공유한다. 따라서 객체 할당 효율을 높이고자 스레드 로컬 할당 버퍼 여러개로 나뉜다. 이는 힙의 일부를 스레드 전용으로 잘라서 객체 할당 시 락 경쟁을 줄이기 위한 기법(TLAB)이지만 어떤 시각에서 보더라도 데이터가 자바 힙에 저장된다는 사실이 중요하다.
자바 힙은 물리적으로 떨어져 있어도 상관없지만 논리적으로는 연속되어야 한다. 또한 힙의 크기를 -Xmx, -Xms 매개변수를 활용하여 확장하거나 고정할 수 있고 새로운 인스턴스에게 할당해줄 힙 공간이 부족하면 OutOfMemoryError를 던진다.

메서드 영역도 자바 힙과 같이 모든 스레드가 공유하고 가상 머신이 읽어들인 클래스 메타데이터, 메서드 코드, 타입 정보, 상수, 정적 변수, (JIT 컴파일러가 제공하는)코드 캐시 등을 저장한다.
자바 힙이 인스턴스와 관련된 데이터를 저장했다면 메서드 영역은 클래스가 JVM에 로딩될 때 만들어지는 ‘클래스 단위 정보’를 저장한다.
메서드 영역 역시 가비지 컬렉터의 관리 대상이지만, 일반 객체처럼 수시로 회수되지는 않으며 클래스 언로딩이 발생할 때만 제한적으로 정리된다.
✔️참고
대표적인 JVM 구현인 HotSpot VM에서는 과거 메서드 영역을 영구 세대(PermGen) 를 통해 구현하였다.
PermGen은 세대별 컬렉션 이론의 Young/Old Generation과는 다른 영역으로, 클래스 메타데이터를 저장하며 GC의 대상이 될 수 있는 영역이었다.영구 세대(PermGen)의 특징
- 클래스 메타데이터를 저장하는 영역
- Young/Old Generation과는 별도의 관리 대상
- GC가 발생할 수 있으며, 이름과 달리 영원히 유지되는 영역은 아님
- 고정 크기의 메모리 공간을 사용 (
-XX:MaxPermSize옵션으로 크기 지정, JDK 7 이하)이러한 고정 크기 구조는
대규모 애플리케이션이나 동적 클래스 로딩 환경에서
PermGen OutOfMemoryError를 빈번하게 발생시키는 원인이 되었다.그 결과, JDK 8부터 PermGen이 제거되고 Metaspace가 도입되었다. Metaspace는 클래스 메타데이터를 네이티브 메모리 영역에 저장하며, 필요에 따라 동적으로 확장될 수 있어 기존 PermGen의 메모리 한계 문제를 해결하였다.

메서드 영역과 비교하면 메서드 영역은 클래스 구조와 실행 정보를 전반적으로 관리하는 영역이고, 런타임 상수 풀은 메서드 영역의 일부로 클래스 버전, 필드, 메서드, 인터페이스 등 클래스 파일에 포함된 설명 정보에 더해 컴파일 타임에 생성된 다양한 상수와 심벌 참조를 저장해 바이트코드 실행 시 동적 링크를 지원한다.
심벌 참조: 실제 메모리 주소가 아닌 이름과 타입 정보로 대상(클래스, 필드, 메서드)을 가리키는 참조
동적 링크: 런타임 상수 풀에서 심벌 참조를 찾고실행 시점에 실제 대상과 연결하는 것
자바 가상 머신은 클래스 파일의 각 영역별로 엄격한 규칙을 정해 놓았다. 하지만 이 런타임 상수 풀에 대해서는 요구사항을 상세하게 정의하지 않아서 가상 머신 제공자가 마음대로 구현할 수 있다. 그렇지만 클래스 파일에 기술된 심벌 참조, 심벌 참조로부터 번역된 직접 참조 역시 런타임 상수 풀에 저장되는게 일반적인다.
다이렉트 메모리는 가상 머신 런타임에 속하지 않고 가상 머신 명세에 정의된 영역도 아니다. 하지만 자주 쓰이는 메모리이고 OutOfMemoryError의 원인이 될 수 있기에 정리해보자.
다이렉트 메모리는 JVM 힙을 거치지 않고 네이티브 메모리 영역(OS 메모리) 에 직접 할당되는 메모리 공간이다.
예를 들어 일반적인 자바 객체는 new Object() →JVM Heap 할당 → GC가 수명 관리의 과정을 거친다. 하지만 다이렉트 메모리의 경우에는 데이터를 자바 힙에 DirectBuffer라는 부분에 객체 참조 정보만 저장되고 실제 데이터는 Native Memory (OS 메모리) 에 저장된다.
네이티브 메모리는 OS가 JVM 프로세스에게 할당해 준 메모리이지만,
JVM Heap이 아닌 영역이며, OS가 직접 할당·회수하고 JVM이 그 위에서 사용하는 메모리다.
이렇게 하면 중간에 데이터를 복사하는 횟수가 감소하고 대량 데이터 처리 시 성능을 향상시킨다는 장점이 있다.
다이렉트 메모리는 JVM 힙 크기의 제약을 받지 않지만 네이티브 메모리 역시 한정된 자원이기 때문에 무분별한 사용은 OutOfMemoryError를 유발할 수 있다.