Java Run-time Data Area 정리 (Java 17 기준 JVM Specification)

오형택·2025년 3월 29일
0

JVM 뽀개기

목록 보기
1/1
post-thumbnail

JVM 공부를 제대로 하고 싶어서 하나하나 해보기로 했다. 저우즈밍의 저서인 JVM 밑바닥까지 파헤치기로 학습의 중심을 잡고 공식 문서와 기타 실습을 통해 내용을 보완해가며 작성할거다. Java 버전은 나온지 너무 오래되지 않은 LTS 버전인 17 로 선정했다.

먼저 Java Runtime Data Area는 이전에 이미 작성했던 내용이지만 새롭게 정리하는 차원에서 다시 써보자. Oracle에서 작성한 공식 JVM 명세(Java SE 17 JVM Specification)를 기반으로 학습했다.


JVM의 런타임 데이터 영역은 JVM이 실행되는 동안 메모리를 어떻게 할당하고 관리하는지를 정의한다.
이 영역은 크게 JVM 프로세스와 생애주기를 함께하는 shared 영역과, 유저 스레드와 생애주기를 함께하는 per-thread (thread-private) 영역으로 나뉜다.

📌 Per-thread 영역

  1. PC 레지스터 (Program Counter Register)
    • JVM이 현재 실행 중인 바이트코드 명령어의 주소를 저장하는 공간.
    • 각 스레드마다 고유하게 존재하는 이유는, JVM의 멀티스레딩이 스레드 단위로 컨텍스트 스위칭되기 때문.
    • native 메서드 실행 시에는 undefined 상태.
    • 유일하게 OutOfMemoryError 발생 조건이 명시되지 않은 영역.

  1. JVM 스택
    • 스레드 별로 존재하며, 메서드 호출마다 하나의 frame이 생성되어 push/pop 구조로 관리.
    • frame 구조: 로컬 변수 테이블, 피연산자 스택, 동적 링크, 메서드 리턴값 저장
    • 로컬 변수 테이블은 컴파일 타임에 크기가 결정되며 절대 변경되지 않음.
    • 메모리는 non-contiguous 할 수 있어 유연한 구현 가능.
    • StackOverflowError, OutOfMemoryError 발생 가능 (단, OOM은 JVM이 동적 확장을 허용할 때만)

non-contiguous 메모리 → 말그대로 메모리를 연속적이지 않은 위치에 할당. 메모리 낭비를 줄일 수 있지만, 제어가 어려워 CPU 오버헤드가 증가하고 실행 속도가 느려질 수 있음.

  1. 네이티브 메서드 스택
    • Java가 아닌 native 메서드(C, C++ 등) 실행을 위한 스택.
    • 역시 per-thread이며, C stack 구조로 구현되거나 JVM 스택과 통합될 수도 있음.
    • JVM 명세는 세부 구현 방식을 정의하지 않음.
    • StackOverflowError, OutOfMemoryError 발생 가능.

🧠 Shared 영역

  1. Java Heap
    • '거의' 모든 Java 객체 인스턴스가 저장되는 공유 메모리 공간.
    • GC에 의해 관리되며, Heap 내부의 Generation 영역(Eden, Survivor, Old 등)은 JVM 구현체의 관례일 뿐, 명세에는 없음. 그렇다면 통상적으로 알려진 Gen 영역들은? 현대 GC 구현체들의 일반적인 설계방식으로 HotSpot을 비롯한 대중적인 JVM 구현체들이 이러한 설계를 차용해서 일반론으로 굳어진 것.
    • 메모리는 고정(fixed-size) 또는 동적(dynamically expand/contract)으로 운용 가능. 현대 주류 JVM들은 동적 확장 가능(-Xmx, -Xms 파라미터 사용)하게 구현.
    • OutOfMemoryError 발생 가능

'거의'?

JVM 명세 상에는 'all class instances and arrays'가 저장되는 영역이라 표현하고 있어 현재(JDK 17)까지는 '모든'이 맞는 것 같지만, Java의 발전에 따라 JIT 컴파일의 탈출분석 기술로 스택 할당과 스칼라 치환 최적화 방식이 달라지면서 예외가 생길 수 있음.
→ 탈출 분석 (Escape Analysis) : 새로 만들 객체가 사용되는 범위를 분석하여 Java Heap에 할당 여부를 결정하는 기술. (나중에 자세히 다뤄보자)

그렇다면 통상적으로 알려진 Gen 영역들은?

현대 GC 구현체들의 일반적인 설계방식으로 HotSpot을 비롯한 대중적인 JVM 구현체들이 이러한 설계를 차용해서 일반론으로 굳어진 것.

  1. 메서드 영역 (Method Area)
    • 클래스 로딩 시 생성되며, 클래스/인터페이스의 메타데이터, static 변수, 컴파일된 코드 캐시 등을 저장.
    • 명세상은 Heap의 논리적 일부지만, 메서드 영역의 저장 위치나 Heap처럼 GC가 관리하도록 할지에 대한 여부 등은 구현체에 맡긴다.
    • 이전에는 메서드 영역을 영구 세대에 저장하면서 OOM에 취약해지는 등 문제가 많았지만, 주류 JVM 구현체에서 메서드 영역은 Heap이 아닌 네이티브 메모리의 Metaspace라는 영역을 구현하여 옮기는 것으로 대체하였다.
    • PermGen → Metaspace 변경은 JDK 8부터 적용되었고, OOM 이슈 개선에 기여.
    • 최대 크기 제한: -XX:MaxMetaspaceSize
    • OutOfMemoryError 발생 가능

클래스 메타데이터와 static 변수는 다르다

클래스 메타데이터가 클래스의 '정의'와 '형태'에 대한 정적인 데이터라면, static 변수는 인스턴스가 아닌 클래스 수준에서 관리되는 변수로 실제 데이터를 저장하는 공간이라는 점에서 구분된다.

  1. 런타임 상수 풀 (Runtime Constant Pool)
    • 메서드 영역에 포함되며, 클래스 파일의 constant_pool을 런타임에 메모리로 로드한 구조.

    JVM Spec에서는 'per-class or per-interface run-time representation of the constant_pool table in a class file' 이라 표현하고 있는데 이해하려면 Java .class 파일의 상수 풀(constant_pool)이 뭔지 알아야한다.
    Java 클래스 파일은 바이너리 데이터이며 constant_pool은 클래스 파일 내에 저장된 테이블이다. constant_pool은 클래스 파일이 메모리에 올라오기 전에 디스크 상에 정적으로 존재하는 symbolic reference이며, 런타임 상수 풀은 JVM를 통해 클래스가 로드되면 constant_pool이 실제 레퍼런스로서 메모리에 로드된 것(runtime representation)이다.
    비유하자면 .class 파일의 constant pool은 요리책에 적힌 재료 리스트 이고, 런타임 상수 풀은 요리사가 실제로 재료를 꺼내서 준비한 상태 라고 볼 수 있는 것.

⚠️ Direct Memory
• JVM 런타임 메모리에 속하지 않는 네이티브 메모리 영역으로, JVM 메모리와 무관해보이지만 OOM의 원인이 될 수 있다.
• Java NIO는 malloc을 통해 Heap이 아닌 메모리에 직접 할당할 수 있는 네이티브 함수를 이용하는 DirectByteBuffer를 사용한다.
• Direct Memory는 GC가 직접 관리하는 대상이 아니기에 계속 할당되고 회수는 되지 않는 상태가 반복되어 OutOfMemoryError: Direct buffer memory를 발생시킬 수 있다.
• 제한 설정: -XX:MaxDirectMemorySize=256m
• 모니터링: jcmd VM.native_memory summary

DirectByteBuffer

JDK 1.4에 도입된 Java NIO는 채널과 버퍼 기반의 I/O 메서드를 제공.
DirectByteBuffer는 NIO를 활용하여 Java 힙과 네이티브 힙이 데이터를 복사해 주고받는 과정을 생략(Zero-Copy)하고 커널 영역에서 직접 통신하도록 하여 I/O 처리 속도를 높이기 위해 사용. (Netty, Kafka, Spark 등에서 사용됨)

GC 관리 대상이 아니면 어떻게?

버퍼에서 할당한 네이티브 영역의 메모리는 GC의 관리 대상이 아니지만 DirectByteBuffer 객체 자체는 Java Heap 내에 위치한다. 따라서, Direct Memory는 DirectByteBuffer 객체 안에 cleaner라는 내부 객체를 통해 간접적으로 해제할 수 있다. 이에 따라 GC가 해당 DirectByteBuffer를 해제할 때 함께 해제되지만, GC는 Heap 메모리가 부족할 때 돌기 때문에 네이티브 메모리가 고갈될 때까지 동작하지 않을 수 있다.

운영 환경 Tip

크기 제한 → -XX:MaxDirectMemorySize=256m와 같이 파라미터를 통해 Direct Memory 제한 설정.
모니터링 → jcmd <pid> VM.native_memory summary 명령이나 -XX:NativeMemoryTracking=summary 파라미터를 설정하여 모니터링 가능.


🔎 앞으로 더 다뤄볼 주제들
• 🔬 Escape Analysis와 스택 할당, 스칼라 치환 최적화
• 🔍 HotSpot JVM의 GC 설계 (G1GC, ZGC 등)와 Heap 분할 구조
• 📦 JVM 메모리 관련 튜닝 옵션 정리
• 🧵 스레드별 Stack 사이즈 설정과 최적화

profile
Software Developer (tobeEngineer)

0개의 댓글