3장#1 - 가비지 컬렉터와 메모리 할당 전략

3.1 들어가며

스레드와 라이프사이클을 같이 하는 메모리 영역(PC 레지스터, JVM 스택, 네이티브 메서드 스택) 은 기본적으로 메모리 할당과 회수가 결정적이기에 어떻게 회수할지는 고민하지 않아도 된다.

반면 자바 힙과 메서드 영역은 불확실한게 많다.

  • 같은 인터페이스라 해도 구현체마다 메모리 크기가 다를 수 있다.

결론적으로 런타임에만 알 수 있다. 그래서 이 메모리 영역들의 할당과 회수는 동적으로 이루어진다. GC 는 바로 이런 영역을 관리하는데 집중한다.

3.2 대상이 죽었는가?

자바 세계에서는 거의 모든 객체 인스턴스가 힙에 저장된다. GC 가 힙을 청소하려면 가장 먼저 어떤 객체가 살아 있고, 또 어떤 객체가 죽었는지 판단해야 한다.

3.2.1 참조 카운팅 알고리즘

많은 교재에서 객체가 살아 있는지 판단하는 알고리즘을 다음과 같이 설명한다.

  1. 객체를 가리키는 참조 카운터(reference counter) 를 추가한다. 참조하는 곳이 하나 늘어날 때마다 카운터 값을 1씩 증가시킨다.

  2. 참조하는 곳이 하나씩 사라질 때마다 카운터 값을 1씩 감소한다.

  3. 카운터 값이 0이 된 객체는 더는 사용할 수 없다.

하지만 자바에서는, 적어도 자바 가상 머신에서는 참조 카운팅을 사용하지 않는다. 이 간결한 알고리즘에도 고려해야 할 특이 상황이 적지 않고, 모든 상황에서 문제 없이 동작하게 하려면 계산할 게 상당히 늘어나기 때문이다.

  • 간단한 참조 카운팅 만으로는 순환 참조(circular reference) 문제를 풀기 어렵다.

  • 순환 참조의 경우 참조 카운트가 0이 될 수 없다..

3.2.2 도달 가능성 분석 알고리즘

자바, C# 등 오늘날의 주류 프로그래밍 언어들은 모두 객체 생사 판단에 도달 가능성 분석(reachability analysis) 알고리즘을 사용한다.

이 알고리즘의 기본 아이디어는 GC 루트라고 하는 루트 객체들을 시작 노드 집합으로 쓰는 것이다. 시작 노드들에서 출발하여 참조하는 다른 객체들로 탐색해 들어간다. 탐색 과정에서 만들어지는 경로를 참조 체인(reference chain) 이라고 한다. 그리고 어떤 객체와 GC 루트 사이를 이어 주는 참조 체인이 없다면 회수 대상이 된다.

자바에서 GC 루트로 이용할 수 있는 객체는 정해져 있다.

  • 가상 머신 스택(스택 프레임의 지역 변수 테이블)에서 참조하는 객체

  • 메서드 영역에서 클래스가 정적 필드로 참조하는 객체

    • 클래스 로딩 시점에 초기화되어 프로그램 종료 시까지 메모리에 남아있을 수 있으므로, 이들이 참조하는 객체들은 항상 GC 루트로 간주된다.

  • 런타임 상수 풀에서 참조하는 객체

  • 네이티브 메서드 스택에서 JNI 가 참조하는 객체

    • 네이티브 코드에서 해당 객체에 접근해야 하므로, JVM 은 이 객체들을 GC 루트로 간주하여 회수하지 않도록 보호한다.

  • 자바 가상 머신 내부에서 쓰이는 참조

    • System 클래스, Class 객체, ClassLoader 객체 등이 참조하는 내부 객체들

  • synchronized 키워드로 잠겨 있는 모든 객체

    • 락이 걸려 있다는 것은 해당 객체가 현재 활발하게 사용 중임을 의미한다.

  • 등등 ..

3.2.3 다시 참조 이야기로

객체의 생사 판단과 '참조'는 떼어서 생각할 수 없다. 참조 카운팅 알고리즘이든, 도달가능성 분석 알고리즘이든 마찬가지다.

JDK 1.2 전의 자바에서는 '참조'를 다음과 같이 매우 전통적인 의미로 정의했다.

"참조 타입 데이터에 저장된 값이 다른 메모리 조각의 시작 주소를 뜻한다면, 이 참조 데이터를 해당 메모리 조각이나 객체를 참조한다고 말한다."

문제 될 게 없는 정의지만 현시점에서는 범위가 살짝 좁다. 이 정의에 따르면 객체의 상태는 '참조됐다' 와 '참조되지 않았다', 이렇게 딱 2가지뿐이다. '버리기는 아까운 객체'를 표현할 방법이 없는 것이다.

  • 예를 들어 '메모리가 여유롭다면 그냥 두고, GC 를 하고 나서도 메모리가 부족하다면 그때 회수하는 객체' 를 표현하고 싶다면 어떨까? (실제로 많은 시스템에서 활용하는 캐시 기능에 적합한 시나리오다)

JDK 1.2 부터 참조 개념이 확장되어 참조를 네 가지로 구분하기 시작했다.

  • 강한 참조(strong reference) : 가장 전통적인 참조로, Object obj = new Object() 처럼 프로그램 코드에서 참조를 할당하는 것을 말한다. 강한 참조 관계가 남아있다면 GC 가 절대로 회수하지 않는다.

  • 부드러운 참조(soft reference) : 일반적인 강한 참조(Strong Reference)와 달리, SoftReference로 참조되는 객체는 GC가 메모리 부족 상황을 감지했을 때만 회수될 수 있다. 이는 주로 캐시(Cache) 구현에 사용되어 메모리 효율성을 높이는 데 기여한다.

  • 약한 참조(weak reference) : 약한 참조(Weak Reference)는 가비지 컬렉터(GC)가 언제든지 회수할 수 있다고 간주하는 객체에 대한 참조이다. 소프트 참조보다도 약한 참조이며, 주로 캐시나 메타데이터 관리와 같이 객체가 반드시 존재해야 할 필요는 없을 때 사용됩니다.

  • 유령 참조(phantom reference) : 유령 참조(Phantom Reference)는 가비지 컬렉터(GC)가 객체를 회수하기 직전에 특정 정리 작업을 수행해야 할 때 사용하는 참조. 가장 약한 형태의 참조이며, 유령 참조를 통해서는 객체에 직접 접근할 수 없다.

3.2.4 살았나 죽었나?

...

3.2.5 메서드 영역 회수하기

메서드 영역은 GC 대상이 아니라고 생각하는 사람들도 있다. "자바 가상 머신 명세" 에 따르면 GC 가 메서드 영역을 반드시 청소해야 하는 것은 아니다.

일반적인 애플리케이션에서 자바 힙은, 그중에서도 특히 신세대는 GC 한번으로 메모리 영역의 70 ~ 99% 를 회수해낸다. 반면 메서드 영역은 회수 조건이 까다로워서 효율이 매우 떨어진다.

메서드 영역의 GC 는 크게 두 가지를 회수한다. 더 이상 사용하지 않는 '상수' 와 '클래스' 이다.

3.3 가비지 컬렉션 알고리즘

3.3.1 세대 단위 컬렉션 이론

현재 상용 가상 머신들이 채택한 가비지 컬렉터는 개부분 세대 단위 컬렉션 이론에 기초해 설계되었다. 해당 이론은 대다수 프로그램에서 관측된 실제 상황들에서 얻은 경험 법칙을 구현한 것이다. 기본적으로 아래 2가지 가정이 뿌리를 이룬다.

  1. 약한 세대 가설(weak generation hypothesis) : 대다수 객체는 일찍 죽는다. (90% 이상)

  2. 강한 세대 가설(strong generation hypothesis) : 가비지 컬렉션 과정에서 살아남은 횟수가 늘어날수록 더 오래 살 가능성이 커진다.

이 두 가정이 합쳐져 널리 알려진 가비지 컬렉터들에 일관된 설계 원칙을 제공한다.

세대 단위 컬렉션 이론을 가상 머신에 적용한 설계자들은 자바 힙을 최소 두개 영역으로 나눈다. 바로 신세대와 구세대이다. 이름에서 알 수 있듯이 신세대에서는 GC 때마다 객체가 죽고 살아남은 소수만 구세대로 승격된다.

신세대에서만 GC 를 하고 싶더라도(마이너 GC), 신세대에 속하지만 구세대에서 참조 중인 객체도 충분이 있을 수 있다. 따라서 살아남을 객체를 찾으려면 도달 가능성을 분석할 때 고정된 GC 루트뿐 아니라 구세대 객체까지 모두 탐색해야 결과를 신뢰할 수 있다.

구세대 전체의 객체들까지 탐색한다는게 이론적으로는 가능하지만, 성능 면에서는 확실히 부담이 클 것이다. 이 문제를 풀려면 세대 단위 컬렉션 이론에 세번째 경험 법칙을 추가해야 한다.

  1. 세대 간 참조 가설(intergenerational reference hypothesis) : 구세대 객체는 신세대 객체를 참조할 가능성이 낮다.

세번째 가설은 사실 처음 두 가설로부터 논리적으로 유추해 낼 수 있는 암묵적인 추론이다. 상호 참조 관계의 두 객체는 삶과 죽음을 함께하는 경향이 있다.

이 가설에 따르면 세대 간 참조의 수는 아주 적기 때문에 구세대 전체를 훑는 건 낭비다. 또한 어떤 객체들이 존재하고 어떤 세대 간 참조가 있는지 일일이 기록하느라 공간을 낭비할 필요도 없다. 그저 신세대에 기억 집합(Remembered Set)이라는 전역 데이터 구조를 하나 두면 된다.

기억 집합을 통해 구세대를 작은 조각 몇 개로 나누고(Card Table), 그중 어느 조각에 세대 간 참조가 있는지 기록(Dirty Check)해 관리하는 것이다. 그리고 마이너 GC 가 수행되면 세대 간 참조를 포함하는 작은 메모리 블럭 안의 객체들만 GC 루트에 추가된다.

이 방식은 객체 사이에서의 참조 관계 변화를 정확하게 관리해야 한다. 그래서 런타임에 일이 늘어나지만 구세대 전체를 훑는 비용보다는 싸다.

3.3.2 마크 & 스윕 알고리즘

가장 기본적인 GC 알고리즘으로 작업을 표시(mark) 와 쓸기(sweep) 라는 두 단계로 나눠 진행한다.

먼저 회수할 객체들에 모두 표시한 다음, 표시된 객체들을 쓸어 담는 형식이다. 또는 반대로 살릴 객체를 표시하고 표시되지 않은 객체를 회수하기도 한다.

해당 알고리즘이 기본인 이유는 이후 알고리즘들이 해당 알고리즘을 발전시켜 나가는 형태로 개발되었다.

해당 알고리즘의 단점은 2가지 이다.

  1. 실행 효율이 일정하지 않다.

    1. 자바 힙이 다량의 객체로 가득 차 있고 그 대부분이 회사 대상이라면 표시하는 일도, 회수하는 일도 모두 커진다.

    2. 즉, 객체가 많아질수록 작업의 효율이 떨어지는 구조다.

  2. 메모리 파편화가 심하다.

    1. GC 후에는 불연속적인 메모리 파편이 만들어진다.

    2. 파편화가 너무 심하면 프로그램이 큰 객체를 만들려 할 때 충분한 크기의 연속된 메모리를 찾기가 점점 어려워지고, 또 다른 GC 를 유발한다.

3.3.3 마크 & 카피 알고리즘

해당 알고리즘은 가용 메모리를 똑같은 크기의 두 블록으로 나눠 한번에 한 블록만 사용한다. 한쪽 블록이 꽉 차면 살아남은 객체들만 다른 블록에 복사하고 기존 블록을 한 번에 청소한다.

대다수 객체가 살아남는다면 메모리 복사에 상당한 시간을 허비하는 반면, 대다수가 회수된다면 생존한 소수의 객체만 복사하면 된다. 더욱이 복사 과정에서 객체들이 메모리의 한쪽 끝에서부터 차곡차곡 쌓이기 때문에 골치 아픈 메모리 파편화문제로부터 해방된다.

이 알고리즘은 구현하기 쉽고 실행 효율도 좋다. 하지만 가용 메모리를 절반으로 줄여 낭비가 심하다는 점이 큰 단점이다.

오늘날 상용 JVM 은 신세대에 이 알고리즘을 사용한다.

IBM 은 '객체들의 생존 기간이 짧다' 라는 신게대의 특성이 더 정량적으로 해석하고자 특별한 연구를 수행했고, 그 결과로 신세대 객체 중 98% 가 첫 번째 GC 에서 살아남지 못했다. 즉, 신세대용 메모리 영역을 1:1로 나눌 필요가 없다는 결론이다.

앤드류 아펠은 이 특성을 반영해 더 최적화된 전략을 제안했다. 시리얼, 파뉴와 같은 핫스팟 VM 의 신세대 컬렉터는 이 전략에 부합한다.

  • 신세대 영역을 에덴, 생존자1, 생존자2 영역으로 구분한다.

  • 순서는 에덴 -> 생존자1 -> 생존자2 -> 생존자1 -> 생존자2 -> ...

  • 에덴 : 생존자1 : 생존자2 = 80% : 10% : 10%

    • 이 말은 마크 & 카피 알고리즘으로 낭비되는 공간이 10% 밖에 안된다.

3.3.4 마크 & 컴팩트 알고리즘

마크 & 카피 알고리즘은 객체 생존률이 높으면 높을수록 복사할 게 많아져서 효율이 나빠진다. 더구나 공간을 50%나 낭비하기 싫다면 할당 보증용 공간을 따로 마련해서 대다수 객체가 살아남는 극단적 상황에 대처해야 한다. 그래서 구세대에는 적합하지 않다.

해당 알고리즘은 표시 & 스윕 후 컴팩트 단계에서 생존한 객체를 메모리 영역 한쪽 끝으로 모은 다음, 나머지 공간을 한꺼번에 비운다.

그런데 GC 후 살아남은 객체를 이동할지는 양날의 검과 같은 결정이다. 특히 구세대에서는 회수 때마다 살아남은 객체가 상당히 많을 것이다. 따라서 생존한 객체를 이동시킨 후 이동된 객체들을 가리키던 기존 참조들을 모두 갱신하기는 매우 부담될 것이다. 더욱이 이런 식의 객체 이동은 사용자 애플리케이션을 모두 멈춘 상태에서(stop the world) 진행하므로 아주 신중하게 고려해야 할 단점이다.

하지만 마크 & 스윕 알고리즘처럼 살아있는 객체를 전혀 이동하지 않는다면 힙이 파편화된다.

이상의 두 관점에서 객체를 이동시킬 때와 아닐 때 모두 단점이 있다.

  • 마크 & 컴팩트 : 파편화는 없지만, 할당 작업이 복잡해 stop the world 가 길다. 때문에, '일시 정시 시간' 이 중요하다면 사용하지 않는것이 좋다.

  • 마크 & 스윕 : stop the world 가 짧지만, 파편화가 발생한다. 때문에 '처리량'이 중요하다면 사용하지 않는것이 좋다.

3.5 클래식 가비지 컬렉터

Stop-the-World 가 필요한 이유

STW 는 GC 과정에서 애플리케이션 스레드의 실행을 일시적으로 중단시키는 작업을 의미한다.

1. 객체 그래프의 일관성 확보

GC 의 목표는 더 이상 참조되지 않는 객체를 찾아 힙에서 회수하는 것이다. 이를 위해서는 GC 가 힙 메모리 내의 모든 객체 간 참조 관계를 정확하게 파악해야 한다. 만약 애플리케이션 스레드가 GC 가 실행되는 동안에도 객체를 생성하거나, 기존 객체의 참조를 변경하거나, 새로운 객체를 참조하게 된다면, GC 는 정확한 객체 그래프를 그릴 수 없게 된다!

이를 위해서 STW 는 모든 애플리케이션 스레드를 멈춰서, GC 가 작업하는 동안 힙의 상태가 변경되지 않도록 "정지된 스냅샷" 을 만들 수 있게 한다.

2. 안전한 힙 수정

GC 는 가비지 객체가 차지하던 메모리를 해제하거나(Sweep), 단편화를 줄이기 위해 살아있는 객체들을 압축(Compact) 하는 과정을 수행한다. 이러한 힙 메모리 구조의 변경은 매우 민감한 작업니다.

어찌 보면 1번과 동일한 이야기를 하는 것 같다.

3. 복잡성 관리

동시에 실행되는 여러 스레드가 메모리를 변경하는 상황에서 가비지를 정확히 찾아내고 회수하는 알고리즘을 설계하는 것은 매우 복잡하다. STW 는 이러한 복잡성을 줄이고 GC 알고리즘을 단순화하는 데 도움이 된다. STW가 없다면 GC는 동시성 문제를 해결하기 위한 훨씬 더 복잡한 동기화 메커니즘과 오버헤드를 필요로 할 것이다.

3.5.1 시리얼 컬렉터 (Serial GC)

시리얼 컬렉터는 가장 기초적이고 오래된 컬렉터로 JDK 1.3.1 전까지 핫스팟 VM 의 구세대용 컬렉터로는 유일한 선택지였다. 이 컬렉터는 '단일 스레드'로 동작하며, 이 의미는 GC 가 시작되면 '회수가 완료될 때까지 다른 모든 작업 스레드가 멈춰야 한다' 는 점이다.

'stop the world' 가 멋지게 들리지만, 순전히 가상 머신이 시작하고 끝내는 작업이다. 사용자가 만든 보통의 스레드들은 자신이 언제 멈출지 제어할 수도, 알 수도 없다. 수많은 애플리케이션에서 용납될 수 없는 제약이다!

  • 상상해보자 자기 컴퓨터가 한 시간마다 5분씩 멈춘다면 어떨까? (개빡치겠지?)

시리얼 컬렉터는 다음과 같이 동작한다.

  • 신세대 : 마크 & 카피 사용, 모든 사용자 스레드 정지(stop the world)

  • 구세대 : 마크 & 컴팩트 사용, 모든 사용자 스레드 정지(stop the world)

초기 핫스팟 VM 설계자들은 stop the world 가 가져올 불쾌한 경험을 완벽히 이해했다. 때문에 JDK 1.3 부터 지금까지 핫스팟 VM 팀은 stop the world 시간을 줄이기 위해서 노력했다. 이를 위한 다양한 GC 들이 등장했다.

시리얼 컬렉터는 이러한 단점에도 다른 컬렉터들의 단일 스레드 알고리즘보다 간단하고 효율적이라는 이점이 있어 코어 수가 적은 테스트 환경에서는 사용하기도 한다.

3.5.2 파뉴 컬렉터 (ParNew GC)

파뉴 컬렉터는 여러 스레드를 활용해서 시리얼 컬렉터를 병렬화한 버전이다. 스레드 회수에 멀티 스레드를 사용한다는 점을 제외하면, 모든 것이 시리얼 컬렉터와 동일하다.

핫스팟 VM 버전에서는 큰 의미가 있다. 왜냐면 시리얼 컬렉터를 제외하고 CMS 컬렉터와 조합하여 사용할 수 있는 유일한 컬렉터였기 때문이다. (다른 컬렉터 조합이 불가하다)

하지만 G1 의 등장과 함께 CMS 컬렉터를 사용하지 않게 되었고 JDK9 부터는 공식 서버용 컬렉터 권장안에서 '파뉴 + CMS' 조합을 빼 버렸다.

파뉴 컬렉터는 단일 코어 프로세서에서는 시리얼 컬렉터보다 성능이 떨어진다.

일반적으로 GC 스레드 수와 코어수를 동일하게 가져간다.

3.5.4 시리얼 올드 컬렉터 (Serial Old GC)

시리얼 올드 컬렉터는 시리얼 컬렉터의 구세대용 버전이다. 마찬 가지로 단일 스레드 컬렉터이며 마크 & 컴팩트 알고리즘을 사용한다.

구버전에서 사용하거나, CMS 컬렉터의 서브용으로 사용한다.

3.5.6 CMS 컬렉터

CMS 컬렉터는 표시와 쓸기 단계 모두를 사용자 스레드와 동시에 수행한다. CMS 컬렉터의 목적은 GC 에 따른 stop the world 시간을 최소화하자는 것이다.

현재 자바 애플리케이션의 주력 분야는 웹 백엔드 시스템은데, 이 분야에서는 응답 시간이 서비스 품질을 좌우하기 때문에 stop the world 시간을 줄이는 것은 매우 중요한 의미가 있다. CMS 컬렉터는 해당 이슈에 매우 적합하다.

이름에서 예상할 수 있듯이 마크 & 스윕 알고리즘을 기초로 구현했다. 동작 방식은 기존 컬렉터들보다 훨씬 복잡하다. 전체 과정을 다음 네 단계로 구성된다.

  1. 최초 표시 (STW)

    1. 최초 표시 단계에서는 GC 루트와 직접 연결된 객체들만 표시하기 때문에 아주 빠르게 끝난다.

  2. 동시 표시

    1. 동시 표시 단계에서는 GC 루트와 직접 연결된 객체들로부터 시작해 객체 그래프 전체를 탐색한다. 시간은 오래 걸리지만, 사용자 스레드를 멈추지 않는다.

  3. 재표시 (STW)

    1. 재표시 단계에서는 동시 표시 도중 사용자 스레드가 참조 관계를 변경한 객체들을 바로잡는다.

    2. 동시 표시 보다는 시간이 적게 걸린다.

  4. 동시 쓸기

    1. 앞의 세가지 표시 단계에서 죽었다고 판단한 객체들을 쓸어 담는다.

    2. 살아 있는 객체는 옮길 필요가 없기 때문에 이 단계 역시 사용자 스레드를 멈추지 않고 동시에 수행한다.

이 중 '최초 표시', '재표시' 단계는 여전히 stop the world 방식이다.

가장 중요한 포인트는 과정 중 가장 긴 '동시 표시', '동시 쓸기' 단계에서 stop the world 를 하지 않는다는 점이다. 그래서 일반적으로 CMS 는 '사용자 스레드와 동시에 수행된다' 고 말할 수 있다.

하지만 다음과 같은 세가지 명백한 단점이 남아 있다.

  1. CMS 는 프로세서 자원에 아주 민감하다.

    1. 사실 동시성을 위해 설계된 프로그램은 모두 프로세서 자원에 민감하다.

    2. GC 스레드도 결국은 프로세스의 계산 능력을 나눠 쓰는 스레드 중 하나이기 때문이다.

    3. 결국 코어 수가 작아진다면, 영향을 매우 크게 받는다는 의미이다.

  2. CMS 가 부유 쓰레기를 처리하지 못해서 동시 모드 실패를 유발할 가능성이 있다.

    1. GC 중에도 사용자 스레드에서 쓰레기가 생겨나기에 다음 GC 까지 처리하지 못한다.

    2. CMS 는 동시성이라는 특징이 있기에 사용자 스레드가 사용할 메모리 공간이 충분히 확보되어야 한다.

    3. 따라서 구세대가 거의 가득 찰 때까지 여유롭게 기다릴 형편이 못 된다. (JDK 5 는 구세대가 68% 까지 차면 CMS 를 가동한다)

  3. CMS 의 기본 알고리즘인 마크 & 스윕의 단점..

    1. 파편화가 심하다.

    2. 구세대에서 쓰이지 않는 공간의 총량은 많으나 새로운 객체 하나를 할당하는 데 필요한 '연속된' 공간을 찾지 못할 수도 있다.

    3. 파편화를 줄여주는 옵션도 있지만 stop the world 가 길어진다.

3.5.7 G1 컬렉터(가비지 우선 컬렉터)

'G1' 은 'Garbage First' 를 짧게 줄인 표현이다. G1 은 부분 회수라는 컬렉션 설계 아이디어와 리전을 회수 단위로 하는 메모리 레이아웃 분야를 개척했다.

G1 의 목표는 지연 시간을 제어하는 동시에, 처리량을 최대한 높이는 것이다.

JDK 8 부터 오라클은 G1 을 완전한 기능을 갖춘 GC 로 표현하기 시작했다.

G1 은 주로 서버용 애플리케이션에 집중한 컬렉터다. 핫스팟 개발팀은 JDK 9 출시와 함께 서버 모드용 기본 컬렉터가 되었다. (CMS 를 대체했다)

G1 의 설계자들은 정지 시간 예측 모델(pause predication model) 을 만들고자 했다.

  • 정지 시간 예측 모델은 목표 시간을 밀리초로 설정하면 가비지 컬렉터가 쓰는 시간이 밀리초가 넘지 않도록 통제하는 것이다.

이 목표를 이루기 위해서 생각의 전환이 필요했다. G1 의 등장 전까지 CMS 를 포함한 모든 컬렉터의 회수 범위는 신세대(Minor GC), 구세대(Major GC), 혹은 자바 힙 전체(Full GC) 였다. G1 은 힙 메모리의 어느 곳이든 회수 대상에 포함할 수 있다. 이를 회수 집합(collection set) 이라 하며 짧게 CSet 이라 한다.

  • 어느 세대에 속하느냐가 아니라 '어느 영역에 쓰레기가 가장 많으냐''회수했을 때 이득이 어디가 가장 크냐' 가 회수 영역을 고르는 기준이 된 것이다. 이것이 G1 의 혼합 GC 모드다.

  • G1 이 개척한 영역(Region) 기반 힙 메모리 레이아웃이 정지 시간 예측 모델이라는 목표를 이루는 열쇠다.

G1 도 세대 단위 컬렉션 이론에 기초하고 있지만 힙 메모리 레이아웃은 다른 컬렉터와 매우 다르다.

  • G1 은 크기와 수가 고정된 세대 단위 영역 구분에서 벗어나, 연속된 자바 힙을 동일 크기의 여러 독립 리전으로 나눈다.

  • 각 리전은 필요에 따라 신세대, 혹은 구세대 용 공간으로 사용될 수 있다.

한편 '큰' 객체를 저장하기 위해 거대 리전(Humongous region) 이라는 특별한 유형도 사용한다. G1 은 리전 용량의 절반보다 큰 객체를 큰 객체로 취급한다. 리전 하나의 크기를 넘어서는 큰 객체는 N개의 연속된 거대 리전에 저장된다.

G1 은 여전히 세대 단위 개념을 사용하지만, 세대가 고정되어 있지는 않다. 리전별 역할을 동적으로 바꿀 수 있고, 같은 역할의 리전이 연이어 배치될 필요도 없다. G1 에서 정지 시간 예측 모델이 가능한 이유는 리전을 최소 회수 단위로 사용하기 때문이다. 즉, 매번 적절한 수의 리전을 계획적으로 회수하는 식으로 자바 힙 전체를 회수해야 하는 상황을 피할 수 있다.

처리 방식을 더 구체적으로 살펴보자. G1 은 각 리전의 쓰레기 누적값을 추적한다. 여기서 값이란 가비지 컬렉션으로 회수할 수 있는 공간의 크기와 회수에 드는 시간의 경험값이다. 그리고 우선순위 목록을 관리하며 사용자가 매개 변수로 설정한 일시 정지 시간(기본값은 200밀리초)이 허용하는 한도 내에서 회수 효과가 가장 큰 리전부터 회수하는 것이다. '가비지 우선' 이라는 이름이 탄생한 이유다. 메모리 공간을 리전 단위로 분할해 우선순위대로 회수함으로써 제한된 시간 내에 가장 효율적으로 회수할 수 있는 것이다.

  • G1 은 각 리전의 쓰레기 누적값을 추적하는 과정의 비용은 크지 않은데 애플리케이션 쓰레드와 함께 이루어지기 때문이다.

G1 의 정지 시간 예측 모델의 이론적 기초는 감소 평균(decaying average) 이다. 가비지 컬렉션이 이루어지는 동안 G1 은 리전별 회수 시간, 리전별 기억 집합에서 더럽혀진 카드 개수 등 측정할 수 있는 각 단계의 소요 시간을 기록한다. 그리고 이 정보로부터 평균, 표준 편차, 신뢰도 같은 통계를 분석한다.

  • 앞에서 강조한 감소 평균은 일반 평균과 비교해 새로운 데이터에 더 민감하다. 일반 평균은 전체적으로 균등한 평균 상태를 알려 주지만,

  • 감소 평균은 '최근' 의 평균적인 상태를 더 정확하게 알려준다.

  • 즉, 리전의 통계적 상태가 더 최근일수록 회수해서 얻는 가치를 더 높게 쳐준다.

    • '최근' 평균 상태가 더 가치가 큰 이유는 세대 단위 컬렉션 이론의 기본 가정 중 하나가 '대부분의 객체는 수명이 짧다'는 것이기 때문이다.

  • 그런 다음 가비지 컬렉션이 시작되면 이 정보를 기초로 어느 리전들을 회수해야 사용자가 기대하는 정지 시간 내에 가장 큰 효과를 거둘지 예측하는 것이다.

사용자 스레드가 실행되는 동안 수행하는 작업을 제외한다면 G1 의 동작은 다음 네 단계로 나뉜다.

  1. 최초 표시 (STW)

    1. (1) GC 루트가 직접 참조하는 객체들을 표시하고, (2) TAMS(Top-At-Mark-Start) 포인터의 값을 수정한다. 즉, (3) 시작 단계 스냅숏(SATB : Snapshot-At-The-Beginning)을 생성한다. 사용자 스레드와 동시에 수행되는 다음 단계에서 새로운 객체들이 가용 리전에 올바르게 할당되도록 하는 조치다.

    2. TAMS(Top-At-Mark-Start) : G1GC 가 동시 표시를 수행하는 동안 "어디까지가 이전에 존재하던 객체이고, 어디부터가 GC 시작 후에 새로 생긴 객체인지" 를 구분하는 경계선 역할을 하여, GC 의 정확성과 동시성을 확보하는데 기여한다. (새로 생긴 객체는 GC 대상에서 제외한다)

    3. STW 는 일어나지만, 소요 시간이 매우 짧다.

  2. 동시 표시

    1. GC 루트로부터 시작하여 객체들의 도달 가능성을 분석하고, 전체 힙의 객체 그래프를 재귀적으로 스캔하며 회수할 객체를 찾는다.

    2. 이 단계는 시간이 걸리지만 사용자 스레드와 동시에 수행된다.

    3. 객체 그래프 스캔이 끝난 후에는 시작 단계 스냅숏(STAB)과 비교하여 동시 실행 도중에 참조가 변경된 객체들은 다시 스캔해야 한다.

  3. 재표시 (STW)

    1. 또 한 번 사용자 스레드를 잠시 멈춰야 하는 단계다. 시작 단계 스냅숏(STAB) 이후 변경된 소수의 객체만 처리하면 되므로 빠르게 끝난다.

  4. 복사 및 청소 (STW)

    1. 통계 데이터를 기초로 리전들을 회수 가치와 비용에 따라 줄 세운 다음, 목표한 일시 정지 시간에 부합하도록 회수 계획을 세운다.

    2. 회수할 리전들을 적절하게 선별하고 선별된 리전들에서 살아남은 객체들을 빈 리전에 이주시킨다 (복사 후 기존 리전은 말끔히 비운다).

    3. 이 단계에서는 생존한 객체를 이동시켜야 하므로 사용자 스레드가 잠시 멈춰야 한다.

    4. 다행히 다수의 GC 스레드가 병렬로 처리한다.

G1GC 의 기본 정지 시간은 200 밀리초이다. 하지만 정지 시간이 매우 짧다면, 어떻게 될까?

  • 목표한 시간이 너무 짧다면, 힙 메모리의 아주 적은 일부만 회수하고 컬렉션을 마쳐야 한다.

  • 그 여파로 회수 속도가 새로 할당되는 속도를 따라잡지 못하여 쓰레기가 점점 쌓여간다. (종국에는 힙이 가득 찰 것이다)

G1 을 기점으로 최신 GC 들은 자바 힙 전체를 한 번에 청소하는 대신, 애플리케이션 메모리 할당 속도(할당량) 에 맞춰 회수하는 방향으로 변화 했다.

Last updated