[Java] JVM Garbage Collection 해부하기

유아 Yooa·2023년 10월 25일

Java

목록 보기
2/3
post-thumbnail

Overview

Effective Java를 공부하다 메모리 누수와 GC에 대한 내용이 언급됐다. 확실하게 이해하고 넘어가보자.


가비지 컬렉터(GC; Garbage Collection : 쓰레기 객체 정리자)

JVM에서는 Java 애플리케이션이 사용하는 메모리를 관리하고 있는데, 이 JVM의 기능 중 더 이상 사용하지 않는 객체를 청소하여 공간을 확보하는 가비지 컬렉터(이하 GC)라는 작업이 있다.

Java Runtime 시 Heap 영역에 저장되는 객체들은 따로 정리하지 않으면 계속해서 메모리에 쌓이게 되어 OutOfMemoryException 이 발생한다. WAS의 경우, 다운될 수가 있다.

이를 방지하기 위해 JVM에서는 주기적으로 사용하지 않는 객체를 수집하여 정리한다.

Stop-The-World

사용하지 않는 객체를 자동 처리해준다는 측면에서 강한 이점이 있지만, 단점도 있다.
메모리가 언제 해제되는지 정확하게 알 수 없어서 제어하기가 어렵고, GC 동작 중에는 JVM이 프로그램 실행을 멈추기 때문에 오버헤드가 발생될 수 있다.

이러한 현상을 STW(Stop THe World)라고 한다.
GC가 작동하는 동안 GC 관련 Thread를 제외한 모든 Thread는 멈추게 되어 서비스 이용에 차질이 생길 수 있다. 따라서 이 시간을 최소화 시키는 것이 쟁점이다.

이러한 이유로 GC가 자주 실행되면 오히려 성능 하락의 원인이 될 수도 있다.
실제로 익스플로러는 GC를 너무 자주 실행하여 성능 문제를 일으킨 적이 있다고.👀

그래서 핫 스팟(Hot Spot) JVM에서는 스레드 로컬 할당 버퍼(TLABs; Thread-Local Allocation Buffers) 라는 것을 사용한다. 이를 통해 각 스레드별 메모리 버퍼를 사용하면 다른 스레드에 영향을 주지 않는 메모리 할당 작업이 가능해진다.

GC의 동작 알고리즘 (Mark&Sweep)

Java의 GC는 매우 다양한 종류가 있지만 공통적으로 크게 다음 2가지 작업을 수행한다.

  • 힙(Heap) 내의 객체 중 가비지를 찾아낸다.
  • 찾아낸 가비지를 처리해서 힙의 메모리를 회수한다.

그럼 GC는 어떤 Object를 가비지로 판단해서 찾아내는 것일까?

GC의 수거 대상 기준: Reachability

Java GC는 객체가 가비지인지 팔별하기 위해 "reachability"라는 개념을 사용한다.

이는 객체가 유효한 참조가 있는 경우 "reachable"로 간주하고, 그렇지 않으면 "unreachable"로 간주하여 해당 객체를 가비지로 처리한다. 이 과정에서 유효한 최초의 참조가 있어야 하며, 이를 "객체 참조의 root set"이라 한다.

root set은 Java 메모리 구조에서 reachable을 결정하는 중요 요소이다. 다음과 같은 요소로 구성된다.

  • GC Roots
    • java stack 영역의 데이터들, 즉 Java 메서드 실행 시에 사용하는 지역 변수와 파라미터들에 의한 참조
    • method 영역의 static 데이터들
    • JNI(Java Native Interface)에 의해 생성된 객체들

GC는 이 root set으로부터 참조를 탐색하며 unreachable한 객체를 판단하고, 해당 객체를 청소 대상으로 처리한다.

  • 다음 순서로 GC가 동작이 된다.
  1. Marking : GC Roots로부터 모든 변수를 스캔하면서 각각 어떤 객체를 참조하고 있는지 찾아서 마킹한다.
  2. Sweep : Unreachable한 객체들을 Heap에서 제거한다.
  3. Compact (optional) : Sweep 후에 분산된 객체들을 Heap의 시작 주소로 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 나눈다.

GC가 처리하는 Heap 메모리 영역

JVM의 Heap 영역은 동적으로 레퍼런스 데이터가 저장되는 공간으로, GC에 대상이 되는 공간이다. GC가 인식하는 Heap 영역을 크게 Young, Old , Perm 영역으로 나눌 수 있다.

Heap영역은 처음 설계될 때 다음의 2가지를 전제 (Weak Generational Hypothesis)로 설계되었다.

  • 대부분의 객체는 금방 접근 불가능한 상태(Unreachable)가 된다.
  • 오래된 객체에서 새로운 객체로의 참조는 아주 적게 존재한다.

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

이러한 특성을 이용해 JVM 개발자들은 보다 효율적인 메모리 관리를 위해, 객체의 생존 기간에 따라 물리적인 Heap 영역을 나누게 되었고 Young 과 Old 총 2가지 영역으로 설계하였다.
이 중 Perm(Permanent) 영역은 거의 사용하지 않는 영역으로서 클래스와 자바 언어 레벨에서 사용되지 않는다.
(* Eden 영역부터 Servior 영역까지를 Young 영역이라 부른다.)

Java 7 버전까지의 Permanent

  • Permanent는 생성된 객체들의 정보의 주소값이 저장된 공간이다.
  • 클래스 로더에 의해 load되는 Class, Method 등에 대한 Meta 정보가 저장되는 영역이고 JVM에 의해 사용된다.
  • Java 7 까지는 힙 영역에 존재했지만 Java 8 버전 이후에는 Native Method Stack에 편입되게 된다

GC 처리 과정

GC가 처리하는 과정을 간단하게 정리하면 하기와 같다.

  1. 객체가 생성되어 Eden 영역에 올라간다.
  2. Eden 영역이 꽉 차면 Survivor1 영역으로 넘어간다. 단, Survivor 영역들 중 하나는 반드시 비어있어야 한다.
  3. 이 과정에서 오랫동안 살아남은 객체는 Old 영역으로 이동한다.

1. 객체가 생성되어 Eden 영역에 올라간다.

처음 생성된 객체든 Eden 영역에 할당(Allocation)된다. 이후 Eden 영역이 꽉 찬다면 할당이 해제되지 않은 객체를 Survivor 영역으로 이동시킨다.

2. Eden 영역이 꽉 차면 Survivor 영역으로 넘어간다. 단, Survivor 영역 중 하나는 반드시 비어있어야 한다.

  • Eden 영역이 꽉 차면 Minor GC 가 발생된다.
  • Mark 과정(reachable 객체 탐색)에서 살아남은 객체들은 Survivor1 영역으로 복사한다.
    • 살아남은 모든 객체들은 age 값이 1씩 증가한다.
  • Eden 영역에 있는 데이터들(사용되지 않는 unreachable 객체)의 메모리를 해제한다(Sweep).

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

만일 age 값이 임계값에 다다르면 Promotion(Old 영역으로 이동) 여부를 결정한다.
JVM 중 가장 일반적인 HotSpot JVM의 경우 이 age의 기본 임계값은 31이다.
객체 헤더에 age를 기록하는 부분이 6 bit로 되어 있기 때문이다.

  • Survivor 영역에 있는 객체는 올라가 있는 Survivor 영역이 꽉 찰 때 다시 GC 심사(Minor GC)를 받는다.
  • Eden 영역과 Survivor0 영역을 모두 Mark하고 살아남은 객체들은 Survivor1 영역으로 복사하고, Eden 영역과 Survivor0 영역의 데이터를 삭제한다.
    • 복사가 될 때는 해당 객체의 age 값이 증가된다.

단, 여기서 Survivor 영역을 거치지 않고 바로 Old 영역으로 이동하는 경우가 있다.

바로 객체의 크기가 Survivor 영역의 크기보다 큰 경우이다.

3. 이 과정에서 오랫동안 살아남은 객체는 Old 영역으로 이동한다.

대부분의 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.

특정 age에 도달한 객체들(Survivor 영역에서 객체의 객체가 살아남은 횟수를 의미하는 값)은 Old generation 영역으로 옮겨진다. Young generation에서 Old generation으로 옮겨지는 현상을 promotion이라 한다.

객체들이 계속 promotion되어 Old 영역에도 데이터가 가득차면,
Old 영역에 있는 모든 객체들을 검사하여 참조되지 않는 객체들을 한꺼번에 삭제하는 Major GC가 실행되게 된다.

GC의 종류

Minor GC

Young(Eden, Survivor) 영역에서 발생하는 GC.
Young 영역은 일반적으로 Old 영역보다 크키가 작기 때문에 GC가 보통 0.5초에서 1초 사이에 끝난다. 그렇기 때문에 Minor GC는 애플리케이션에 크게 영향을 주지 않는다.

Major GC

Old, Perm 영역에서 발생하는 GC. Full GC라고도 불리운다.
Old 영역의 Major GC는 일반적으로 Minor GC보다 시간이 오래걸리며, 10배 이상의 시간을 사용한다.

Major GC가 일어나면 Thread가 멈추고 Mark and Sweep 작업을 해야 해서 CPU에 부하를 주기 때문에 멈추거나 버벅이는 현상이 일어난다.
=> STW 문제 발생.


GC 알고리즘

GC 수행 과정에서의 SWT가 발생하고, 자바가 발전과 함께 Heap의 사이즈가 커지면서 애플리케이션의 지연(Suspend) 현상이 두드러지게 되었다.
따라서 이를 최적화 위해 다양한 GC 알고리즘이 개발 되었다.

이러한 GC 알고리즘은 설정을 통해 Java에 적용할 수 있으니, 상황에 따라 필요한 GC 방식을 설정해서 사용할 수 있다.

1. Serial GC

  • 서버의 CPU 코어가 1개일 때 사용하기 위해 개발된 가장 단순한 GC
  • GC를 처리하는 스레드가 1개 (싱글 스레드)
  • 다른 GC에 비해 stop-the-world 시간이 길다
  • Minor GC 에는 Mark-Sweep을 사용하고, Major GC에는 Mark-Sweep-Compact를 사용한다.

2. Parallel GC

  • Java 8의 default GC
  • Young 영역의 Minor GC를 멀티 쓰레드로 수행 (Old 영역은 여전히 싱글 스레드)
  • Serial GC에 비해 stop-the-world 시간 감소

3. Parallel Old GC

  • Parallel GC를 개선
  • Old 영역에서도 GC를 멀티 스레드로 수행
  • Mark-Summary-Compact 알고리즘 사용

4. CMS(Concurrent Mark Sweep) GC

  • stop-the-world 시간을 줄이기 위해 고안

  • 애플리케이션의 스레드와 GC 스레드가 동시 실행

  • GC 과정이 매우 복잡하고, GC 대상을 파악하는 과정이 복잡한 여러 단계로 수행되어 CPU 사용량이 비교적 높음

  • 메모리 파편화 문제

  • Java 9 버전부터 deprecated 되었고 Java14에서 사용 중지

  • compact 과정이 없음

  • inital Mark - GC Root에서 참조하는 객체들만 식별

  • Concurrent Mark - 이전 단계에서 식별한 객체들이 참조하는 모든 객체 추적

  • Remark - 이전 단계에서 식별한 객체를 다시 추적, 추가되거나 참조가 끊긴 객체 확정

  • Concurrent Sweep - unreachable 객체들을 삭제

5. G1 (Garbage First) GC

  • CMS GC를 개선

  • Java 9+의 default GC

  • 4GB 이상의 heap 메모리, STW 시간이 0.5초 정도 필요한 상황에 사용

    • heap이 너무 작을 경우 미사용 권장
  • 기존 GC 알고리즘의 Young/Old 영역이 아닌, Heap을 일정 크기의 Region으로 나눔

    • 전체 Heap 영역을 바둑판처럼 분할하여 각 영역에 객체를 할당하고 GC를 실행
    • 상황에 따라 역할(Eden, Survivor, Old 등)을 동적으로 부여
  • 전체 heap이 아닌 region 단위로 탐색, 그래서 전체 영역(eden, survivor, old generation)을 탐색하지 않아서 빠르다.

    • 메모리가 많이 차있는 영역(region)을 인식하는 기능을 통해 메모리가 많이 차있는 영역을 우선적으로 GC
    • 살아남은 객체들에 대해서는 효율적이라고 생각하는 위치로 객체를 Reallocate(재할당)
  • compact 진행

6. Shenandoah GC

  • OpenJDK 12에 출시
  • Red Hat에서 개발한 GC
  • 기존 CMS가 가진 단편화, G1이 가진 pause의 이슈를 해결
    • '큰 GC 작업을 적은 횟수로 수행하는 것 보다 작은 GC 작업을 여러번 수행하는 게 낫다'라는 컨셉
  • 강력한 Concurrency와 가벼운 GC 로직으로 heap 사이즈에 영향을 받지 않고 일정한 STW 시간이 소요가 특징

7. ZGC (Z Garbage Collector)

  • OpenJDK 15에 출시
  • 대량의 메모리(8MB ~ 16TB)를 low-latency로 잘 처리하기 위해 디자인 된 GC
  • G1의 Region 처럼, ZGC는 ZPage라는 영역을 사용하며, G1의 Region은 크기가 고정인데 비해, ZPage는 2mb 배수로 동적으로 운영됨. (큰 객체가 들어오면 2^ 로 영역을 구성해서 처리)
  • ZGC가 내세우는 최대 장점 중 하나는 힙 크기가 증가하더도 STW의 시간이 절대 10ms를 넘지 않는다는 것

GC의 메모리 누수

GC가 의도치 않게 객체를 살려두는 상황에 메모리 누수가 발생한다.
예를 들어, 한 객체가 유효한 참조를 가지고 있다면 가비지 컬렉터는 그 객체뿐 아니라 그 객체를 참조하는 모든 객체와 이어지는 객체들까지 회수해가지 못한다. 이는 객체 그래프가 복잡하고 참조가 꼬여 있는 경우 특히 발생한다.

GC 메모리 누수 예제 : Stack

간단하게 스택 코드를 구성하고 스택에 element를 push 했다가 pop하는 코드를 디버깅 해보자.

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }

    /**
     * 원소를 위한 공간을 적어도 하나 이상 확보
     * 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
     */
    private void ensureCapacity() {
        if(elements.length == size) {
            elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
}

public class Item07 {
    public static void main(String[] args) {
        Stack stack = new Stack();

        for(int i = 0; i < 5; i++) {
            stack.push(i);
        }

        for(int i = 0; i < 5; i++) {
            stack.pop();
        }
    }
}


위와 같이 객체 컬렉션에서 단순히 요소의 크기만을 조절하게 객체를 꺼내는 경우, 다 쓴 참조(obsolete reference)가 남아있을 수 있다. GC는 이러한 다 쓴 참조를 인식하지 못하고 참조를 유지한다.

이러한 상황은 메모리 누수를 초래하고, 심한 경우 OutOfMemoryException 이나 디스크 페이징을 유발하여 성능에 부정적 영향을 끼칠 수 있다.

해법은 간단하다. 해당 참조를 다 썼을 때 null 처리 (참조 해제)하면 된다.

public Object pop() {
    if (size == 0)
        throw new EmptyStackException();
    Object result = elements[--size];
    elements[size] = null; // null 처리!!
    return result;
}

다 쓴 참조를 null 처리하면 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NPE을 던지며 종료된다는 측면에서도 이점을 가진다고.

메모리 누수의 해결은 무조건적인 null 처리인가? NOPE!

메모리 누수 문제를 해결하겠다고 모든 객체에 대해 무조건적인 null 처리하는 것은 실용적이지 않다. 오히려 코드를 복잡하게 만들고, 올바르게 관리되지 않을 경우 에러를 발생시킬 수 있다.

물론 예외적인 상황에서는 명시적인 null 처리가 훨씬 안전할 것 같다. 파일 핸들이나 네트워크 연결, 데이터베이스 연결 등의 자원 관리 객체에 있어서는 명시적인 자원 해제가 필요할 것 같다.

다 쓴 참조를 해제하는 가장 좋은 방법은 해당 참조를 담은 변수의 범위(scope)를 최소화하여 참조가 필요 없어질 때 자연스럽게 해제되도록 하는 것이다.

변수의 범위를 최소화함으로써 변수의 생명주기를 관리하고 필요 없어진 참조가 변수의 범위를 벗어나면 자동으로 해제된다. 이렇게 하면 명시적인 Null 처리를 수행할 필요가 없고, 코드의 가독성과 유지보수성을 향상시킬 수 있다.


참고

profile
기록이 주는 즐거움

0개의 댓글