[JVM] 메모리 구조와 가비지 컬렉터(GC)

개요

JVM(Java Virtual Machine)은 자바 프로그램을 실행하기 위해 메모리를 여러 영역으로 분할해서 관리한다. 그리고 그 안에서 만들어지는 객체들의 수명을 추적해 더 이상 쓰이지 않는 객체를 자동으로 회수한다. 이 자동 회수 메커니즘이 가비지 컬렉터(Garbage Collector, GC)이다. C/C++ 에서 개발자가 직접 malloc/free 로 처리하던 메모리 관리를 JVM 은 런타임에 대신해 준다.

GC 가 동작할 때마다 일부 또는 전체 애플리케이션 스레드가 멈추고(Stop-The-World, STW), 그동안 사용자 요청 처리는 대기 상태가 된다. 운영 환경에서 발생하는 GC 튜닝 이슈, OOM(OutOfMemoryError) 장애, 응답 지연 스파이크 등이 이로 인해 발생할 수 있다.

이 글은 JVM 메모리 영역을 정리한 뒤, GC 가 무엇을 살리고 무엇을 회수하는지 판단하는 원리(도달 가능성 분석)와 세 가지 기반 알고리즘(Mark-Sweep, Mark-Copy, Mark-Compact)을 단계별 그림으로 본다. 그다음 Serial 부터 ZGC 까지 실제 GC 구현체가 그 알고리즘을 어떻게 조합해 STW 를 줄이는지 단계별로 따라간다. 마지막으로 운영에서 자주 쓰는 JVM 옵션과 OOME 진단, 베스트프랙티스를 정리한다.

핵심 용어를 먼저 정리한다.

용어의미
Young / Old힙을 객체 수명으로 나눈 세대. Young 은 단명 객체, Old 는 장수 객체
Minor GC / Full GCYoung 만 회수하는 짧은 GC / 힙 전체를 회수하는 큰 GC
Stop-The-World (STW)GC 를 위해 모든 사용자 스레드를 멈추는 구간
도달 가능성 분석GC 루트에서 참조로 닿는 객체만 살리는 판정 방법
컴팩션(Compaction)살아남은 객체를 한쪽으로 몰아 단편화를 없애는 작업

JVM 런타임 데이터 영역

JVM 은 메모리를 크게 모든 스레드가 공유하는 영역과 스레드마다 독립적으로 가지는 영역으로 나눠 관리한다.

공유 영역은 객체나 클래스 정보처럼 여러 스레드가 함께 보는 데이터를 담는다. 스레드 영역은 각 스레드의 실행 위치와 지역 변수를 담는다. 공유 영역은 동기화 비용이 발생할 수 있는 곳이고, 스레드 영역은 스레드 독립적으로 접근할 수 있는 곳이다.

메서드 영역(Metaspace)

클래스 메타데이터, 상수, 정적 변수, JIT(Just-In-Time) 컴파일러가 컴파일한 코드 캐시 등을 저장하는 공간이다. 흔히 논힙(Non-Heap)으로 분류된다. JDK(Java Development Kit) 7 까지는 이 정보를 힙 안의 Permanent Generation 에 두었지만, JDK 8 부터는 네이티브 메모리에 별도로 할당되는 Metaspace 로 대체됐다.

Metaspace 는 기본적으로 OS 메모리 한계까지 늘어날 수 있다. 즉 -XX:MaxMetaspaceSize 를 명시하지 않으면 클래스 누수가 발생했을 때 호스트 메모리를 사용한다.

힙(Heap)

모든 스레드가 공유하며 동적으로 생성된 객체 인스턴스가 저장되는 공간이다. GC 의 주된 관리 대상이고, 운영에서 가장 자주 튜닝되는 영역이다. Young Generation(Eden, S0, S1)과 Old Generation 으로 나눠서 관리한다.

PC 레지스터

스레드별로 현재 실행 중인 바이트코드 명령의 주소를 기록하는 작은 영역이다. 멀티스레드에서 컨텍스트 스위칭이 끊임없이 일어나는데, 다시 차례가 돌아왔을 때 어디서부터 실행해야 하는지를 알기 위한 위치 정보다.

VM 스택

스레드 프라이빗 영역으로, 메서드가 호출될 때마다 스택 프레임을 하나씩 쌓는다. 스택 프레임에는 지역 변수 테이블, 피연산자 스택, 동적 링크, 메서드 반환값이 들어간다. 크기는 컴파일 시점에 결정되어 런타임에 동적으로 늘어나지 않는다.

네이티브 메서드 스택

JNI(Java Native Interface)로 호출되는 네이티브(C/C++) 메서드 실행을 위한 스택이다. 핫스팟 JVM 은 VM Stack 과 네이티브 메서드 스택을 구분하지 않고 하나로 합쳐 관리한다.

다이렉트 메모리

런타임 데이터 영역이 아닌, Unsafe 와 NIO(New I/O)를 통해 직접 할당받는 OS 메모리다. DirectByteBuffer 가 대표적이며, 자바 힙과 네이티브 힙 사이에 데이터를 복사하는 비용 없이 입출력을 수행할 수 있어 Zero Copy 구현에 사용된다. 실제 사용 메모리는 자바 힙과 다이렉트 메모리의 합이므로 합산해서 관리해야 한다.

힙 구조와 세대 가설

힙은 단순히 객체를 쌓는 큰 통이 아니라, 객체의 수명에 따라 영역을 나눠 관리한다.

  • Eden: 새로 생성된 객체가 처음 자리잡는 곳. Minor GC가 자주 발생한다.
  • Survivor(S0, S1): Minor GC에서 살아남은 객체가 잠시 머무는 곳. age count가 누적되며 둘 사이를 왕복한다.
  • Old(Tenured): age가 임계점을 넘은 객체가 승격(promotion)되어 옮겨지는 곳.

객체가 태어나서 죽거나 승격되기까지의 흐름을 단계별로 보면 다음과 같다.

  1. STEP 1: new 로 생성된 객체가 Eden에 쌓인다. Eden이 가득 차면 Minor GC가 발동한다.
  2. STEP 2: Minor GC는 도달 가능한 객체만 Survivor(S0)로 복사하고 age를 1 올린 뒤, 죽은 객체가 차지하던 Eden을 한 번에 비운다.
  3. STEP 3: GC를 반복하며 age가 임계점(기본 15)을 넘은 객체는 Old로 승격된다.

왜 세대로 나누는가

이 구조는 대부분의 객체가 잠깐 살다 죽는다(약 98%)는 경험적 관찰에서 출발한다. 일부 객체(1~2%)만이 오래 살아남는다.

만약 힙을 단일 영역으로 두면 GC가 발생할 때마다 힙 전체를 스캔해야 한다. 그러면 한 번의 GC가 길어지고 STW 시간이 늘어 애플리케이션 정지 시간이 커진다.

Young과 Old로 나누면 빠르게 죽을 객체는 작은 Young 영역에서만 존재한다. 그래서 Young 영역만 검사하면 되기에 짧고 잦은 Minor GC로 처리하고, 오래 사는 객체만 Old로 보내서 드물게 큰 GC(Full GC, Mixed GC 등)를 돌릴 수 있다.

세대 간 참조와 카드 테이블

영역을 나눴기 때문에 새로운 문제가 생긴다. Young에서 Old로, Old에서 Young으로의 참조는 분명히 발생한다. Minor GC를 돌릴 때 Young만 보면 되는데, Old의 객체가 Young의 객체를 참조하고 있다면 Young의 객체는 살아 있는 셈이다. 이 참조를 알기 위해 매번 Old 전체를 스캔하면 분리한 의미가 없어진다.

이를 해결하기 위해 기억 집합(Remembered Set) 자료구조를 둔다. 가장 대표적인 구현이 카드 테이블(Card Table)인데, 힙을 512 바이트 단위의 카드로 분할하고 각 카드는 1바이트 플래그로 표현한다. Old에서 Young으로의 참조가 발생하면 쓰기 배리어가 해당 카드를 더러움(dirty)으로 표시한다. Minor GC는 더러운 카드만 스캔하면 되므로 Old 전체 스캔을 피할 수 있고, 이로 인해 Young의 객체를 제거하지 않을 수 있다.

객체의 생성과 메모리 레이아웃

객체 생성 과정

  1. new A() 가 바이트코드 new, dup, invokespecial 로 변환된다.
  2. 메서드 영역의 런타임 상수 풀에서 심벌릭 참조로 클래스 A를 찾고, 클래스 로딩 여부를 확인한다(미로딩이면 Loading, Linking, Initialization 수행).
  3. 클래스 메타데이터에서 인스턴스 크기를 얻어 힙에 메모리를 할당한다.
    • Bump the Pointer: 힙이 연속된 메모리(컴팩션 GC)일 때 포인터를 그냥 옮겨 할당.
    • Free List: 단편화된 메모리(컴팩션 없는 GC)일 때 비어 있는 자리 목록에서 적당한 블록을 찾아 할당.
  4. 동시성 문제를 막기 위해 CAS(Compare-And-Swap) 연산 또는 TLAB(Thread Local Allocation Buffer)으로 할당 충돌을 회피한다. TLAB는 스레드마다 미리 받아두는 작은 전용 영역이라 락 없이 빠르게 할당한다.
  5. 메모리 영역을 0 또는 null로 초기화한 뒤 객체 헤더(해시코드, GC age, 락 정보, Klass Pointer)를 설정하고, 마지막으로 생성자 <init> 이 실행되면 객체 생성이 완료된다.

대부분의 할당은 스레드 전용 버퍼인 TLAB 안에서 포인터를 옮기는 것만으로 끝나 락 없이 매우 저렴하다.

객체의 메모리 레이아웃

힙에 올라간 객체는 헤더, 인스턴스 데이터, 정렬 패딩 세 부분으로 구성된다.

  • 객체 헤더
    • Mark Word: 해시코드, GC age, 락 상태, 락 점유 스레드 등 런타임 데이터를 담는다. 좁은 공간에 많은 정보를 담아야 해서 락 상태별로 비트 레이아웃이 달라진다.
    • Klass Word: Metaspace의 클래스 메타데이터를 가리키는 포인터. 런타임에 이 객체가 어느 클래스인지 식별하기 위한 정보다.
  • 인스턴스 데이터: 부모 클래스의 필드가 먼저 오고 현재 클래스의 필드가 그 뒤를 잇는다. primitive 는 값을 직접, reference는 힙 주소를 저장한다.
  • 정렬 패딩: 핫스팟 JVM에서 객체 크기는 반드시 8바이트의 배수여야 한다(CPU 메모리 접근 최적화). 남는 0~7 바이트를 패딩으로 채워 정렬한다.

GC 동작의 기본(도달 가능성과 STW)

GC의 출발점은 어떤 객체를 살릴 것인가 어떤 객체를 회수할 것인가를 판단하는 일이다. JVM은 이를 위해 도달 가능성 분석(Reachability Analysis) 알고리즘을 사용한다.

GC 루트에서 시작해 참조를 따라가며 닿을 수 있는 객체는 모두 살아 있고, 닿을 수 없는 객체는 회수 대상이다. GC 루트의 후보는 다음과 같다.

  • VM 스택에서 현재 수행 중인 메서드의 지역 변수, 매개변수
  • 메서드 영역에서 클래스가 정적 필드로 참조하는 객체
  • 메서드 영역의 상수로 참조되는 객체
  • 네이티브 메서드 스택에서 JNI 가 참조하는 객체
  • synchronized 로 잠긴 객체

도달 가능한 객체란 이 GC 루트 중 하나에서 참조를 타고 닿을 수 있는 객체를 말한다.

왼쪽 GC 루트에서 참조 체인으로 닿는 A, B, C, G는 도달 가능한 객체이고 살아 있다. 오른쪽 아래 D 와 E는 서로를 참조하지만 어느 루트에서도 닿는 경로가 없다. 이렇게 서로만 참조하며 고립된 덩어리를 섬(순환 참조)이라 하고, F는 아무도 참조하지 않는 고아 객체다. 셋 다 도달 불가라 회수 대상이 된다. 판정 기준이 참조 카운트가 아니라 루트로부터의 도달 가능성이기 때문에, 서로 참조하는 섬도 회수된다.

삼색 마킹과 메모리 배리어

STW를 최소화하려면 사용자 스레드와 GC 스레드를 동시에 돌려야 하는데, 그 과정에서 참조가 바뀌면 GC가 잘못된 결과를 낼 수 있다. 이를 막기 위한 핵심 도구가 삼색 마킹(Tri-color Marking) 알고리즘과 메모리 배리어(Memory Barrier)이다.

삼색 마킹은 도달 가능성 분석을 세 가지 색으로 진행한다. 아래 그림이 단계별 진행을 보여준다.

  • 흰색: 아직 방문하지 않은 객체(GC 후보)
  • 회색: 방문했지만 참조 객체들은 아직 검사하지 않은 객체
  • 검은색: 방문했고 참조 객체들을 모두 검사한 객체

STEP 1에서는 GC 루트가 직접 참조하는 객체만 회색으로 칠한다. STEP 2에서는 회색 객체의 자식을 따라가며 검사를 마친 객체를 검은색으로, 새로 발견한 객체를 회색으로 바꾼다. STEP 3에서 회색이 모두 사라지면 마킹이 끝나고, 끝까지 흰색으로 남은 객체(E)가 회수 대상이다.

문제는 사용자 스레드가 도는 중에 검은색 객체가 새로운 흰색 객체를 참조하기 시작하면, GC가 그 흰색 객체를 회수 대상으로 잘못 판단할 수 있다는 점이다. 메모리 배리어는 이런 상황에서 GC 에게 변경을 알린다.

  • 쓰기 배리어(Write Barrier): 객체 참조 변경 시 GC가 알 수 있도록 변경 사실을 기록한다. 카드 테이블 갱신도 여기서 일어난다.
  • 읽기 배리어(Read Barrier): 컴팩션 시 객체가 새로운 장소로 이동할 때 최신 위치로 안내한다. ZGC, Shenandoah가 적극 활용한다.

안전 지점과 OopMap

참조를 추적하려면 객체 참조가 저장된 위치를 알아야 한다. JIT 컴파일 단계에서 OopMap이라는 자료구조를 만들어 스택과 레지스터의 어느 위치가 객체 참조인지 기록해 둔다. GC는 OopMap을 보고 GC 루트를 빠르게 찾는다.

그림에서 스택 프레임의 slot1 과 slot3 만 객체 참조(oop)다. OopMap 은 슬롯별로 참조인지 값인지를 1비트로 표시하므로, GC 는 스택을 바이트 단위로 해석하지 않고 참조 슬롯만 골라 루트로 따라간다.

OopMap을 전체 명령마다 만들 수는 없기 때문에 JVM은 안전 지점(Safepoint)을 지정하고, 사용자 스레드는 안전 지점을 폴링하다가 GC 요청을 만나면 멈춘다. 이때 모든 스레드가 동시에 멈추는 구간이 STW다.

GC 요청이 와도 스레드는 즉시 멈추지 않고 다음 safepoint까지 수행한 뒤 멈춘다. 세 스레드가 서로 다른 시점에 safepoint에 도달해 멈추고, 마지막 스레드(T3)까지 멈춘 순간부터 GC가 실행된다. 이 전원 정지 구간이 STW이고, GC가 끝나면 모두 재개한다.

GC 알고리즘 세 가지

Mark-Sweep

표시(Mark)한 뒤 표시 안 된 객체를 그 자리에서 비운다(Sweep). 객체를 옮기지 않기 때문에 빠르지만, 빈 공간이 조각조각 흩어지는 단편화가 남는다.

STEP 1의 상태에서 STEP 2로 도달 가능한 객체(A, B, C, D)를 표시하고, STEP 3에서 표시 안 된 자리를 비운다. 회수 후 빈 공간이 흩어져, 큰 객체를 넣을 연속 공간이 없으면 할당 실패가 날 수 있다.

Mark-Copy

공간을 둘로 나눠 한쪽만 쓰다가, GC 때 살아있는 객체만 반대편으로 촘촘히 복사하고 원본을 통째로 비운다.

STEP 1에서 From-space의 생존 객체(A, B, C)만 To-space로 복사한다. STEP 2에서 두 공간의 역할이 교대되어, From은 통째로 비워지고 To가 사용 중 공간이 된다. 단편화가 없고 할당이 포인터 이동만으로 끝나 빠르지만, 절반은 항상 비워둬야 해 공간 효율이 낮다. 객체가 적은 Survivor 영역에 유리하다.

Mark-Compact

표시한 뒤 살아있는 객체를 한쪽 끝으로 밀어 붙인다(Compact). 단편화가 사라지고 뒤쪽에 연속된 빈 공간이 생긴다.

STEP 3에서 생존 객체(A, B, C, D)가 앞으로 압축되면서 뒤쪽이 연속된 빈 공간이 된다. 단편화가 사라져 Bump-the-Pointer 할당이 가능해진다. 대신 객체를 옮기고 그 객체를 가리키던 참조를 모두 갱신하는 비용이 든다. 크고 객체가 많은 Old 영역에 주로 쓴다.

알고리즘동작장점단점
Mark-Sweep표시하고 회수단순함메모리 단편화
Mark-Copy살아남은 객체를 다른 영역으로 복사단편화 없음, 빠름메모리 절반만 활용
Mark-Compact표시하고 회수 후 앞쪽으로 압축단편화 해결, 메모리 효율 좋음객체 이동 비용, 참조 갱신 비용

GC 구현체별 동작 비교

다음은 자주 만나는 GC 구현체들의 동작을 비교한 표다. 이어지는 절에서 각 구현체가 위 알고리즘을 어떻게 조합하는지 단계별로 본다.

GC세대 구분STW컴팩션강점약점
Serial GCO항상 발생O단순함, 작은 힙에 적합멀티 코어 활용 불가
Parallel GCO항상 발생O처리량(throughput) 최적STW 길이는 여전
CMS GCOOld 일부만 STWX (Sweep 까지만)짧은 STW단편화, CPU 사용량 증가 (JDK 9 deprecated, JDK 14 제거)
G1 GCO (리전 기반)부분 STWO예측 가능한 일시 정지메모리 사용량 증가
ZGC옵션 (세대 구분 ZGC 가능)거의 없음(sub-ms)O초저지연처리량은 G1 보다 약간 떨어짐
ShenandoahX (단일 세대)거의 없음O초저지연RedHat 주도, OpenJDK 빌드 의존

Serial과 Parallel

Serial은 단일 스레드로 GC를 수행한다. 단순하고 작은 힙 환경에 적합하다. Parallel은 같은 방식을 멀티 스레드로 돌린다. 처리량을 우선시하는 배치 작업이나 백엔드 워커에 적합하다. 둘 다 GC 내내 애플리케이션을 멈추지만, STW 구간을 몇 개의 GC 스레드로 훑느냐가 다르다.

Serial은 GC 스레드 하나가 STW 구간 전체를 훑기 때문에 정지 시간이 길다. Parallel은 코어 수만큼 GC 스레드를 나눠 같은 일을 병렬로 처리해 STW 구간을 짧게 줄인다.

sequenceDiagram
    autonumber
    participant APP as App Threads
    participant GC as GC Threads

    APP->>APP: 정상 실행
    Note over APP: Young 영역 가득 참
    APP->>GC: STW 시작
    GC->>GC: GC 루트 열거
    GC->>GC: 마킹과 복사 (병렬)
    GC->>APP: STW 종료
    APP->>APP: 실행 재개

CMS

CMS(Concurrent Mark Sweep)는 STW 를 줄이기 위해 등장했다. Young은 Serial 또는 ParNew 가 처리하고, Old만 CMS 가 담당한다. 무거운 마킹과 회수를 사용자 스레드와 동시에 돌리고, 짧은 STW 두 번만 멈춘다.

  1. 최초 표시(Initial Mark): STW 상태에서 GC 루트가 직접 참조하는 객체를 마킹.
  2. 동시 표시(Concurrent Mark): 사용자 스레드와 동시에 마킹을 이어 나감.
  3. 재표시(Remark): 짧은 STW 로 동시 표시 중 변경된 참조를 보정.
  4. 동시 스윕(Concurrent Sweep): 사용자 스레드를 멈추지 않고 회수.

CMS는 컴팩션 단계가 없어서 메모리 단편화가 누적된다. 또한 GC 스레드가 CPU 자원을 많이 잡아 처리량을 떨어뜨린다. 이런 단점 때문에 JDK 9에서 deprecated, JDK 14에서 제거됐다.

G1 GC

G1(Garbage First)은 힙을 동일한 크기의 리전(Region)으로 나눈다. Eden, Survivor, Old의 세대 구분은 그대로지만, 각 세대가 여러 리전으로 흩어진다. 그리고 각 리전마다 카드 테이블(기억 집합)을 둬서 리전 간 참조를 추적한다.

회수 시점에는 통계 데이터(이전 리전 회수 시간, 쓰레기 비율)를 기반으로 가비지가 많은 리전부터 우선 회수한다. 그래서 이름이 Garbage First다. STEP 1에서 가비지 비율이 높은 리전을 회수 집합(Collection Set)으로 선정하고, STEP 2에서 그 리전의 생존 객체만 빈 리전으로 복사한 뒤 원본 리전을 통으로 회수한다.

전체 사이클은 CMS와 비슷한 동시 표시 단계를 거치되, 마지막에 생존 객체를 복사하는 대피(Evacuation) 단계가 있다.

  1. 최초 표시(Initial Mark): STW 상태에서 GC 루트가 직접 참조하는 객체와 TAMS(Top At Mark Start) 포인터 설정.
  2. 동시 표시(Concurrent Mark): 사용자 스레드와 동시에 도달 가능성을 분석.
  3. 재표시(Remark): 짧은 STW로 참조 변경 보정.
  4. 복사 및 청소(Cleanup): 가비지 비율이 높은 리전을 선별해 살아남은 객체만 빈 리전으로 복사하고, 원본 리전을 회수.

G1은 예측 가능한 일시 정지 시간(-XX:MaxGCPauseMillis)을 목표로 한다. JDK 9부터 기본 GC 이며, 4GB 이상 힙에서 가장 표준적인 선택지다.

ZGC

ZGC 는 초저지연(sub-millisecond pause time)을 목표로 한다. 리전 기반(ZPage)이지만 처음에는 세대 구분이 없었다(JDK 21부터 세대 구분 ZGC가 도입됨). ZPage는 소(2MB), 중(32MB), 대(4MB 이상 동적)로 분류된다.

ZGC의 핵심은 읽기 배리어를 이용한 자가 치유(Self-Healing)다.

STEP 1에서 참조 R은 옛 ZPage의 객체 O를 가리킨다. STEP 2에서 O를 새 ZPage의 O’로 복사하고, 옛 주소와 새 주소를 잇는 포워딩 테이블을 남긴다. 이 시점에도 R은 아직 옛 주소를 가리킨다. STEP 3에서 어떤 스레드가 옛 객체를 조회하는 순간 읽기 배리어가 발동해 포워딩 테이블을 보고 R을 O’로 갱신한다. 객체 조회만으로 참조가 갱신되므로 자가 치유라고 부른다.

  1. 동시 표시: 사용자 스레드와 동시에 마킹. mapped0, mapped1 을 번갈아 가며 마킹해서 사이클 간 살아남은 객체를 구분.
  2. 동시 재배치 준비: 청소할 리전을 선정해 재배치 집합 구성.
  3. 동시 재배치: 살아남은 객체를 새 ZPage 로 복사하고 포워딩 테이블을 건다.
  4. 동시 재매핑: 남은 옛 참조를 정리. 자가 치유 덕분에 다음 사이클의 동시 표시와 맞물려도 안전하다.

ZGC의 STW는 보통 1ms 미만이다. 응답 지연이 중요한 실시간 시스템, 대용량 캐시 서비스에 적합하다.

Shenandoah

Shenandoah는 RedHat이 주도하는 초저지연 GC다. G1 처럼 리전을 쓰지만 세대 구분이 없다. 핵심은 브룩스 포인터(Brooks Pointer)와 읽기 배리어다.

객체 헤더에 브룩스 포인터를 두고, 평소에는 자기 자신을 가리킨다(STEP 1). 객체가 복사되면 옛 객체의 브룩스 포인터가 새 위치를 가리키도록 갱신된다(STEP 2). 그래서 옛 주소로 접근해도 브룩스 포인터를 따라 새 위치로 갈 수 있다(STEP 3). ZGC의 포워딩 테이블과 비슷한 아이디어다.

코드 예시

1. 도메인 로직에서 흔히 보는 객체 생성과 GC 패턴

@Service
@RequiredArgsConstructor
public class OrderHistoryService {
    private final OrderRepository orderRepository;
 
    // 매 요청마다 다량의 단명 객체가 생성되었다가 곧 죽는다 (Young 에서 회수)
    public List<OrderHistoryView> recentHistory(Long userId) {
        return orderRepository.findByUserId(userId).stream()
            .map(OrderHistoryView::from)      // DTO 변환 객체
            .filter(v -> !v.cancelled())
            .toList();
    }
}

대부분의 객체는 Young에서 죽기 때문에 Minor GC의 빈도와 길이만 신경 쓰면 충분하다. 문제는 의도치 않게 Old 로 올라가는 객체다. 예를 들어 캐시 누수, ThreadLocal 미정리, 이벤트 리스너 미해제는 단명해야 할 객체를 Old 로 끌어올린다.

2. JVM 메모리 옵션(Spring Boot Dockerfile)

FROM eclipse-temurin:21-jre
 
COPY app.jar /app/app.jar
 
ENTRYPOINT ["java", \
  "-XX:+UseContainerSupport", \
  "-XX:MaxRAMPercentage=75.0", \
  "-XX:InitialRAMPercentage=75.0", \
  "-XX:MaxMetaspaceSize=256m", \
  "-XX:+UseG1GC", \
  "-XX:MaxGCPauseMillis=200", \
  "-XX:+HeapDumpOnOutOfMemoryError", \
  "-XX:HeapDumpPath=/var/log/heapdump.hprof", \
  "-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags", \
  "-jar", "/app/app.jar"]

컨테이너 환경에서는 절대값 -Xms-Xmx 보다 -XX:MaxRAMPercentage 가 안전하다. 파드 메모리 limit이 바뀌어도 자동으로 따라간다.

3. GC 로그와 힙 덤프 모니터링

# 실행 중인 JVM 의 힙 사용량 확인
jcmd <pid> GC.heap_info
 
# 클래스 단위 인스턴스 수와 점유 메모리
jcmd <pid> GC.class_histogram | head -30
 
# 네이티브 메모리 추적 (-XX:NativeMemoryTracking=detail 켜져 있어야 함)
jcmd <pid> VM.native_memory summary
 
# GC 강제 트리거 (운영에서는 신중하게)
jcmd <pid> GC.run
 
# 힙 덤프 즉시 생성
jcmd <pid> GC.heap_dump /tmp/heap.hprof

jstat -gcutil <pid> 1000 을 1초 주기로 돌려서 Young과 Old 사용률, GC 빈도, 누적 GC 시간을 모니터링할 수도 있다.

4. application.yml: Spring Boot 에서 진단 엔드포인트 노출

# application.yml. 런타임 GC 옵션은 보통 환경변수나 Dockerfile 에서 잡지만
# 진단 엔드포인트는 actuator 로 노출해 장애 시 원격으로 덤프를 받는다
spring:
  application:
    name: order-service
 
management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, heapdump, threaddump
  metrics:
    enable:
      jvm: true

/actuator/heapdump/actuator/threaddump 를 노출해 두면, 장애 시 별도 SSH 없이도 힙 덤프와 스레드 덤프를 받을 수 있다. 다만 인증을 반드시 걸어둬야 한다.

메모리 관련 JVM 옵션 모음

힙 크기

옵션설명
-Xms최소(초기) 힙 크기
-Xmx최대 힙 크기
-XX:InitialRAMPercentage컨테이너 메모리 대비 초기 힙 비율
-XX:MaxRAMPercentage컨테이너 메모리 대비 최대 힙 비율
-XX:+UseContainerSupportcgroup의 메모리, CPU 제한 인식 (JDK 10+ 기본 활성)

Metaspace

옵션설명
-XX:MetaspaceSize초기 Metaspace 크기 (이 값을 넘으면 첫 Full GC 트리거)
-XX:MaxMetaspaceSizeMetaspace 최대 크기. 꼭 설정해야 OS 메모리 잠식 방지
-XX:MaxMetaspaceFreeRatioMetaspace 점유율 임계 비율

GC 선택과 튜닝

옵션설명
-XX:+UseG1GCG1 GC 사용(JDK 9+ 기본)
-XX:+UseZGCZGC 사용
-XX:+UseShenandoahGCShenandoah GC 사용
-XX:MaxGCPauseMillis목표 일시 정지 시간(G1/ZGC 에서 의미)
-Xlog:gc*GC 로그 출력(JDK 9 통합 로깅)

진단과 디버깅

옵션설명
-XX:+HeapDumpOnOutOfMemoryErrorOOME 발생 시 힙 덤프 자동 생성
-XX:HeapDumpPath힙 덤프 저장 경로
-XX:NativeMemoryTracking=detail네이티브 메모리 사용 상세 추적
-XX:+UnlockDiagnosticVMOptions진단용 숨겨진 옵션 해제

OOME 패턴과 진단

JVM 메모리는 영역별로 OOME가 발생할 수 있다. 메시지로 어느 영역이 문제인지 식별할 수 있어야 한다.

1. 자바 힙 오버플로

  • 에러: java.lang.OutOfMemoryError: Java heap space
  • 원인: 객체가 계속 생성되며 힙 한계를 넘어섬. 대부분 메모리 누수.
  • 조사: 힙 덤프(-XX:+HeapDumpOnOutOfMemoryError)를 받아 어떤 객체가 비정상적으로 많이 살아 있는지, 어느 GC Root에서 참조 사슬이 끊어지지 않는지 확인. Eclipse MAT 또는 IntelliJ Profiler로 분석.

2. 스택 오버플로

  • 에러: java.lang.StackOverflowError
  • 원인: 스레드 스택 깊이가 허용 최대치를 초과. 주로 재귀 호출이 종료 조건 없이 반복될 때.
  • 에러: java.lang.OutOfMemoryError: unable to create native thread
  • 원인: 스레드를 너무 많이 만들어 커널 자원이 고갈됨. 스레드 풀 미사용, 라이브러리 누수 의심.

3. Metaspace 오버플로

  • 에러: java.lang.OutOfMemoryError: Metaspace
  • 원인: 동적으로 로드되는 클래스가 너무 많을 때 발생. Spring, Hibernate, CGLib 등이 리플렉션이나 바이트코드 조작으로 런타임에 클래스를 생성하면 Metaspace를 차지한다.
  • 대응: -XX:MaxMetaspaceSize 로 상한을 설정하고, 클래스로더 누수 여부 확인. 특히 핫 리로드를 자주 하는 개발 환경에서 잘 발생한다.

4. 다이렉트 메모리 오버플로

  • 에러: java.lang.OutOfMemoryError: Direct buffer memory
  • 원인: NIO DirectByteBuffer, Netty의 직접 버퍼 등이 OS 메모리를 할당한 뒤 회수되지 않음. 힙 덤프에는 흔적이 잘 남지 않는다.
  • 조사: 힙 덤프가 깨끗한데 OOME 가 나면 다이렉트 메모리 의심. -XX:NativeMemoryTracking=detailjcmd VM.native_memory 로 추적.

주의사항과 베스트프랙티스

1. -Xms-Xmx 는 같은 값으로

GC 도중 힙 크기를 재조정하는 오버헤드가 사라진다. 운영 환경에서 권장되는 패턴이다.

2. -XX:MaxMetaspaceSize 는 반드시 설정

미설정 시 OS 메모리 한계까지 늘어나 호스트가 OOM-Killer 대상이 될 수 있다. 컨테이너 환경에서는 파드 자체가 죽는다.

3. -XX:+HeapDumpOnOutOfMemoryError 는 항상 켜둔다

사후 분석 비용 대비 디스크 비용은 무시할 수 있다. 단, 힙 덤프 경로는 컨테이너 외부 볼륨으로 마운트해 두어야 파드 재시작 시 덤프가 사라지지 않는다.

4. 컨테이너에서는 절대값 대신 Percentage

파드 리소스 limit 이 바뀌어도 자동으로 따라간다. -Xms2g -Xmx2g 대신 -XX:InitialRAMPercentage=75 -XX:MaxRAMPercentage=75 가 안전하다.

5. GC 로그는 기본

-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags 로 GC 로그를 남기면 성능 이슈 추적에 결정적이다. GCViewer, GCEasy 같은 도구로 분석 가능하다.

6. Old 로 빠르게 승격되는 객체 주의

  • 캐시(Map<K, V>)에 객체를 무한정 쌓는다(메모리 누수).
  • ThreadLocal 을 remove() 하지 않는다(스레드 풀에서 누적).
  • 이벤트 리스너 등록 후 해제하지 않는다(등록자 객체 살아남음).

이 패턴들이 모이면 Old 가 차고 Full GC 가 자주 일어난다. Full GC 가 자주 일어나면 STW 가 길어지고, 결국 API 응답이 느려진다.

7. Full GC 가 잦으면 우선 메모리 누수 의심

GC 튜닝(Xmx 늘리기, GC 알고리즘 바꾸기) 전에 힙 덤프를 받아 누수 원인부터 찾아야 한다. 누수가 있는 상태에서 Xmx 만 키우면 장애 시점이 미뤄질 뿐 근본 해결이 안 된다.

8. ZGC 와 Shenandoah 는 응답 지연이 중요할 때만

처리량(throughput) 관점에서는 G1 이 여전히 가장 균형이 좋다. ZGC 와 Shenandoah 는 sub-ms 지연이 필요한 경우에만 검토한다. 처리량을 1~10% 정도 양보하는 트레이드오프가 있다.

9. 객체 풀링은 단명 객체에 쓰지 않는다

객체 생성 비용을 줄이자며 Pool 을 만드는 패턴이 있는데, Young GC 는 매우 저렴하다. 객체 풀링은 오히려 Old 로 객체를 올려서 Full GC 부담을 늘릴 수 있다. 대용량 버퍼, 커넥션처럼 명시적으로 비용이 큰 리소스에만 적용한다.