Home GC는 단순한 자동 관리 도구가 아니다. (Java GC)
Post
Cancel

GC는 단순한 자동 관리 도구가 아니다. (Java GC)

GC에 대해 흔히 알려진 개념들은 어느정도 알고 있지만, 얘기를 해보면서, 그리고 조금 더 알아가보면서 아직 모르는 부분들이 많다고 느껴져 GC에 대해 더 자세히 정리해보려고 한다.

GC란?

GC는 더 이상 사용되지 않는 객체를 자동으로 메모리에서 해제해주는 Java의 메모리 관리 메커니즘이라고 보면 된다.

GC 덕분에 Java는 개발자가 직접 free() 같은 메모리 해제를 명시적으로 하지 않아도 되고, 메모리 누수와 관련된 부담을 줄여준다.

하지만 실제로 GC만 믿고 있다가 메모리 관리가 제대로 되지 않는 경우도 많다고 한다. GC는 절대적인 것이 아니다.

GC에 관련된 메모리 구조

우리가 일반적으로 OS를 배울 때 메모리 구조에 대해 배운다.

Text - Data - BSS - Heap - Stack 영역으로 이루어진 메모리 구조이다. OS와 하드웨어가 프로그램을 실행할 때 사용하는 메모리 구조다.

여기서 얘기할 GC와 관련된 JVM 메모리 구조는 조금 다르다.

Java 프로그램이 실행될 때, JVM은 내부적으로 별도의 런타임 메모리 구조를 구성한다. 이 JVM 메모리 구조는 OS 메모리 구조의 Heap이나 Data 영역 위에 얹혀있다.

JVM 메모리 구조

  • Heap 영역 (GC 대상)
    • JVM에서 가장 핵심적인 영역으로, 객체가 생성되고 관리되는 공간이다. GC는 이 영역을 대상으로 불필요한 객체를 제거하게 된다.
    • 이 Heap 영역은 또 다시 나뉘게 된다.
      • Young 영역
        • 대부분의 객체가 생성되는 공간
        • GC가 자주 일어난다 (Minor GC)
        • 세부 구조
          • Eden: 객체가 처음 생성되는 곳
          • Survivor: Eden에서 살아남은 객체가 이동하는 공간
      • Old 영역
        • Young 영역을 거쳐 살아남은 객체가 저장되는 공간
        • GC가 상대적으로 적게 발생하지만, 한 번 발생했을 때 오래 걸리는 작업을 한다. (Major GC)
  • Metaspace (JDK 8+)
    • 클래스의 메타데이터 (클래스/메서드 정보, static 변수 등등) 가 저장되는 영역
    • 이전에는 PermGen 영역이라는 곳에서 이 역할을 했지만, JDK 8부터 대체되었다.
    • 메모리는 기본적으로 OS가 관리하고, 힙 밖에 존재한다.
      • PermGen은 고정크기였어서 많은 클래스를 로딩하는 앱에서는 OutOfMemoryError가 자주 발생
      • 반면 MetaSpace는 JVM Heap 바깥 영역에 존재하며, JVM이 직접 관리하지 않고 OS에 위임하여 메모리를 할당받는 구조
      • 메타데이터를 JVM Heap에 직접 저장하는게 아닌, OS Heap에 저장한다.
  • Stack 영역
    • 각 스레드마다 독립적으로 존재한다.
    • 메서드 호출 시 생성되는 프레임, 지역 변수 등이 저장된다.
    • GC 대상이 아님
    • Stack에 있는 객체 참조는 GC Root 중 하나가 될 수 있다.
  • Native Method Stack
    • 자바 외의 네이티브 메서드를 실행할 때 사용된다.
  • PC Register
    • 현재 실행 중인 명령어의 주소를 저장하는 레지스터이다. 스레드마다 존재한다.

GC의 동작 방식

JVM이 객체를 수거할지 말지를 판단할 때 객체가 참조되었는지를 본다.

이는 단순히 null 인지 아닌지를 보는 것이 아닌 Reachability Analysis를 통해 객체가 유효한지 판단하게 된다.

GC Root

GC Root는 JVM이 기준점으로 삼는 절대 살아있는 참조이다.

GC는 이 GC Root에서부터 시작해 참조 체인을 따라가며 객체들이 접근 가능한지 분석하게 된다.

  • 스레드 스택 프레임에 있는 지역 변수, 파라미터
  • static 변수
  • JNI를 통해 참조되는 객체 (native code)
  • 활성화된 스레드 객체 자체

Reachability Analysis (도달성 분석)

GC는 GC Root로부터 시작해 객체를 따라가며 도달할 수 있는 객체는 사용 중으로 간주한다.

반대로 GC Root로부터 어떤 경로로도 도달할 수 없는 객체는 사용되지 않음으로 판단하고 GC 대상이 된다.

순환 참조 문제 해결

이 덕분에 순환 참조 문제가 해결된다. 서로가 서로를 참조하고 있지만 실제로 아무도 사용하지 않는 객체들이 있다.

1
2
3
4
5
6
7
class A {
	B b;
}

class B {
	A a;
}

이런 구조에서 a와 b 객체가 서로를 참조하고 있어도, GC Root에서 이들 중 아무도 도달하지 못하면 GC는 이런 객체도 정상적으로 수거할 수 있다.

단순하게 참조 개수가 아닌 도달성 분석을 사용하기 때문에 순환참조 문제를 잘 처리할 수 있는 것이다.

그럼에도 GC가 제대로 일어나지 않는 경우?

GC Root를 통해 여전히 참조 중인 객체가 수거 대상이 아니기 때문에 발생할 수 있다. 즉 의도치않게 GC Root로부터 접근이 가능한 객체가 있을 수 있다는 것이다.

  • static 변수에 객체를 저장한 경우
    • 클래스 로더가 살아있는 한 static 필드는 GC Root로 간주된다.
    • 객체를 더 이상 사용하지 않아도 메모리에서 해제되지 않는다.
  • 이벤트 리스너/콜백을 등록만 하고 제거하지 않은 경우
    • publisher.addListener(listener);
      • 이렇게 등록하고, 나중에 해제하지 않으면 publisher가 계속 listener를 참조하고 있어 GC 대상이 되지 않는 것.
      • 해결하기 위해 명시적으로 해제하거나, 약한 참조를 사용해 GC 회수를 유도할 수 있다.
  • ThreadLocal 사용 후 제거하지 않은 경우
    • ThreadPool과 함께 사용될 경우 치명적인 메모리 누수로 이어질 수 있다.
    • ThreadLocal은 스레드마다 독립적인 값을 저장하기 위해 사용되는데, 이 때 이 값이 remove되지 않으면 스레드가 종료될 때까지 저장된 값이 살아있게 된다.
    • 특히 ThreadPool과 함께 사용되면 Thread는 종료되지 않기 때문에 그 값이 유지되어 문제가 된다.
    • GC Root인 Thread에서 객체를 계속 참조 중이기 때문에 GC가 불가능하다.
      • try - finally 블록을 사용해 제거를 보장하여 방지할 수 있다.
  • JNI를 통한 객체 참조
    • 네이티브 코드에서 객체를 참조하고 있으면 GC가 이를 인식하지 못해 수거하지 않음

즉 대부분의 경우 GC Root를 통해 계속 연결이 유지되는 구조를 개발자가 실수로 만들어낸 경우 GC가 제대로 동작하지 않을 수 있는 것이다.

이런 경우 어떻게 잡아내나?

보통 명백하게 바로 드러나지 않고, 점진적으로 메모리를 먹고 죽게 된다.

JVM 모니터링을 통해 사용량 변화에서의 문제를 찾아내고, 힙 덤프를 떠 분석한다고 함.

GC의 종류

Java에는 다양한 GC 알고리즘이 존재한다.

대표적인 GC 알고리즘을 알아보기 전에, Minor GC, Major GC에 대해 이해해야 한다.

Minor GC vs Major GC

  • Minor GC
    • Young 영역을 대상으로 한다.
    • Eden 영역이 가득 찼을 때 발생한다.
    • 매우 짧은 Stop-the-World가 발생한다.
    • 속도가 빠르다. 비교적 작은 메모리 영역이 대상이기 때문.
    • 자주 발생하지만 부담이 적고, 대부분의 객체는 Young 영역에서 생성되고 빠르게 소멸되어 효율적이다.
    • 살아남은 객체는 Survivor에서 Old 영역으로 점진적 이동이 일어난다.
  • Major GC
    • Old 영역을 대상으로 한다. MetaSpace가 포함될 수도 있다.
    • Old 영역이 가득 찼을 때, System.gc()를 명시적으로 호출했을 때 발생한다.
    • 특정 알고리즘의 내부 정책에 의해 발생하기도 한다.
    • Stop-the-World가 발생한다. 비교적 더 길다.
    • 속도가 느리다. 메모리 범위도 넓고 작업도 복잡하다.
    • 서버 지연 시간 문제의 주범이 되는 경우가 많은 작업이다.

System.gc()는 Major GC를 유도할 수 있기 때문에 실무에서 남용하면 안된다.

GC 튜닝의 핵심은 Major GC를 최소화하고 Minor GC에서 대부분의 객체가 수거되도록 객체 생애주기 관리를 잘 하는 것에 있다.

Stop-the-World??

GC가 수행될 때 JVM의 모든 애플리케이션 스레드를 강제로 중지시키는 현상을 말한다.

GC는 애플리케이션의 객체 그래프를 안전하게 분석하고 처리해야 하는데, 멀티 스레드 환경에서 객체들이 계속 변경되면 정확한 GC가 어려워 일시적으로 이 현상을 발생시키게 되는 것이다.

이 시점에서 JVM은 오직 GC 작업만 수행하게 된다. 사용자의 요청이나 트래픽 처리 등등 전부 멈추게 된다.

  • GC를 위한 것임은 알겠지만, 이 현상이 발생하면 사용자 입장에서는 버벅임, 끊김, 응답 없음 현상을 마주하게 된다.
  • 실시간 게임, 금융 시스템 등에서 이러한 지연은 치명적이다.

따라서 GC 알고리즘을 통해 이 Stop-the-World 시간을 줄이거나 분산시키는 전략이 필요하다. 그리고 Major GC를 최대한 피하는 것이 좋다.

GC 알고리즘

대표적인 4가지 알고리즘이 존재한다.

  • Serial GC
    • 단일 스레드로 GC를 수행한다.
    • 전체 애플리케이션이 정지된다.
    • 가장 단순한 구조로 작은 애플리케이션에만 적합하다.
    • -XX: +UseSerialGC
  • Parallel GC (Throughput GC)
    • 여러 스레드로 GC를 병렬 수행한다.
    • 애플리케이션 처리량을 높이는 목적이 있다.
    • 똑같이 Stop-the-World 기반이기 때문에 단순하고 빠르지만 지연 시간 최적화는 어렵다.
    • -XX: +UseParallelGC
  • G1 GC (Garbage First)
    • JDK 9부터 기본 GC 알고리즘이다.
    • 힙을 작은 Region으로 나누어 GC를 수행한다.
    • 필요 영역만 GC하여 정지 시간을 예측할 수 있다.
    • Major GC도 병렬 + 부분 영역 GC로 분산처리한다.
    • -XX: +UseG1GC
  • ZGC / Shenandoah (최신 GC)
    • 초저지연 GC로 정지 시간 목표가 10ms 미만이다.
    • 대부분의 GC 작업을 애플리케이션과 동시에 수행한다.
    • 매우 큰 힙 단위에서 유리하다.
    • 아직은 일부 환경에서 실험적으로 사용하는 단계라고 한다.
    • -XX: +UseZGC
    • -XX: +UseShenandoahGC

G1 GC

전체 힙을 고정된 크기의 Region으로 나누는데, Young/Old 영역이 Region 단위인 것이다.

GC 대상 Region을 우선순위 기반으로 선택한다. 불필요한 객체가 많은 Region 먼저 처리하게 된다.

Minor GC, Major GC 구분 없이 Mixed GC라는 개념으로 처리하게 된다. Stop-the-World 시간을 짧고 예측 가능하게 유지하려는 목적이다.

정리

실무에서 GC에 관련된 문제는 생각보다 자주, 그리고 예상치 못한 방식으로 발생한다고 한다.
처음엔 JVM이 알아서 해주는 것처럼 느껴지지만, 실제로 문제가 생기면 결국 개발자가 직접 이해하고 판단해야 하는 영역이다.

앞으로도 개발자로 일하면서, GC는 분명 여러 번 마주치게 될 것이다.
그럴 때마다 단순한 개념 수준이 아니라, 직접 문제를 분석하고 조정할 수 있는 수준까지 이해하고 있어야 될 것 같다.

우선 GC에 대해 기초부터 주요 알고리즘, 실무와 관련해 정리를 했고, 이 내용들을 이해한 채로 실제 문제들을 마주하고 해결해보면서 더 깊게 이해하고 활용할 수 있을 것 같다.

This post is licensed under CC BY 4.0 by the author.

채팅 시스템 Kafka Consumer 실패 처리 및 DLQ, Replay 설계 (+ Trace)

채팅 시스템 roomId 기반 파티셔닝