dev notes

자바가상머신의 메모리 구조: JVM Runtime Data Area

2024-07-0414 min read
공유

개요#

처음에는 JVM 메모리 구조를 굳이 세세하게 볼 일이 없다고 생각했습니다. 자바는 GC가 알아서 처리해주니까, 애플리케이션만 잘 짜면 되는 것처럼 느껴질 때가 많았기 때문입니다.

근데 실제로 메모리 문제를 의심해야 하는 상황을 겪고 나니, 적어도 JVM이 메모리를 어떻게 나누고 어디서 어떤 문제가 날 수 있는지는 알고 있어야 판단이 빨라진다는 걸 느꼈습니다. JVM을 크게 나누면 힙 메모리를 관리하는 가비지 컬렉터, 자바 클래스를 메모리 영역에 로드하는 클래스 로더 시스템, 바이트코드를 실행하는 실행 엔진으로 구성되고, 이번에 보는 메모리 영역이 그 바닥을 이루고 있습니다. (JVM Specification - Runtime Data Areas)

JVM 전체 구조 — 클래스 로더, GC, 실행 엔진, 메모리 영역

JVM 전체 구조 — 클래스 로더, GC, 실행 엔진, 메모리 영역

프로그램 카운터 (PC)#

CPU에서의 pc와 유사하게 JVM에서도 스레드 마다 각 스레드의 현재 실행하고 있는 코드 위치를 저장하고 있습니다. CPU의 경우 물리적인 메모리 주소를 표기하고 JVM의 경우에는 자바 바이트코드의 인덱스를 표기합니다.

  • 스레드별로 단일값이 저장되는 형식
  • 스레드가 네이티브 메서드를 실행시킬때는 undefined 값을 지니게 된다
  • 단일값이며 위치를 표기하고 있기에 OOM 에러의 조건이 명시되어있지 않다

자바 가상머신 스택#

스레드 프라이빗하며 메서드 호출에 관련된 정보를 프레임에 담아 추가하는 방식으로 저장합니다.

  • 로컬 변수 배열: 메서드의 로컬 변수, 매개변수, 내부에서 선언된 변수를 저장. 크기는 컴파일 시 결정.
  • 오퍼랜드 스택: JVM 명령어가 연산을 수행할 때 사용하는 스택. 피연산자를 일시적으로 저장.
  • 복귀 주소: 메서드가 완료되면 이 주소로 돌아갑니다.
  • 런타임 상수 풀 레퍼런스: 현재 클래스의 런타임 상수 풀에 대한 참조를 포함.
java
public class Example {
    public static void main(String[] args) {
        System.out.println("Hello, World!");
    }
}

Hello World 바이트코드 — PC 레지스터 동작 예시

Hello World 바이트코드 — PC 레지스터 동작 예시

  • getstatic #7: System.out 정적 필드를 가져와 오퍼랜드 스택에 푸시
  • ldc #13: 상수 풀에서 문자열을 가져와 오퍼랜드 스택에 푸시
  • invokevirtual #19: println 메서드를 호출
  • return: 메서드 종료

발생할 수 있는 오류: StackOverFlow (스택 허용 공간 초과), OutOfMemory (동적 할당 시 메모리 부족)

네이티브 메서드 스택 영역#

스레드마다 할당되며 C나 C++로 작성된 네이티브 메서드들을 실행하기 위해 사용하는 메모리 영역입니다. JNI를 통해 자바 코드와 상호작용합니다.

메서드 영역#

모든 스레드가 공유하는 메모리 공간으로 클래스와 관련된 메타데이터들이 포함됩니다. 자바 8을 기준으로 영구영역에서 메타스페이스로 변경되었습니다.

  • 클래스 메타데이터 저장: 클래스 로더가 로드한 클래스/인터페이스의 메타데이터
  • 런타임 상수 풀: 클래스 파일 내의 상수 풀을 런타임 상수 풀로 변환하여 저장
  • JIT 컴파일된 코드: Just-In-Time 컴파일러가 생성한 네이티브 코드를 저장

발생할 수 있는 오류: 메서드 영역이 꽉 차면 OOME 발생

다이렉트 메모리#

java.nio 패키지의 다이렉트 버퍼를 통해 사용되는 메모리 영역으로 JVM 힙이 아닌 네이티브 메모리에 할당됩니다.

다이렉트 메모리 — NIO를 통한 네이티브 메모리 할당

다이렉트 메모리 — NIO를 통한 네이티브 메모리 할당

발생할 수 있는 오류: 사용되는 메모리의 영역합이 물리 메모리의 한계를 넘는다면 OOME 발생

자바 힙#

자바 어플리케이션이 사용 가능한 가장 큰 메모리 영역이며 모든 스레드가 공유합니다. 모든 객체의 인스턴스가 이 곳에 저장이 되고 가비지 컬렉터가 관리하는 메모리 영역입니다.

발생할 수 있는 오류: 최대 할당 공간을 넘으면 OOME 발생. 자바 애플리케이션에서 OOME가 가장 많이 발생하는 영역.

힙의 세대 구조

힙은 크게 Young Generation과 Old Generation으로 나뉩니다.

  • Young Generation: 새로 생성된 객체가 여기에 할당됩니다. Eden 영역과 두 개의 Survivor 영역(S0, S1)으로 구성됩니다.
  • Old Generation: Young에서 오래 살아남은 객체가 승격(promotion)되는 곳입니다.
힙 메모리
├── Young Generation
│   ├── Eden       ← 새 객체 할당
│   ├── Survivor 0 ← Minor GC 생존 객체
│   └── Survivor 1 ← S0 ↔ S1 번갈아 사용
└── Old Generation ← 오래 살아남은 객체

왜 굳이 세대를 나누는가 하면, 대부분의 객체는 생성된 직후에 바로 쓸모없어진다는 관찰(Weak Generational Hypothesis) 때문입니다. 요청 하나 처리하면서 만든 DTO, 임시 리스트 같은 건 메서드가 끝나면 바로 참조가 끊기니까요. 이런 단명 객체를 Young에서 빠르게 정리하고, 오래 살아남은 객체만 Old로 넘기면 GC 효율이 올라갑니다.

가비지 컬렉션#

Minor GC (Young Generation)

Eden이 가득 차면 Minor GC가 발생합니다. 살아있는 객체를 Survivor 영역으로 복사하고, Eden을 비웁니다. Survivor 간에는 한쪽에서 다른 쪽으로 복사되면서 번갈아 사용됩니다.

Minor GC는 Young Generation만 대상이라 범위가 작고, Stop-the-World 시간도 짧습니다. 수 ms에서 수십 ms 정도.

Major GC / Full GC (Old Generation)

Old Generation이 가득 차면 Major GC가 발생합니다. 힙 전체 또는 Old 영역을 대상으로 하기 때문에 Minor GC보다 훨씬 오래 걸립니다. 수백 ms에서 수 초까지.

이게 애플리케이션 성능에 직접적으로 영향을 줍니다. Full GC가 돌면 그 동안 모든 애플리케이션 스레드가 멈추기 때문에(Stop-the-World), 사용자 요청이 그 시간만큼 지연됩니다.

GC 알고리즘

JVM 버전에 따라 기본 GC가 다릅니다.

GC특징기본 적용
Serial GC단일 스레드. 소규모 애플리케이션용-
Parallel GC멀티 스레드로 Young GC 처리. 처리량(throughput) 중시Java 8 기본
G1 GC힙을 리전으로 나눠서 관리. STW 시간 예측 가능Java 9+ 기본
ZGC대용량 힙에서 STW를 수 ms 이내로 유지Java 15+ 사용 가능

EC2 프리티어(1GB RAM)에서 Spring을 돌릴 때는 기본값인 G1 GC를 쓰더라도 힙 자체가 작아서 GC 부담이 큰 편은 아니었습니다. 문제는 GC가 아니라 물리 메모리 부족으로 인한 OOM이었습니다.

Grafana에서 본 GC#

Prometheus 모니터링 구축기에서 Dayner 서버에 Prometheus + Grafana를 세팅해뒀는데, Thread Starvation 장애를 디버깅할 때 GC 메트릭을 직접 확인했습니다.

그때 봤던 것:

  • jvm_gc_pause_seconds — GC pause time이 균등한 간격으로 발생하면 정상, 갑자기 길어지면 메모리 이슈
  • jvm_memory_used_bytesjvm_memory_committed_bytes의 차이가 좁아지면 GC 압박이 심해지고 있다는 신호

Heap 영역은 안정적이고 GC도 균등하게 돌고 있어서, "이건 메모리 누수가 아니라 시스템 레벨 문제다"라고 판단할 수 있었습니다. 결국 원인은 EC2 스왑 메모리 부족이었는데, JVM 메모리 구조를 알고 있었기 때문에 힙 문제를 빠르게 배제하고 다른 쪽으로 시선을 돌릴 수 있었습니다.

String Pool은 어디에 있는가#

String Pool 글에서 리터럴 문자열이 String Pool에 저장된다고 했는데, 이 Pool의 물리적 위치가 Java 버전에 따라 달라집니다.

버전String Pool 위치비고
Java 6 이하Perm Generation (메서드 영역)크기 고정, 문자열 많으면 OOME
Java 7Heap으로 이동GC 대상이 됨
Java 8+Heap (Metaspace 도입으로 Perm 제거)-XX:StringTableSize로 버킷 수 조절

Java 6에서는 String Pool이 Perm 영역에 있어서 크기가 고정이었습니다. intern()을 많이 쓰면 Perm이 차서 java.lang.OutOfMemoryError: PermGen space가 터졌습니다. Java 7부터 힙으로 옮기면서 GC가 관리하게 되었고, 이 문제가 해소됐습니다.

바이트코드 실행과 런타임 상수 풀#

람다 디슈거링 글에서 invokedynamic 바이트코드가 LambdaMetafactory를 통해 메서드 핸들을 참조한다고 했는데, 이 참조가 일어나는 곳이 바로 런타임 상수 풀입니다.

메서드 영역에 저장된 런타임 상수 풀에는 클래스 파일의 상수(문자열, 숫자, 메서드/필드 참조)가 런타임 형태로 변환되어 들어가 있습니다. invokedynamic이 실행되면 이 상수 풀에서 부트스트랩 메서드 정보를 찾고, LambdaMetafactory를 호출해서 람다 표현식에 대한 MethodHandle을 연결합니다.

바이트코드에서 #7, #13, #19 같은 숫자가 나오는 게 바로 이 상수 풀의 인덱스입니다. 위의 Hello World 예시에서 getstatic #7은 상수 풀 7번 항목인 System.out 필드를 가리킵니다.

결국 JVM 메모리 영역을 외우는 게 중요한 건 아니었습니다. 중요한 건 문제를 봤을 때 "이건 힙 쪽인가, 스택 쪽인가, 아니면 JVM 바깥 문제인가"를 조금이라도 빨리 가르는 일이었습니다.

실제로 Grafana에서 Heap이 안정적인 걸 보고 "이건 JVM 메모리 문제가 아니다"라고 판단할 수 있었던 것도 그 정도 감각이 있었기 때문입니다. 장애 상황에서는 이 차이가 생각보다 큽니다. 어디를 먼저 제외할 수 있는지만 알아도 시간을 꽤 아낄 수 있습니다.

Connected Notes