[JAVA] Garbage Collection 1

Seongje kim, 25 January 2020

이번 포스트의 주제는 첫 포스트에서 다뤘던 Java 가비지 컬렉션(Garbage Collection)에 관한 주제입니다.
1 ~ 2 부로 진행될 예정이며 아래 Java 메모리 구조 포스트를 참고하시면 더 좋을 것 같습니다.
(Java) Memory structure

Garbage Collection/Collector (GC)


Garbage Collector(GC) 는 Java의 중요한 메모리 관리 도구로써 JVM 의 힙 영역의 쓰레기 청소를 담당한다.

간단하게 GC 의 역할을 되짚어보자.

JVM 의 힙 영역에 객체들이 할당되면 각 객체는 메모리를 점유하고 있게 된다. 하지만 그 객체들이 더 이상 필요없음에도 불구하고 메모리를 여전히 점유하고 있다면 이는 자원 손실 및 성능 저하를 유발한다. 이와 같이 메모리 상의 필요없는 객체를 쓰레기로 분류하고 이들을 처리하는 메모리 관리자로써의 역할을 하는 것이 바로 ‘GC’ 이다.

Java에서는 일반적으로 개발자가 프로그램 코드로 메모리를 명시적으로 해제하지 않기 때문에 GC 가 힙 영역의 쓰레기 객체를 담당하는 청소부로써의 역할을 하는 것이다.

GC 는 이른바 ‘Mark and Sweep’ 이라는 과정을 통해 메모리 공간을 확보한다. 이는 스택 영역에서 도달할 수 없는 힙 영역의 객체를 말하는 Unreachable Object (즉, 쓰레기 객체)를 탐색하고 이들을 우선적으로 메모리에서 제거하는 과정이다. GC 가 실행되면 스택 영역의 모든 변수들을 스캔하여 각각 어떤 객체를 참조하고 있는지 찾아 Marking 하고 그 결과, Marking 되어 있지 않은 쓰레기 객체들을 Sweep 한다.

일반적으로 GC 의 대상이 되는 Unreachable Object 은 아래의 경우들에 해당한다.

  1. 모든 객체 참조가 null 일 경우
  2. 객체가 블록 안에서 생성되고 블록이 종료되었을 경우
  3. 부모 객체가 null 이 되었을 경우, 자식 또는 포함된 객체

하지만 CG 를 실행하기 위해서 JVM 은 애플리케이션(GC를 실행하는 스레드를 제외한 모든 스레드)의 실행을 일시적으로 멈추고, 완료되었을 때 중단되었던 스레드들을 다시 시작한다. 이를 ‘Stop the World’ 라고 말한다. 보통 GC 를 튜닝하는 목적은 이 Stop the World 의 소요 시간을 줄이는 것이다.

힙 영역의 구조

GC 는 ‘Weak Generational Hypothesis’ 이라고 불리는 가설 또는 전제 조건을 바탕으로 탄생했다.

  1. 대부분의 객체는 금방 접근 불가 상태(Unreachable)가 된다.
  2. 오래된 객체에서 젊은 객체를 참조하는 경우는 아주 적다.

가장 일반적인 JVM 모델인 Hotspot JVM 은 위 전제 조건을 적절히 적용하기 위해 힙 영역을 크게 2개의 물리적 공간으로 구분한다. 이렇게 나뉘어진 공간이 Young 영역과 Old 영역이다.

  • Young Generation : 새롭게 생성한 객체의 대부분이 이곳에 위치한다. 대부분의 객체가 금방 접근 불가능 상태가 되기 때문에 매우 많은 객체가 Young 영역에 생성되었다가 사라진다. 이 영역에서 객체가 사라질 때를 ‘Minor GC’ 가 발생한다고 말한다.

  • Old Generation : 계속 사용되어 접근 불가능 상태가 되지 않고 Young 영역에서 살아남은 객체가 여기로 복사된다. 대부분 Young 영역보다 크게 할당하며 크기가 큰 만큼 Young 영역보다 GC 는 적게 발생한다. 이 영역에서 객체가 사라질 때, ‘Major GC(Full GC)’ 가 발생한다고 말한다.

또한 위 그림에서 볼 수 있듯이 Young 영역과 Old 영역 이외에 Permanent Generation(Perm) 영역이 존재한다. 이 영역은 애플리케이션이 아닌 JVM 이 사용하는 영역으로 JVM 메모리 영역 중 Method Area 에 해당한다. 보통 클래스 및 메서드의 Meta 정보나 Static 변수, 상수 정보들이 저장되는 공간이다.

아래 JVM Runtime Data Area 의 모습을 통해 각 영역들을 확인할 수 있다.

그러나 Java 8 부터 JVM 메모리 구조 개선 사항으로 Perm 영역은 ‘Metaspace’ 영역으로 변경되었다. 이는 Perm 영역의 메모리 누수 문제와 관련이 있다.

Perm 영역이 Metaspace 영역으로 변경되면서 더 이상 힙 영역이 아닌 Native 메모리 영역으로 취급하게 된다. 힙 영역이 JVM에 의해 관리되는 영역인 반면 Native 메모리는 OS 레벨에서 관리하고 동적으로 조정하기 때문에 개발자는 기존 Perm 영역 확보에 대한 튜닝을 걱정하지 않게 되었다. 즉, 변경될 가능성이 아주 적은 각종 Meta 정보를 OS 가 관리하는 영역으로 옮겨 기존 Perm 영역의 크기 제한을 없앤 것이다.

단, 기존 Perm 영역의 Static 변수와 상수는 힙 영역으로 이동하여 최대한 GC 의 대상이 될 수 있도록 하였다.

또한 Old 영역에는 카드 테이블(Card Table)이 존재한다. Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우, 이를 처리하기 위해 테이블에 정보를 표시한다. 그래서 Young 영역의 GC(Minor GC) 를 실행할 때에는 Old 영역에 있는 모든 객체의 참조를 확인하지 않고, 이 카드 테이블만 확인하여 GC 의 대상인가를 식별한다.

Young 영역

Young 영역은 크게 3 개의 영역으로 구분된다.

  1. Eden 영역
  2. Survivor 0 / 1 영역

우선 새로 생성한 객체는 대부분 Eden 영역에 할당되며 Survivor Space 는 비워진 상태로 시작한다.
이후 Eden 영역이 꽉 차게 되면 Minor GC 가 발생한다.

최초에 Minor GC 가 발생하면 Eden 영역에서 살아남은 객체(Reachable Object)들은 Survivor 영역 중 하나로 이동된다. 한번의 GC 수행 결과로 모든 살아남은 객체가 Survivor 영역으로 이동 되었다면 남은 쓰레기 객체(Unreachable Object)들은 Eden 영역이 비워짐과 동시에 메모리에서 제거된다. 이처럼 Minor GC 가 계속 발생하게 되면 살아남은 객체들이 Survivor 영역에 쌓이게 된다.

살아남은 객체들을 이동시킬 때는 하나의 Survivor 영역만 사용한다. 즉, Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아 있어야 한다.

이후 Minor GC 에서 하나의 Survivor 영역이 가득 차게 되면 그 중 살아남은 모든 객체를 다른 Survivor 영역으로 이동시키며 이전에 가득 찼던 Survivor 영역이 비워진다. 이때, 살아남은 객체들은 Survivor 영역의 이동과 함께 Age 값이 증가된다.

결과적으로 위 Minor GC 의 과정들을 반복하면서 살아남는 객체들은 계속 Age 값이 증가하게 되고, 기준 Age 를 초과한 객체들은 Old 영역으로 옮겨지는데 이 과정을 ‘Promotion’ 이라고 한다. 즉, Minor GC 가 반복되면 Promotion 도 계속 발생한다.

아래 그림은 Minor GC 전후 과정을 보여준다.

Old 영역

이제 Minor GCPromotion 작업이 반복되면서 Old 영역이 가득 차게 되면 Major GC 가 발생한다. Major GC 발생 시, Old 영역의 모든 객체들을 검사하여 쓰레기 객체들을 한꺼번에 제거한다. 이 과정은 비교적으로 Minor GC 보다 시간(Stop the World)이 오래 걸리게 된다.

Old 영역의 GC 를 수행하는 방식은 Java 7을 기준으로 5개가 존재한다.
수행 방법에 따라 절차가 달라지며 성능에 큰 영향을 미친다.

  • Serial GC

Serial GC 는 적은 메모리와 CPU 코어 개수가 적을 때 적합한 방식이다. 특히 운영 서버 같은 경우에 적용한다면 심각한 성능 저하를 유발한다.

이 방식은 ‘Mark-Sweep-Compact’ 이라는 알고리즘을 사용한다.

우선 Old 영역에 살아 있는 객체를 식별(Mark)한다. 그다음 Sweep 단계에서 영역의 앞 부분부터 확인하여 살아 있는 것만 남긴다. 마지막 Compaction 단계에서 Sweep 이후 비워진 힙 공간에 대해 살아남은 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채운다.

  • Parallel GC

Parallel GC 는 메모리가 충분하고 코어의 개수가 많을 때 유리하다.

Parallel GCSerial GC 와 기본적인 알고리즘은 같다. 그러나 Serial GCGC 를 처리하는 스레드가 하나인 반면에 Parallel GC 는 여러 개이다. 때문에 Serial GC 보다 빠르게 객체를 처리할 수 있다.

  • Parallel Old GC

이 방식은 ‘Mark-Summary-Compaction’ 단계를 거친다. Summary 단계는 앞서 GC 를 수행한 영역에 대해서 별도로 살아 있는 객체를 식별한다는 점에서 Sweep 단계와 다르며, 약간 더 복잡한 단계를 거친다.

  • CMS GC

이 방식은 애플리케이션의 응답 속도가 중요할 때 사용하며, ‘Low Latency GC’ 라고도 부른다.

초기 Initial Mark 단계에서는 클래스 로더에서 가장 가까운 객체 중 살아 있는 객체만 찾는 것으로 끝낸다. 따라서 멈추는 시간은 매우 짧다. 그다음 Concurrent Mark 단계에서는 Initial Mark 단계에서 확인한 객체에서 참조하고 있는 객체들을 따라가면서 확인한다. 이는 다른 스레드가 실행 중인 상태에서 동시에 진행된다.

이후 Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인한다. 마지막으로 Concurrent Sweep 단계에서 확인된 쓰레기 객체들을 처리하며, 이 작업도 다른 스레드와 동시에 진행된다.

결과적으로 이 방식을 사용한다면 Stop the World 시간이 매우 짧아진다. 하지만 다른 GC 방식보다 메모리와 CPU 를 더 많이 사용하게 되며 Compaction 이 기본적으로 제공되지 않는다. 따라서 조각난 메모리가 많아 Compaction 을 실행하면 Stop the World 시간이 다른 방식보다 더 길기 때문에 Compaction 작업이 얼마나 자주, 오랫동안 수행되는지 확인해야 한다.

  • G1 GC

G1 GCCMS GC 의 대체 방안으로 고안되었으며, 성능상 뛰어나다는 장점이 있다.

이 방식은 메모리를 바둑판처럼 각각의 영역으로 구분하고 각 영역에 객체를 할당하여 GC 를 실행한다. 그다음 해당 영역이 꽉 차면 다른 영역에서 객체를 할당하고 GC 를 실행한다. 즉, 기존의 Young, Old 영역에서 진행하는 메모리 처리 방식이 한 영역 안에서 모두 진행되는 것이다.


이것으로 이번 포스트를 마치고 Java 가비지 컬렉션 2 부에서 ‘G1 GC 방식’ 에 대해 더 자세히 다루겠습니다. 감사합니다.