🧹 Garbage Collector(GC)란?

GC는 가비지 컬렉터의 준말로, 힙 메모리 영역에 존재하는 객체들을 정기적으로 정리해 주며 메모리를 관리하는 역할을 맡는다.

이는 1) 인터프리터 2) JIT 컴파일러와 함께 JVM 실행 엔진(Execution Engine)내에 속해 있다.

GC가 필요한 이유

JVM 런타임 데이터 영역 중, 힙 영역에는 new 로 새롭게 생성한 객체들이 올라간다.

이러한 객체들은 스택 영역에 있는 스택 프레임과 다르게 명확한 생명주기를 갖고 있지 않아서, 저절로 소멸하지 않는다.

때문에 GC가 주기적으로 이를 스캔하며, 참조되지 않는 객체(Unreachable)에 대해서 메모리 할당을 해제하며 제거하게 된다.

왜 힙 메모리에 있는 객체들은 알아서 소멸하지 않을까?

스택 영역에 올라가는 데이터들은 메서드 호출 시에 스택 프레임이 잡히고, 메서드가 종료되면 프레임 자체가 제거되어 소멸한다.

하지만 힙 영역에 있는 객체들은 동일하게 제거할 수가 없는데, 이는 참조된다는 특성 때문이다.

Java에서 객체를 생성하면 해당 객체 자체는 힙에 저장되고, 그 주소(참조값)가 스택에 저장된다. 이 참조값을 통해 여러 메서드에서 해당 객체를 참조하고 사용할 수 있게 된다.

예를 들어 메서드 내에서 다른 메서드를 호출하며, 파라미터로 객체를 넘기는 경우가 있다.

때문에, 단순히 메서드가 끝났다고 해서 객체를 바로 삭제할 수 없는 것이다.

얼마나 주기적으로 스캔하는 것일까?

1. 힙 공간 부족

  • Eden 영역이나 Old 영역의 메모리가 일정 비율 이상 사용되었을 때 GC가 발생
  1. System.gc() 호출
  • 명시적으로 GC 요청. 하지만 JVM이 무조건 실행하진 않음
  1. 메모리 할당 실패
  • 새로운 객체를 생성할 때 힙 공간이 부족하면, GC로 공간 확보 후 다시 시도

위와 같은 경우에서 스캔하며 메모리를 확보하려고 시도한다.

이외에도 백그라운드 스레드에서 지속적으로 GC가 동작하기도 하는데, 이는 GC의 종류마다 달라진다.

참조되지 않는 객체에 대해서만 제거할까?

그렇다. 하나라도 참조되고 있는 객체에 대해서는 절대로 제거하지 않는다.

만약 이를 제거하게 된다면 치명적인 시스템 장애로 이어질 수 있기 때문에, 안전하게 회수 가능한 객체에 대해서만 정리한다.

GC의 장단점

장점

C/C++과 같은 Unmanaged 언어는, 개발자가 수동으로 메모리 할당과 해제를 해주어야 하는 번거로움이 존재한다.

반면 GC는 참조되지 않는 객체들을 알아서 정리해줌으로써, 개발자 입장에서 메모리 관리에 대한 신경을 쓰지 않고 오로지 개발에만 집중할 수 있게 된다.

특히, 메모리 누수(Memory Leak)이중 해제(Double Free)와 같은 문제에서 비교적 자유로워질 수 있다.

이러한 GC는 Java에만 존재하는 개념은 아니며, Python & JS & Go 등 다른 많은 언어에서도 기본적으로 내장되어 있다. 그리고 이들을 Managed 언어라고 부른다.

메모리 누수(Memory Leak)란? 더 이상 사용하지 않지만, 여전히 참조되고 있어서 GC(또는 시스템)가 회수하지 못하는 메모리를 말한다.

예를 들어, 이벤트 리스너를 등록했지만 해제하지 않거나 & 컬렉션에 객체를 계속 담아두고 제거하지 않을 경우 등이 존재한다.

이 경우에는 점점 사용 가능한 메모리가 줄어들어 성능 저하나 OOM(OutOfMemoryError) 발생 가능성이 존재한다.

물론 GC에서도 해당 문제가 일어날 여지가 있으나, 개발자가 참조만 끊는다면 메모리 정리가 되기에 비교적 자유롭다.

이중 해제 (Double Free)란? 이미 해제한 메모리를 다시 해제하려고 시도하는 것을 말한다.

예를 들어 free(ptr); 호출 후, 같은 ptr을 다시 free(ptr); 하는 경우가 있다.

그 결과 메모리 오류, 시스템 크래시 등이 발생할 수 있다.

GC를 사용하는 언어에서는 직접 해제를 하지 않기 때문에, 이중 해제 문제에서는 자유로울 수 있다.

단점

GC를 사용하는 언어는 개발자 입장에서 메모리를 신경쓰지 않아도 되니 매우 편리하게 느껴진다.

그렇다면 과연 좋은 점만 존재할까? 물론 아래와 같은 단점도 존재한다.

1. Stop-the-World 발생

대표적으로 발생하는 문제점으로, GC가 동작할 때 모든 애플리케이션 스레드가 일시 중지되는 것을 말한다.

짧으면 수 밀리초, 길면 수 초까지 중단될 수 있기 때문에 사용자 체감 지연이 발생할 수 있고, 특히 실시간성이 중요한 서비스에 치명적이다.

그렇기 때문에, 게임 & 임베디드 등 실시간성과 정밀한 메모리 제어가 중요한 환경에서는 C++과 같은 Unmanaged 언어가 선택된다.

2. GC 타이밍 예측 불가

GC는 JVM의 내부 알고리즘에 의해 발생하므로 정확한 실행 시점을 제어할 수가 없다.

만약 특정 시점에 꼭 필요한 작업이 있는데, 그 직전에 GC가 발생하면 예상치 못한 레이턴시가 생기는 문제가 발생할 수 있다.

3. 처리 비용(오버헤드) 발생

GC 작업도 CPU 자원을 사용하기 때문에 처리 비용이 물론 발생한다.

4. 메모리 관리에 대한 통제권 부족

개발자가 직접 객체를 해제할 수 없으므로, 메모리 튜닝과 디버깅이 까다로운 편이다. 특히 메모리 누수가 발생해도 GC가 관여하지 못하는 영역이면 추적이 어렵다.


🏃🏻 GC의 동작원리

위에서 언급했던 것처럼, 새로운 객체는 힙 영역 내에 저장되며 이를 스택 & 메소드 영역에서 참조하게 된다.

만약 1곳에서라도 참조되고 있는 객체라면 Reachable하다고 말하며, 그렇지 않고 아무도 참조하고 있지 않다면 Unreachable한 상태가 되어서 GC의 대상이 된다.

그리고 이를 판단하기 위해서 GC는 도달성 분석을 실행하게 된다.

도달성 분석이란?

도달성 분석은 GC Root에서부터 시작해 객체들이 서로를 참조하는 연결 관계를 따라가며, 해당 객체가 도달 가능한지를 판단하는 과정을 말한다.

도달이 가능한, 즉 Reachable한 객체는 아직 시스템 내에서 사용중이기에 제거하지 않고, 그렇지 않은 Unreachable한 객체는 제거하게 된다.

해당 과정은 일반적으로 BFS 또는 DFS로 이루어진다.

GC Root?

JVM에서 도달성 분석을 할 때 기준이 되는 출발점으로, 루트들로부터 시작해서 참조 그래프를 따라가게 된다.

대표적으로 GC Root의 종류는 아래와 같다.

  1. 현재 실행 중인 스레드의 스택 프레임 안에 있는 지역 & 매개 변수
  2. 클래스의 static 필드에서 참조하는 객체
  3. JNI에서 참조 중인 객체

Mark-and-Sweep

Mark-and-Sweep 알고리즘은 가장 기본적인 GC 알고리즘으로, 가비지 컬렉션이 될 대상 객체를 식별(Mark)하고 제거(Sweep)해 나간다.

이후 GC 종류에 따라서, 파편화된 메모리 영역을 앞에서부터 채워나가며 압축하는 Compact 과정이 존재하기도 한다.

1. Mark 과정

  • GC Root에서 시작하여 참조 그래프를 따라가며, 도달할 수 있는 객체는 모두 마킹한다.

2. Sweep 과정

  • Mark 과정이 끝난 이후, 도달하지 못 하는(Unreachable) 객체들을 힙 메모리 영역에서 제거한다.

장점

  1. 내부 구조가 직관적이라 구현과 이해하기에 쉽다.
  2. 모든 객체에 대한 선형 탐색이 이루어지기에 정확하다.

단점

  1. 속도가 느려 Stop-the-World 시간이 길어진다.
  2. Compact 과정이 없다면, 메모리 단편화가 발생한다.
  3. 최적화가 부족해, 객체가 많아질수록 성능이 저하된다.

메모리 단편화?

객체들이 제거된 후 빈 공간이 된 메모리들이, 여기저기에 흩어져 있어서 연속되지 않은 조각으로 남아 비효율적인 상황을 말한다.

[ A ][   ][ C ][   ][ E ]

해당 상황에서 빈 공간은 2칸이지만, 두 칸이 연속되지 않기 때문에 사이즈가 2인 객체를 넣을 수는 없다. 이 때문에 전체 사용량은 낮은데도 OOM이 발생하는 경우가 생긴다.

🧑🏻‍💻 메모리 단편화와 같은 문제 때문에, Compact 과정이 도입되기도 한다. 이 과정은 살아있는 객체들을 한쪽으로 몰아붙여 연속된 공간을 확보하는 방식이다.

하지만 객체를 이동시키는 데에는 추가 연산과 포인터 갱신 비용이 발생하기 때문에, 성능 오버헤드가 크고, 여전히 Stop-the-World가 발생한다는 한계가 존재한다.

힙 메모리 구조

힙 영역은 처음 설계될 때, 아래 2가지를 전제로 설계되었다.

Weak Generational Hypothesis(약한 세대 가설)

  1. 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
  2. 오래된 객체에서 새로운 객체로의 참조는 매우 드물게 존재한다.

즉, 대부분의 객체는 일회성이며 메모리에 오래 남아있는 경우가 드물다는 것이다.

이러한 특성을 이용해 JVM 개발자들은 보다 효율적인 메모리 관리를 위해서, 1) Young 2) Old로 물리적인 힙 영역을 나누게 되었다.

1. Young 영역(Young Generation)

  • 새롭게 생성된 객체가 할당되는 영역이다.
  • 많은 객체가 Young 영역에 생성되었다가, 사라진다.
  • Young 영역에서 발생하는 GC작업을, Minor GC라고 부른다.

2. Old 영역(Old Generation)

  • Young 영역에서, Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역이다.
  • Young 영역보다 크게 할당되는데, 수명이 길고 사이즈가 큰 객체들이 주로 Old 영역에 할당되기 때문이다.
  • Old 영역에서 발생하는 GC 작업을, Major GC 또는 Full GC 라고 부른다.

세부적인 Young 영역

Young 영역에서는 객체가 자주 생성되고 금방 사라지기 때문에, JVM은 이 영역에서의 GC 성능을 최적화하는 데 중점을 둔다.

때문에 Young 영역을 다시 3가지 영역으로 나누게 된다.

1. Eden 영역

  • new를 통해서 새롭게 생성된 객체들이 위치하게 된다.
  • Minor GC 이후 살아남은 객체들은 Survivor 영역으로 보내게 된다.
  • 여기서 Eden은, 성경에서 에덴 동산의 에덴과 동일한 의미를 가진다.

2. Survivor 0, 1 영역

  • 최소 1번 이상의 Minor GC에서 살아남은 객체들이 존재하는 영역이다.
  • 여기서 중요한 포인트는, Survivor 0 또는 Survivor 1 영역 둘 중 하나는 꼭 비어있는 공간이어야 한다는 것이다.

Minor GC

위에서 Minor GC는 Young 영역에서 발생하는 GC 작업이라고 언급하였다. Minor GC는 아래와 같은 동작 과정을 가진다.

  1. 처음 생성된 객체들은 Eden 영역에 위치한다.
  2. 객체가 계속 생성되어, Eden 영역이 꽉 차게 되면 Minor GC가 실행된다.
  3. Mark 단계에서 Reachable한 객체를 탐색한다.
  4. Eden 영역에서 살아남은 객체는, 하나의 Survivor 영역으로 이동한다.
  5. Eden 영역에서 사용되지 않는 객체(Unreachable)들의 메모리를 해제한다.(Sweep 단계)
  6. 살아남은 모든 객체들은 Age 값이 1씩 증가한다.

Age 값이란?

Survivor 영역에서 객체가 살아남은 횟수를 의미하며, Object Header에 기록된다.

  • Eden에 있는 객체의 초기 age 값은 0이다.
  • Object Header는 JVM이 객체에 부여하는 내부 메타데이터 공간이다.

만약 age 값이 임계값에 다다른다면 Promotion(Old 영역으로 이동하는) 여부를 결정한다. 가장 보편적인 HotSpot JVM의 경우에는 age의 기본 임계값이 15이다.

즉, age가 15에 도달하면 Young -> Old 영역으로 이동할 수 있게 되는 것이다.

Minor GC를 알아보며, 아래와 같은 궁금증이 생겨났다.

1. Minor GC가 발생하여 제거되는 대상은 Eden 영역 뿐인지?

그렇지 않다. 여기에는 Survivor From 영역도 포함된다.

Survivor From 영역이란?

위에서 Survivor 0, 1 영역을 설명하며, 둘 중 하나는 꼭 비어 있어야한다고 말하였다.

그 이유는 Minor GC에서 살아남아, age를 +1 하게 된 객체들이 복사될 깨끗한 공간이 필요하기 때문이다.

여기서 Survivor From은 Eden과 함께 Minor GC의 대상이 되는 영역이며, Survivor To는 복사가 되는 공간이 된다.

이를 유지하기 위해서, Survivor 0, 1은 번갈아 가며 From, To 역할을 맡게 된다.

즉, Survivor 0이 From인 상태에서 Minor GC가 발생했다면, Survivor 1이 To인 상태이므로 age + 1 된 객체들을 복사하게 되고, 다음 과정에서는 그 반대가 되는 것이다.

Survivor From, to (=Survivor 0, 1)을 왔다갔다하며(age++) 살아남은 객체만이, Old 영역으로 이동할 수 있게 된다.

2. Eden이 꽉 찬 경우에만 Minor GC가 발생하는 것인지?

일반적으로는 Eden이 꽉 찬 경우에 발생하고는 한다.

하지만 Survivor 공간이 부족하거나, System.gc() 호출 등 외부 요인으로 인해서 발생할 가능성도 존재한다.

Survivor 공간이 부족한 경우?

Survivor From -> To로 이동시켜야 하는데, 공간이 부족하다면 JVM은 Early Promotion (조기 승격)의 방식을 택하게 된다.

이는 age와 상관없이 아직 Old로 승격할 나이가 안 된 객체를 그냥 Old 영역으로 승격시켜버리는 것을 의미한다.

조기 승격이 많이 이루어지게 된다면, Old 영역이 포화되어 Major GC가 발생하는 횟수가 늘어날 수 있다.

때문에 이를 피하기 위해서, Survivor 영역 크기와, 임계값을 적절하게 조정하는 것이 필요하다.

3. age의 기본 임계값은 왜 15인지?

Java 객체는 생성될 때 Object Header 라는 메타데이터 영역을 갖는다. 이 중에서도 Mark Word는 GC 수행과 관련된 다양한 정보를 포함하는 필드이다.

이미지 출처

Mark Word는 보통 64비트 크기이며, 이 안에는 아래와 같은 정보들이 포함된다.

  1. Identity Hash Code
  2. Lock 상태 정보 (lightweight, biased locking 등)
  3. Object age (객체 생존 횟수)

이 중 Object age에게는 약 5~6비트 정도의 공간이 주어진다. 때문에 5비트라고 한다면, 0~31까지의 값을 사용할 수 있는 것이다.

그렇기에 많은 사람들이 Hotspot JVM의 기본 임계값이 31이라고들 하는데, 이는 조금 틀린 부분인 것 같다.

31은 표현 가능한 범위의 끝, 즉 최대값일 뿐이며, Old 영역으로 승격되는 기준 임계값은 기본적으로 15이다.

그리고 해당 임계값은, 필요에 따라 -XX:MaxTenuringThreshold 옵션으로 개발자가 조정 가능하다.

다시 정리하자면, Object age는 이론적으로 최대 31까지 표현할 수 있지만, 성능 최적화와 GC 효율성을 고려해 HotSpot JVM은 기본 임계값을 15로 설정해두었다.

Major GC

이제부터는 Old 영역 내에서 발생하는 Major GC에 대해 정리하고자 한다.

대표적으로 Major GC가 발생하게 되는 순간은 아래와 같다.

1. Old 영역이 가득 찬 경우

  • Old 영역 자체가 이미 대부분 사용 중인 상태이다.
  • Old 영역의 사용률이 높아서 JVM이 선제적으로 Major GC를 수행한다.
  • 이 경우에는 정기적인 GC 관리 차원에서 발생한다.

2. Promotion Failure (승격 실패)

  • Minor GC 이후, age가 임계값에 다다른 객체들을 Promotion하려는 시점에 Old 영역에 자리가 없어 실패하는 경우이다.
  • 이 때 JVM은 즉시 Major GC를 강제로 트리거하여 Old 영역을 비우려고 시도한다.

Major GC 🆚 Full GC?

  • Major GC는 일반적으로 Old 영역만을 대상으로 수행되는 GC이다. 즉, Minor or Major GC로 영역이 명확하게 구분되어 있다.

반면, Full GCOld + Young + 기타 영역(Metaspace 등) 을 모두 수집하는 무거운 작업이다.

  • JVM이 강제적으로 전체 메모리를 점검해야 한다고 판단할 때 발생한다.
    • System.gc() 호출
    • Old 영역 포화 / Promotion 실패
    • Permanent/Metaspace 부족 등 ..

G1 GC 등 일부 알고리즘에서는 두 용어가 유연하게 같은 의미로 쓰이기도 한다.

Major GC는 Minor GC에 비해서 많은 시간이 소요된다. 때문에 모든 프로세스가 멈추는 Stop-the-World 문제가 더욱 대두된다. (물론 Minor GC에서도 Stop-the-World 문제는 있다.)

왜 Major GC가 Minor GC보다 오래 걸릴까?

1. 대상 영역의 크기가 크다

  • Major GC는 Old 영역 전체를 대상으로 하는데, 일반적으로 Young 영역보다 크기도 크고 객체 수명도 길다.
  • 즉, 물리적으로 검사할 대상이 많으니 시간이 오래 걸릴 수밖에 없는 것이다.

2. 객체 수명이 길다

  • Old 영역에는 오랫동안 살아남은 객체들이 존재하는데, 이들은 연결 관계가 복잡하고 참조 그래프도 깊은 경우가 많다.
  • 때문에 Reachable 판별(Mark 단계)에 시간이 더 많이 들게 된다.

지금까지 GC란 무엇인지, 동작원리는 어떻게 되는지, 힙 영역은 어떻게 구성되어 있고 어떤 GC가 발생하여 정리되는지 등을 알아보았다.

결과적으로 GC에서 발생하는 가장 큰 문제는 Stop-the-World이기 때문에, 개발자들은 끊임없이 GC 알고리즘을 발전시켜 왔다.


🧠 GC 알고리즘 종류

Java를 운영하는 환경이 계속해서 발전함에 따라서, 이를 맞추기 위해 힙의 사이즈도 점점 커지게 되었다.

때문에 애플리케이션의 지연 현상이 더욱 문제가 되었고, 이를 최적화하기 위해서 다양한 GC 알고리즘이 개발되었다.

아래에서 소개하는 알고리즘은 모두 Java 내에서 설정을 통해 적용할 수가 있다. 즉, 상황에 따라서 필요한 GC 방식을 쓸 수 있다는 것이다.

1. Serial GC

  • 서버의 CPU 코어가 1개일 때 사용하기 위해서 개발된 가장 단순한 GC이다.
  • GC를 처리하는 스레드가 1개이기 때문에 가장 Stop-the-World 시간이 길다.
  • Minor GC에서는 Mark-and-Sweep / Major GC에서는 Mark-Sweep-Compact를 사용한다.
  • 실무에서는 거의 사용하지 않는다.

2. Parallel GC 

  • Java 8의 디폴트 GC이다.
  • Serial GC와 기본적인 알고리즘은 같지만, Young 영역의 Minor GC를 멀티 스레드로 수행 (Old 영역은 여전히 싱글 스레드)한다.
  • Serial GC에 비해 Stop-the-World 시간이 감소되었다.

3. Parallel Old GC (Parallel Compacting Collector)

  • Parallel GC를 개선한 버전이다.
  • Young 영역 뿐만 아니라, Old 영역에서도 멀티 스레드로 GC를 수행한다.
  • 새로운 가비지 컬렉션 청소 방식인 Mark-Summary-Compact 방식을 이용한다.

Summary 단계?

  • 힙 전체를 스캔하여 살아있는 객체들의 위치 정보를 요약(Summary)한다.
  • 즉, 어떤 영역이 비었고, 어떤 영역은 살아있으며, 어디로 복사해야 할지를 미리 계산하는 것이다.
  • 해당 정보를 기반으로 다음 단계에서 압축(compaction)을 효율적으로 수행할 수 있게 된다.

4. CMS GC (Concurrent Mark Sweep)

  • 어플리케이션 스레드와 GC 스레드가 동시에 실행되어 Stop-the-World 시간을 최대한 줄이기 위해 고안된 GC 알고리즘이다.
  • 단, GC 과정이 매우 복잡해지는데, GC 대상을 파악하는 과정이 여러단계로 수행되기 때문에 다른 GC 대비 CPU 사용량이 높다.
  • 또한 메모리 파편화 문제도 발생한다.
  • 때문에 CMS GCJava9 버전부터 deprecated 되었고 결국 Java14에서는 사용이 중지되었다.

5. G1 GC (Garbage First)

  • CMS GC를 대체하기 위해 jdk7 버전에서 최초로 release된 GC이다.
  • Java 9+ 버전의 디폴트 GC로 지정되었다.
  • 기존의 Young/Old 영역을 고정된 구역으로 나누는 방식이 아닌, Region이라는 고정 크기 블록들로 전체 Heap을 분할해서 유동적으로 관리하는 방식이다.

Region이란?

전체 Heap 영역을 Region이라는 영역으로 체스같이 분할하여 상황에 따라 Eden, Survivor, Old 등 역할을 고정이 아닌 동적으로 부여하는 것을 의미한다.

Eden / Survivor / Old Region는 모두 Generation 과 동일하며, 매우 큰 객체(Region 크기의 50%를 초과)를 저장하는 Humongous Region이 존재하는 것이 차이점이다.

ps. G1 GC에 대해서는 더 자세히 다음 글에서 다루어 볼 예정이다.

6. Shenandoah GC

  • Shenandoah GC는 애플리케이션 스레드와 GC 스레드가 동시에 실행되는 동시성(Concurrent) GC로, Stop-The-World 시간을 몇 ms 수준으로 최소화하기 위해 고안된 GC 알고리즘이다.
  • 전체 GC 과정(Mark, Compact 등)을 대부분 백그라운드에서 수행하며, 이동(Compacting) 단계조차도 동시 처리된다는 점이 특징이다.
  • 단점으로는 GC 로직이 복잡하여 CPU 사용량이 높고, GC 수행 중 객체를 이동시키기 위한 리디렉션 포인터 등 부가 메모리 비용이 발생한다.

즉, GC가 더 많은 CPU 자원을 사용하더라도, 애플리케이션의 Stop-The-World 시간을 줄이기 위해 설계된 알고리즘이다

  • Java 12 이상에서 사용 가능하며, 힙 크기가 크고 짧은 지연 시간이 요구되는 애플리케이션에 적합하다.

7. ZGC (Z Garbage Collector)

  • ZGC는 초저지연(low-latency)을 목표로 설계된 GC로, Stop-The-World 시간이 1~2ms 수준을 넘지 않도록 보장하는 것이 가장 큰 특징이다.
  • Java 15부터 정식 릴리스되었으며, 8MB ~ 16TB에 이르는 초대형 힙에서도 일정한 응답 시간을 유지할 수 있도록 설계되었다.
  • G1 GC의 Region 개념과 유사하게 ZPage라는 단위로 메모리를 관리하지만, ZPage는 고정 크기가 아닌 2MB 배수의 크기로 동적 확장된다.
  • 대부분의 GC 단계(Mark, Relocate 등)를 백그라운드에서 동시 처리하며, 오직 Root 스캔 등 일부 단계만 극히 짧은 Stop-The-World 를 발생시킨다.

참고한 블로그