주제
자바 GC(Garbage Collector)의 동작 원리와 알고리즘별 특징 - G1 GC
동기
채팅 서비스 개발 중 RabbitMQ를 구현하면서 대규모 트래픽에 RabbitMQ의 메모리가 소진되어 다운되는 상황이 발생했다.
이때 JVM으로 돌아가고 있던 Java WAS는 어떻게 메모리를 관리하는걸까가 궁금해졌다.
평소 GC가 자동으로 쓰레기 메모리를 해제해준다고 알고있었는데 이걸 어떻게 아는지 궁금했다.
알게된 내용
1. GC (Garbage Collector)란?
[ Garbage Collector란? ]
GC는 Garbage Collector(가비지 컬렉터)으로 자동으로 객체에 할당된 메모리를 '자동으로' 해제해주는 친구이다.
C나 C++를 사용할 때를 생각해보면 동적으로 생성한 객체들에 대해서는 free나 delete로 따로 메모리를 해제해주어야 했다.
하지만 Java나 Kotlin에서는 이러한 메모리 누수를 방지하기 위해 가비지 컬렉터(Garbage Collector, GC)가 주기적으로 검사하여 메모리를 청소해준다.
이 메모리를 청소하는 친구를 Garbage Collector라 부르고 메모리를 청소하는 행위를 Garbate Collection이라 한다.
[ Minor GC와 Major GC ]
JVM의 Heap 영역은 처음 설계될 때 다음의 2가지를 전재(Weak Generational Hypothesis)로 설계되었다.
- 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
- 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.
- 새로운 객체에서 오래된 객체로의 참조가 아닌 오래된 객체에서 새로운 객체를 바라보는 것은 적게 존재한다는 의미
쉽게 말해서 객체는 대부분 일회성이고 메모리에 오랫동안 남아있는 경우는 드물다는 것이다. 그렇게 때문에 객체의 생존기간에 따라 물리적인 Heap 영역을 나누게 되었고 Young, Old 총 2개의 영역으로 설계되었다. 초기에는 Perm 영역이 존재했지만 Java8부터 제거되었다.

- Young 영역 (Young Generation)
- 새롭게 생성된 객체가 할당(Allocation) 되는 영역
- 대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 바로 사라진다.
- Young 영역에 대한 가비지 컬렉션을 Minor GC라고 부른다.
- Old 영역 (Old Generation)
- Young 영역에서 Reachable 상태를 유지해 살아남은 객체가 복사되는 영역
- Young 영역보다 크게 할당되며, 영역의 크기가 큰 만큼 가비지는 적게 발생한다.
- Old 영역에 대한 가비지 컬렉션을 Major GC라고 부른다.
Old 영역이 Young 영역보다 크게 할당되는 이유는 Young 영역의 수명이 짧은 객체들은 큰 공간을 필요로 하지 않고 큰 객체들은 바로 Old 영역에 할당되기 때문이다.
예외적으로 Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우도 존재할 것이다. 이러한 경우를 대비해 Old 영역에는 512 bytes의 덩어리(Chunk)로 되어 있는 카드 테이블(Card Table)이 존재한다.

카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 그에 대한 정보가 표시된다.
카드 테이블이 도입된 이유는 간단하다. Young 영역에서 Minor GC가 실행될 때 모든 Old 영역에 존재하는 객체를 검사하여 참조되지 않는 Young 영역의 객체를 식별하는 것이 비효율적이기 때문이다.
쉽게 말해서 Minor GC가 실행될 때, Old 영역의 객체가 참조하지 않는 Young 영역의 객체만을 찾아서 없애야 한다.
이걸 찾기 위해서 모든 Old 영역을 뒤져보는건 비효율적이기 때문에 card table의 dirty bit를 통해 Old 영역의 객체가 Young 객체를 참조하는지 빠르게 확인할 수 있고, 참조되는 Young 객체들을 표시한 후 나머지 Young 객체들을 제거할 수 있다
2. Garbage Collection(가비지 컬렉션)의 동작 방식)
[ Garbage Collection의 동작 방식 ]
Young 영역과 Old 영역은 서로 다른 메모리 구조로 되어있기에 세부적인 동작 방식은 다르다. 하지만 기본적으로 가비지 컬렉션이 실행된다고 하면 다음의 2가지 공통적인 단계를 따른다.
1. Stop The World
2. Mark and Sweep
1. Stop The World
stop the world는 GC를 실행하기 위해 JVM이 애플리케이션의 실행을 멈추는 작업이다.
GC가 실행될 때는 GC를 실행하는 쓰레드를 제외한 모든 쓰레드들의 작업이 중단되고, GC가 완료되면 작업이 재개된다.
당연히 GC를 제외한 모든 쓰레드가 중단되면 애플리케이션이 멈추기 때문에, GC의 성능 개선을 위해 튜닝을 한다고 하면 보통 stop the world의 시간을 줄이는 작업을 하는 것이다.
2. Mark and Sweep
- Mark: 사용되는 메모리와 사용되지 않는 메모리를 식별하는 작업
- Sweep: Mark 단계에서 사용되지 않는다고 식별된 메모리를 해제하는 작업
Stop The World를 통해 모든 작업을 중단시키면, GC는 스택의 모든 변수 or Reachable 객체를 스캔하면서 각각이 어떤 객체를 참고하고 있는지를 탐색하게 된다. 그리고 사용되고 있는 메모리를 식별하는데, 이 과정을 Mark라 한다.
이후에 Mark 되지 않은 객체들을 메모리에서 제거하는데 이 과정을 Sweep라고 한다.
[ Minor GC의 동작 방식 ]
Minor GC를 이해하기 위해서는 Young 영역의 힙 메모리 구조에 대해 이해를 해야한다. Young 영역은 1개의 Eden 영역과 2개의 Survivor 영역, 3가지로 나뉜다.
- Eden 영역: 새로 생성된 객체가 할당(Allocation) 되는 영역
- Survivor 영역: 최소 1번의 GC에서 살아남은 객체가 존재하는 영역
객체가 새롭게 생성되면 Young 영역의 Eden 영역에 할당된다. 그리고 Eden 영역이 꽉 차면 Minor GC가 발생하게된다.
이때 사용되지 않는 메모리(객체)는 해제되고 Eden 영역에 존재하는 객체는 (사용중인) Survivor 영역으로 옮겨진다.
Survivor 영역은 2개이지만 반드시 1개의 영역에만 데이터가 존재해야 하는데, Young 영역의 동작 순서를 자세히 살펴보자.
- 새로 생성된 객체가 Eden 영역에 할당된다.
- 객체가 계속 생성되어 Eden 영역이 꽉 차게되고 Minor GC가 실행된다.
- Eden 영역에서 사용되지 않는 객체의 메모리가 해제된다.
- Eden 영역에서 살아남은 객체는 1개의 Survivor 영역으로 이동된다.
- 1~2번의 과정이 반복되다가 Survivor 영역이 가득차게 되면(1개의 Survivor 영역) Survivor 영역의 살아남은 객체를 다른 Survivor 영역으로 이동시킨다.(1개의 Survivor 영역은 반드시 빈 상태가 된다.)
- 이러한 과정을 반복해 계속해서 살아남은 객체는 Old 영역으로 이동(Promotion) 된다.
객체의 생존 횟수를 카운트하기 위해 Minor GC에서 객체가 살아남은 횟수를 의미하는 age를 Object Header에 기록한다.
그리고 Minor GC 때 Object Header에 기록된 age를 보고 Promotion 여부를 결정한다.
또한 Survivor 영역 중 1개는 반드시 사용이 되어야한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 모두 사용량이 0이라면 현재 시스템이 정상적인 상태가 아님을 알 수 있다.
이러한 진행 과정을 그림으로 살펴보면 다음과 같다.

그렇다면 왜 Minor GC의 Suvivor 영역은 2개 중 1개만 사용되어야 하는걸까?
이는 메모리 단편화 때문이다.
Minor GC가 발생하면 Eden 영역에서 Survivor 영역으로 올 객체들 + Survivor 영역에서 살아남은 객체들을 Survivor 영역에 유지해야한다.
이때 1개의 Survivor 영역만 사용한다면 Survivor에서 빠질 객체들을 빼고 새롭게 들어올 객체들을 넣어야한다.
하지만 이때 Eden의 영역이 더 컸다면 Survivor 영역에 들어오지 못하게 된다.
즉, 전체 남는 메모리 공간은 있지만 단편화로 인해서 이동이 불가능하게 되는 문제가 생긴다.
따라서 2개의 Survivor 영역을 번갈아 사용하면서 s0에서 남는 것만 복사한 후에 s1으로 옮기고 뒤에 Eden 영역에서 오는 객체를 붙인다.
그런데 이게 1개의 Survivor 영역으로 해결이 불가능한지에 대해서 궁금했다.
compact를 사용해서 뺄 영역은 빼고 s0를 압축한 후에 처리하면 안되는가?
compact 방식은 정렬과 비슷하다. 임시 영역(s1)이 없기 때문에 복사를 할 수가 없고 하나하나 비교하면서 뺄 객체를 찾아서 뒤로 보내야한다. 이런 방식이 너무 비효율적이기 때문에 2개의 Survivor 영역을 사용한다.
요약: "Survivor 2개를 쓰는 이유는 Compact(정렬 방식)보다 Copy(필터링)가 훨씬 빠르기 때문"
[ Major GC의 동작 방식 ]
Young 영역에서 오래 살아남은 객체는 Old 영역으로 Promotion된다. 그리고 Major GC는 객체들이 계속 Promotion되어 Old 영역의 메모리가 부족해지면 발생하게 된다. Young 영역은 일반적으로 Old 영역보다 크기가 작기 때문에 GC가 보통 0.5초에서 1초 사이에 끝난다. 그렇게 때문에 Minor GC는 애플리케이션에 크게 영향을 주지 않는다.
하지만 Old 영역은 Young 영역보다 크며 Young 영역을 참조할 수도 있다. 그렇기 때문에 Major GC는 일반적으로 Minor GC보다 시간이 오래 걸리며, 10배 이상의 시간을 사용한다. 참고로 Young 영역과 Old 영역을 동시에 처리하는 GC는 Full GC라 한다.
3. GC의 알고리즘
JVM이 메모리를 자동으로 관리해주는 것은 개발자에게 있어 큰 메리트이다. 하지만 문제는 GC를 수행하기 위한 Stop the World에 의해 애플리케이션이 중지되는 것이다. Heap의 사이즈가 커지면서 애플리케이션의 지연(Suspend) 현상이 두드러지게 되었고, 이를 최소화 하기 위한 다양한 GC 알고리즘이 존재한다.
[ Serial GC ]
Serial GC의 Young 영역은 앞서 말한 Mark Sweep 알고리즘으로 수행된다. 하지만 Old 영역에서는 Mark Sweep Compact 알고리즘이 사용되는데, 기존의 Mark Sweep에 Compact라는 작업이 추가되었다. Compact는 Heap 영역을 정리하기 위한 단계로 유효한 객체들이 연속되게 쌓이도록 힙의 가장 앞부분부터 채워 객체가 존재하는 부분과 객체가 존재하지 않는 부분으로 나누는 것이다.
java -XX:+UseSerialGC -jar Application.java
Serial GC는 서버의 CPU 코어가 1개일 때 사용하기 위해 개발되었으며, 모든 가비지 컬렉션 일을 처리하기 위해 1개의 쓰레드만 이용한다. 따라서 CPU의 코어가 여러 개인 운영 서버에서 Serial GC를 사용하는 것은 반드시 피해야한다.
[ Parallel GC ]
Parallel GC는 Throughput GC로 알려져 있으며, 기본적인 처리 과정은 Serial GC와 동일하다. 하지만 Parallel GC는 여러개의 쓰레드를 통해 Parallel하게 GC를 수행함으로써 GC의 오버헤드를 상당히 줄여준다. Parallel GC는 멀티 프로세서 or 멀티 쓰레드 머신에서 중간 규모부터 대규모의 데이터를 처리하는 애플리케이션을 위해 고안되었으며, 옵션을 통해 애플리케이션의 최대 지연 시간 또는 GC를 수행할 쓰레드의 갯수 등을 설정해줄 수 있다.
java -XX:+UseParallelGC -jar Application.java
// 사용할 쓰레드의 갯수
-XX:ParallelGCThreads=<N>
// 최대 지연 시간
-XX:MaxGCPauseMillis=<N>
Parallel GC가 GC의 오버헤드를 상당히 줄여주었고, Java8까지 기본 가비지 컬렉터(Default Garbage Collector)로 사용되었다. 그럼에도 불구하고 Application이 멈추는 것은 피할 수 없었고, 이러한 부분을 개선하기 위해 다른 알고리즘이 더 등장하게 되었다.
[ CMS(Concurrent Mark Sweep) GC ]
CMS GC는 Parallel GC와 마찬가지로 여러개의 쓰레드를 이용한다. 하지만 기존의 Serial GC나 Parallel GC와 다르게 Mark Sweep 알고리즘을 Concurrent(동시)하게 수행한다.

이러한 CMS GC는 애플리케이션의 지연 시간을 최소화하기 위해 고안되었으며, 애플리케이션이 구동중일 때 프로세서의 자원을 공유하여 이용가능 해야한다. CMS GC가 수행될 때에는 자원이 GC를 위해서도 사용되므로 응답이 느려질 순 있지만 응답이 멈추지는 않게 된다.
하지만 이러한 CMS GC는 다른 GC 방식보다 메모리와 CPU를 더 많이 필요로 하며, Compaction 단계를 수행하지 않는다는 단점이 있다. 이 때문에 시스템이 장기적으로 운영되다가 조각난 메모리들이 많아 Compaction 단계가 수행되면 오히려 Stop The World 시간이 길어지는 문제가 발생할 수 있다.
// deprecated in java9 and finally dropped in java14
java -XX:+UseConcMarkSweepGC -jar Application.java
만약 GC가 수행되면서 98% 이상의 시간이 CMS GC에 소요되고, 2% 이하의 Heap만 회수된다면(Compaction 단계가 없어 메모리 단편화로 인해 GC가 돌아도 추가로 넣을 공간이 없는 경우) CMS GC에 의해 OutOfMemoryError가 던져질 것이다. 물론 이를 disable 하는 옵션이 있지만, CMS GC는 Java9 버젼부터 deprecated 되었고 결국 Java14에서는 사용이 중지되었다.
[ G1(Garbage First) GC ]
G1(Garbage First) GC는 장기적으로 많은 문제를 일으킬 수 있는 CMS GC를 대체하기 위해 개발되었고, Java 7부터 지원되기 시작했다.
기존의 GC 알고리즘에서는 Heap 영역을 물리적으로는 Young 영역(Eden 영역과 2개의 Survivor 영역)과 Old 영역으로 나누어 사용했다. G1 GC는 Eden 영역에 할당하고, Survivor로 카피하는 등의 과정을 사용하지만 물리적으로 메모리 공간을 나누지 않는다. 대시 Region(지역)이라는 개념을 새로 도입하여 Heap을 균등하게 여러 개의 지역으로 나누고, 각 지역을 역할과 함께 논리적으로 구분하여(Eden 지역인지, Survivor 지역인지, Old 지역인지) 객체를 할당한다.

G1 GC에서는 Eden, Survivor, Old 역할에 더해 humongous와 Availabe/Unused라는 2가지 역할을 추가하였다.
Humongous는 Region 크기의 50%를 초과하는 객체를 저장하는 Region을 의미하며
Availabe/Unused는 사용되지 않은 Region을 의미한다.
G1 GC의 핵심은 Heap을 동일한 크기의 Region으로 나누고, 가비지가 많은 Region에 대해 우선적으로 GC를 수행하는 것이다. 그리고 G1 GC도 다른 가비지 컬렉션과 마찬가지로 2가지 GC(Minor GC, Major GC)로 나누어 수행되는데, 각각에 대해 살펴보도록 하자.
1. Minor GC
한 지역에 객체를 할당하다가 해당 지역이 꽉 차면 다른 지역에 객체를 할당하고 Minor GC가 실행된다. G1 GC는 각 지역을 추적하고 있기 때문에, 가비지가 가장 많은(Garbage First) 지역을 찾아서 Mark and Sweep를 수행한다. 논리적인 Region으로 구분되어 있어 각 지역의 가비지 비율을 빠르게 비교할 수 있다.
Eden 지역에서 GC가 수행되면 살아남은 객체를 식별(Mark)하고, 메모리를 회수(Sweep)한다. 그리고 살아남은 객체를 다른 지역으로 이동시키게 된다. 복제되는 지역이 Available/Unused 지역이면 해당 지역은 이제 Survivor 영역이 되고, Eden 영역은 Available/Unused 지역이 된다.
2. Major GC (Full GC)
시스템이 계속 운영되다가 객체가 너무 많아 빠르게 메모리를 회수할 수 없을 때 Major GC(Full GC)가 실행된다. 그리고 여기서 G1 GC와 다른 GC의 차이점이 두각을 보인다.
기존의 다른 GC 알고리즘은 모든 Heap의 영역에서 GC가 수행되었으며, 그에 따라 처리 시간이 상당히 오래 걸렸다. 하지만 G1 GC는 어느 영역에 가비지가 많은지를 알고 있기 때문에 GC를 수행할 지역을 조합하여 해당 지역에 대해서만 GC를 수행한다. 그리고 이러한 작업은 Concurrent하게 수행되기 때문에 애플리케이션의 지연도 최소화할 수 있다.
G1 GC Minor GC는 다른 GC 방식에 비해 잦게 호출될 것이다. 하지만 Reion으로 나눈 부분 중 한 곳에 해당하는 작은 규모의 메모리 정리 작업이고 Parallel하게 수행되기 때문에 지연이 크지 않으며, 가비지가 많은 지역에 대해 정리하므로 훨씬 효율적이다.
또한 Minor GC의 잦은 호출로 Major GC 호출은 적기 때문에 애플리케이션의 Stop the World 시간을 효과적으로 줄일 수 있다.
java -XX:+UseG1GC -jar Application.java
이러한 구조의 G1 GC는 예측 가능하고 일정한 낮은 지연시간을 제공하며 큰 힙 메모리와 멀티코어 환경으로 운영되는 애플리케이션을 위해 고안되었다. 또한 G1 GC는 대용량 메모리 환경에서 안정적인 응답 시간을 제공하기 때문에 Java 9부터 기본 가비지 컬렉터로 사용되게 되었다.
4. GC 실습
public class GCTest {
public static void main(String[] args) {
// 큰 객체들 생성
for (int i = 0; i < 1000000; i++) {
String temp = new String("임시객체" + i);
// temp는 루프 끝나면 참조 끊어짐 = 쓰레기됨
}
System.out.println("GC 실행 완료!");
}
}
이걸 실제로 어떻게 확인할 수 있을까?
먼저 VM Options를 수정해주자.

- Xlog:gc : GC 로그를 콘솔에 출력
- Xmx512m : 최대 힙 메모리를 512MB로 제한
그 다음 GC가 동작할 환경을 구성
public class Main {
public static void main(String[] args) {
System.out.println("=== GC 실습 시작 ===");
// 1단계: 대량의 객체 생성 (GC 유발)
System.out.println("\n1단계: 대량 객체 생성");
createManyObjects();
// 2단계: 강제 GC 실행
System.out.println("\n2단계: 강제 GC 실행");
System.gc();
// 잠깐 대기 (GC 완료 대기)
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 3단계: 더 많은 객체 생성
System.out.println("\n3단계: 더 많은 객체 생성");
createEvenMoreObjects();
System.out.println("\n=== GC 실습 종료 ===");
}
// 대량의 임시 객체 생성 (쓰레기가 될 객체들)
private static void createManyObjects() {
for (int i = 0; i < 100000; i++) {
String temp = new String("임시객체_" + i);
StringBuilder sb = new StringBuilder();
sb.append("더미데이터").append(i);
// 배열도 생성
int[] arr = new int[100];
for (int j = 0; j < 100; j++) {
arr[j] = j * i;
}
// 이 객체들은 루프 끝나면 참조가 끊어짐 = 쓰레기가 됨
}
System.out.println("10만개 객체 생성 끝");
}
// 더 많은 객체 생성
private static void createEvenMoreObjects() {
for (int i = 0; i < 500000; i++) {
// 더 큰 객체들 생성
String[] bigArray = new String[50];
for (int j = 0; j < 50; j++) {
bigArray[j] = "큰객체_" + i + "_" + j;
}
}
System.out.println("50만개 대형 객체 생성 끝");
}
}
콘솔 결과를 보면 다음과 같다.
[0.012s][info][gc] Using G1
=== GC 실습 시작 ===
1단계: 대량 객체 생성
[0.136s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 21M->1M(256M) 1.281ms
[0.154s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 32M->1M(256M) 0.950ms
10만개 객체 생성 끝
2단계: 강제 GC 실행
[0.159s][info][gc] GC(2) Pause Full (System.gc()) 7M->0M(8M) 3.052ms
3단계: 더 많은 객체 생성
[1.226s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 4M->1M(8M) 0.877ms
[1.230s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 3M->1M(8M) 0.954ms
[1.233s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 3M->1M(8M) 0.236ms
[1.236s][info][gc] GC(6) Pause Young (Normal) (G1 Evacuation Pause) 3M->1M(8M) 0.258ms
[1.240s][info][gc] GC(7) Pause Young (Normal) (G1 Evacuation Pause) 3M->1M(132M) 0.957ms
[1.303s][info][gc] GC(8) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.638ms
[1.326s][info][gc] GC(9) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.291ms
[1.349s][info][gc] GC(10) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.417ms
[1.372s][info][gc] GC(11) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.414ms
[1.394s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.574ms
[1.417s][info][gc] GC(13) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.546ms
[1.438s][info][gc] GC(14) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.545ms
[1.459s][info][gc] GC(15) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.674ms
[1.480s][info][gc] GC(16) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.700ms
[1.501s][info][gc] GC(17) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.727ms
[1.523s][info][gc] GC(18) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.727ms
[1.544s][info][gc] GC(19) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.731ms
[1.565s][info][gc] GC(20) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.720ms
[1.586s][info][gc] GC(21) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.714ms
[1.609s][info][gc] GC(22) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.826ms
[1.634s][info][gc] GC(23) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.581ms
[1.656s][info][gc] GC(24) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.569ms
[1.678s][info][gc] GC(25) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.751ms
[1.699s][info][gc] GC(26) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.787ms
[1.720s][info][gc] GC(27) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.705ms
[1.741s][info][gc] GC(28) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.728ms
[1.762s][info][gc] GC(29) Pause Young (Normal) (G1 Evacuation Pause) 79M->1M(132M) 0.698ms
50만개 대형 객체 생성 끝
=== GC 실습 종료 ===
실제로 한번의 for문을 돈 후에는 해당 객체가 쓸모없어 지는데 계속해서 메모리 공간에 남아있다가 GC에 의해 삭제되는 것을 볼 수 있다.
그렇다면 GC가 작동하지 않는다면 (비활성화 or 참조를 끊을 수 없는 객체만 있는 경우) 어떻게 될까?
GC가 동작하지 않을 때
1. GC를 비활성화 한 경우
VM Options를 다음과 같이 바꾸자.
-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xlog:gc -Xmx128m
- XX:+UnlockExperimentalVMOptions : 실험적인 JVM 기능들을 사용할 수 있게 잠금 해제
- XX:+UseEpsilonGC : 아무것도 안하는 GC(실험용)
저기서 XX:+UnlockExperimentalVMOptions을 적지 않는다면 아래와 같은 에러가 뜨니 꼭 추가해주자.

똑같은 코드를 실행한 다음에는 어떻게 될까?
[0.015s][info][gc] Using Epsilon # 아무것도 안하는 Epsilon GC 사용
[0.016s][warning][gc,init] Consider enabling -XX:+AlwaysPreTouch to avoid memory commit hiccups
=== GC 실습 시작 ===
1단계: 대량 객체 생성
[0.302s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 6726K (5.13%) used
[0.311s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 14239K (10.86%) used
[0.323s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 22835K (17.42%) used
[0.328s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 30341K (23.15%) used
[0.335s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 38207K (29.15%) used
[0.340s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 46072K (35.15%) used
[0.344s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 53938K (41.15%) used
10만개 객체 생성 끝
2단계: 강제 GC 실행
[0.350s][info][gc] GC request for "System.gc()" is ignored
3단계: 더 많은 객체 생성
[1.362s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 60499K (46.16%) used
[1.374s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 67594K (51.57%) used
[1.384s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 74450K (56.80%) used
[1.390s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 83046K (63.36%) used
[1.394s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 90552K (69.09%) used
[1.401s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 98417K (75.09%) used
[1.406s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 103M (81.09%) used
[1.411s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 111M (87.09%) used
[1.416s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 119M (93.09%) used
[1.422s][info][gc] Heap: 128M reserved, 128M (100.00%) committed, 126M (99.09%) used
Terminating due to java.lang.OutOfMemoryError: Java heap space
Process finished with exit exit code 3
위와 같이 OutOfMemoryError가 뜨게된다.
2. GC가 처리하지 못하는 객체들로 구성
VM Options를 다음과 같이 바꾸자.
-Xlog:gc -Xmx64m
더 빠르게 GC를 다운시키기 위해서 힙의 크기를 64MB로 수정
public class Main {
private static List<Object> keepObjects = new ArrayList<>();
public static void main(String[] args) {
try{
System.out.println("=== 메모리 부족 실험 시작 ===");
System.out.println("\n1단계: 대형 객체 생성");
createBigObjects();
System.out.println("\n=== 실험 종료 ===");
System.out.println("최종 전역 리스트 크기: " + keepObjects.size() + "개");
} catch (OutOfMemoryError e){
System.out.println("\n OutOfMemoryError 발생");
System.out.println("최종 전역 리스트 크기: " + keepObjects.size() + "개");
System.out.println("에러 메시지: " + e.getMessage());
}
}
private static void createBigObjects() {
for (int i = 0; i < 10000; i++) {
// 큰 바이트 배열 생성 (각각 약 10KB)
byte[] bigArray = new byte[10000];
for (int j = 0; j < 10000; j++) {
bigArray[j] = (byte)(j % 256);
}
// 전역 리스트에 추가 → GC가 제거 못함
keepObjects.add(bigArray);
if (i % 1000 == 0) {
long usedMemory = Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory();
System.out.println("진행: " + i + "/10000, 메모리: " + (usedMemory / 1024 / 1024) + "MB");
}
}
System.out.println("1만개 대형 객체 생성 끝");
}
}
이 코드를 돌리면 어떻게 될까?
[0.014s][info][gc] Using G1
=== 메모리 부족 실험 시작 ===
1단계: 대형 객체 생성
진행: 0/10000, 메모리: 2MB
진행: 1000/10000, 메모리: 12MB
[0.169s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 20M->19M(64M) 10.622ms
진행: 2000/10000, 메모리: 20MB
[0.184s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 29M->29M(64M) 5.755ms
진행: 3000/10000, 메모리: 29MB
[0.195s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 36M->36M(64M) 3.248ms
진행: 4000/10000, 메모리: 39MB
[0.202s][info][gc] GC(3) Pause Young (Concurrent Start) (G1 Evacuation Pause) 41M->41M(64M) 2.697ms
[0.202s][info][gc] GC(4) Concurrent Mark Cycle
[0.205s][info][gc] GC(4) Pause Remark 43M->43M(64M) 0.569ms
[0.207s][info][gc] GC(4) Pause Cleanup 44M->44M(64M) 0.041ms
[0.208s][info][gc] GC(4) Concurrent Mark Cycle 5.210ms
[0.210s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 45M->45M(64M) 2.009ms
[0.213s][info][gc] GC(6) Pause Young (Concurrent Start) (G1 Evacuation Pause) 47M->47M(64M) 0.930ms
[0.213s][info][gc] GC(7) Concurrent Mark Cycle
[0.215s][info][gc] GC(7) Pause Remark 48M->48M(64M) 0.630ms
[0.216s][info][gc] GC(7) Pause Cleanup 48M->48M(64M) 0.030ms
[0.217s][info][gc] GC(7) Concurrent Mark Cycle 3.961ms
[0.218s][info][gc] GC(8) Pause Young (Normal) (G1 Evacuation Pause) 49M->49M(64M) 0.986ms
진행: 5000/10000, 메모리: 49MB
[0.221s][info][gc] GC(9) Pause Young (Concurrent Start) (G1 Evacuation Pause) 51M->51M(64M) 0.850ms
[0.221s][info][gc] GC(10) Concurrent Mark Cycle
[0.224s][info][gc] GC(11) Pause Young (Normal) (G1 Evacuation Pause) 53M->52M(64M) 1.038ms
[0.225s][info][gc] GC(10) Pause Remark 53M->53M(64M) 0.298ms
[0.225s][info][gc] GC(10) Pause Cleanup 53M->53M(64M) 0.004ms
[0.225s][info][gc] GC(10) Concurrent Mark Cycle 4.472ms
[0.227s][info][gc] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 54M->54M(64M) 0.861ms
[0.230s][info][gc] GC(13) Pause Young (Concurrent Start) (G1 Evacuation Pause) 56M->56M(64M) 0.752ms
[0.230s][info][gc] GC(14) Concurrent Mark Cycle
[0.233s][info][gc] GC(14) Pause Remark 58M->58M(64M) 0.705ms
[0.233s][info][gc] GC(14) Pause Cleanup 58M->58M(64M) 0.005ms
[0.234s][info][gc] GC(14) Concurrent Mark Cycle 3.651ms
진행: 6000/10000, 메모리: 58MB
[0.235s][info][gc] GC(15) Pause Young (Normal) (G1 Preventive Collection) 58M->58M(64M) 0.868ms
[0.238s][info][gc] GC(16) Pause Young (Concurrent Start) (G1 Preventive Collection) 59M->59M(64M) 0.665ms
[0.238s][info][gc] GC(17) Concurrent Mark Cycle
[0.239s][info][gc] GC(18) Pause Young (Normal) (G1 Preventive Collection) 60M->60M(64M) 0.636ms
[0.241s][info][gc] GC(17) Pause Remark 61M->61M(64M) 0.335ms
[0.241s][info][gc] GC(17) Pause Cleanup 61M->61M(64M) 0.003ms
[0.242s][info][gc] GC(17) Concurrent Mark Cycle 4.651ms
[0.244s][info][gc] GC(19) Pause Young (Normal) (G1 Preventive Collection) 61M->61M(64M) 1.297ms
[0.246s][info][gc] GC(20) To-space exhausted
[0.246s][info][gc] GC(20) Pause Young (Concurrent Start) (G1 Preventive Collection) 62M->63M(64M) 0.861ms
[0.246s][info][gc] GC(21) Concurrent Mark Cycle
[0.252s][info][gc] GC(22) Pause Full (G1 Compaction Pause) 63M->62M(64M) 5.444ms
[0.252s][info][gc] GC(21) Concurrent Mark Cycle 5.726ms
[0.253s][info][gc] GC(23) To-space exhausted
[0.253s][info][gc] GC(23) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.450ms
[0.258s][info][gc] GC(24) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.405ms
[0.273s][info][gc] GC(25) Pause Full (G1 Compaction Pause) 63M->63M(64M) 14.609ms
[0.274s][info][gc] GC(26) Pause Young (Concurrent Start) (G1 Evacuation Pause) 63M->63M(64M) 0.612ms
[0.274s][info][gc] GC(28) Concurrent Mark Cycle
[0.278s][info][gc] GC(27) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.944ms
[0.283s][info][gc] GC(29) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.071ms
[0.283s][info][gc] GC(28) Concurrent Mark Cycle 9.377ms
[0.283s][info][gc] GC(30) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.207ms
[0.287s][info][gc] GC(31) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.604ms
[0.292s][info][gc] GC(32) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.765ms
[0.293s][info][gc] GC(33) Pause Young (Concurrent Start) (G1 Evacuation Pause) 63M->63M(64M) 0.318ms
[0.293s][info][gc] GC(35) Concurrent Mark Cycle
[0.296s][info][gc] GC(34) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.505ms
[0.302s][info][gc] GC(36) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.981ms
[0.302s][info][gc] GC(35) Concurrent Mark Cycle 9.835ms
[0.303s][info][gc] GC(37) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.203ms
[0.308s][info][gc] GC(38) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.953ms
[0.314s][info][gc] GC(39) Pause Full (G1 Compaction Pause) 63M->63M(64M) 6.173ms
[0.315s][info][gc] GC(40) Pause Young (Concurrent Start) (G1 Evacuation Pause) 63M->63M(64M) 0.933ms
[0.315s][info][gc] GC(42) Concurrent Mark Cycle
[0.320s][info][gc] GC(41) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.335ms
[0.328s][info][gc] GC(43) Pause Full (G1 Compaction Pause) 63M->63M(64M) 7.717ms
[0.328s][info][gc] GC(42) Concurrent Mark Cycle 12.625ms
[0.328s][info][gc] GC(44) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.226ms
[0.333s][info][gc] GC(45) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.923ms
[0.340s][info][gc] GC(46) Pause Full (G1 Compaction Pause) 63M->63M(64M) 6.617ms
[0.341s][info][gc] GC(47) Pause Young (Concurrent Start) (G1 Evacuation Pause) 63M->63M(64M) 0.433ms
[0.341s][info][gc] GC(49) Concurrent Mark Cycle
[0.347s][info][gc] GC(48) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.412ms
[0.352s][info][gc] GC(50) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.756ms
[0.352s][info][gc] GC(49) Concurrent Mark Cycle 10.638ms
[0.352s][info][gc] GC(51) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.319ms
Exception: java.lang.OutOfMemoryError thrown from the UncaughtExceptionHandler in thread "main"
[0.357s][info][gc] GC(52) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.681ms
[0.363s][info][gc] GC(53) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.954ms
[0.364s][info][gc] GC(54) Pause Young (Concurrent Start) (G1 Evacuation Pause) 63M->63M(64M) 0.333ms
[0.364s][info][gc] GC(56) Concurrent Mark Cycle
[0.368s][info][gc] GC(55) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.790ms
[0.373s][info][gc] GC(57) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.169ms
[0.374s][info][gc] GC(56) Concurrent Mark Cycle 9.657ms
[0.374s][info][gc] GC(58) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.322ms
[0.379s][info][gc] GC(59) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.206ms
[0.385s][info][gc] GC(60) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.133ms
[0.385s][info][gc] GC(61) Pause Young (Concurrent Start) (G1 Evacuation Pause) 63M->63M(64M) 0.338ms
[0.385s][info][gc] GC(63) Concurrent Mark Cycle
[0.389s][info][gc] GC(62) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.499ms
[0.395s][info][gc] GC(64) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.440ms
[0.395s][info][gc] GC(63) Concurrent Mark Cycle 9.499ms
[0.395s][info][gc] GC(65) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.257ms
[0.399s][info][gc] GC(66) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.116ms
[0.403s][info][gc] GC(67) Pause Full (G1 Compaction Pause) 63M->63M(64M) 4.233ms
[0.404s][info][gc] GC(68) Pause Young (Concurrent Start) (G1 Evacuation Pause) 63M->63M(64M) 0.296ms
[0.404s][info][gc] GC(70) Concurrent Mark Cycle
[0.407s][info][gc] GC(69) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.404ms
[0.413s][info][gc] GC(71) Pause Full (G1 Compaction Pause) 63M->63M(64M) 6.073ms
[0.414s][info][gc] GC(70) Concurrent Mark Cycle 9.915ms
[0.414s][info][gc] GC(72) Pause Young (Normal) (G1 Evacuation Pause) 63M->63M(64M) 0.231ms
[0.417s][info][gc] GC(73) Pause Full (G1 Compaction Pause) 63M->63M(64M) 3.559ms
[0.423s][info][gc] GC(74) Pause Full (G1 Compaction Pause) 63M->63M(64M) 5.440ms
GC가 계속 동작하다가 중간에 OutOfMemoryError가 터지는걸 볼 수 있다.
또한 추가 메모리 공간을 얻기 위해서 GC가 계속 아래와 같은 작업들을 하는 것을 볼 수 있다. 하지만 메모리 크기는 줄어들지 않는걸 볼 수 있다.
- GC(15) Pause Young (Normal) (G1 Preventive Collection) # 예방적 수집 시도(메타데이터)
- GC(20) To-space exhausted # 공간 부족
- GC(22) Pause Full (G1 Compaction Pause) # 메모리 압축 시도(Minor GC, Major GC)
결론
채팅 서비스 개발의 RabbitMQ의 메모리 소진 문제를 겪으면서 들었던 궁금증이 JVM의 GC까지 이어졌다.
공부를 하면서 Garbage Collector에 대해서 알게 되었고 자동으로 어떻게 메모리를 정리한다는지를 알게 되었다.
출처
https://mangkyu.tistory.com/118
https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.html
'일기' 카테고리의 다른 글
| [개발일기] - 인덱스에 대해서 자세히 알아보기 (0) | 2025.10.01 |
|---|---|
| [개발일기] 09.19 - 데이터베이스란 무엇인가? (0) | 2025.09.19 |
| [개발일기] 09.16 - 자바 JVM은 어떻게 동작할까? (0) | 2025.09.16 |
| 6.9 - 오늘의 기록 (채팅 이해, 다양한 방법으로 구현) (0) | 2025.06.09 |
| 5.26 - 오늘의 기록 (채팅에 어떤 db를 사용하는게 맞을까?) (0) | 2025.05.26 |