JVM의 런타임 데이터 영역은 다섯 개의 주요 영역으로 구성됩니다. PC 프로그램 카운터, 가상 머신 스택, 네이티브 메서드 스택, 힙, 메서드 영역이 바로 그것입니다. 이 다섯 영역은 스레드 공유 영역과 스레드 전용 영역으로 구분되며, 각각의 특성과 역할에 대해 살펴보겠습니다.
1. PC 프로그램 카운터
프로그램 카운터는 상대적으로 작은 크기의 메모리 영역으로, 현재 실행 중인 스레드의 바이트코드 행 번호를 저장하는 역할을 합니다. 스레드가 일시 정지되었다가 다시 실행될 때, 이전에 실행하던 위치부터 계속 진행할 수 있도록 이 영역에 정보를 기록합니다. 각 스레드마다 독립적인 프로그램 카운터가 필요하며, 스레드 간의 카운터는 서로 영향을 주지 않습니다. 이러한 특성을 인해 이 영역을 스레드 전용(T thread-private) 메모리라고 합니다. 프로그램 카운터는 스레드의 실행 위치 정보만 저장하면 되므로, OutOfMemoryError가 발생하는 유일한 영역입니다.
2. 가상 머신 스택
가상 머신 스택도 프로그램 카운터와 마찬가지로 스레드 전용 영역입니다. 이 영역의 수명은 스레드의 수명과 동일합니다. JVM 스택은 Java 메서드 실행의 메모리 모델을 설명하며, 각 메서드 실행 시 스택 프레임이 생성됩니다. 스택 프레임은 로컬 변수 테이블, 연산 스택, 동적 링크, 메서드 종료 정보 등을 저장합니다. 메서드가 호출되어 실행을 완료하는 전체 과정은 스택 프레임이 스택에_push되었다가_pop되는 과정과 일치합니다. 스레드가 요청한 스택 깊이가 JVM이 허용하는 최대 깊이를 초과하면 StackOverflowError가 발생합니다. JVM 스택 크기는 동적으로 변경되며, 메모리 부족 시 자동으로 확장하려고 시도하지만 확장 실패 시 OutOfMemoryError가 발생합니다.
3. 네이티브 메서드 스택
네이티브 메서드 스택도 스레드 전용 영역으로, 가상 머신 스택과 유사한 역할을 하지만Native 메서드를 서비스한다는 점에서 차이가 있습니다. 네이티브 메서드 스택도 StackOverflowError와 OutOfMemoryError를 발생시킬 수 있습니다.
4. Java 힙
Java 힙은 JVM이 관리하는 메모리 중 가장 큰 부분입니다. 위圖에서 볼 수 있듯이, 힙은 모든 스레드가 공유하는 영역이며,Java 객체 인스턴스를 저장하는 것이 주요 목적입니다. 사실상 모든 객체 인스턴스가 여기에 메모리를 할당받습니다. 힙은 JVM의 가비지 컬렉션이 발생하는 주요 지역이므로 GC 힙이라고도 불립니다. 힙에 더 이상 인스턴스를 할당할 수 없는状况이 되고 힙 확장도 불가능할 때 OutOfMemoryError가 발생합니다. 또한 JDK 1.7 이후로 런타임 상수 풀이 메서드 영역에서Java 힙으로 이동했습니다.
5. 메서드 영역
메서드 영역도 힙과 마찬가지로 모든 스레드가 공유하는 영역입니다. 이 영역에는 가상 머신이 로드한 클래스 정보, 상수, 정적 변수, 컴파일러가 생성한 코드 등이 저장됩니다. JVM 사양에 따르면, 메서 영역이 메모리 할당 요구를 만족하지 못할 경우 OutOfMemoryError 예외가 발생합니다.
6. 런타임 상수 풀
런타임 상수 풀은 메서드 영역의 일부입니다(JDK 1.7 이전). Class 파일에는 클래스의 버전, 필드, 메서드, 인터페이스 등 설명 정보 외에도 상수 풀이라는 항목이 있습니다. 상수 풀에는 컴파일 시 생성된 리터럴과 심볼릭 레퍼런스가 저장되며, 클래스 로딩 시 메서드 영역의 런타임 상수 풀로 이동합니다.
런타임 상수 풀의 중요한 특징은 동적 성격을 갖는다는 것입니다. Java 언어는 상수가 반드시 컴파일 시점에 생성되어야 하는 것이 아니며, 실행 시에도 새로운 상수를 풀에 추가할 수 있습니다.
JDK 버전에 따른 동작 차이를 확인하기 위해 다음 테스트 코드를 실행해 보겠습니다. 빠른 메모리溢 出을 유발하기 위해 JVM 파라미터 -Xmx5m -XX:MaxMetaspaceSize=1M을 설정합니다.
public class MemoryTest {
public static void main(String[] args) {
var storage = new java.util.ArrayList<String>();
int counter = 0;
while(true) {
storage.add(Integer.toString(counter++).intern());
System.out.println(counter);
}
}
}
실행 환경(JDK 1.8)에서 관찰된 에러 메시지는 다음과 같습니다:
Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
at java.lang.Integer.toString(Unknown Source)
at java.lang.String.valueOf(Unknown Source)
at MemoryTest.main(MemoryTest.java:8)
기존 서적에서 설명하는 PermGen space溢出 대신 GC overhead limit exceeded 에러가 발생했습니다. 이는 런타임 상수 풀의 위치가 변경되었음을 확인시켜 줍니다. 이 가설을 검증하기 위해 동일한 소스 코드를 사용하되 JVM 파라미터를 -Xmx20m -Xms20m -XX:-UseGCOverheadLimit로 변경하여 실행합니다. 여기서 -XX:-UseGCOverheadLimit은 GC 실행 시간이过长 때 발생하는 예외를 비활성화하는 옵션입니다. 힙 크기를 제한하고 실행하면 다음과 같은 결과가 나타납니다:
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
이 결과를 통해 추가된 상수들이 힙 영역에 저장되고 있음을 확인할 수 있습니다.