개요
자바가 제공하는 메모리 관리는 할당과 해제를 하여 관리해야하는 다른 언어들과는 달리 개발자의 편의성 측면에서 많은 이점을 가져온다. 하지만 그 이면으로 내가 직접 관리를 하지 않기 때문에 GC 과정에 이상이 생길시에 원인을 분석하여 해결해야된다는 점이 있는데 이는 자바가상머신의 메모리에 대해서 알아보면 원인 분석 및 이해가 빨라질수 있다.
따라서 이를 구조를 설명하고 구조별로 일어날만한 오류들을 정리를 해보려한다.(고봉밥으로다가)
들어가기에 앞서서 JVM 을 크게 나눠보자면 위에도 언급했던 힙 메모리를 관리하는 가비지 컬렉터, 자바 클래스를 메모리 영역(어디인지는 아래에 나올것이다.) 에 로드해주는 클래스 로더 시스템, JVM의 바이트 코드를 실행하는 실행 엔진으로 기능적 요소가 존재하고 이번에 설명할 메모리 영역이 나머지를 채워주고 있다.
한빛 미디어의 '이것이 자바다' 와 프로그래밍 인사이트의 'jvm 밑바닥까지 파헤치기' 를 읽고 정리하는 글이다.
사전 지식
- 자바는 다중 스레드를 지원하므로 스레드 마다 별도의 메모리를 가지는 경우(PC, 자바 메서드 스택, 네이티브 메서드 스택)도 있고 전체에 통용되는 메모리 공간(힙 메모리, 메서드 메모리)도 존재한다.
- 사람들이 흔히 말하는 자바는 힙과 스택으로 메모리가 구성되어있다~ 라는 말은 자바+네이티브 스택을 스택으로 자바힙과 네이티브힙을 힙으로 칭한다고 이해하면 쉽다.
프로그램 카운터 (PC)
정의
CPU 에서의 pc와 무척 유사하게도 하이퍼 스레딩을 지원하는 CPU에서 각 스레드 마다 독립적인 프로그램 카운터가 존재하는 것 처럼 JVM 에서도 스레드 마다 각 스레드의 현재 실행하고 있는 코드 위치를 저장하고 있다.
다만 차이점이라고 하면 CPU의 경우에는 실행중인 명령어의 물리적인 메모리 주소를 표기하고 JVM의 경우에는 자바 바이트코드의 인덱스를 표기하고 있다는 차이점이 있다.
JVM의 인터프리터는 pc 값을 수정하여 다음 실행할 자바 바이트 코드를 명시하고 JIT 컴파일러는 해당 pc 를 활용해서 실행을 한다.
- 스레드별로 다중값이 아닌 단일값이 저장되는 형식을 가지고 있다.
- 스레드가 네이티브 메서드를 실행시킬때는 undefined 값을 지니게 된다.
발생할수 있는 오류
단일값이며, 위치를 표기하고 있기에 OOM 에러의 조건이 명시되어있지 않다.
자바 가상머신 스택
정의
PC 와 마찬가지로 스레드 프라이빗 하며 메서드 호출에 관련된 정보를 프레임에 담아 추가하는 방식으로 저장한다.
- 로컬 변수 배열 (Local Variables Array)
- 메서드의 로컬 변수, 메서드의 매개변수, 내부에서 선언된 변수를 저장
- 배열의 크기는 메서드가 컴파일될 때 결정
- 일반적으로 슬롯 하나의 크기는 32비트로 double,long 타입처럼 길이가 64비트인것은 슬롯 2칸을 차지한다.
- 오퍼랜드 스택 (Operand Stack):
- JVM 명령어가 연산을 수행할 때 사용하는 스택
- 피연산자를 일시적으로 저장하고, 연산 결과를 저장합니다.
- 오퍼랜드 스택의 크기 또한 메서드가 컴파일될 때 결정됩니다.
- 메서드 호출과 복귀 주소 (Return Address):
- 메서드가 호출될 때, 호출한 메서드의 실행 위치를 저장한다.
- 메서드가 완료되면 이 주소로 돌아간다.
- 실행 시 상수 풀 레퍼런스 (Reference to Runtime Constant Pool):
- 현재 클래스의 런타임 상수 풀에 대한 참조를 포함하고 런타임시에 JVM 이 참조한다.
- 상수 풀에는 클래스 파일에 포함된 리터럴 상수와 메서드, 필드, 클래스 참조 등이 저장된다.
간단한 바이트 코드를 보면서 이해해보자(오퍼랜드)
이외에도 로컬변수도 저장하고 상수풀도 이용하는 예제를 보려면 아래를 읽어보자
public class Example {
public static void main(String[] args) {
System.out.println("Hello, World!");
}
}
getstatic #7
- System.out 정적 필드를 가져와 오퍼랜드 스택에 푸시
- 변경 후:
- 오퍼랜드 스택: [System.out]
ldc #13
- 상수 풀에서 문자열 "Hello, World!"를 가져와 오퍼랜드 스택에 푸시
- 변경 후:
- 오퍼랜드 스택: [System.out, "Hello, World!"]
invokevirtual #19
- println 메서드를 호출
- 변경 후:
- 오퍼랜드 스택: []
invokevirtual은 System.out 객체의 println 메서드를 호출하고 해당 오퍼랜드 스택에서 objectref와 인수("Hello, World!")를 꺼내고 새로운 프레임을 생성하여 메서드를 실행한다.
return
- 메서드를 종료하고 호출한 위치로 제어를 반환
- 변경 후:
- 오퍼랜드 스택: []
발생할수 있는 오류
스택 형태를 띄고 있기 때문에 스레드에서 요청한 값이 가상머신 허용 공간보다 큰 경우에는 StackOverFlow 오류를, 스택 용량을 동적으로 할당 가능한 가상 머신(클래식 VM)에서는 여유 메모리가 충분하지 않다면 OutOfMemory 오류를 발생시킨다.
네이티브 메서드 스택 영역
정의
pc, jvm 스택 영역과 더불어 스레드마다 할당이 되어있는 메모리 영역이다.
자바가상머신이 자바가 아닌 다른 언어로 작성된 네이티브 메서드들을 실행하기 위해 사용하는 메모리 영역으로 주로 C나 C++로 작성되며 JVM에 의해 직접 관리되고 JNI(Java Native Interface)를 통해 자바 코드와 상호작용한다.
역할 및 특성
- 네이티브 라이브러리 로드 네이티브 메서드를 포함하는 라이브러리를 로드하고 메서드 주소를 참조
- 플랫폼 종속성: 네이티브 메서드 영역은 특정 플랫폼에 종속적이며, 네이티브 라이브러리는 각 운영체제에 맞게 제공
발생할수 있는 오류
자바 가상머신 스택과 동일하게 스택 형태를 띄고 있기 때문에 스레드에서 요청한 값이 가상머신 허용 공간보다 큰 경우에는 StackOverFlow 오류를, 스택 용량을 동적으로 할당 가능한 가상 머신(클래식 VM)에서는 여유 메모리가 충분하지 않다면 OutOfMemory 오류를 발생시킨다.
메서드 영역
정의
위에 설명했던 영역과는 다르게 모든 스레드가 공유하는 메모리 공간으로 클래스와 관련된 메타데이터 들이 포함된다.
저 메타데이터들이 자바 8을 기준으로 영구영역에서 메타스페이스로 변경되었다는 것이 자주 설명이 되고는 한다. 간단히 설명하면 자바가 주력으로 하던 핫스팟에서는 문자열이나 정적변수를 저장하는 메서드 영역을 영구세대에 할당했기에 메모리의 한계가 존재했는데 JRockit에는 메모리 최댓값의 한계가 없었고 이는 가상 머신에 따라서 성능 차이와 메모리 관리의 번거로움을 주었었다.
따라서 이를 해결하기 위해서 따로 메타스페이스라는 영역을 만들고 메모리 제약을 없애주었다. 라고 간단히 알고있고 이에 대해서 JSR 을 읽던 패치 노트를 이용하여 나중에 포스팅 해보겠다.
역할 및 특성
- 클래스 메타데이터 저장: 클래스 로더가 로드한 클래스와 인터페이스의 메타데이터를 저장
- 런타임 상수 풀: 클래스 파일 내의 상수 풀을 런타임 상수 풀로 변환하여 저장
- JIT 컴파일된 코드: Just-In-Time(JIT) 컴파일러가 생성한 네이티브 코드를 저장
발생할수 있는 오류
메서드 영역이 꽉 차면 OOME 가 발생할수 있다.
런타임 상수 풀
정의
각 클래스 또는 인터페이스의 메타데이터 내에 포함된 상수 풀로, 클래스 파일이 JVM에 의해 생성자로 인해 생성되고 가상머신 위로 로드될 때 저장된다.
역할 및 특성
- 리터럴 상수 및 참조 저장: 문자열 리터럴, 기본 타입의 상수, 메서드 및 필드 참조를 저장
- 클래스별 관리: 클래스 파일의 상수 풀(Constant Pool)에서 가져온 것을 클래스별로 관리
- 동적 상수 풀: 실행 중에 새로운 상수가 추가될 수 있으며, String.intern() 메서드를 통해 새로운 문자열 상수를 추가 가능
발생할 수 있는 오류
이는 메서드 영역에 포함되어있기 때문에 메서드 영역을 넘어가는 메모리를 할당하려하면 OOME가 발생한다.
다이렉트 메모리
네이티브 메서드 스택 영역하고 함께 네이티브 메모리를 구성하고 있다는 공통점이 있어 바로 아래에 설명을 해본다.
정의
java.nio 패키지의 다이렉트 버퍼를 통해 사용되는 메모리 영역으로 JVM 힙이 아닌 네이티브 메모리에 할당된다.
*다이렉트 버퍼는 네이티브 메모리에 직접 접근하여 데이터를 읽고 쓰므로, 일반적인 힙 메모리보다 빠른 I/O 처리가 가능하다.
다이렉트 버퍼를 이용해 네이티브 메모리를 사용하여 I/O 성능 변화를 모니터링하는 포스팅
발생할수 있는 오류
사용되는 메모리의 영역합이 물리 메모리의 한계를 넘는다면 이또한 OutOfMemory(OOME) 가 발생한다.
추가
가상머신 런타임에도 들어가지 않고 NIO의 다이렉트 버퍼를 쓰지 않으면 고려안해도 될 사항인가? 라고 의문을 가졌지만 삼성sds 기술블로그에서 java환경에서 메모리 부족이 자주 일어나는 상황을 설명한 케이스를 잘 읽다가 내 생각이 틀렸음을 깨달았다. 실제환경에서는 모니터링시의 기준책정 시에도 들어가는 중요한 요소 였다.
자바 힙
자바 어플리케이션이 사용 가능한 가장 큰 메모리 영역이기도 하면서 이 또한 모든 스레드가 공유하며 가상 머신이 구동시에 만들어진다는 특징을 가지고 있다.
자바 코드에 짜여져 있는 모든 객체의 인스턴스가 이 곳에 저장이 되고 가비지 컬렉터가 관리 하는 메모리 영역이라는 특징이 있다. 이 공통 부분은 여러 스레드에서 나누어 쓴다는 것 때문에 객체 할당 효율을 높이려고 일종의 버퍼를 구현해두는데 이는 힙의 영역을 잘게 나누어서 다발적으로 들어오는 메모리 할당과 해제 속도를 증가 시켰다.
GC 가비지 컬렉터와 TLAB 스레도 로컬 할당 버퍼에 대해서는 다룰 내용이 많아서 따로 포스팅을 통하려고 한다.
발생할수 있는 오류
자바 힙의 경우에도 크기를 사용자가 지정하거나 동적으로 확장 가능하게 구현할 수도 있는데 역시나 최대 할당 공간이 늘어나면 OutOfMemory(OOME) 가 발생한다.
자바 애플리케이션 동작에 있어서 OOME 가 가장 많이 발생하는 영역이라고도 한다.
추후에 힙 덤프 스냅샷을 통해서 오버플로우를 의도적으로 만들어보고 의도적으로 만들어진 객체(메모리 누수가 발생한)로 부터 GC의 루트까지의 참조를 살펴보면서 추적하는 과정에 대해서도 따로 포스팅을 해볼 예정이다.
마무리
자바의 가상머신을 구성하고 있는 메모리들에 대해서 알아보고 간단하게 특징에 대해서 전체적으로 확인해보는 시간을 가졌다.
사실 이번 포스팅을 작성하면서 거의 2주 가까이 걸렸던것 같은데 이내용 저내용 담다보니 글의 방향도 규모도 좀 커진 감이 있었다. 붙여놓았던 내용(가비지 컬렉팅이나 구체적인 오류 발생상황들)을 다 덜어내고 작은 포스팅 덩어리로 모듈화(?)를 해서 마무리를 할 생각이다.
'Java' 카테고리의 다른 글
ArrayList/LinkedList 단순 순회시 For, Enhanced For , Iterator, ListIterator, Stream.forEach() 성능비교 (1) | 2024.08.27 |
---|---|
자바 가비지 컬렉터(GC)의 발전; 시리얼 컬렉터부터 ZGC까지 (0) | 2024.07.20 |
JVM스택메모리 구조 이해를 위한 바이트코드 예시 (0) | 2024.06.25 |
JVM에서 자바 메서드와 네이티브 메서드 실행의 차이점 (0) | 2024.06.24 |
Optional: 안정적인 Null 처리 그리고 orElse(), orElseGet(), orElseThrow() 에 대한 이해 (0) | 2023.10.24 |