가비지 컬렉션(GC) 해부: 자바 개발자가 꼭 알아야 할 메모리 관리의 모든 것

자바 기반의 Spring Boot로 백엔드 개발을 하다 보면 어느 순간 애플리케이션이 느려지거나 식은땀 나게 만드는 OutOfMemoryError같은 에러를 한번씩은 봤을 것이다. 이 때 GC의 동작 원리를 정확히 알고 있다면 문제의 원인을 찾아 해결하는데 많은 도움이 된다. 이번 포스팅에서는 자바 애플리케이션을 개발하면서도 막연히 알고 있던 가비지 컬렉션(GC)에 대해서 정리해 보고자 한다.

GC를 배워야 하는 이유

자바를 처음 배우면 가장 먼저 듣는 장점 중 하나가 바로 “자동 메모리 관리”다. C 언어나 C++같은 언어에서는 개발자가 malloc()이나 calloc()으로 메모리를 동적으로 할당하고 free()로 직접 동적 메모리를 해제해야 한다. 이는 개발자가 실수로 메모리 해제 코드를 깜빡하게 되면 메모리 누수가 발생하는 원인이 될 수 있다. 혹은 이미 해제된 메모리를 참조하면 Segmentation Fault와 함께 프로세스가 종료되는 무시무시한 일을 겪을 수도 있다. 자바는 이러한 위험한 작업을 JVM의 GC에게 일임함으로써 자동으로 메모리 관리를 하도록 하여 비즈니스 로직에 조금 더 집중할 수 있도록 한다.
하지만 GC가 메모리를 정리하는 동안 애플리케이션은 잠시 멈춰야 하는 Stop-The-World(STW)가 발생할 수 있고, GC가 빈번하게 발생하거나 너무 오래 돌게되면 애플리케이션의 성능에 막대한 영향을 주게 된다.
그래서 우리는 GC가 언제, 어떻게, 왜 동작하는지 알아야 이러한 문제에 대처할 수 있는 것이다.

상호배타적 의미

개발자가 메모리를 직접 관리하게 되면 할당과 해제에 조금 더 신경을 써야 겠지만 자바의 GC와 같은 방식에 대한 학습은 덜 필요하겠지? 반대로 자바는 GC를 통해 개발자가 직접 메모리 할당과 해제에 신경쓸 필요는 없지만 내부 GC의 동작 원리에 대해서 이해하고 있어야 하는 학습 곡선이 생긴다. 어쨌든 자바로 개발을 하기로 한 이상 GC는 필수적으로 알아야 할 요소다.

자바 메모리 구조

GC를 이해하려면 먼저 GC가 활동하는 무대인 JVM의 메모리 구조를 알아야 한다. JVM이 운영체제로부터 할당 받은 메모리는 크게 5가지 영역으로 나뉘는데 아래 그림은 Java8 이상 버전의 JVM메모리 구조를 나타낸다.

힙 영역 (Heap Area)

  • 모든 스레드가 공유하는 영역이다.
  • new로 생성된 모든 객체(Instance)와 배열이 저장된다.
  • Java8 부터는 정적변수(static variable)와 문자열 풀(String Pool)도 이곳에 상주하고 GC의 주요 타겟이 된다.

메서드 영역 (Method Area / Metaspace)

  • 모든 스레드가 공유하는 영역이다.
  • 클래스 이름, 부모 클래스 이름, 메서드 정보, 바이트코드등 클래스 메타데이터가 저장된다.
  • Java8 부터는 Metaspace라는 이름으로 불리며 JVM 힙이 아닌 OS의 네이티브 메모리를 사용한다.

스택 (Stack Area)

  • 각 스레드마다 별도로 생성된다. (공유 안 됨)
  • 메서드 실행시 필요한 지역변수, 매개변수, 리턴 값등이 임시로 저장된다.
  • 메서드 수행이 끝나면 즉시 삭제되어 깨끗하게 관리된다. GC의 대상이 아니다.

PC 레지스터

  • 각 스레드마다 존재한다.
  • 현재 스레드가 실행 중인 JVM명령(Instruction)의 주소를 기록한다.
  • CPU의 레지스터와 유사하게 다음에 실행할 코드 라인을 가리키는 포인터 역할을 한다.

네이티브 메서드 스택

  • 각 스레드마다 존재한다.
  • 자바가 아닌 다른 언어(C, C++등)으로 개발된 네이티브 코드를 실행할 때 사용하는 스택이다.
  • JNI를 통해 호출되는 메서드를 위한 공간이다. 네이티브 영역이라 GC의 대상이 아니다.

이 5가지 영역 중에서 특히 힙(Heap)과 스택(Stack) 영역의 상호작용이 자바 프로그래밍의 핵심이라고 할 수 있다.

메서드 영역 PermGen에서 Metaspace로 진화

자바8 이전과 이후 메모리 구조에 변화가 있었는데 주요 변화는 다음과 같다.

  • Java8 이전 (PermGen)
    • 클래스 메타데이터(클래스 이름, 메서드 정보등)와 정적 변수, 상수 풀 등이 PermGen이라는 영역에 저장되었다.
    • PermGen은 힙의 일부처럼 관리되고 크기가 고정이라서 정적 변수를 너무 많이 쓰거나 클래스를 많이 로딩하면 OutOfMemoryError 에러가 발생하여 꽤나 골칫거리였다.
  • Java8 이후 (Metaspace)
    • 기존의 PermGen에 저장되던 클래스 메타데이터 정보가 이곳에 저장된다.
    • PermGen이 사라지고 Metaspace가 등장했다.
    • Metaspace는 JVM의 힙 영역이 아닌 OS의 메모리를 사용하는데 RAM이 허용하는 한 크기가 동적으로 늘어날 수 있기 때문에 로딩되는 클래스가 많더라도 OutofMemoryError 발생 위험을 크게 줄일 수 있다.

클래스 메타 정보 이외에 정적 변수와 상수 풀 데이터는 Heap 영역으로 이동되어 과거의 PermGen에서 GC가 잘 안되던 부분을 더 명확한 GC 관리 대상으로 이전을 했다.

가비지 컬렉터(GC)의 동작 원리

이제 GC가 어떻게 동작하는지 살펴보자. GC가 하는 일은 단순하게 생각하면 쓰레기 찾기버리기다.

Reachability: 무엇이 쓰레기지?

GC는 어떤 객체를 삭제해야 할지 어떻게 판단할까? 자바는 도달 가능성(Reachability)라는 개념을 사용한다.

  • Reachable: 누군가 자신을 참조하고 있어서 유효한 상태
  • Unreachable: 아무도 자신을 참조하고 있지 않아서 쓰레기로 전락할 상태 – 수거 대상 (Garbage)

그렇다면 “참조하고 있다”는 건 어디서부터 참조를 말하는 걸까? 그 시작점이 되는 객체들을 GC Root이라고 부른다. GC Root에서 시작해서 참조 연결이 이어지는 객체는 살려두고 이어지지 않는 객체는 버린다.

대표적인 GC Root

  1. 스택(Stack)의 지역 변수 및 매개변수: 현재 실행 중인 메서드 안에서 사용 중인 변수들이 가리키는 객체는 당연히 살려야 한다.
  2. 활성 스레드(Active Thread): 실행 중인 스레드 자체도 루트가 된다.
  3. 정적 변수(Static Variable): 클래스의 static 필드에서 참조하는 객체들은 애플리케이션이 끝날 때까지 살아있어야 하는 경우가 많다.
  4. JNI 참조: C/C++ 같은 네이티브 코드에서 만든 객체 참조다.

GC Root가 될 수 있는 것들은 대부분 힙 영역 밖에 있는 존재들이다. 힙 밖에서 힙 안을 가리키는 화살표가 바로 생명줄이다.

Mark-and-Sweep

GC는 기본적으로 Mark-and-Sweep 알고리즘을 사용하는데 개념은 다음과 같다.

  1. Mark(표시하기): GC는 모든 GC Root에서 시작해서 참조된 객체들을 쭉 살펴본다. 참조가 유지되는 객체는 마크를 해둔다.
  2. Sweep(쓸어담기): 힙 메모리 전체를 훑으면서 마크가 없는 객체들은 메모리에서 해제한다.
  3. Compact(압축하기): 중간중간에 해제된 메모리 때문에 빈공간이 생기는 이른바 메모리 파편화가 발생하는데 파편화가 심할 수록 사이즈가 큰 객체를 만들때 자리가 없기 때문에 문제가 생길 수 있다. 그래서 생존한 객체들을 한쪽으로 모아서 빈 공간을 크게 확보하는 작업을 말한다.

Stop-The-World (STW)

GC가 열심히 청소를 하는 동안 애플리케이션의 모든 스레드는 일시 정지한다. 엄마가 방을 청소하고 있는데 아이가 계속 어지럽히면 정리가 안된다고 생각하면 쉽다. GC가 객체의 생존 여부를 판단하고 메모리를 옮기는 중에(Compaction) 애플리케이션이 새로운 객체를 만들거나 참조를 바꾸면 꼬일 수 있기 때문이다.
우리가 흔히 말하는 GC 튜닝이 바로 이 STW 시간을 줄여 모든 스레드가 멈추는 시간을 최소화하기 위한 노력인 것이다.

가비지 컬렉션(GC) 매카니즘

이제 조금 더 깊게 GC의 동작에 대해서 살펴보자. 자바는 효율적인 GC를 위해 다음과 같은 전제를 두고 작동한다.

  • 대부분의 객체의 생명 주기는 짧을 것이다. (대부분의 객체가 참조된 이후 금방 Unreachable 상태가 될 것이다.)
  • 오래된 객체에서 새로운 객체로의 참조는 매우 적게 발생할 것이다.

쉽게 말해 우리가 for문 안에서 만드는 임시 변수나 DTO 같은 것들은 잠깐 쓰이고 바로 버려질 것이다. 그래서 JVM은 힙을 Young Generation과 Old Generation으로 나누어 관리한다.

Young Generation 영역

Young Generation 영역은 3개의 구역으로 나눈다.

  • Eden: 객체가 처음 생성되면 상주하는 곳이다. (태어나는 곳)
  • Survivor0 (S0) & Survivor1 (S1): Eden에서 살아남은 객체들이 옮겨지는 곳이다.

Eden 영역이 모두 차면 Minor GC가 일어나는데 Minor GC의 목적은 Young Generation 영역(Eden + S0 + S1) 전체를 청소하는 것이다. 애플리케이션 구동 후 GC가 처음 일어날 때 Young Generation의 동작과정은 다음과 같다.
처음 GC가 발생할 때는 S0, S1 영역에는 객체가 없기 때문에 S0, S1 영역은 보지 않을 것이다.

  1. 새로 태어난 객체는 무조건 Eden 영역에 상주한다.
  2. Eden이 모두 차면 Minor GC가 발생한다.
  3. Eden에 있는 객체 중 reachable한 객체만 골라서 비어있는 S0 영역으로 복사한다.
  4. S0으로 이동한 객체의 Age를 증가시킨다.
  5. Eden의 나머지 쓰레기(unreachable) 객체들은 싹 정리한다.

다음 GC부터는 Young Generation은 다음과 같이 동작한다.

  1. Eden 영역과 S0 영역의 모든 reachable한 객체를 S1으로 이동시킨다.
  2. S1으로 이동한 객체의 Age를 증가시킨다.
  3. Eden 영역과 S0 영역의 모든 쓰레기(unreachable) 객체들을 정리한다.
  4. 다음 Minor GC에서 Eden과 S1의 모든 reachable한 객체를 S0로 이동시킨다.
  5. S0으로 이동한 객체의 Age를 증가시킨다.

위와 같은 과정을 반복하면서 살아남은 객체들은 S0와 S1을 반복적으로 이동하게 되고 그 때마다 Age가 증가 된다.
어느 정도 나이(Age)를 먹은 객체들은 특정 값을 넘어서는 순간 Old 세대가 된다.

S0와 S1을 반복적으로 이동하면서 한쪽 Survivor 영역을 비우는 이유가 바로 파편화를 방지하기 위한 것이다.

Old Generation 영역

Young 영역에서 오랫동안 살아남은 객체들은 나이(Age)가 기준을 넘기면 Old Generation 영역으로 이동하게 된다. 이것을 Promotion(승급)이라고 하며 Old 영역은 Young 영역보다 크기가 크다. 만약 Old Generation이 가득 차게되면 Major GC 혹은 Full GC가 발생하는데 Major GC는 힙 전체를 청소하고 Minor GC보다 STW 시간이 더 길다. 앞선 전제를 통해 봤을 때 Major GC가 자주 발생한다는 것은 심각한 성능 저하가 발생할 수 있으며 어디선가 메모리 누수가 발생하고 있음을 의심해 봐야 한다.

Promotion: 승급 조건과 MaxTenuringThreshold

객체가 Old 영역으로 가기 위한 나이 기준 값은 MaxTenuringThreshold 라는 옵션으로 결정된다.

  • 기본값: 보통 15이고 Minor GC를 15번 버텨야 Old 영역으로 갈 자격이 주어진다.
  • 동적 조정: JVM은 상황에 따라 이 값을 유동적으로 줄이기도 한다. 만약 Survivor 영역의 메모리가 부족하면 나이가 15가 안되더라도 Old 영역으로 보낼 수 있다.
New 라고 표시된 새로운 객체들이 Young Generation내의 Eden 영역으로 들어와 쌓인다. Survivor0(S0), Survivor(S1), Old Generation영역은 아직 비어있는 상태다.
Eden 영역이 객체로 가득차면 Minor GC가 발생한다. 이때 사용되지 않는 객체는 제거되고 살아있는 객체는 Survivor 영역 (여기서는 S0)으로 이동하면 나이(Age)가 1 증가한다.
Survivor 영역에서 여러 번의 Minor GC를 거치며 살아남아 특정 나이에 도달한 객체들은 Old Generation으로 이동한다. (Promotion) 
Old Generation이 가득 찬 상태에서 Major GC가 발생한다. 사용되지 않는 객체들은 제거되고(흐린 표현) 살아남은 객체들은 한쪽으로 모여 압축한다. 이 과정은 Old Generation의 공간을 효율적으로 확보한다.

다양한 GC 알고리즘

자바는 사용 환경에 맞춰 다양한 GC 알고리즘을 제공한다. 상황에 맞는 도구를 선택하는 것이 성능 최적화의 지름길이 될 수 있다.

Serial GC & Parallel GC

  • Serial GC (-XX: +UseSerialGC)
    • GC 스레드가 딱 1개다.
    • 구조가 단순하지만 GC가 일어나는 동안 STW 시간이 길다. (청소부가 1명이라 청소하는 시간이 오래 걸림)
    • CPU 코어가 1개인 환경이나 메모리가 아주 적은 경우에 적합하다.
    • 웹 서버와 같이 수많은 요청을 동시에 처리해야 하는 환경이 아닌 혼자 돌아가는 애플리케이션이나 잠깐 실행되고 종료되는 애플리케이션과 같이 단발성 CLI툴이나 배치 프로세스에서 사용하면 좋을 것 같다.
  • Parallel GC (-XX: +UseParallelGC)
    • Java8의 디폴트 GC다.
    • Young 영역의 GC 작업을 여러 스레드가 병렬로 처리한다. (Old 영역은 설정에 따라 다름)
    • 처리량을 중시하는 배치 작업이나 데이터 분석 작업에 유리하다.

CMS GC (-XX: +UseConcMarkSweepGC)

  • STW 시간을 최소화하기 위해 애플리케이션 실행 중에 백그라운드에서 GC 작업을 동시에 수행한다.
  • 하지만 CPU 소모가 많고 메모리 파편화가 자주 발생한다는 단점이 있다.
  • Java9 부터는 Deprecated 되었고, Java14에서 완전히 삭제됐다.

G1(Garbage First) GC (-XX: +UseG1GC)

  • 현 시점 가장 많이 사용되고 있는 GC 알고리즘 이다.
  • Java9 부터 현재(Java25) 까지 기본 GC로 사용되고 있다.
  • 이전처럼 힙을 물리적인 Young/Old 영역으로 나누지 않고 Region이라는 작은 사각형 블록들로 바둑판처럼 쪼갠것이 특징이다.
  • 각 Region에게 Young이나 Old 역할을 동적으로 부여한다.
  • 이름 처럼 Garbage가 많은 Region을 먼저(First) 청소하여 효율이 매우 좋다.
  • 대용량 메모리에서도 예측 가능한 짧은 중단 시간을 제공하여 웹 애플리케이션에서 가장 권장되는 알고리즘이다.
  • Eden을 더 늘리면 다음 GC에서 Survivor / Promotion을 감당할 수 없다고 판단될 때 GC가 발생한다.
  • Young GC가 발생하다가 Old 영역이 어느정도 쌓이면 Mixed GC가 발생한다.

Region의 크기

G1GC의 Region의 크기는 고정된 크기가 아닌 힙 크기에 따라 자동으로 결정된다.

  1. 2048개의 법칙
    • JVM은 시작할 때 전체 힙 메모리를 약 2048개의 Region으로 나누려고 시도한다.
      (Region Size = Total Heap Size / 2048)
  2. 범위의 제한
    • 계산된 값이 아무리 작거나 커도 Region의 크기는 반드시 최소 1MB에서 최대 32MB 사이여야 한다.
  3. 2의 거듭제곱
    • 크기는 반드시 1, 2, 4, 8, 16, 32 MB중 하나여야 한다.
힙 크기(Xmx)계산된 Region 사이즈실제 Region 사이즈
1 GB0.5 MB1 MB (최소값)
2 GB1 MB1 MB
4 GB2 MB2 MB
8 GB4 MB4 MB
16 GB8 MB8 MB
32 GB16 MB16 MB
64 GB 이상32 MB 이상32 MB (최대값)

G1GC의 동작 사이클

G1GC는 크게 다음 3가지 단계를 순환하며 동작한다.

  1. Young Only Phase (평상시 모드)
    • 애플리케이션이 실행되는 대부분의 시간 동안 G1GC는 이 모드에 있다.
      • 동작: 오직 Young 영역(Eden + Survivor)만 청소한다.
      • 특징: Old 영역은 건드리지 않는다. 새로 생긴 객체만 빠르게 청소하고, 살아남은 객체는 Old 영역으로 Promotion 시킨다.
  2. Concurrent Marking Cycle (전환 준비 단계)
    • Young GC만 계속하다 보면 언젠가 Old 영역에 객체가 쌓이게 된다. Old 영역의 사용량이 특정 임계치(기본 45%)를 넘어서면 G1GC는 Old 영역 청소 준비를 한다.
      • 동작: 애플리케이션 실행과 동시에(Concurrent) Old 영역에 있는 객체들 중 살아있는 객체가 무엇인지 파악(Marking) 하기 시작한다.
      • 특징: 이 때도 Young GC는 계속 발생한다. Marking 작업은 백그라운드에서 조용히 이루어진다.
  3. Mixed GC Phase (대청소 모드)
    • Marking이 끝나서 어느 Old Region에 쓰레기가 많은지 파악이 완료되면 Mixed GC 단계로 진입한다.
      • 동작: 모든 Young 영역 + 쓰레기가 많은 일부 Old 영역을 합쳐서 동시에 청소한다.
      • 특징: 한 번의 Mixed GC로 모든 Old 영역을 다 치우는게 아니다. 목표한 만큼 메모리가 확보될 때까지 여러 번의 Mixed GC를 나누어 수행한다.
      • 종료: 충분히 메모리가 확보되면 다시 Young Only Phase로 돌아간다.
1. 초기상태
힙 메모리가 여러 개의 Region으로 나누어져 있다. 각 셀의 알파벳은 해당 Region의 현재 역할을 나타낸다.

E: Eden Region
S: Suvivor Region
O: Old Region
흰색E: 빈 Region
2. Young GC
Eden 영역이 꽉 차면 GC가 발생한다.(Eden을 더 늘리면 다음 GC에서 Survivor / Promotion을 감당할 수 없다고 판단될 때)
이 때 Eden과 일부 Survivor 영역이나 Old영역으로 복사하고 복사가 완료되면 원래의 Region은 Empty 상태가 된다. (이미지에서 S -> E로의 화살표는 잘못 표기된 것이니 무시하기 바란다.)
3. Mixed GC(Young + Old 회수)
GC가 수행될 때 쓰레기가 가장 많은 Old Region도 함께 선택하여 청소한다. (Mixed GC)
이를 통해 전체 힙을 멈추는 Major GC의 빈도를 획기적으로 줄일 수 있게 된다.
(여기서도 S -> E 방향의 화살표는 잘못 표기된 것이니 무시하기 바란다.)

객체는 항상 다름과 같은 한 방향으로 흐른다.

  • New -> Eden (생성)
  • Eden -> Survivor (생존, 여러 Survivor Region으로 분산되어 이동한다)
  • Survivor -> Survivor (또 생존, 여러 Survivor Region으로 분산되어 이동한다)
  • Survivor -> Old (장수, Promotion, 여러 Old Region으로 분산되어 이동한다)

ZGC (Z Garbage Collector) (-XX: +UseZGC)

  • G1GC 처럼 전체 힙 메모리를 잘게 쪼개서 관리하는 Region 구조를 기반으로 한다.
  • Java11(실험적), Java15(정식)부터 도입된 차세대 GC다.
  • 목표: 힙 크기가 수 테라바이트(TB)가 되어도 STW시간을 10ms 이하로 유지하는 것이 목표다.
  • 원리: Colored Pointers(포인터에 색깔 정보를 입힘)와 Load Barriers(객체을 읽을 때 끼어들어 처리)라는 고급 기술을 사용한다.
  • 현재 OpenJDK 계열에서 STW를 가장 짧고 예측 가능하게 만든 GC 알고리즘이다.
  • Low Latency(저지연)가 생명인 실시간 서비스에 적합하다.
  • G1GC에 비해서 CPU 소모가 더 많다.
  • 64bit 시스템에서 사용가능하다. (32bit 시스템은 사용 불가)
  • 핵심 키워드는 Colored Pointer, Load Barrier, Concurrent Reallocation 이다.

ZGC가 애플리케이션 중지 없이 수행되는 메카니즘

아래 그림은 ZGC가 애플리케이션 중지 없이 GC가 일어날 수 있는지 설명하는 그림이다.

1. Colored Pointer (포인터에 색깔 입히기)
64bit 객체 참조 포인터의 상위 비트들이 Color Bits로 사용된다. 이 비트들은 객체가 살아있는지 (Marked0/1), 혹은 재배치(이동) 되었는지(Remapped)를 나타낸다. 이 정보를 통해 ZGC는 객체 참조 포인터만 보고 객체의 상태를 알 수 있다.
2. Load Barrier (접근 검문소)
애플리케이션 스레드가 객체에 접근하면 Load Barrier가 참조 포인터의 색깔을 확인한다. 색깔이 올바른 상태라면 바로 통과 하여 객체에 접근한다. 잘못된 상태(예: GC가 객체를 이동시키는 중)라면 잠시 멈춰 포인터를 올바르게 고친 후(Slow Path) 객체에 접근한다.
3. Concurrent Relocation
객체가 Old Region에서 New Region으로 이사를 갔다. 애플리케이션이 아직 Old Pointer(Bad Color)를 가지고 접근하려 하면 Load Barrier가 Forwarding Table(주소록)을 확인하여 새 주소를 찾아낸다. 그리고 포인터를 New Pointer(Good Color)로 업데이트 한 후 새 위치의 객체에 접근하게 한다. 이 모든 과정이 애플리케이션 멈춤 없이 아주 빠르게 일어난다.

ZGC가 STW를 가장 단축할 있는 GC인데 왜 기본 GC로 사용하지 않을까?

ZGC는 STW가장 최소화할 수 있는 GC는 맞지만 일반적으로 사용할 수 있는 범용적 GC가 아니다. G1GC는 중/대형 힙 크기에서도 비교적 안정적으로 사용할 수 있지만 ZGC는 중형 힙 크기에는 적당하지 않은 GC알고리즘이다. 즉 범용적으로 사용하기에는 G1GC에 비해서 부족하기 때문이다.

GC관련 JVM 옵션

애플리케이션 실행시 관련 GC 로그를 생성하고 GC 동작에 필요한 몇가지 옵션들이 있다.
Java 11이상 부터는 -XX:+PrintGCDetails, -XX:+PrintGCTimeStamps와 같은 옵션들은 폐기되고 -Xlog 하나로 통합되었다.

-Xlog:gc*:file=/var/log/gc.log:time,uptime,level,tags:filecount=10,filesize=10M
Plaintext

위 옵션은 GC관련 모든 로그를 /var/log/gc.log 파일에 기록하고 로그 파일에는 시간(time), JVM 시작 후 경과 시간(uptime), 로그 레벨(level), 로그가 어떤 영역에서 나온 것인지(tag) 정보를 기록한다. 또한 로그 파일 개수는 10개로 제한하고 로그 파일 사이즈는 10M로 제한한다. 파일 크기가 10M가 되면 자동으로 rotate 한다.

다음은 GC관련 주요 JVM 옵션 표다. G1GC 기준으로 작성하였다.

옵션설명기본값
-Xms힙 메모리의 초기 크기운영체제마다 다름
-Xmx힙 메모리의 최대 크기보통 Xms와 동일하게 설정
-XX:+UseG1GC (+UseZGC)G1GC 알고리즘 사용Java 9이상 기본 알고리즘
-XX:MaxGCPauseMillisGC 최대 멈춤 시간 목표 설정 (STW)G1GC 튜닝에서 가장 중요 (200ms)
-XX:NewRatioYoung:Old 영역 비율기본 2 (Old가 Young의 2배)
-XX:SurvivorRatioEden:Survivor 영역 비율기본 8 (Eden이 Survivor의 8배)
-XX:G1HeapRegionSize힙을 나누는 region의 크기 지정 (1 ~ 32MB, 2의 거듭제곱)자동 계산되지만 고정할 수도 있음
-XX:InitiatingHeapOccupancyPercentOld 영역이 전체 힙의 몇 %가 찼을 때 Mixed GC(Old 영역 청소)를 위한 marking을 시작할지 결정45% (OOM이 발생하거나 Old 영역이 급격히 찬다면 이 값을 줄여서 미리 청소하게 하는 것이 좋음)
-XX:G1NewSizePercentYoung 영역의 최소 크기 비율5%
-XX:G1MaxNewSizePercentYoung 영역의 최대 크기 비율60%
-XX:MaxTenuringThresholdOld 영역으로 이동할 Age 설정15
-XX:+HeapDumpOnOutOfMemoryErrorOOM 에러가 발생해서 서버가 죽을 때 죽기 직전의 힙 메모리 상태를 hprof 파일로 덤프를 뜬다.OOM 발생시 원인 파악을 위한 중요한 자료가 된다. (필수로 지정)
-XX:HeapDumpPath힙 덤프 파일의 경로 지정기본적으로 애플리케이션을 실행한 디렉토리에 생성되나 힙 덤프 파일의 사이즈가 크다는 것을 감안하여 디스크 크기가 여유있는 디렉토리로 지정을 하는 것이 좋음.
-XX:MetaspaceSize힙이 아닌 Metaspace 클래스 정보 저장 공간의 최대 크기를 제한약 21MB (128~256MB 정도 설정 권장)
-XX:+DisableExplicitGC코드상의 System.gc() 호출을 무시하는 옵션false
-XX:ParallelGCThreads청소할 스레드 개수CPU 코어 수에 따라 결정
(코어 수가 많다고 무작정 1:1로 늘리면 스레드 간 동기화 오버헤드가 커지므로 8개 이상 부터는 증가폭을 줄이는 것이 좋음)
-XX:ConcGCThreads백그라운드에서 Marking 작업을 수행할 스레드 수ParallelGCThreads 수치의 1/4

JVM의 기본값을 확인하고자 할 때 다음 명령이 도움이 될 수 있다.
java -XX:+PrintFlagsFinal -version

GC 동작 시뮬레이션

애플리케이션에서 GC 동작에 대해서 시각적으로 어떻게 동작하는지 살펴보자.
샘플 코드는 while loop 내에서 100KB byte 배열 인스턴스를 생성만 하도록 하는 변수가 있고 loop이 100번 수행될 때마다 static List에 100KB 크기의 byte 배열을 저장하도록 했다. 시간이 지날 수록 static List에는 100KB byte 배열이 누적되서 쌓일 것이고 static List에 5000개(500MB) 가 쌓이면 List를 clear 하도록 했을 때 G1GC의 GC 그래프는 어떻게 되는지 살펴보자. JVM 힙 메모리는 512MB를 설정하였다.

GC 동작에 대한 시각화는 VisualVM을 사용하였다. VisualVM에서 GC동작을 시각화하여 확인하려면 VisualVM에서 VisualGC 플러그인을 설치하면 된다.
VisualVM 실행 > Tools > Plugins > Available Plugins > VisualGC
테스트 애플리케이션을 실행하면 VisualVM 좌측 메뉴 Local 항목에 실행한 애플리케이션이 pid와 함께 목록에 나타난다. 해당 애플리케이션을 선택하여 상단의 VisualGC 탭을 선택하면 GC동작을 시각화하여 볼 수 있다.

샘플코드

public class GCLoadTest {
    // Old 영역으로 넘어갈 객체들을 담을 리스트 (메모리 누수 시뮬레이션)
    private static List<byte[]> oldGenList = new ArrayList<>();

    public static void main( String[] args ) throws InterruptedException {
        System.out.println("Start GC Load Test ...");

        int loopCount = 0;
        while ( true ) {
            // 1. Eden 영역에 할당될 짧은 생명주기 객체 (100KB) - 수거 대상
            byte[] shortLived = new byte[100 * 1024];

            // 2. 가끔씩 Old 영역으로 promotion될 객체 저장
            // 100 루프마다 100KB씩 Old 대상 생성
            if( loopCount % 100 == 0 ) {
                oldGenList.add( new byte[100 * 1024] );

                if ( oldGenList.size() % 100 == 0 ) {
                    System.out.println("Total retained count: " + oldGenList.size() );
                    System.out.println("Total retained size: " + ( oldGenList.size() * 100 / 1024 ) + "MB");
                    System.out.println("=============================");
                }
            }

            // 3. OOM 방지용 sleep
            Thread.sleep( 2 );
            loopCount++;

            // Old 영역 과도한 증가 방지 (테스트용으로 500MB가 차면 비움)
            if ( oldGenList.size() > 5000 ) {
                oldGenList.clear();
                System.out.println("Cleared old gen list. (Released Memory)");
            }
        }
    }
}
Java

위 코드를 컴파일하고 다음과 같이 실행해보자.

java -Xms512m -Xmx512m -XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-Xlog:"gc*:file=gc.log:time,uptime,level,tags" \
GCLoadTest
Bash

gc.log 파일의 첫 부분을 보면 다음과 같은 내용이 있다.

[2025-12-28T00:03:47.682+0900][0.012s][info][gc,heap] Heap region size: 1M
[2025-12-28T00:03:47.683+0900][0.013s][info][gc     ] Using G1
[2025-12-28T00:03:47.683+0900][0.013s][info][gc,heap,coops] Heap address: 0x00000007a0000000, size: 512 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
Plaintext

‘Heap region size: 1M’: Heap region 크기는 1MB (xmx 512MB 힙 크기에 대한 region 크기는 1MB다.)
‘Using G1’을 통해서 G1GC 알고리즘을 사용한다는 것을 확인할 수 있다.

첫번째 GC로그를 살펴보자.

[2025-12-28T00:03:48.341+0900][0.671s][info][gc,start     ] GC(0) Pause Young (Normal) (G1 Evacuation Pause)
[2025-12-28T00:03:48.341+0900][0.672s][info][gc,task      ] GC(0) Using 9 workers of 9 for evacuation
[2025-12-28T00:03:48.343+0900][0.673s][info][gc,phases    ] GC(0)   Pre Evacuate Collection Set: 0.0ms
[2025-12-28T00:03:48.343+0900][0.673s][info][gc,phases    ] GC(0)   Evacuate Collection Set: 1.5ms
[2025-12-28T00:03:48.343+0900][0.673s][info][gc,phases    ] GC(0)   Post Evacuate Collection Set: 0.2ms
[2025-12-28T00:03:48.343+0900][0.673s][info][gc,phases    ] GC(0)   Other: 0.7ms
[2025-12-28T00:03:48.343+0900][0.674s][info][gc,heap      ] GC(0) Eden regions: 25->0(306)
[2025-12-28T00:03:48.343+0900][0.674s][info][gc,heap      ] GC(0) Survivor regions: 0->1(4)
[2025-12-28T00:03:48.343+0900][0.674s][info][gc,heap      ] GC(0) Old regions: 0->0
[2025-12-28T00:03:48.343+0900][0.674s][info][gc,heap      ] GC(0) Humongous regions: 0->0
Plaintext

각 로그를 한줄씩 파악해보자.

Using 9 workers of 9 for evacuation
총 9개의 GC 스레드를 사용하여 병렬로 작업을 처리함.

총 GC 수행 시간은 다음과 같이 확인할 수 있다.
Pre Evacuate Collection Set: 0.0ms: 객체를 옮기기 전 청소할 리전을 고르는 시간
Evacuate Collection Set: 1.5ms: 실제로 메모리를 정리하는 시간
Post Evacuate Collection Set: 0.2ms: 이사 후 정리 시간
Other: 0.7ms: 그 외 자잘한 작업 시간 (이 시간이 비정상적으로 길다면 OS문제나 가상화 환경 문제를 의심해 볼수 있음)
총 GC에 소요된 시간 = STW 시간 = 2.4ms

Eden regions: 25 -> 0(306)
25개의 Eden region이 모두 비워져 0개가 되었다는 의미.

(306)과 같이 괄호 안의 값은 다음 GC가 수행될 때까지 채워질 Eden region의 목표 개수를 의미한다. 앞선 실행 옵션에서 -XX:MaxPauseMillis=200으로 지정했는데 25개의 region을 청소하는데 2.4ms 밖에 걸리지 않았기 때문에 목표 시간 200ms에 맞추려고 청소할 region 개수를 더 늘린 것이라고 보면 되겠다.

Survivor regions: 0 -> 1(4)
GC전 Survivor 영역은 비어있고 GC 후 살아남은 객체들이 1개의 Survivor 리전으로 이동했다는 의미.

(4)와 같이 괄호 안의 값은 다음에 306개의 Eden region을 정리할 때 살아남는 객체를 예상하여 미리 survivor region을 비워두기 위한 예약 개수라고 보면 되겠다.
(Survivor 영역이 4개가 될 때까지 기다리는 것이 아니다. GC가 발생했을 때 살아남은 객체를 이동하는 Survivor region이 1개만 사용되면 1개만 사용하는 것이고, 4개가 넘치도록 객체들이 살아남는 다면 그 때는 Old 영역으로 넘긴다.)

Old regions: 0 -> 0
생성된 old region은 없음을 의미.

이번엔 oldGenList 리스트의 개수가 거의 5000개에 육박할 시점의 GC로그를 살펴보자.

[2025-12-28T00:24:49.524+0900][1261.901s][info][gc,start       ] GC(1510) Pause Young (Normal) (G1 Evacuation Pause)
[2025-12-28T00:24:49.524+0900][1261.901s][info][gc,task        ] GC(1510) Using 9 workers of 9 for evacuation
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,phases      ] GC(1510)   Pre Evacuate Collection Set: 0.0ms
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,phases      ] GC(1510)   Evacuate Collection Set: 0.4ms
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,phases      ] GC(1510)   Post Evacuate Collection Set: 0.1ms
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,phases      ] GC(1510)   Other: 0.1ms
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,heap        ] GC(1510) Eden regions: 1->0(25)
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,heap        ] GC(1510) Survivor regions: 0->0(4)
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,heap        ] GC(1510) Old regions: 511->511
[2025-12-28T00:24:49.525+0900][1261.901s][info][gc,heap        ] GC(1510) Humongous regions: 0->0
Plaintext

Old regions: 511 -> 511
Old region이 511개로 거의 대부분의 힙을 차지하고 있다.

Survivor regions: 0 -> 0
Survivor region은 0개로 아마도 이 시점에는 Survivor을 거치지 않고 바로 Old region으로 객체가 이동하고 있는 것으로 보인다.

수행 시간은 초 0.6ms 밖에 걸리지 않았지만 이는 아마도 Old region이 대부분의 힙을 차지하고 있으므로 사용가능한 Eden region이 상당히 부족했을 것이다. 메모리 할당을 하기 위해서 GC가 매우 빈번하게 발생했을 것으로 예상된다. 실제로 이 시점의 GC로그를 보면 1초 안에서 쉬지않고 계속 GC가 발생함을 확인할 수 있다. 애플리케이션의 성능이 상당히 저하된 시점이라고 볼 수 있다.

VisualVM의 VisualGC를 통한 GC 그래프는 다음과 같다.

붉은색 네모 영역이 oldGenList가 clear()된 시점이다. 초반의 GC그래프가 짤려서 그렇지 초반의 그래프는 붉은색 네모 영역(메모리 초기화 시점)과 거의 동일하다고 보면 된다.
그래프를 보면 알 수 있듯이 oldGenList에 데이터가 쌓일 수록 Eden과 Survivor region의 개수는 점점 작어지고 Old region의 개수가 점점 늘어나는 것을 볼 수 있다. clear를 하지 않았다면 OutOfMemoryError가 발생했을 것이다.

VisualVM의 Sampler 탭을 보면 어떤 타입에서 메모리 누수가 발생하는지 확인할 수 있다.

Sampler 힙 메모리와 스레드 메모리를 통해서 대충 어디에서 누수가 발생하는지 짐작할 수 있다.
Sampler – 힙 메모리를 보면 byte[] 배열에 대부분의 힙 메모리가 할당되고 있음을 확인할 수 있고 스레드 메모리를 보면 main 스레드에서 대부분의 힙 메모리를 사용하고 있음을 알 수 있다.
실제 운영 환경에서는 OutOfMemoryError에 대해서 gc 로그 파일과 힙 덤프 파일을 VisualVM에 입력하여 분석하는 것이 많은 도움이 될 것이다.


지금까지 GC에 대한 내용을 정리해 봤다.
GC 로그를 분석해 본 적도 없고 막연히 GC가 중요한 부분이라고 생각만하고 있었지 세세하게 알고 있지는 않았다. 이번 포스팅을 작성하면서 GC에 대해서 본인이 가장 많이 배운 것 같다.
이 포스팅을 통해서 개발하는 서비스가 더 안정적이고 빠르게 동작하도록 하는데 도움이 되길 바란다.

참고 사이트

https://newrelic.com/blog/apm/java-garbage-collection
https://medium.com/@khurshidbek-bakhromjonov/java-memory-management-understanding-the-jvm-heap-method-area-stack-24a4d4fa2363
https://www.ibm.com/think/topics/garbage-collection-java
https://www.baeldung.com/jvm-static-storage