PermGen은 왜 Metaspace로 바뀌었나

seonwoo_jung·2026년 6월 5일

1. 도입

오래된 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 문서를 기준으로 적었다. 버전마다 미묘하게 다른 기본값은 단정하기보다 "대체로 이런 흐름"으로 이해한 선에서 적는다.

2. 핵심 개념

두 영역의 차이를 한 문장으로 줄이면 이렇게 이해했다.

PermGen은 JVM 힙 안에 고정 크기로 잡아 둔 클래스 메타데이터 영역이었고, Metaspace는 그 메타데이터를 힙 밖의 네이티브 메모리로 옮겨 기본적으로 크기 제한 없이 관리하는 영역이다.

  • PermGen (Permanent Generation): JDK 7까지 존재했다. 이름 그대로 "영구 세대"로, 힙의 한 부분이었다. 여기에는 로드된 클래스의 메타데이터(클래스 구조, 메서드 바이트코드 등), 런타임 상수 풀, 그리고 초기 버전에서는 interned String 풀과 static 변수까지 들어 있었다. 최대 크기는 -XX:MaxPermSize로 고정했다.
  • Metaspace: JDK 8에서 도입됐다. 클래스 메타데이터를 힙이 아니라 네이티브 메모리에 둔다. 기본적으로 상한이 없고(사용 가능한 네이티브 메모리까지 늘어남), 필요하면 -XX:MaxMetaspaceSize로 상한을 건다.

여기서 가장 중요한 차이는 "메타데이터가 힙 안에 있느냐, 밖에 있느냐"다. PermGen은 힙의 일부였기 때문에 -Xmx로 잡은 힙 예산을 클래스 메타데이터와 일반 객체가 나눠 써야 했고, 크기도 미리 고정해야 했다. Metaspace는 이 메타데이터를 힙 밖으로 빼내면서 "미리 크기를 못 박는다"는 부담을 상당 부분 덜어냈다고 이해했다.

3. 내부 동작

무엇이 어디로 옮겨졌나

PermGen → Metaspace 전환은 단순히 영역 하나를 통째로 옮긴 게 아니라, 그 전부터 단계적으로 내용물이 빠져나간 결과에 가깝다. 정리하면 이렇다.

저장 대상JDK 6JDK 7JDK 8+
interned String 풀PermGen힙으로 이동
static 변수PermGen힙으로 이동(클래스 객체 쪽)
클래스 메타데이터PermGenPermGenMetaspace(네이티브)

JDK 7 릴리스 노트에 따르면 interned String 풀은 JDK 7에서 이미 PermGen에서 일반 힙으로 옮겨졌다고 알려져 있다. static 변수 역시 비슷한 시기에 힙 쪽(클래스를 대표하는 java.lang.Class 객체)으로 옮겨진 것으로 이해했다. 그래서 JDK 8에서 PermGen을 없앨 때 실제로 Metaspace로 넘어간 핵심은 순수한 클래스 메타데이터였다.

자주 헷갈리는 지점 하나: "Metaspace는 String 풀이 커지면 터진다"는 오해다. interned String은 JDK 7부터 힙에 있으므로, 그쪽이 문제면 OutOfMemoryError: Metaspace가 아니라 일반 힙 OOM으로 나타난다.

크기 결정과 GC 트리거

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을 깊게 파고들 때 이 구분이 등장한다.

4. 옵션과 동작 확인

전환 전후의 대표 옵션을 비교하면 이렇다.

항목PermGen (≤ JDK 7)Metaspace (JDK 8+)
위치힙의 일부네이티브 메모리
최대 크기 옵션-XX:MaxPermSize-XX:MaxMetaspaceSize
기본 상한고정(예: 64~82MB대)사실상 무제한
초기/임계 옵션-XX:PermSize-XX:MetaspaceSize
OOM 메시지OutOfMemoryError: PermGen spaceOutOfMemoryError: 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로 와서 영역이 자동으로 늘어난다고 해서 누수 자체가 사라지는 건 아니라는 점은 따로 기억해 둘 만하다.

5. 정리

  • 한 줄 요약: PermGen은 "힙 안의 고정 크기 클래스 영역", Metaspace는 "힙 밖 네이티브 메모리의 자동 확장 클래스 영역"이다. JDK 8에서 JEP 122로 전환됐다.
  • interned String/static은 그보다 앞선 JDK 7에서 이미 힙으로 빠졌으므로, "Metaspace = PermGen 그대로"는 아니다.
  • 상한이 기본 무제한이라는 점은 편한 동시에, 상한을 일부러 걸지 않으면 네이티브 메모리를 잠식할 수 있다는 새 함정을 만든다.

더 파고들 만한 주제:

  • 클래스 언로딩 조건: 클래스로더가 GC되는 정확한 도달 가능성(reachability) 규칙.
  • Compressed Class Space의 별도 크기 관리와, Metaspace OOM을 둘로 나눠 진단하는 법.

참고 자료

  • JEP 122: Remove the Permanent Generation (openjdk.org)
  • Oracle / HotSpot VM Options 문서 — MetaspaceSize, MaxMetaspaceSize, CompressedClassSpaceSize
  • JDK 7 Release Notes — interned String 풀의 힙 이동 관련 항목
  • The Java Language / VM Specification — 런타임 상수 풀, 메서드 영역(Method Area)의 개념적 정의

0개의 댓글