오래된 Java 6/7 트러블슈팅 글을 보면 java.lang.OutOfMemoryError: PermGen space가 단골로 등장한다. 그런데 막상 JDK 8 이상으로 돌아가는 요즘 서비스에서 메모리 에러를 만나면 메시지가 java.lang.OutOfMemoryError: Metaspace로 바뀌어 있다. "이름만 바뀐 건가? 아니면 동작 자체가 다른 건가?"라는 의문이 들어서 정리해 봤다.
이 글에서는 클래스 메타데이터가 어디에 저장되는가, 그 영역의 크기가 어떻게 정해지는가, 그리고 왜 PermGen을 없애고 Metaspace로 갈아탔는가를 따라가 본다. JDK 8에서 PermGen을 제거한 변경은 JEP 122(Remove the Permanent Generation)에 정리되어 있고, 옵션별 동작은 Oracle/HotSpot 문서를 기준으로 적었다. 버전마다 미묘하게 다른 기본값은 단정하기보다 "대체로 이런 흐름"으로 이해한 선에서 적는다.
두 영역의 차이를 한 문장으로 줄이면 이렇게 이해했다.
PermGen은 JVM 힙 안에 고정 크기로 잡아 둔 클래스 메타데이터 영역이었고, Metaspace는 그 메타데이터를 힙 밖의 네이티브 메모리로 옮겨 기본적으로 크기 제한 없이 관리하는 영역이다.
-XX:MaxPermSize로 고정했다.-XX:MaxMetaspaceSize로 상한을 건다.여기서 가장 중요한 차이는 "메타데이터가 힙 안에 있느냐, 밖에 있느냐"다. PermGen은 힙의 일부였기 때문에 -Xmx로 잡은 힙 예산을 클래스 메타데이터와 일반 객체가 나눠 써야 했고, 크기도 미리 고정해야 했다. Metaspace는 이 메타데이터를 힙 밖으로 빼내면서 "미리 크기를 못 박는다"는 부담을 상당 부분 덜어냈다고 이해했다.
PermGen → Metaspace 전환은 단순히 영역 하나를 통째로 옮긴 게 아니라, 그 전부터 단계적으로 내용물이 빠져나간 결과에 가깝다. 정리하면 이렇다.
| 저장 대상 | JDK 6 | JDK 7 | JDK 8+ |
|---|---|---|---|
| interned String 풀 | PermGen | 힙으로 이동 | 힙 |
| static 변수 | PermGen | 힙으로 이동(클래스 객체 쪽) | 힙 |
| 클래스 메타데이터 | PermGen | PermGen | Metaspace(네이티브) |
JDK 7 릴리스 노트에 따르면 interned String 풀은 JDK 7에서 이미 PermGen에서 일반 힙으로 옮겨졌다고 알려져 있다. static 변수 역시 비슷한 시기에 힙 쪽(클래스를 대표하는 java.lang.Class 객체)으로 옮겨진 것으로 이해했다. 그래서 JDK 8에서 PermGen을 없앨 때 실제로 Metaspace로 넘어간 핵심은 순수한 클래스 메타데이터였다.
자주 헷갈리는 지점 하나: "Metaspace는 String 풀이 커지면 터진다"는 오해다. interned String은 JDK 7부터 힙에 있으므로, 그쪽이 문제면 OutOfMemoryError: Metaspace가 아니라 일반 힙 OOM으로 나타난다.
PermGen은 고정 크기 모델이었다. -XX:MaxPermSize로 정한 상한을 넘기면 그대로 OutOfMemoryError: PermGen space였다. 클래스가 많은 애플리케이션 서버나, 배포할 때마다 클래스로더가 새로 뜨는 환경에서는 이 값을 키워 두는 게 흔한 대응이었다.
Metaspace는 모델이 다르다. HotSpot 문서 기준으로 이해한 흐름은 이렇다.
-XX:MetaspaceSize: 최초로 Metaspace GC(클래스 언로딩 시도)를 유발하는 임계치에 가깝다. "최소 크기"라기보다 "이만큼 차면 한 번 정리를 시도한다"는 high-water mark로 동작하는 것으로 알려져 있다.-XX:MaxMetaspaceSize: 상한. 기본값은 사실상 무제한(네이티브 메모리가 허용하는 만큼).즉 Metaspace는 필요하면 네이티브 메모리에서 더 받아 늘어나고, 임계치를 넘을 때 GC가 죽은 클래스로더의 메타데이터를 회수한다. 회수 후 사용량을 보고 임계치를 위/아래로 조정하기도 하는 것으로 이해했다. 상한을 명시적으로 걸지 않으면, 클래스 메타데이터가 무한정 늘어 호스트의 네이티브 메모리를 잠식할 수 있다는 점이 새로운 주의사항이 됐다. PermGen 시절에는 상한이 사실상 강제였다면, Metaspace에서는 상한을 "일부러 걸어 두는" 운영 습관이 권장되는 셈이다.
[ JDK 7 ] [ JDK 8+ ]
+------------------------+ +------------------------+
| Java Heap | | Java Heap |
| Young / Old | | Young / Old |
| + interned String | | + interned String |
| + static 변수 | | + static 변수 |
| + (PermGen: 힙 일부) | +------------------------+
| 클래스 메타데이터 | ↑ -Xmx 로 제한
+------------------------+
↑ -Xmx (PermGen 별도 -XX:MaxPermSize)
----- 힙 경계 -----
+------------------------+
| Native Memory |
| └ Metaspace |
| 클래스 메타데이터 |
| (-XX:MaxMetaspaceSize)
+------------------------+
한 가지 더 짚을 디테일: 64비트 JVM에서 압축 클래스 포인터(-XX:+UseCompressedClassPointers)를 쓰면, 클래스 메타데이터 중 일부(klass 포인터용)는 별도의 Compressed Class Space에 들어간다. 이 영역은 -XX:CompressedClassSpaceSize로 따로 잡히고(예약 크기 기본값이 큰 편으로 알려져 있다), Metaspace 사용량과 별개로 추적된다. Metaspace 관련 OOM을 깊게 파고들 때 이 구분이 등장한다.
전환 전후의 대표 옵션을 비교하면 이렇다.
| 항목 | PermGen (≤ JDK 7) | Metaspace (JDK 8+) |
|---|---|---|
| 위치 | 힙의 일부 | 네이티브 메모리 |
| 최대 크기 옵션 | -XX:MaxPermSize | -XX:MaxMetaspaceSize |
| 기본 상한 | 고정(예: 64~82MB대) | 사실상 무제한 |
| 초기/임계 옵션 | -XX:PermSize | -XX:MetaspaceSize |
| OOM 메시지 | OutOfMemoryError: PermGen space | OutOfMemoryError: Metaspace |
JDK 8 이상에서 PermGen 옵션을 그대로 넘기면 무시되며 경고가 뜬다. 직접 확인해 보면 동작이 드러난다.
# JDK 8+ 에서 옛 PermGen 옵션을 주면 경고 후 무시된다
$ java -XX:MaxPermSize=128m -version
OpenJDK 64-Bit Server VM warning: Ignoring option MaxPermSize;
support was removed in 8.0
# Metaspace 상한을 일부러 작게 걸어 두고 동작 확인
$ java -XX:MaxMetaspaceSize=64m -jar app.jar
클래스가 비정상적으로 계속 늘어나는 상황(엣지 케이스)을 흉내 내면 Metaspace OOM을 직접 볼 수 있다. 클래스로더로 새 클래스를 끝없이 정의하면, 그 메타데이터가 Metaspace에 쌓인다.
// 주의: 학습용. 클래스로더가 회수되지 않게 잡고 있으면 메타데이터가 쌓인다.
import javassist.ClassPool;
public class MetaspaceLeak {
public static void main(String[] args) throws Exception {
ClassPool pool = ClassPool.getDefault();
for (int i = 0; ; i++) {
// 매 반복마다 새 클래스(고유 이름)를 정의 → 메타데이터 누적
Class<?> c = pool.makeClass("Gen" + i).toClass();
if (i % 1000 == 0) System.out.println("defined " + i);
// c 를 어딘가 강하게 참조해 두면 클래스 언로딩이 막혀 더 빨리 터진다
}
}
}
// 작게 건 -XX:MaxMetaspaceSize 와 함께 돌리면
// 결국 java.lang.OutOfMemoryError: Metaspace 로 끝난다
여기서 핵심 포인트: Metaspace 회수의 단위는 보통 개별 객체가 아니라 클래스로더다. 어떤 클래스로더가 로드한 클래스들은, 그 클래스로더(와 그 클래스들의 인스턴스)가 모두 도달 불가능해질 때 함께 언로드되며 메타데이터가 회수된다. 그래서 "핫 리로드"가 잦은 환경에서 옛 클래스로더가 어딘가에 참조로 묶여 있으면, 버전이 다른 같은 클래스가 계속 쌓이는 클래스로더 누수가 생긴다. PermGen 시절의 고질적 OOM 상당수가 사실 이 패턴이었고, Metaspace로 와서 영역이 자동으로 늘어난다고 해서 누수 자체가 사라지는 건 아니라는 점은 따로 기억해 둘 만하다.
더 파고들 만한 주제:
MetaspaceSize, MaxMetaspaceSize, CompressedClassSpaceSize