Post

G1 가비지 컬렉터

G1 가비지 컬렉터가 등장한 배경, 간단한 동작방식과 튜닝방법에 대해서 알아봅니다.

가비지 컬렉터, 다른 이름으로 말하면 자동 동적 메모리 관리 시스템은 지난 수십년 동안 자바와 같은 매니지드 언어가 인기를 얻은 이유 중 하나입니다. 가비지 컬렉터는 새로 생성된 객체에 공간을 할당하고 객체가 더 이상 필요하지 않을 때 재사용할 수 있는 메모리 공간을 확보하고, 메모리 조각 모음을 수행하는 등 눈에 띄지 않는 곳에서 개발자의 생산성에 큰 이점을 주었습니다. 가비지 컬렉터 덕분에 개발자는 비즈니스 로직 구현에 집중할 수 있게 되었습니다.

image

자바 애플리케이션 성능을 위해서 필요한 설정 중 하나는 최대 힙 크기 사이즈를 설정하는 것입니다. 그리고 관리는 JVM이나 가비지 컬렉터에 맡겨버리는 경우가 많습니다.

그러나 분당 수천 또는 수백만개의 요청을 수신하고 기가바이트의 활성 메모리를 처리해야 하는 애플리케이션 이라면 가비지 컬렉터를 선택하고 설정하는 것은 중요한 작업입니다. 그리고 가비지 컬렉터의 작동방식을 이해해야 설정정보를 올바르게 조작할 수 있습니다. JDK 11 이상을 사용하는 경우 G1 가비지 컬렉터가 자바의 기본 가비지 컬렉터일 가능성이 높습니다.

G1 가비지 컬렉터의 역사

G1 가비지 컬렉터의 시작을 알기 위해서는 1999년까지 거슬러 올라가야합니다. 이야기가 본격적으로 시작된것은 2004년 10월 이였습니다. 2004년 ISMM(Intenational Symposium on Memory Management)회의에서 G1 가비지 컬렉터에 대해 설명하는 논문이 처음 발표되었습니다.

image

여기서 논문의 저자는 다중 프로세서 시스템을 사용하면서 대용량 메모리에 액세스하는 가비지 컬렉터에 대해서 설명했습니다. 이 논문의 목표는 당시 컴퓨터의 기술적인 측면에서 일어나고 있던 현상에 대응하는 것이였습니다. 무어의 법칙이 여전히 유효해서 트랜지스터의 수가 2년마다 두배로 증가했지만 그만큼 발생하는 발열때문에 클럭속도가 정체되기 시작했습니다.

image

이로 인해 IBM에서는 Power4 및 power5 CPU와 같은 멀티코어 제품이 나오고, 인텔에서는 2002년에 하이퍼스레딩기술이 도입된 제온이나 펜티엄4 프로세서가 출시되는 등 CPU 설계철학이 변하기 시작했습니다.

동시에 무어의 법칙으로 인해 메모리 비용이 크게 감소하고있었습니다. 1990년도에는 테라바이트당 가격이 수천만 달러였지만 2000년도에는 수십만 달러, 현재는 약 10,000달러로 떨어지면서 컴퓨팅의 미래는 병렬화와 수 기가바이트의 힙 작업에 달려있습니다.

그리고 Java에는 이러한 리소스를 활용할 수 있는 가비지 컬렉터가 필요하게 되었습니다. 당시의 자바 개발자가 사용할 수 있는 가비지 컬렉터인 Serial GC, Parallel GC, CMS GC는 이러한 컴퓨팅 리소스를 활용하는데 적합하지 않았기 때문입니다. Serial GCParallel GC는 하던 모든 작업을 중지하고 작업을 수행했습니다. 단일 스레드만 사용하는 Serial GC의 경우에는 처리해야 하는 힙의 크기가 100MB를 넘어가면 일시중지 문제가 발생하기 시작했고, Parallel GC의 경우 2GB 정도부터 문제가 발생하기 시작하였습니다.

CMS GC나 Concurrent Mark And Sweep GC도 동시에 작업을 수행할 수 있지만 단일 스레드에서만 활용할 수 있습니다. 수 기가바이트의 힙을 사용하는 자바 애플리케이션의 경우 일시중지 시간문제로 인한 문제가 발생하지 않고 사용할 수 있는 힙 크기에 한계가 있기 때문에 장기적인 관점에서 불이익이 발생합니다.

따라서 멀티코어 프로세서와 수 기가바이트의 힙을 사용한다는 목표를 달성하기 위해서 G1 가비지 컬렉터를 사용합니다. 그렇다면 실제로 G1가비지 컬렉터가 어떻게 동작해서 멀티코어 프로세서와 수 기가바이트 힙을 다루는 걸까요?

G1 가비지 컬렉터의 디자인

G1 가비지 컬렉터가 어떤식으로 동작하는지는 G1 가비지 컬렉터 문서에 설명되어 있습니다.

image

G1 팀이 소개하는 G1가비지 컬렉터의 특징은 다음과 같은 것들이 있습니다.

  • Generational
  • Incremental
  • Parallel
  • Mostly Concurrent
  • Stop-the-World
  • Evacuating

하나하나 자세히 살펴봅시다.

Genrational

Generational 이라는 특징은 힙을 Old/Young Generation 두 가지 세대로 나누는 것을 말합니다. 애플리케이션이 실행되고 객체가 힙 공간에 할당되면 처음에는 Young generation에 배치됩니다. 하지만 G1의 경우 한가지 예외사항이 있긴합니다. 이 예외사항에 대해서는 나중에 다루어 보겠습니다.

Generation GC는 대부분의 객체가 짧은 기간동안만 생존한다는 가설을 활용해서 힙을 나눕니다. 세대별 가비지컬렉터는 Young Generation에서 접근할 수 없는 객체를 자주 검색하는 방식으로 이러한 특성을 활용합니다. 이러한 특징을 사용해서 접근할 수 없는 객체가 존재할 확률이 더 높은 곳만 집중적으로 탐색하기 때문에 CPU리소스를 효율적으로 사용할 수 있습니다.

Young Generation에 존재하는 객체들 중 충분한 횟수의 가비지컬렉션에서 살아남은 객체들은 Old generation으로 이동하게 됩니다. Old Generation은 Young Generation보다 스캔 빈도가 낮으며 일반적으로 스캔하기 전에 특정 조건을 충족해야합니다. 이에 대해서는 나중에 다루겠습니다.

Incremental

G1의 Incremental이 동작하는 방식을 이해하기 위해서는 G1이 힙을 처리하는 방법을 더 자세히 살펴볼 필요가 있습니다. ZGC에 대해서 알고있다면 지역화된 가비지 컬렉터라는 개념에 익숙할 것입니다. 하지만 지역화된 메모리 관리에 대한 G1의 접근방식은 ZGC와는 다소 다릅니다. ZGC는 활성된 객체의 수에 따라 다양한 크기의 영역이 있는 동적 영역모델을 사용합니다. 대신 G1은 힙을 동일한 크기의 영역으로 나누는 방식을 선택합니다. 영역 크기는 직접 구성할 수 있고, JDK18 에서는 최대 512MB까지 설정할 수 있습니다. 영역크기에 대한 설정정보를 따로 주지 않으면 G1은 내부적인 휴리스틱을 사용해서 영역의 수와 크기를 계산해 적용합니다.

image

이전 섹션에서 다뤘던 걸 생각해보면 이러한 영역들은 크게 젊은 영역(Young Generation)과 오래된 영역(Old Generation)으로 나뉘며, 힙의 일부영역들만 사용된다는 점에 유의해야합니다. 위 이미지에서 회색으로 표시된 부분은 사용되지 않는 부분입니다. G1의 최대 힙 크기를 설정할 때 예상되는 사용량보다 어느정도의 여유공간을 확보하는 것이 중요한 이유는 뒤에서 설명하겠습니다.

incremental 가비지 컬렉션으로 돌아가서, G1이 힙의 젊은 영역만 자주 스캔하는 점에서 incremental GC라는 이름이 붙었습니다. 하지만 G1은 때때로 혼합수집(mixed collection)이라는 작업을 통해 젊은 영역과 오래된 영역을 함께 수집하는 경우도 있습니다. 이부분도 뒷부분에서 다루어보도록 하겠습니다.

하지만 G1이 오래된 지역을 회수할 때 무작위로 오래된 지역을 선택하는 것이 아니라 쓰레기가 가장많은 지역, 그러니까 사용되지 않는 객체가 가장 많은 지역을 선택하기 때문에 G1(Garbage-First)라는 이름이 붙게 되었습니다.

그렇다면 G1은 어떤 지역에 쓰레기가 가장 많은지 어떻게 알아내는 걸까요? 이 질문에 답하기 위해서는 G1의 아키텍쳐에 대해서 좀더 살펴보아야 합니다.

Remembered Set

어떤 지역을 회수해야할지 파악하기 위해서 G1은 기억 집합(Remembered Set)쓰기 장벽(Write Barrier)를 사용합니다.

image

위 이미지에서도 보듯이 Person 객체는 다른 클래스 객체인 address를 필드로 가지고 있습니다. 이 예제처럼 참조가 지역 경계를 넘나드는 경우가 종종 있습니다. 이런 경우 G1은 이 참조에 대한 내용을 테이블에 기록해두어야 합니다. 왜냐하면 참조되고 있다는 것을 기록해두지 않는다면 가비지 컬렉션 중에 Address 객체가 포함된 지역은 회수되지만 Person 객체가 포함된 지역은 회수되지 않는 등, 두 공간 중 하나만 회수 대상으로 선택될 수 있기 때문입니다.

G1은 기억 집합(Remembered Set)을 사용해서 애플리케이션이 Person 인스턴스로 작업할 때 Address의 현재 위치를 검색할수 있도록 합니다.

image

하지만 가비지 컬렉터가 모든 지역간 참조에 관심이 있는 것은 아닙니다. 위 이미지와 같이 젊은 영역(Young Generation)간의 참조는 어차피 모든 젊은 영역이 가비지 컬렉션 대상에 포함되기 때문에 관심이 없습니다.

image

젊은 영역에서 오래된 영역(Old Generation)으로의 참조도 젊은 영역에서 시작된 참조이기 때문에 항상 가비지 컬렉션의 대상에 포함되기 때문에 특별히 중요하지 않게 여깁니다.

image

그러나 오래된 영역에서 젊은 영역으로의 참조는 오래된 영역이 가비지 컬렉션의 대상에 포함되지 않을 수 있기 때문에 관심을 가져줍니다.

Write Barrier

그렇다면 G1이 어떻게 참조를 인식하는 걸까요? 이는 Write Barrier를 통해서 이루어집니다. Write Barrier는 핫스팟 인터프리터나 C1 또는 C2, JIT 컴파일러가 애플리케이션 코드에 삽입할 수 있는 가비지 컬렉션 코드조각입니다. Write Barrier를 추가하는데 사용되는 프로세스는 최적화 수준에 따라서 달라집니다.

예를 들어서 다음과 같은 코드가 있다고 해봅시다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Person {

    private Address address;
    private int age;

    public void setAddress(Address address) {
        this.address = address;
    }

    public void setAge(int age) {
        this.age = age;
    }

}

Write Barrier라는 이름에서도 알 수 있듯이 Write Barrier는 애플리케이션 코드가 참조값을 수정하는 곳에 삽입됩니다. 위의 코드 예제에서 Write Barrier는 참조를 업데이트 하는 setAddress() 메서드에서만 삽입됩니다. 반면, setAge() 메서드는 나이(age)가 원시타입인 int데이터이므로 Write Barrier가 필요하지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Person {

    private Address address;
    private int age;

    public void setAddress(Address address) {
        // <write barrier가 코드를 삽입하는 부분>
        this.address = address;
        // <write barrier가 코드를 삽입하는 부분>
    }

    public void setAge(int age) {
        this.age = age;
        // <write barrier가 코드를 삽입하지 않음. 참조타입이 아니기 때문>
    }

}

Write Barrier의 정확한 동작을 설명하는 것은 이 글의 목적을 벗어나는 내용이기 때문에 여기서는 간단하게만 알아보겠습니다. 높은 수준의 Write Barrier는

  1. 참조가 같은 영역에 있는지 확인한다.
  2. 값이 null인지 확인한다.
  3. 젊은 영역(Young Generation)인지 확인한다.
  4. 검사 결과를 반영하기 위해 Remembered Set과 같이 필요한 데이터를 업데이트한다.

와 같은 로직들이 포함되어 있습니다.

image

나중에 다룰 객체 수명(Object Liveness), 기억된 집합(Remembered Set), 쓰기 장벽(Write Barrier), 내부 휴리스틱(Internal Heuristics)의 조합을 사용해서 G1이 가비지 컬렉션을 수행할 이상적인 Old Generation을 찾아냅니다.

가비지 컬렉션을 진행할 최우선 순위 중 하나는 Garbage가 많이 발생하는 지역입니다. 가비지 컬렉터가 수집하기 위해서 지정하는 영역을 Collection Set라고 부릅니다.

G1이 힙을 분할하는 방법에 대해 설명하기 전에 이전에 말했던, G1이 새로운 객체를 젊은 영역에 할당하지 않는 한가지 큰 예외에 대해 알아봅시다.

image

설정된 영역크기의 크기보다 큰 객체의 경우 별도의 영역에 할당하는 경우가 그렇습니다. 예를 들어서 영역 크기가 16MB로 설정되어 있고 9MB 크기의 객체가 있는 경우, 이 객체는 자체 16MB영역에 배치되고 17MB 오브젝트는 연속된 두개의 16MB 영역에 배치됩니다.

대용량 오브젝트의 경우도 Young Generation 영역처럼 자동으로 가비지 컬렉션의 검사 대상이 됩니다.

Parallel & Mostly Concurrent

다음으로, 병렬과

Reference

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