자바 애플리케이션이 운영 환경에서 갑자기 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 플래그가 켜져 있어야 동작합니다. 평소 운영 옵션에 슬쩍 넣어두면 사고 났을 때 정말 고맙습니다.
자주 하는 실수
- Metaspace 상한을 안 잡는 것. 기본값이 사실상 무제한입니다. 동적 프록시나 핫 리로딩이 많은 애플리케이션은 Metaspace가 끝없이 늘어나다가, 컨테이너 환경에서 호스트 OOMKiller에게 통째로 잘려 나가는 일이 흔해요. 운영에선
-XX:MaxMetaspaceSize=256m정도는 명시하는 걸 추천드립니다. -Xmx만 늘리면 다 해결된다는 착각. Heap이 아니라 스레드를 너무 많이 만들거나, NIO Direct Memory가 새는 경우엔-Xmx를 키워봐야 의미가 없습니다. 오히려 호스트 메모리만 더 잡아먹어요.- Java 8 이전 자료를 그대로 따라 하기. PermGen은 이미 사라졌고 옵션도 다릅니다.
-XX:MaxPermSize는 그냥 무시돼요. 블로그 검색하실 때는 작성 시점을 꼭 확인하세요. - 스레드 1만 개 띄우기. Stack 기본 크기가 보통 1MB라서 스레드 1만 개면 Stack 합계만 10GB입니다.
unable to create new native thread는 메시지와 달리 거의 메모리 부족이에요. 저도 이걸 모르고 한참을 "왜 스레드 풀이 안 늘어나지" 하면서 헤맸습니다.
마무리
정리하면 세 줄입니다.
- Heap = 객체, Stack = 메서드 프레임, Metaspace = 클래스 정보. 이 셋만 명확히 구분해도 OOM 메시지의 절반은 읽힙니다.
- 운영에선
-Xmx,-Xss,-XX:MaxMetaspaceSize세 옵션을 묵시적 기본값에 맡기지 말고 명시적으로 잡아두세요. - 진단은
jstat -gc와jcmd VM.native_memory summary두 개부터 시작하면 거의 다 잡힙니다.
다음 글에서는 Heap 안에서 실제로 청소를 하는 GC들(Serial / Parallel / G1 / ZGC)이 어떤 워크로드에 뭐가 맞는지 비교해 볼게요.
'IT·리눅스 > Java' 카테고리의 다른 글
| [Java] G1 GC 내부 동작 - Region과 Garbage First의 정체 (0) | 2026.05.11 |
|---|---|
| [Java] GC의 종류 - Serial / Parallel / G1 / ZGC 비교 (0) | 2026.05.08 |
| Java 메서드 완전 정복: 함수 만들기와 호출하기 (1) | 2025.04.30 |
| Java 공부를 시작한다면, VS Code와 Eclipse 중 무엇을 선택할까? (3) | 2025.04.25 |
| [Java 기초] 배열(Array) 선언부터 평균 구하기까지 정리 (1) | 2025.04.22 |