IT·리눅스/Java

[Java] JVM 메모리 구조 - Heap, Stack, Metaspace 한 번에 잡기

jmineekim 2026. 5. 7. 10:00

자바 애플리케이션이 운영 환경에서 갑자기 OOM(OutOfMemoryError)으로 죽었다는 알람을 받아본 적, 한 번쯤은 있으실 겁니다. 새벽에 폰 알람에 깨서 로그를 보면 어떤 날은 Java heap space, 어떤 날은 Metaspace, 어떤 날은 unable to create new native thread인데, 정작 그게 무슨 차이인지 헷갈려서 일단 -Xmx만 늘려놓고 다시 잠드신 적도 있으실 거예요. 저도 그랬습니다. 오늘은 그 OOM 메시지 한 줄을 제대로 읽어내기 위한 출발점, JVM 메모리 구조를 한 번에 정리해 볼게요.

개념 / 원리

JVM은 시작할 때 OS로부터 일정량의 메모리를 받아서, 자기만의 규칙대로 영역을 쪼갭니다. 크게 다섯 군데인데, 이름만 들으면 헷갈리지만 역할은 의외로 단순해요.

회사 사무실에 비유해 볼게요.

  • Heap은 공용 창고입니다. new 키워드로 만든 객체와 배열이 전부 여기 들어가고, 모든 스레드가 같이 씁니다. GC 청소부가 주기적으로 들러서 안 쓰는 짐을 치워주는 곳도 여기예요.
  • Stack은 직원 개인 책상입니다. 메서드를 호출할 때마다 프레임이 책상 위에 쌓이고, 메서드가 끝나면 자동으로 치워집니다. 스레드마다 책상이 따로 있고요.
  • Metaspace는 회사 규정집입니다. 클래스의 설계도(메타데이터)가 들어가는 곳인데, Java 8부터 예전 PermGen을 대체했습니다. 중요한 차이는 Heap이 아니라 네이티브 메모리를 쓴다는 점.
  • Native Method Stack은 JNI 호출용 스택, PC Register는 현재 실행 중인 명령어 위치를 저장하는 영역입니다. 이 둘은 OOM 트러블슈팅에서 직접 만질 일은 거의 없어요.

표로 정리하면 이렇습니다.

영역 저장 대상 공유 여부 터질 때 메시지
Heap 객체, 배열 스레드 공유 Java heap space
Stack 지역변수, 메서드 프레임 스레드별 StackOverflowError
Metaspace 클래스 메타데이터 스레드 공유 Metaspace
Native Method Stack 네이티브 호출 스레드별 unable to create new native thread

Heap은 내부적으로 Young(Eden + Survivor 2개)과 Old로 다시 쪼개집니다. GC가 실제로 동작하는 무대가 바로 여기예요.

실전 예제

말로만 들으면 안 와닿으니, 세 가지 OOM을 직접 재현해 볼게요. Java 17 기준입니다.

먼저 Heap을 일부러 터뜨리는 코드.

import java.util.ArrayList;
import java.util.List;

public class HeapOom {
    public static void main(String[] args) {
        List<byte[]> bucket = new ArrayList<>();
        while (true) {
            bucket.add(new byte[1024 * 1024]); // 1MB씩 적재
        }
    }
}

작은 힙으로 띄우면 몇 초 안에 떨어집니다.

javac HeapOom.java
java -Xmx64m HeapOom
# Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

다음은 스택 오버플로우. 무한 재귀가 책상을 다 채워버리는 케이스입니다.

public class StackOom {
    public static void main(String[] args) {
        recurse(0);
    }
    static void recurse(int depth) {
        recurse(depth + 1); // 영원히 리턴 안 함
    }
}
java StackOom
# Exception in thread "main" java.lang.StackOverflowError

마지막으로 운영 중에 외워두면 좋은 진단 명령어 두 개.

# 1초마다 Eden/Survivor/Old/Metaspace 사용량 출력
jstat -gc <PID> 1000

# 네이티브 메모리까지 한눈에
jcmd <PID> VM.native_memory summary

jcmd의 native_memory는 -XX:NativeMemoryTracking=summary 플래그가 켜져 있어야 동작합니다. 평소 운영 옵션에 슬쩍 넣어두면 사고 났을 때 정말 고맙습니다.

자주 하는 실수

  1. Metaspace 상한을 안 잡는 것. 기본값이 사실상 무제한입니다. 동적 프록시나 핫 리로딩이 많은 애플리케이션은 Metaspace가 끝없이 늘어나다가, 컨테이너 환경에서 호스트 OOMKiller에게 통째로 잘려 나가는 일이 흔해요. 운영에선 -XX:MaxMetaspaceSize=256m 정도는 명시하는 걸 추천드립니다.
  2. -Xmx만 늘리면 다 해결된다는 착각. Heap이 아니라 스레드를 너무 많이 만들거나, NIO Direct Memory가 새는 경우엔 -Xmx를 키워봐야 의미가 없습니다. 오히려 호스트 메모리만 더 잡아먹어요.
  3. Java 8 이전 자료를 그대로 따라 하기. PermGen은 이미 사라졌고 옵션도 다릅니다. -XX:MaxPermSize는 그냥 무시돼요. 블로그 검색하실 때는 작성 시점을 꼭 확인하세요.
  4. 스레드 1만 개 띄우기. Stack 기본 크기가 보통 1MB라서 스레드 1만 개면 Stack 합계만 10GB입니다. unable to create new native thread는 메시지와 달리 거의 메모리 부족이에요. 저도 이걸 모르고 한참을 "왜 스레드 풀이 안 늘어나지" 하면서 헤맸습니다.

마무리

정리하면 세 줄입니다.

  • Heap = 객체, Stack = 메서드 프레임, Metaspace = 클래스 정보. 이 셋만 명확히 구분해도 OOM 메시지의 절반은 읽힙니다.
  • 운영에선 -Xmx, -Xss, -XX:MaxMetaspaceSize 세 옵션을 묵시적 기본값에 맡기지 말고 명시적으로 잡아두세요.
  • 진단은 jstat -gcjcmd VM.native_memory summary 두 개부터 시작하면 거의 다 잡힙니다.

다음 글에서는 Heap 안에서 실제로 청소를 하는 GC들(Serial / Parallel / G1 / ZGC)이 어떤 워크로드에 뭐가 맞는지 비교해 볼게요.

 
반응형