이전 글에서는 클래스 로더(Class Loader) 가 자바 프로그램의 클래스 파일(.class)을 메모리에 어떻게 로드하는지 살펴봤다.
하지만 이렇게 로드된 클래스와 우리가 생성한 객체들이 JVM 내부에서 어디에 저장되고, 어떻게 관리되는지는 아직 다루지 않았다.
이번 글에서는 JVM의 런타임 데이터 영역(Runtime Data Area) 을 본격적으로 다룬다.
그런데 이 런타임 데이터 영역은 Java 버전에 따라 약간의 차이가 있다.
특히 메서드 영역(Method Area) 부분에서,
PermGen은 JVM 힙 내부에 고정된 크기의 메모리를 할당해 클래스 정보를 관리했지만, 고정된 크기 때문에 메모리 부족(OutOfMemoryError) 문제가 자주 발생했다.
반면, Metaspace는 JVM 힙 바깥 OS 네이티브 메모리를 사용하여, 필요할 때 메모리를 동적으로 확장할 수 있도록 설계되었다.
덕분에 클래스를 많이 로드해도 메모리 한계에 덜 부딪히게 되었다.
따라서 이번 글은 최신 환경에서 가장 널리 쓰이는 Java 8 기준으로 런타임 데이터 영역의 구조와 실행 흐름을 설명할 예정이다.
지금부터 클래스 로딩 이후, JVM이 메모리를 어떻게 구분하고 활용하는지, 구체적으로 알아보자.
Java 프로그램이 실행될 때 JVM은 여러 메모리 영역을 사용하여 데이터를 관리합니다. 이를 런타임 데이터 영역(Runtime Data Area) 이라고 부르며, 크게 5가지 영역으로 나눌 수 있다.
런타임 데이터 영역 구성
- 메서드 영역 (Method Area)
- 힙 영역 (Heap)
- 스택 영역 (Stack)
- PC 레지스터 (Program Counter Register)
- 네이티브 메서드 스택 (Native Method Stack)
스레드 공유 여부 | 영역 |
---|---|
공유 | 힙 영역 (Heap), 메서드 영역 (Method Area) |
비공유 (스레드마다 별도) | 스택 영역 (Stack), PC 레지스터 (Program Counter Register), 네이티브 메서드 스택 (Native Method Stack) |
메서드 영역은 프로그램 실행 중 클래스 관련 정보를 저장하는 메모리 공간이다.
클래스 로더에 의해 로딩된 클래스의 구조, 메서드 정보, 상수 풀(Constant Pool), static 변수 등이 저장되며, 모든 스레드가 공유하는 특징을 갖는다.
메서드 영역은 클래스가 처음 로딩될 때, Class Loader에 의해 메모리에 올라오며, 모든 스레드가 공유한다.
Java 8 기준 변화: PermGen → Metaspace
Java 7까지는 메서드 영역을 PermGen (Permanent Generation) 이라는 고정된 메모리 영역에 저장했다.
하지만 PermGen은 메모리 크기가 제한적이라, 클래스가 많이 로딩되면 OutOfMemoryError: PermGen space 가 자주 발생하는 문제가 있었다.Java 8부터는 PermGen이 완전히 제거되고, Metaspace라는 새로운 구조로 대체되었다.
- Metaspace는 JVM 힙 영역이 아닌 네이티브 메모리(운영체제가 관리하는 메모리) 를 사용
- 필요한 만큼 동적으로 크기를 확장할 수 있어, PermGen 시절의 고정된 한계 문제를 해결
- 최대 크기는 기본적으로 제한이 없지만, 옵션(-XX:MaxMetaspaceSize)을 통해 설정
public class Example {
public static final String GREETING = "Hello, World!";
private int number;
public int add(int x, int y) {
return x + y;
}
}
항목 | 저장 예시 |
---|---|
클래스 이름 | Example |
상속 정보 | java.lang.Object 를 상속 (명시적으로 작성하지 않아도 기본적으로) |
필드 정보 | private int number |
메서드 정보 | public int add(int x, int y) |
바이트코드 | add 메서드의 실제 JVM 명령어(bytecode) |
상수 풀(Constant Pool) | 문자열 "Hello, World!" , 메서드 참조 정보 등 |
Static 변수 | public static final String GREETING |
런타임 상수 풀(Runtime Constant Pool) 이란?
JVM 매서드 영역에 존재하는 클래스 또는 인터페이스마다 하나씩 생성되는 특별한 테이블이다.
이 테이블은 컴파일 결과인 .class
파일 안의 constant_pool
데이터를 기반으로 생성되며, 메서드 호출, 필드 접근, 상수 참조 등에 필요한 메타 정보를 저장한다.
그럼 런타임 상수 풀이 왜 필요한가?
JVM은 바이트코드를 한 줄씩 읽어 해석(인터프리트) 하는 방식으로 프로그램을 실행하는데, 이때 매번 필요한 메서드/필드/상수 정보를 빠르게 찾기 위해 런타임 상수 풀이 필요하다.
런타임 상수 풀은 일종의 "빠른 참조용 색인 테이블" 역할한다.
바이트 코드를 해석하면서 필요한 참조 정보를 런타임 상수 풀에서 바로 찾아서 빠르게 실행이 가능하다.
어떻게 생성될까? 컴파일된 .class
파일에는 constant_pool
이라는 테이블이 포함되어 있다.
JVM이 클래스를 처음 로딩할 때 .class
를 읽고 constant_pool
을 바탕으로 메모리(Method Area)에Runtime Constant Pool
을 생성한다.
사용되지 않는 클래스는 로딩되지 않으므로,
.class
파일은 디스크에만 존재하고 메모리에 올라오지 않는다. (Lazy Loading)
주의할 점으론 런타임 상수 풀도 메서드 영역(Method Area) 안에 저장된다. 따라서 메모리 공간이 부족하면 ➔ OutOfMemoryError: Metaspace
가 발생할 수 있다.
힙 영역은 프로그램 실행 중 new
연산자를 통해 생성되는 객체와 배열이 저장되는 메모리 공간이다. 또한 Java 8 이후부터는 static 변수가 참조하는 객체와 일부 문자열 상수(String Pool)도 Heap 영역에 위치한다.
즉, 런타임 동안 동적으로 생성되는 참조 타입 데이터는 모두 힙에 저장되며,
이 객체들은 가비지 컬렉션(GC) 대상이 되어 필요 없는 메모리를 정리할 수 있게 된다.
힙에 저장된 객체들은 스택(Stack)
이나 다른 객체 필드를 통해 참조되고 참조가 끊긴 객체는 GC(Garbage Collection)
대상이 된다.
Heap은 동적으로 생성된 데이터의 저장소이며, JVM이 런타임 중 관리한다.
아래 이미지는 Oracle HotSpot JVM을 기준으로 한 Heap 구조입니다.
대부분의 JVM 구현(OpenJDK, Oracle JDK 등)이 HotSpot을 기반으로 하기 때문에, 우리가 사용하는 일반적인 Java 프로그램의 Heap 메모리 구조와 거의 동일합니다.
Garbage Collection(GC) 에 대한 자세한 내용은 다음 글에서 다룰 예정이다.
지금은 GC가 사용하지 않는 객체의 메모리를 회수하여 자원을 정리하는 역할을 수행한다는 것까지만 이해하고 넘어가자.
힙을 Young Generation과 Old Generation으로 나눈 것은 가비지 컬렉션(GC) 효율을 높이기 위함이다.
자바에서는 String을 리터럴 방식으로도 만들 수 있고, new 키워드로도 만들 수 있다.
String str1 = "text"; // String Constant Pool 에 저장
String str2 = "text"; // 기존 "text" 상수 재활용. str 1이랑 같은 메모리 참조
String str3 = new String("text"); // 다른 객체와 마찬가지로 Heap 영역에 할당
String str4 = new String("text"); // 새로운 객체를 생성. str3이랑 다른 메모리 참조
추가로 Runtime Constant Pool과 String Pool을 헷갈렸는데, 둘은 완전히 다른 개념이다.
Java 7
이전에는 Runtime Constant Pool도 PermGen (논리적으로 Heap 영역에 포함) 안에 있었고, String Pool도 PermGen 안에 있었다.
Java 8
부터는 Runtime Constant Pool
과 String Pool
의 저장 위치가 명확히 분리되었다.Runtime Constant Pool
은 메서드 영역인 Metaspace
에 저장되고, String Pool
은 Heap
영역에 저장되어 GC(Garbage Collection)
의 관리 대상이 되었다.Java 7까지는 String Pool도 PermGen에 저장되어 있었는데, 문자열 리터럴이 과도하게 쌓이면 PermGen 공간이 부족해져 OutOfMemoryError(OOM) 가 발생하는 문제가 있었다.
Java 8에서는 이러한 문제를 해결하기 위해 String Pool을 Heap으로 이동시켜 GC를 통한 메모리 회수가 가능하도록 변경했다.
스택 영역(Stack) 은 메서드가 호출될 때마다 생성되는 메모리 공간으로, 각 메서드 호출에 대한 실행 정보를 담는 Stack Frame들이 쌓이는 구조를 가진다.
Stack 영역은 메서드 호출의 흐름을 관리하고,
메서드별 지역 변수, 매개변수 등을 저장하는 역할을 한다.
Stack Frame이란? 메서드 호출 시마다 스택에 쌓이는 일시적인 메모리 블록이다.
하나의 Stack Frame에는 다음과 같은 정보가 포함된다:
Stack Frame은 메서드가 끝나면 자동으로 pop되어 메모리에서 소멸된다.
주의할 점은 재귀 호출이 과도하거나, 메서드 호출 깊이가 너무 깊어지면 Stack OverflowError
가 발생한다.
이유는 Stack 영역은 크기가 제한적이기 때문이다.
프로그램 카운터(Program Counter Register, PC 레지스터)는 현재 실행 중인 바이트코드 명령어의 주소(위치)를 저장하는 소형 메모리 공간이다.
각 스레드는 자신만의 독립적인 PC 레지스터를 가지고 있으며, 스레드가 문맥 전환(Context Switching)될 때, 이 PC 레지스터를 통해 어디까지 실행했는지를 기억한다.
프로그램 카운터(PC) 레지스터의 동작 흐름은 스레드가 생성되면 각 스레드는 자신만의 PC 레지스터를 가진다.
메서드를 호출하거나, 분기문(if, for, switch 등)이 실행될 때
➔ PC 레지스터 값이 업데이트되어 다음 실행할 바이트코드를 가리킨다
명령어를 하나 실행하면 ➔ 다음 명령어의 주소로 자동 이동한다.
바이트코드 해석을 빠르게 수행하기 위한 필수적인 구조이다.
네이티브 메서드 스택(Native Method Stack) 은 Java가 아닌, C나 C++ 같은 네이티브 언어로 작성된 메서드(Native Method)를 실행할 때 사용하는 스택이다.
Java 애플리케이션은 필요한 경우 운영체제에 가까운 기능(C API 호출 등)을 사용하기 위해 네이티브 메서드를 호출할 수 있는데, 이때 JVM 내부에서 별도로 관리되는 네이티브 스택이 사용된다.
"Java 코드가 아닌 C코드 메서드를 호출할 때 필요한 별도 스택" 이라고 이해하면 된다.
JVM 메모리 구조를 이해할 때 꼭 짚고 넘어가야 하는 부분이 있다.
바로 어떤 메모리 영역이 여러 스레드 간에 공유되는지, 어떤 영역은 각 스레드마다 독립적으로 존재하는지 이다.
멀티스레드 환경에서는 이 구분이 매우 중요한데, 공유 영역은 동기화 이슈가 발생할 수 있고, 개별 스레드 전용 영역은 충돌 없이 안전하게 사용할 수 있기 때문이다.
구분 | 스레드 간 공유 여부 | 설명 |
---|---|---|
Heap | 공유 | 객체 인스턴스 저장. 모든 스레드가 접근 가능. 동기화 필요. |
Method Area (Metaspace) | 공유 | 클래스 정보 저장. 모든 스레드가 읽고 사용. |
Stack | 스레드별 독립 | 메서드 호출 시 생성되는 Stack Frame 저장. |
Program Counter(PC) Register | 스레드별 독립 | 현재 스레드가 실행 중인 바이트코드 명령어 주소 저장. |
Native Method Stack | 스레드별 독립 | 네이티브 메서드 호출 시 사용하는 별도 스택. |
JVM의 런타임 데이터 영역을 이해하면, 자바 프로그램이 메모리를 어떻게 관리하는지, 어떤 구조로 데이터를 저장하고 실행하는지에 대한 큰 그림을 정확히 그릴 수 있다.
이제 런타임 데이터 영역의 기본 구조를 이해했으니, 다음 글에서는 GC(Garbage Collection)의 동작 원리와 각 세대별(Young, Old) GC가 어떻게 메모리를 회수하는지에 대해 좀 더 구체적으로 살펴보자.