[JVM 밑바닥까지 파헤치기] 3장 - 가비지 컬렉터와 메모리 할당 전략

Falco·2024년 7월 5일
post-thumbnail

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

죽은 메모리 찾기

자바는 모든 객체 인스턴스가 힙에 저장된다. GC가 힙을 청소하려면 객체가 살아 있고, 죽었는지 판단해야 한다.(죽었다는 의미는 프로그램에서 더이상 사용하지 않음을 의미한다.)

참조 카운팅 알고리즘

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

  1. 객체를 가르키는 참조 카운터를 추가한다.
  2. 참조하는 곳이 사라지면 -1 증가하면 +1
  3. 0이된 객체는 더이상 사용하지 않는다.

자바에서는 참조 카운팅을 사용하지 않는다. 이유로는 고려해야할 상황이 적지 않고, 모든 상황을 계산하자니 느리기 떄문이다. (순환 참조의 문제도 있다.)

실행할 때 -Xlog:gc* 매개변수를 지정하여 가비지 컬렉션 정보를 자세하게 출력할 수 있다.

도달 가능성 분석 알고리즘

이 알고리즘의 기본 아이디어는 GC루트라고 하는 루트 객체들을 시작 노드 집합으로 쓰는 것이다. 시작 노드들로부터 출발하여 참조하는 다른 객체를 탐색한다. (참조 체인(reference chain) 생성) 참조 체인에 없다면 회수 대상이 된다.

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

  • JVM 스택에서 참조하는 객체 : 현재 실행중인 메서드에서 쓰는 매개 변수, 지역 변수, 임시 변수
  • 메서드 영역에서 클래스가 정적 필드로 참조하는 객체 : 자바 클래스의 참조 타입 정적 변수
  • 메서드 영역에서 상수로 참조되는 객체 : 문자열 테이블 내부 참조
  • JVM내부에서 쓰이는 참조
  • 동기화 락으로 잠겨 있는 모든 객체

등등 다른 객체들도 '임시로'추가될 수 있다.

현 시점에서 최신 GC들은 모두 부분 컬렉션을 지원한다. (이후 설명) 또한 GC루트가 너무 많아지지 않도록 다양한 최적화를 적용한다.

참조 이야기 (Reference Type)

객체의 생사 판단과 참조는 뗴어서 생각할 수 없다. 참조 개수를 세어 판단하는 알고리즘이든, 참조체인이든 모두 참조로 분석한다.

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

JVM이 발전하며 '버리기는 아까운'객체를 표현하는 방식이 생겨나게 된다. 이는 "메모리가 여유롭다면 그냥 두고, GC하고나서도 메모리가 부족하면 그때 회수하자."라는 개념이 도입되게 된다.

강한 참조 (strong reference)

val test = Test()

전통적인 참조를 의미한다. 관계가 남아 있는 객체는 GC가 절대로 회수하지 않는다.

부드러운 참조 (soft reference)

유용하지만 필수는 아닌 객체를 표현한다. 부드러운 참조만 남은 객체라면 메모리 오버플로우가 나기 전에 두 번쨰 회수를 위한 회수 목록에 추가된다. SoftReference클래스 형태로 구현된다.

    println("약한 참조 객체 만들기")
    var strong: Referred? = Referred()
    val soft = SoftReference(strong)
    garbageCollect()

약한 참조 (weak reference)

부드러운 참조와 비슷하지만, 연결 강도가 더 약하다. 대상 객체를 참조하는 경우가 WeakReferences 객체만 존재하는 경우 GC의 대상이 된다. 다음 GC 실행시 무조건 힙 메모리에서 제거된다.

    println("약한 참조 만들기")
    var strong: Referred? = Referred()
    val weak = WeakReference(strong)

유령 참조 (phantom reference)

참조 중에 가장 약하다. 객체 수명에 영향을 주지 않으며, 이를 통해 객체 인스턴스를 가져오는 것마저 불가능하다.

유령 참조를 거는 유일한 목적은 객체가 회수될 때 알림을 받기 위함이다.

    println("팬텀 참조 생성")
    var ref: Game? = Game()
    val refQueue: ReferenceQueue<Game?> = ReferenceQueue<Game?>()
    val phantomRef: PhantomReference<Game> = PhantomReference<Game>(ref, refQueue)
    // 팬텀 참조는 아래처럼만 생성이 가능하다
    ref = null

참조 == 생사유무 ?

도달 불가능하다고 해서 객체가 죽은것인가?(weak reference는 왜 있는 것인가?) 아니다! 아직 '유예'단계가 남았다. 사망 선고를 내리려면 두 번의 표시(marking)단계를 거쳐야 한다.

도달 가능성 알고리즘으로 참조 체인을 찾지 못한 객체에는 첫 번째 표시가 이루어지며 이어서 필터링이 진행된다. "필터링 조건은 종료자(finalizer) 메서드를 실행해야 하는 객체인가" 이다. finalize()가 필요없거나 이미 호출한 경우 모두 필터링이 진행된다.

finalize()를 실행해야 하는 객체로 판명되는 F-Q(F-Queue)라는 대기열에 추가된다. 그러면 가상 머신이 나중에 우선순위가 낮은 종료자 스레드를 생성해 F-Q에 들어있는 객체들의 finalize()를 실행한다. 참고로 JVM은 스레드를 시작만하고, 기다리지 않는다. 즉 무한루프가 돌거나 너무 오래걸리면 큐에 모든 객체는 대기해야하고, 최악의 경우 GC시스템이 망가질 수 있다.

Javadoc은 다음과 같이 설명한다.

- 목적: 객체가 더 이상 참조되지 않을 때 정리 작업을 수행.
- 동작: 가비지 컬렉션 중 JVM이 객체에 대한 참조가 더 이상 없다고 결정하면 finalize 메서드를 호출.
- 재정의: Object 클래스의 finalize는 특별한 작업을 하지 않으므로, 하위 클래스가 재정의하여 시스템 리소스 해제나 기타 정리 작업을 수행할 수 있음.
- 호출 스레드: JVM은 어떤 스레드가 finalize 메서드를 호출할지 보장하지 않지만, 호출 스레드는 사용자 동기화 잠금을 유지하지 않음이 보장됨.
- 예외 처리: finalize 메서드에서 던져진 예외는 무시되고, 해당 객체의 정리 작업이 종료됨.
재호출 금지: JVM은 동일한 객체에 대해 finalize 메서드를 두 번 이상 호출하지 않음.

- 주요 사항
1. finalize는 객체가 가비지 컬렉션되기 전 최종 정리 작업을 수행하는 메서드.
객체가 더 이상 접근 불가능할 때 실행되며, 리소스 해제 등의 작업을 수행할 수 있음.
2. finalize 호출은 한 번만 이루어지며, 예외가 발생해도 무시됨.
3. finalize를 사용하는 것은 권장되지 않으며, 자바 9부터는 사용이 중지됨. 
 (AutoCloseable 인터페이스와 try-with-resources 구문을 사용하여 리소스를 정리하는 것이 권장됨)

finalize()메서드는 객체가 부활할 수 있는 마지막 기회다. 참조 체인상의 다른 아무 객체와 다시 연결을 수행하면 객체는 회수되지 않는다. 하지만 이런 방법은 권장하지 않는다. 코틀린에서 쓰는 방법은 다음과 같다. -> Kotlin Finalize

메서드 영역 회수하기

힙영역뿐만 아니라 메서드 영역또한 GC의 영역이다. 메서드 영역에서의 GC는 크게 2가지를 회수한다. '상수' 및 '클래스'이다.

상수

상수를 회수하는 방법은 힙에서 객체를 회수하는 방법과 비슷하다. (참조가 없다면 삭제한다.)

클래스

클래스는 조금 더 까다롭다. 아래 3가지 조건을 부합하면 회수한다.

  1. 이 클래스의 인스턴스가 모두 회수되었다. (힙에 해당 클래스와 하위 클래스의 인스턴스가 하나도 존재하지 않는다.)
  2. 클래스 로더가 회수되었다.
  3. 이 클래스에 해당하는 java.lang.Class객체를 아무 곳에서 참조하지 않고, 리플랙션 기능으로 이 클래스의 메서드를 이용하는 곳도 없다.

위처럼 회수는 할 수 있지만, 힙에 비해 가성비가 안좋다.

가비지 컬렉션 알고리즘

JVM에 따라 GC 알고리즘은 차이가 많다.

객체의 생사를 판별하는 방식을 기준으로 참조 카운팅 GC추적 GC로 나눌 수 있다. 참조 카운팅 GC는 이제 주류가 아니기에 설명하지 않고 이후 내용은 모두 추적 GC에 관한 내용이다.

세대 단위 컬렉션 이론

오늘날 JVM의 GC는 대부분 세대 단위 컬렉션 이론에 기초에 설계되었다. 이는 다음과 같은 경험에 뿌리를 둔다.

  1. 약한 세대 가설 : 대다수 객체는 일찍 죽는다.
  2. 강한 세대 가설 : GC에서 살아남은 횟수가 늘어날수록 더 오래 살 가능성이 커진다.
  3. 세대 간 참조 가설 : 세대 간 참조 개수는 같은 세대 안에서의 참조보다 훨씬 적다.

이 두개의 경험을 기반으로 GC의 설계 원칙을 만들었다.

힙을 몇 개의 영역으로 나누고, 객체들을 나이에 따라 각기 다른 영역에 할당한다. (나이는 살아남은 횟수이다.) 이러한 방식을 통해 적은 비용으로 대량의 메모리를 확보할 수 있다.

위와 같은 알고리즘을 통하여 하나 또는 몇 개 영역만 선택해 회수할 수 있는데 이를 기준으로 마이너 GC, 메이저 GC, 전체 GC라고 부르기도 한다.

신세대에서만 마이너GC를 하고 싶어도 구세대에서 참조 중인 객체가 있을 수 있다. 이는 고정된 GC루트뿐만 아니라 구세대 객체까지 모두 탐색해야 결과를 신뢰할 수 있다.

하지만 이는 낭비가 심하기에 신세대에 기억 집합이라는 전역 데이터 구조를 하나 두어 관리한다. 이 구조를 통해 구세대를 작은 조각 몇 개로 나누고, 그 중 어느조각에 세대 간 참조가 있는지 기록해 관리한다.

마크-스윕(Mark - sweep) 알고리즘

이름처럼 해당 알고리즘은 작업을 표시쓸기 두단계로 진행한다. 회수할 객체를 모두 표시한 다음, 모두 쓸어 담는 식이다. 가장 초기 알고리즘이고 해당 알고리즘의 단점을 보완하는 식으로 다음 알고리즘이 나오게된다. 단점은 크게 두 가지다.

  • 실행 효율이 일정하지 않다 : 힙이 가득 차있으면, 객체를 쓸어 담는 작업의 효율이 떨어진다.
  • 메모리 파편화가 심하다 : 가비지 컬렉터가 쓸고 간 자리에는 불연속적인 메모리 파편이 만들어진다.
    -> (compact과정이 없음)

마크-카피 알고리즘

회수할 객체가 많아질수록 효율이 떨어지는 문제를 해결하기위해 나왔다. 이 알고리즘은 가용 메모리를 똑같은 크기의 두 블록으로 나눠 한 번에 한 블록만 사용한다. 한쪽 블록이 꽉 차면 살아남은 객체들만 다른 블록에 순차적으로 복사하고, 기존 블록은 한번에 청소한다.

이는 쉽고 효율도 좋지만, 단점으로 메모리 낭비가 심하다. 따라서 생존자 공간과 에덴 공간의 비율을 8:1로 설정하여 메모리를 활용한다.(핫스팟 가상 머신)

생존가 공간이 가득차게되면 다른 메로리 영역을 활용해 메모리 할당을 보존하게 된다.(핸들 승격)

마크-컴팩트 알고리즘

카프-카피 알고리즘은 객체 생존율이 높을수록 복사할 게 많아져서 효율이 나빠진다. 또한 보증용 공간을 따로 마련하여 객체가 살아남는 상황에도 대처해야 한다. 그래서 새로 나온게 마크-컴팩트 알고리즘이다.

이는 생존한 객체를 메모리 영역의 한쪽 끝으로 모은 다음 나머지 공간을 한꺼번에 지운다. 이는 생존한 객체를 이동하기에 기존 참조하고 있던 레퍼런스를 모두 수정해야 한다. 따라서 어플리케이션을 멈추게 되며 이 현상을 스톱 더 월드(Stop the world)라고 한다.

핫스팟 JVM 알고리즘 맛보기

루트 노트 열거

루트 노드 열거는 GC 루트 집합으로부터 참조 체인을 찾는 작업을 말한다. 오늘날 자바 어플리케이션은 점점 커지고 있고, 메서드 영역만 GB가 되는 어플리케이션도 있다. 즉, 모든 참조를 하나하나 확인하려면 "스톱 더 월드"문제를 피할 수 없다.(루트 노드 열거 시 쓰레드를 정지해야 함)

루트 노드 열거는 일관성이 보장되는 스냅샷의 상태에서 수행해야 하며, 이 조건을 지키기 위해 사용자쓰레드를 정지한다.

따라서 GC시 모든 사용자 스레드가 일시 정지하며, CMS, G1, ZGC의 경우에도 루트노드를 열거할 떄만은 일시정지를 피할 수 없다.

안전 지점

GC는 사용자 스레드를 멈출 때 프로그램이 안전 지점에 도달할 떄까지는 절대 멈추지 않는다. 따라서 안전 지점을 너무 적게 설정해서 컬렉터가 너무 오래 기다리게 하거나, 반대로 너무 많이 설정해서 부하가 커지지 않도록 주의해야 한다.

안전 지점과 관련하여 쓰레드가 가장 가까운 안전 지점까지 실행하고 멈추게할 방법이 필요하다. 이에따라 선제적 멈춤과 자발적 멈춤이라는 2가지 선택지가 있다.

선체적 멈춤(preemptive suspension)

스레드의 코드가 GC를 특별히 신경 쓰지 않는다. GC가 시작되면 시스템이 모든 사용자 스레드를 인터럽트한다. 스레드가 중단된 위치가 안전 지점이 아니라면 스레드를 재개하고 안전 지점에 도달할 때 까지 인터럽트를 반복한다. (이런 방식을 쓰는 JVM은 거의 없다.)

자발적 멈춤

GC가 스레드 수행에 직접 관여하지 않는다. 그 대신 간단히 플래그 비트를 설정하고, 각 스레드가 실행중에 플래그를 적극적으로 폴링한다. 플래그 값이 true이면 가장 가까운 안전 지점에서 스스로 멈춘다. (보통 이런 플래그는 객체 생성이나, 힙 메모리를 사용하는 상황 이전에 존재한다.)

이러한 폴링은 코드에서 자주 일어나므로 매우 효율적이어야 한다.

안전 지역

안전 지점 메커니즘은 실행 프로그램이 그리 길지 않은 시간에 안전 지점에 도달하여 GC가 작동할 수 있도록 보장한다. 하지만, 실행중이 아닌 프로그램은 이 안전 지점까지 실행할 수 없다. (잠자기 전 상태이거나 블록된 상태의 사용자 쓰레드) 이들은 JVM의 인터럽트 요청에 응답할 수 없다. 또한 이런 쓰레드가 다시 활성화되어 프로세서를 할당받을떄까지 가상 머신이 기다리는 것도 말이 안된다. 이에 따라 안전지역이라는 개념이 탄생하게 되었다.

안전 지역(safe region)은 일정 코드 영역에서는 참조 관계가 변하지 않음을 보장한다. 즉, 안전 지역내부에서는 어디서든 GC를 시작하여도 된다.

기억 집합과 카드 테이블

GC는 세대 간 참조 문제를 겪을 수 있다. 이에따라 신세대에 기억 집합이라는 데이터 구조를 두어 객체들의 세대 간 참조 문제를 해결한다.

기억 집합은 비회수 영역에서 회수 영역을 가리키는 포인터들을 기록하는 데이터구조이다.

이 기억 집합의 구현체 중 쓰이는 것은 카드 테이블이라는 구조이다.

  • 카드 테이블 : 레코드 하나(카드)가 메모리 블록 하나에 매핑된다. 특정 레코드가 마킹되어 있다면 해당 블록에 세대 간 참조를 지닌 객체가 존재한다는 뜻이다.

카드테이블에서 해당 원소를 1로 표시하면 그 객체는 '더럽혀졌다(dirty)'라고 말한다. 세대 간 포인터를 갖는 객체가 하나도 없다면 0으로 표시된다. 객체를 회수할 때는 더럽혀진 원소만 확인하면 어떤 카드 페이지의 메모리블록이 세대 간 포인터를 포함하는지 파악할 수 있다.(루트 전체 열거 필요 없음)

이런식으로 세대 간 참조를 포함한 블록만 GC루트에 추가해 함께 스캔한다.

쓰기 장벽

다른 세대의 객체가 현 블록안의 객체를 참조하면 카드 테이블이 1로 변한다.(더러워진다.) 이를 더럽히기 위한 방식이 쓰기 장벽 기술이다. 먼저 지금 이야기하는 "쓰기 장벽"을 이해하기전에 뒤에서 다룰 "읽기 장벽"을 확실하게 구분해야한다.

  • 읽기 장벽 : 동시 비순차 실행 문제를 해결하기 위한 메모리 장벽 기술이다.
    • 컴파일러 최적화나 CPU최적화가 실행되면 명령어 실행 순서가 바뀔 수 있다. 이를 비순차 실행이라 한다.
    • 읽기 명령 순서를 보장하기 위한 기술을 읽기 장벽이라 생가하여도 좋다.

한편 쓰기 장벽은 AOP에 비유할 수 있다. 이는 참조 타입에 객체가 대입되면 Advice기능이 동작하여 대입 전후로 추가 동작을 수행한다. (이를 통해 카드테이블을 업데이트한다.)

이는 오버헤드가 존재하지만, 구세대 전체를 스캔하는 비용보다는 훨씬 싸다.

동시 접근 가능성 분석

루트 노드 열거를 활용하여 객체의 생사를 판단한다. 이는 스냅숏 상태에서 진행해야 하며 이에 따라 쓰레드는 멈춰있어야 한다.

왜 스냅샷 상태에서 루트 노드 열거를 진행해야 하는가에 대해서 책에서 설명한다. 그 이유로써 2가지 상황이 나올 수 있는데

  1. 죽은 객체를 살았다고 잘못 표시할 수 있다. 이는 좋지 않은 일이지만 그래도 감내할 수 있다. 다음번 GC때 청소될 것이기에
  2. 살아 있는 객체를 죽었다고 표시할 수 있다. 이는 아주 치명적인 오류다. 이러한 오류는 스캔 도중 객체가 사라질 때 일어난다.

이에따른 해법은 증분 업데이트와 시작 단계 스냅숏을 활용하는 방법이 있다.

클래식 가비지 컬렉터

JVM은 업체나 버전에 따라서 다른 가비지 컬렉터를 제공하고, 매개변수를 활용하여 다른 컬렉터를 활용할 수도 있다.이에따라 jDK7~11까지의 가비지 컬렉터에 대해 알아보자.

시리얼 컬렉터(serial Collector)

가장 기초적이고 오래된 컬렉터이다. 이는 단일 스레드로 동작한다.

이는 간단하고 효율적이다. 또한 메모리 사용량도 적다. 하지만, 단일 스레드이기에 그만큼 Stop the World의 시간도 증가하게 된다.

  • -XX:+UseSerialGC를 통해 사용가능하다.

파뉴 컬렉터

이는 여러 스레드를 활용하여 시리얼 컬렉터를 병렬화한 버전이다. 스레드 회수에 멀티스레드를 이용한다는 점만 제외하면 시리얼 컬렉터와 동일하다.

  • -XX:SurviorRatio, XX:PretenureSizeThreadhold 등의 설정이 가능하다.

병렬 스캐빈지 컬렉터

이는 마크-카피 알고리즘에 기초하며, 여러 스레드를 이용해 병렬로 회수하는 등, 많은 면에서 파뉴 컬렉터와 동일하다. 다른 점은 처리량을 제어하는데 목표를 둔다.

처리량 = 사용자 코드 실행 시간 / (사용자 코드 실행 시간 + 가비지 컬렉터 실행 시간)

응답시간이 빠르면 사용자 경험을 개선할 수 있다. 하지만 처리량이 높다면 프로세서 자원을 가장 효율적으로 써서 프로그램이 전체 작업을 최대한 빠르게 끝낼 수 있도록 한다. 계산 중간에 상호 작용할 일이 많지 않은 분석 업무 등에 특히 알맞다.

  • -XX:MaxGCPauseMillis : 메모리 회수에 소요되는 시간이 이 설정값을 넘지 않도록 최선을 다한다.
    • 이는 GC회수와 반비례 관계임으로 유의
  • -XX:GCTimeRatio : 어플리케이션 총 실행 시간에 대한 가비지 컬렉션 시간의 비율을 의미한다.
    • 이 값이 N이라면 코드 실행 시간의 1/(1+N)이상을 소비하지 않게 해 달라는 뜻이다. (기본값은 99이다)
  • -XX:+UseAdaptiveSizePolicy : 신세대의 크기(-Xmn), 에덴과 생존자 공간의 비율(-XX:SurvivorRatio), 구세대로 옮겨갈 객체의 크기(-XX:PretenureSizeThreshold) 등 세부 설정용 매개 변수들을 일일이 지정하지 않아도 된다. 가상 머신이 성능 모니터링 지표를 수집하여 최적의 정지 시간과 최대 처리 량을 제공할 수 있도록 모든 매개변수 값을 조율한다.

컬렉터에 대한 조예가 깊지 않아서 수동으로 최적하기가 어려운 운영자에게는 적응형 조율 전략을 지원하는 PS 컬렉터가 괜찮은 선택일 수 있다. 메모리 관리 최적화를 가상 머신에 맡겨 보는 것이다. -Xmx, X:MaxGCPauseMillis, -XX:GCTimeRatio등의 매개 변수 목표만 설정하고 JVM에게 맡겨보자.

CMS(Concurrent Mark and Sweep) 컬렉터

이는 표시(마커)와 쓸기(스윕) 단계를 모두 사용자 스레드와 동시에 수행한다.

CMS 컬렉터의 목적은 가비지 컬렉션에 따른 일시 정지 시간을 최소로 하는 것이다.

사용자에게 더 나은 경험을 선사해야하는 경우 (어플리케이션, 브라우저 등) 적합한 컬렉터 이다.

이는 닉값하듯이 마크-스윕 알고리즘을 기초로 구현된다. 동작 방식은 기존 콜렉터들보다 훨씬 복잡하다. 전체 과정은 다음과 같다.

  1. 최초 표시 (스톱 더 월드 O)
  2. 동시 표시
  3. 재표시 (스톱 더 월드 O)
  4. 동시 쓸기

최초 표시로 1차로 표시하고, 사용자스레드와 동시에 동시표시를 진행한다. 그리고 재표시를 통해 동시 표시 도 중 사용자 스레드가 참조관계를 변경한 객체를 바로잡고, 동시쓸기를 진행한다. 살아 있는 객체는 옮길 필요가 없기에 이 단계에 역시 사용자 스레드와 동시에 수행된다.

명백히 말하면 Stop-The-World를 수행하긴 한다.

하지만 이에도 세 가지 명백한 단점이 있다.

단점

첫째, 프로세서 자원에 아주 민감하다. 사실 동시성을 위해 설계된 프로그램은 모두 프로세서 자원에 민감하다. 동시 수행 단계에서 사용자 스레드를 멈추지는 않더라도 어플리케이션을 느리게 하고 전체 처리량을 떨어뜨린다. (GC 쓰레드가 일을 잡아 먹는다.)

CMS가 가동하는 GC스레드 수는 기본적으로 (프로세서 코어 수 + 3) / 4 이다. 예를들어 코어가 4개이면 25%를 사용한다는 것이다. 코어가 3개 이하면 사용자 프로그램에 미치는 영향이 상당히 클 것 이다.

둘째, CMS가 부유 쓰레기를 발생시킬 수 있다. 어떤 객체는 표시 스레드가 지나간 후에 쓰레드기 될 수 있다. 이러한 객체는 쓸기 단계에서 회수할 수 없으며 이는 다음 GC때까지 기다려야 한다.

셋째, 사용하는 메모리 공간또한 많이 잡아 먹는다. 다른 컬렉터들과 달리 CMS는 구세대가 거의 가득찰 때까지 여유롭게 기다릴 형편이 못된다. 동시쓸기동안에도 프로그램이 올바르게 동작해야하는 메모리 공간이 필요하다.

넷째, 마크 & 스윕 알고리즘의 고질적인 문제로 상당한 파편화를 일으킨다는 사실이다. 이런 파편화를 해결하기 위해서는 앞서 전체 GC를 수행해야 한다.

CMS 컬렉터는 발전된 기능의 콜렉터인 G1, 셰년도어, ZGC가 등장하며 JDK14에서 완전히 제거되었다.

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

G1 컬렉터의 G1Garbage First(가비지 우선)을 짧게 줄인 표현이다.

이는 부분 회수(partial collection)이라는 컬렉터 설계 아이디어와 리전(region)을 회수 단위로 하는 메모리 레이아웃 분야를 개척했다.

G1은 주로 서버용 어플리케이션에 집중한 컬렉터다.

G1의 등장 전까지 CMS를 포함한 모든 컬렉터의 회수 범위는 신세대 전체(마이너 GC), 구세대 전체(메이저 GC)또는 자바 힙 전체(전체 GC)였다. 하지만, G1은 힙메모리 어디든 회수 대상에 포함할 수 있으며, 이를 회수 집합(collection set)이라 하며 CSet이라고도 한다.

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

G1이 사용하는 영역 기반 힙 메모리 레이아웃이 정지 시간 예측 모델이라는 목표를 이루는 열쇠이다. 이는 세대 단위 컬렉션 이론에 기초하지만, 힙메모리의 공간을 논리적으로 나누어 사용한다. 즉, 각 리전(나뉘어진 공간)이 에덴이나 생존자 공간, 구세대용 공간으로 쓰일수도 있다.

'큰'객체를 저장하기 위해서는 거대 리전이라는 특별한 유형도 활용한다. (G1은 리전 용량의 절반보다 큰 용량을 큰 객체로 취급한다.) --XX:G1HeapReginoSize매개변수로 설정할 수 있다. 범위는 1MB ~ 32MB 까지 2의 제곱수를 활용한다. 이런 리전 크기를 넘어서는 큰 객체는 N개의 연속된 거대 리전에 저장된다.

  • E : 에덴
  • O : 구세대 (Old generation)
  • S1 : Survivor Space 1
  • S2 : Survivor Space 2

G1은 세대 컬렉션이론을 기반으로 하지만 세대가 고정되어 있지는 않다. 리전이 연이어 배치될 필요도 없고, 각 리전이 동적으로 변화할 수 있다. G1에서 정지 시간 예측이 가능한 이유는 리전을 최소 회수 단위로 사용하기 떄문이다. 즉, 매번 적절한 수의 리전을 계획적으로 회수한다.

좀 더 자세하게

G1은 각 리전의 쓰레기 누적값을 추적한다. 여기서 값이란 (회수할 수 있는 공간 크기 / 회수에 드는 시간의 경험 값)이다. 그리고 우선순위 목록을 관리하며 사용자가 -XX:MaxGCPauseMillis매개 변수로 설정한 일시 정지 시간(기본값은 200밀리초)이 허용하는 한도내에서 회수 효과가 가장 큰 리전부터 회수하는 것이다.

즉 G1은 가성비있게 리전을 회수한다.

이것이 가비지 우선이라는 이름이 탄생한 이유다. 메모리 공간을 리전 단위로 분할해 우선순위대로 회수함으로써 제한된 시간 내에 가장 효율적으로 회수할 수 있다.

어떻게 구현하지

힙 메모리를 리전 단위로 나눈다.라는 개념은 크게 어렵지 않다. 하지만, 세부 기술은 생각만큼 간단하지 않다. G1이 상용되기 까지 거의 10년이 걸렸다. G1이 해결해야 했던 주된 문제 몇 개를 살펴보자.

첫 째, 힙을 다수의 독립된 리전으로 나눈다면 객체들의 리전 간 참조 문제를 해결해야 한다. 이전에도 관련 문제를 해결하기 위해 기억집합을 도입하였다. (GC루트로 부터 힙 전체를 스캔하는 일을 피하기 위해)
G1의 기억 집합은 기본적으로 해시 테이블 구조이다. 키는 다른 리전들로부터의 시작 주소고, 값은 하나의 집합이다. 값에 저장되어 있는 원소들은 카드 테이블의 인덱스 번호이다. 내가 가르키는 대상나를 가르키는 대상을 모두 기록하는 양방향 기억집합이기에 카드테이블 구현이 매우 복잡하다. 그리고 리전 개수 역시 전통적인 컬렉터보다 훨씬 많기 때문에 더 많은 메모리를 사용한다.

둘 째, 동시 표시 단계 동안 GC스레드와 사용자 스레드가 서로 간섭하지 않도록 보장해야 한다. 가장 먼저 해결해야한 문제는 사용자 스레드가 객체 참조 관계를 수정해도 원래의 객체 그래프 구조를 파괴하지 않도록 보장하는 일이다.
G1은 스냅샷 알고리즘을 활용해 이를 해결한다. GC수행중에도 새로 만들어진 객체는 계속 만들어진다.TAMS라는 두 개의 포인터를 설계해 활용한다. 상세 동작 과정은 아래와 같다.

  1. 리전 공간 일부를 동시회수되는 동안 새로운 객체를 할당하기 위한 공간으로 나눈다.
  2. 동시회수 동안 새로 생성되는 객체의 주소는 반드시 이 두 포인터보다 높은 주소 영역에 할당된다.
  3. 이 주소보다 높이 있는 객체는 암묵적으로 표시된 것을 간주하여 회수 대상에서 제외한다.

메모리 회수 속도가 메모리 할당 속도를 따라가지 못하면 Stop The World를 경험하게 된다.

셋 째, 신뢰할 수 있는 정지 시간 예측 모델을 구현한다. 사용자가 --XX:MaxGCPauseMillis 매개 변수로 설정한 일시 정지 시간은 가비지 컬렉션이 일어나기 전의 기댓값일 뿐이며, G1이를 지키기 위해 최선을 다해야 한다.
G1이 이를 구현하기 위한 이론적 기초는 감소 평균(decaying average)이다. 가비지 컬렉션이 이루어지는 동안 G1은 리전별 회수 시간, 쓰레기 수 등을 기반해 예측시간을 분석한다. (평균, 표준 편차, 신뢰도기반 검정) 감소 평균은 일반 평균과 비교해 새로운 데이터에 더 민감하다. 즉, 최근은 평균적인 상태를 더 정확하게 알려주고, 리전의 상태를 더 최근에 게산할수록 얻는 이득을 더 높게 쳐준다. 이 정보를 기초로 어느 리전을 회수해야 예측 시간을 맞출 수 있을지 예측한다.

G1의 동작 과정

  • 최초 표시 (Stop the world)

GC루트가 직접 참조하는 객체들을 표시하고 TAMS포인터 값을 수정한다. 즉, 시작 단계 스냅숏을 생성한다. 사용자 스레드와 동시에 수정되는 다음 단계에서 개로운 객체들은 상위 포인터 공간에 저장되게 된다. (이 때는 쓰레드가 정지된다. 하지만 이 시간은 매우 짧기에 G1은 일시 정지가 없다고들 한다.)

  • 동시 표시

GC로부터 시작하여 객체들의 도달 가능성을 분석하고, 전체 힙 객체 그래프를 재귀적으로 스캔하며 회수할 객체를 찾는다. 스캔이 끝난 후 시작 단계 스냅숏과 비교하여 동시 실행 도중 참조가 변경된 객체들을 다시 스캔해야 한다.(재표시)

  • 재표시 (Stop the world)

시작 단계 스냅숏 이후 변경된 소수 객체만 처리하면 됨으로 빨리 끝난다.

  • 복사 및 청소 (Stop the world)

통계 데이터를 기초로 리전들을 회수 가치와 비용에 따라 줄 세운 다음, 목표한 일시 정지 시간에 부합하도록 회수 계획을 세운다. 회수할 리전을 선별 및 선별된 리전에서 살아남을 객체를 빈 리전에 이주시킨다.(복사 후 기존 리전을 비운다.) 따라서 쓰레드가 잠시 멈춘다. 다수의 GC쓰레드가 이를 동시에 처리한다.

위 과정에서 알 수 있듯이 G1은 동시 표시 단계를 제외하고는 모두 사용자 스레드를 멈춘다. 즉 짧은 지연 시간만을 추구하는게 아니라, 지연 시간을 제어한 동시에 처리량을 최대한 높이는 것이다.

이상적인 처리량과 지연시간의 균형점을 찾아라

일반적으로 기대 적정 기대 정지 시간은 100~300ms이다. 너무 짧게 설정하면 쓰레기가 회수 되지 않고 계속 쌓이며, GC는 계속 돌것이다.

오늘날의 GC들

모던 자바에서 사용될 수 있는 GC는 많지 않다.

  • 시리얼
  • 페러럴
  • G1 (JDK 9~)
  • ZGC (JDK 15~)
    • 세대 구분 ZGC (JDK 21~)
  • 셰넌도어 (JDK 12~)

ZGC와 셰년도어는 지연시간 최소화를 목표로 하는 최첨단 컬렉터들이다. 초기에 이 컬렉터들은 신세대와 구세대를 구분하지 않았다. 그러다가 JDK 21부터는 세대 구분 ZGC라고 하여, ZGC에 세대 구분 모드가 추가되었다. 장기적으로 세대 구분 모드를 기본 모드로 설정할 예정이다.

저지연 가비지 컬렉터

GC를 측정하는 가장 중요한 지표는 세 가지이다.

  • 처리량
  • 지연 시간
  • 메모리 사용량

이다. 보통 세 지표 모두 뛰어난 성능을 내는 GC는 없고, 두 가지정도는 달성할 수 있다.

이 중 메모리 사용량은 무어의 법칙에 따라 거의 18개월마다 사용 가능 량이 2배씩 증가하고 있다. 따라서 컬렉터가 메모리를 살짝 더 사용하는 건 큰 문제가 되지 않는 추세다.

하지만, 지연 시간은 다르다. 메모리를 늘리면 지연 시간에 악영향을 준다. 직관적으로도 그렇다. 힙 메모리 1TB를 처리하고자 한다면, 1GB를 청소할 때보다 오래 걸리는게 당연하다.

즉 이에 따라 지연시간이 가장 중요한 성능 지표가 되었다. 아래 사진을 참고해보자.

  • CMS등장 전에는 모두 Stop The World였다.
  • CMS, G1은 차례로 증분 업데이트와 스냅숏을 통해 표시단계를 동시에 수행한다. 하지만, 처리에 있어서는 Stop The World가 재현된다.
  • 셰넌도어와 ZGC는 거의 모든 과정이 동시에 수행된다. 이는 최초 표시, 최종 표시에만 일시 정지가 짧게 일어난다. 이 부분의 일시 정지 시간은 거의 고정적이다. (힙 크기와 내부 객체가 늘어나다 해도 길어지지 않는다.)
    • 따라서 이를 저지연 가비지 컬렉터라고 칭한다.

셰넌도어

셰넌도어는 G1을 계승하여 만든 GC로 힙 레이아웃이 비슷하며, 최초 표시 및 동시 표시 등 여러 단계에 공통점이 있다.

개선 사항

그렇다면 어떤 면에서 G1보다 나아졌을까? 셰넌도어 역시 힙을 리전들로 쪼개 처리하며, 큰 객체 전용의 거대 리전을 지원하고, 기본적으로 회수 가치가 큰 리전을 먼저 회수한다. 하지만 최소 세 가지 면에서 확실히 다르다.

첫째, 동시 모으기를 지원한다. G1은 여러 스레드를 활용해 모으기 단계를 병렬로 수행하지만, 사용자 스레드와 동시에 수행할 수는 없다. 이는 추후에 더 설명

둘째, JDK21까지 세대 단위 컬렉션을 사용하지 않는다. 따라서 성능적인 관점에서 약간의 저하가 있다.

셋째, 메모리와 컴퓨팅 자원을 많이 사용하는 기억 집합 대신 연결 행렬(connectino matrix)을 통해 리전 간 참조 관계를 기록한다. (자세한 내용은 책의 149p 참고)

동작 방식

셰넌도어의 동작 과정은 대략 아홉 단게로 나눌 수 있다.

  1. 최초 표시 (Stop The world)

GC루트에서 직접 참조하는 객체들에 표시한다. 이 단계는 여전히 스톱 더 월드다. (하지만 매우 짧으며, GC루트 수에 영향을 받는다.)

  1. 동시 표시

(G1 동일) 객체 그래프를 타고 힙을 탐색하며 도달 가능한 모든 객체를 표시한다. 이 단계에서 사용자 스레드는 동시에 수행된다. 참조하지 않는 객체가 또 생성될 수 있음

  1. 최종 표시 (Stop The world)

(G1 동일) 모든 표시를 완료하고, 가성비 좋은 리전들을 추려 회수 집합을 생성한다.

  1. 동시 청소

살아 있는 객체가 하나도 없는 리전을 청소한다.

  1. 동시 이주

이부분이 셰넌도어의 특징이다. 이 단계는 회수 집합 안에 살아 있는 객체들을 다른 빈 리전으로 복사한다. 이 때 사용자 스레드와 동시에 실행된다.
여기서 사용자가 사용하는 객체가 이동하게 되면, 이동 직후 객체를 가르키던 포인터를 모두 수정해야한다. 이러한 문제를 해결하기 위해 셰넌도어는 읽기장벽과 포워딩 포인터를 이용한다. 이단계의 실행시간은 회수 집합의 크기에 달렸다.

  1. 최초 참조 갱신 (Stop The World)

이주 단계에서 객체를 복사한 다음, 힙에서 옛 객체를 가르키는 모든 참조 포인터를 수정해야 한다. 이 작업을 참조 갱신이라고 한다. 최초 참조 갱신 단계에서는 사실 별다른 처리를 하지 않는다. 그저 스레드들의 집결지를 설정해 동시 이주 단계의 모든 GC스레드와 사용자스레드가 이주를 끝마쳤음을 보장한다. 이 작업은 쓰레드를 일시정지시키지만, 아주 금방 끝난다.

  1. 동시 참조 갱신

참조 갱신을 실제로 시작하며 사용자 스레드와 동시에 수행한다. (참조 수가 많으면 많을 수록 오래 걸림) 이는 동시 표시와 다르다. 객체 그래프를 탐색할 필요 없이, 물리 메모리 주소의 순서대로 참조 타입을 선형 검색하여 이전 값을 새로운 값으로 수정한다.

  1. 최종 참조 갱신 (Stop Thw World)

힙의 참조 갱신이 끝난다면 GC루트 집합의 참조도 갱신한다.

  1. 동시 청소

이주와 참조 갱신이 끝나면 회수 집합의 모든 리전에는 살아있는 객체가 남아 이씾 않는다. 따라서 동시 청소를 진행한다.


가장 중요한 단계는 동시 표시, 동시 이주, 동시 참조 갱신이다.

  • 포워딩 포인터
    • 객체 이동과 사용자 프로그램을 동시해 수행하는 방법이다. (간접 포인터, 브룩스 포인터라고도 함)
  • 읽기 장벽

등의 기술을 통해 셰넌도어를 구현한다.

ZGC

ZGC는 오라클이 개발한 저지연 가비지 컬렉터이다. JDK11에 실험 버전으로 탑재되었고, JDK 15에 마침내 정식 버전이 들어갔다. 또한 JDK 21부터는 신세대와 구세대를 구분해 처리하는 세대 구분 ZGC가 추가되었다.

ZGC와 셰넌도어의 목표는 아주 흡사하다. 둘 다 처리량에 미치는 영향을 최소로 억제하면서 힙 크기에 상관없이 가비지 컬렉션으로 인한 일시 정지 시간을 10밀리 안쪽으로 줄이고자 했다.

기술 용어를 도입해 정의하자면, ZGC의 특성은 다음과 같이 정의할 수 있다.

ZGC는 세대 구분 없이 리전 기반 메모리 레이아웃을 사용한다. 낮은 지연 시간을 최우선 목표로 하며, 동시 마크-컴팩트 알고리즘을 구현하기 위해 읽기 장벽, 컬러 포인터,메모리 다중 매핑 기술을 이용하는 가비지 컬렉터이다.

메모리 기반 레이아웃

G1처럼 메모리를 리전기반으로 나누지만 차이가 있다. ZGC의 리전은 동적으로 생성/파괴 된다. (Page, ZPage라고 표기하기도 한다.) 그뿐 아니라 크기도 동적으로 달라진다.

  • Small 리전 : 2Mb로 고정되며, 256kb미만 작은 객체를 담는다.
  • Medium 리전 : 32Mb로 고정되며, 256kb이상, 4Mb미만 객체를 담는다.
  • Large 리전 : 크기가 동적으로 변할 수 있다. 단 2Mb의 배수여야 한다. (4Mb 이상의 큰 객체용 공간이다.) Large 리전은 하나의 큰 객체만 담는다. 하나의 객체만 담는 특성이기에 크기는 Medium리전보다 작을 수 있다.
    • Large 리전은 재할당을 하지 않는데, 큰 객체를 복사하는 비용이 매우 크기 때문이다.

병렬 모으기와 컬러 포인터

ZGC를 상징하는 설계는 바로 컬러 포인터 기술(태그 포인터, 버전 포인터)이다. 가비지 컬렉터나 가상 머신 자체에서만 이용하는 추가데이터를 객체에 저장하고 싶을 때 ZGC이전에는 주로 객체 헤더에 필드를 추가했다.

ZGC에서는 포인터 자체에 소량의 추가 정보를 저장한다. 따라서 객체에 직접 도달할 필요가 없다.

64비트 시스템은 이론상 최대 16EB(2의 64제곱)크기의 메모리를 이용할 수 있다.(1024TB) 이 때 현실적인 물리 주소 공간 한계는 16TB(2의 44제곱)이다. 따라서 리눅스 에서 64포인터 중 46비트로 표현할 수 있는 64TB메모리만 해도 오늘날 서버에는 충분하다.

이 점을 이용해 ZGC 컬러포인터 기술은 주소 공간을 44비트까지로 제한하고, 그 다음 상위 4비트를 네 가지 플래그 정보를 저장하는 데 이용한다. JVM은 이 플래그들을 통해 포인터만 보고도 객체의 상태를 바로 알 수 있는 것이다.

  • JDK12까지는 주소 공간을 42비트로 제한하여 최대 4TB만 쓸 수 있다.

이처럼 컬러 포인터는 메모리 용량이 제한되고, 32비트 플랫폼에서는 동작하지 않으며, 압축 포인터(-XX:UseCompressedOops)같은 여러 기술을 지원할 수 없다.

컬러 포인터의 장점은 아래와 같다.

  1. 한 리전 안의 생존 객체들이 이동하면 그 즉시 해당 리전을 재활용할 수 있다.

전체 힙에서 해당 리전으로 참조를 전부 수정할 때 까지 기다릴 필요가 없다.

  1. 가비지 컬렉션 과정에서 메모리 장벽의 수를 크게 줄일 수 있다.

메모리 장벽을 설정하는 이유는 주로 객체 참조를 변경하기 위함이다. 이 정보를 포인터 자체 (컬러 포인터)에 둔다면 일부 기록 작업이 필요 없어진다. ZGC는 따라서 쓰기 장벽을 전혀 사용하지 않고 읽기 장벽만을 사용한다.

  1. 컬러 포인터를 객체 표시 및 재배치와 관련해 더 많은 정보를 담을 수 있는 확장 가능한 저장 구조로 쓸 수 있다.

플래그 4비트 또한 주소공간으로 활용 가능하다.

ZGC의 동작 방식

ZGC의 동작은 크게 네 단계로 나뉜다. 네 단계 모두 사용자 스레드와 동시에 실행되지만, 사이에 일시적으로 사용자 스레드를 정지시키는 작업이 있다.

  1. 표시 단계
    GC루트를 찾아 표시한다.

  2. 동시 표시
    객체 그래프를 탐색하며 도달 가능성을 분석한다. (ZGC는 이를 컬러포인터에 표시한다.)

  3. 동시 재배치 준비
    청소해야 할 리전들을 선정하며 재배치 집합을 만든다. G1의 회수집합과는 다르다.
    G1이 리전을 나눈 이유는 회수 효울 순서로 점진적으로 회수하기 위함이다. 그러나 ZGC가 리전을 나눈 목적은 다르다. ZGC는 GC마다 모든 리전을 스캔한다. G1이 관리 집합을 관리하는 비용 대신 스캔을 광범위하게 하는 비용을 선택했다.그래서 ZGC의 재배치 집합에서는 리전 안의 생존 객체들을 다른 리전으로 복사한 후 리전 자체를 회수할지 여부만을 결정한다.

  4. 동시 재배치
    재배치는 ZGC의 핵심 단계이다. 이 단계에서 재배치 집합에 속한 기존 객체를 새로운 리전으로 복사한다. + 포워드 테이블에 옛 객체와 새 객체의 이주 정보를 저장한다. 컬러 포인터 덕분에 객체가 재배치 집합에 속하는지 참조만 보고 알 수 있다.
    사용자 스레드가 객체에 동시 접근하고자 하면, 메모리 장벽이 끼어들어, 해당 리전의 포워드 테이블에 기록된 정보를 보고 새로운 객체로 포워드 시킨다. 그와 동시에 해당 참조의 값도 새로운 객체를 직접 가리키도록 갱신한다.(이를 자가치유라고 한다.)

  5. 동시 재매핑
    재매핑이란 힙 전체에서 재배치 집합에 있는 옛 객체들을 향하는 참조 전부를 갱신하는 것이다. 이는 급하게 진행되지 않아도 된다.(자가 치유가 일어남) 이 점을 활용해 ZGC는 '동시 재매핑 단계를 다음 가비지 컬렉션 주기가 시작되는 동시 표시 단계와 통합한다.'라는 아이디어를 구현해 내 객체 그래프를 탐색하는 단계를 한번 줄였다.


현 시점에서 ZGC는 가비지 컬렉터 연구가 낳은 최고의 결실이라고 할 수 있다. 모든 회수 단계가 거의 사용자 스레드와 동시에 실행되며 짧은 일시 정지 단계도 GC루트의 크기에만 영향을 받는다. 힙 메모리 크기와는 관련이 없다. 그 결과 힙 크기와 상관없이 정지 시간을 10밀리초 이내로 줄이겠다는 목표를 달성할 수 있었다.

타 컬렉터와의 비교




처리량, 지연 시간, 일시 정지 시간 등 모든 분야가서 병렬, G1 가비지 컬렉터보다 용이하지만, 메모리 사용량이 더 높은 것을 알 수 있다.

세대 구분 ZGC

세대 구분 ZGC(generational ZGC)는 ZGC를 확장하여 신세대와 구세대를 구분하도록 했다. (세대를 구분해서 얻는 가장 큰 이점은 물론 수명이 짦은 젊은 객체들을 더 자주 회수한다.)

이는 JDK21에 정식으로 추가되었다.

어플리케이션 스레드들이 메모리를 소비하는 속도보다 ZGC가 회수하는 속도가 빠르게 유지되는 한 문제 될 일이 없다. 하지만 나이에 상관없이 모든 객체를 함께 보관하므로 매번 모든 객체를 대상으로 회수 작업을 진행해야 한다.

약한 세대 가설에 따라 젊은 객체는 일찍 죽는 경향이 강하다. 따라서 젊은 객체들만 대상으로 하면 더 적은 노력으로 더 많은 메모리 공간을 확보할 수 있다. 다시 말해 젊은 객체를 더 자주 회수하면 ZGC를 활용하는 어플리케이션의 성능을 개선할 수 있다.

아직 완벽히 세대 구분ZGC가 ZGC를 대체한 것은 아니다. -XX:UseZGC 매개 변수를 지정하면 여전히 기본 ZGC가 선택되며, 다음과 같이 -XX:ZGenerational를 붙여야 한다.

java -XX:+UseZGC -XX:ZGenerational ...

세대 구분 ZGC의 경우는 읽기 장벽을 개선하였고 쓰기장벽이 도입되었다. 또한 컬러 포인터에 새로운 메타데이터를 추가하여 활용한다. 이에 따라 작업량이 상당히 줄었으며, 이외 에도 수 많은 기법이 적용되었다. 아래는 적용된 기술 몇가지를 소개한다.

다중 매핑 메모리 제거

ZGC는 읽기 장벽 부하를 줄이기 위해 다중 매핑 메모리 기법을 활용한다. 다중 매핑은 같은 힙 메모리를 세 개의 독립된 가상 주소로 매핑한다. 컬러 포인터에도 해당 주소를 위한 비트를 사용하였지만, 세대 구분 ZGC에서는 삭제되어 다른 용도로 활용할 수 있다.

이중 버퍼를 활용한 기억 집합 관리

기존 가비지 컬렉터는 기억 집합을 활용해 객체 주소를 관리했다.

세대 구분 ZGC는 비트맵을 이용해 객체 필드의 위치를 정확하게 기록한다. 비트맵의 비트 하나가 객체 필드 주소 하나를 표현한다.

밀집도 기반 리전 처리

신 세대에서 객체를 재배치할 떄 살아 있는 객체 수와 이들이 차지하는 메모리양은 리전에 따라 다르다. 최근에 할당된 리전이라면 더 많은 객체가 살아 있을 가능성이 크다.

세대 구별 ZGC는 어느 리전부터 회수해야할지 정하기 위해 밀집도를 분석한다. 밀집도가 약하면 구세대로 승격시킨다. 이처럼 리전을 그대로 둔 채 노화시키는 방식으로 신세대 리전을 회수하는 비용을 줄일 수 있다.

거대 객체 처리

거대한 객체를 바로 신세대에 할당한다. 또한 할당된 객체는 객체 재배치 없이 리전을 노화시킬 수 있다.

적합한 가비지 컬렉터 선택하기

적절한 GC를 선택하는 요인은 크게 아래 3가지다.

  • 어플리케이션 주 목적이 무엇인가?
  • 어플리케이션 구동하는 서브시스템은 무엇인가?
  • JDK 제공자는 어디인가?

오라클은 다음과 같이 안내하고 있다.

  • 최대 100MB정도 작은 데이터를 다루는 어플리케이션이다. -> 시리얼 컬렉터
  • 어플리케이션이 단일 프로세서만 사용하고 일시 정지 시간 관련 제약이 없다면 -> 시리얼 컬렉터
  • 어플리케이션의 최대 성능이 가장 중요하고, 지연 시간 관련 제약이 없거나, 1초 이상의 지연 시간도 허용된다면 -> 가상 머신의 기본 컬렉터나 패러렐 컬렉터
  • 처리량보다 응답 시간이 중요하고 가비지 컬렉션에 따른 일시 정지가 짧아야 한다면 -> G1
  • 응답 시간이 매우 중요하면 -> 세대구분 ZGC

가상 머신과 가비지 컬렉터 로그

자바 가상 머신의 메모리 문제를 다루려면 GC 로그를 읽을 줄 알아야 한다.

JDK9버전부터는 -Xlog매개변수로 로그를 설정할 수 있다.

-Xlog[:[selector][:[output][:[decorators][:output-options]]]]

가장 중요한 매개변수는 selector이다. 실렉터는 태그와 로그 래밸로 구성된다. 가비지 컬렉터의 태그는 gc이며, 로그 레밸은 출력 정보의 상세함 정도를 정한다. (Trace, Debug, Info, Warning, Error, Off 까지 6단계가 있다.)

각 로그 출력마다 데코레이터로 정보를 덧붙일 수 있다.

  • time : 현재 날짜와 시간
  • uptime : 가상 머신 구동 시간부터 흐른 시간(단위: 초)
  • timemillis : 밀리초 단위의 현재 시각
  • uptimemillis : 가상머신의 구동 시간
  • pid : 프로세스 아이디
  • tid : 스레드 아이디
  • level : 로그레밸

추가 정보를 따로 지정하지 않으면 uptime, level, tag가 설정된다.

  • 기본정보를 보고자 한다면 -Xlog:gc를 사용한다.
  • 상세 정보를 보려면 -Xlog:gc*을 사용한다.
  • 가비지 컬렉션 전후로 힙과 메서드 영역의 용량 변화를 보려면 -Xlog:gc+heap=debug를 사용한다.
  • 사용자 스레드의 동시 실행 시간과 일시 정지 시간을 확인하려면 -Xlog:safepoint를 사용한다.
  • 회수 후 남은 객체들의 나이 분포를 보려면 -Xlog:gc+age=trace를 활용한다.

가비지 컬렉터 매개 변수 정리

가비지 컬렉터사용 플래그매개 변수 및 설명
Serial GC-XX:+UseSerialGC-XX:NewSize=<size>: 초기 젊은 세대 크기
-XX:MaxNewSize=<size>: 최대 젊은 세대 크기
-XX:SurvivorRatio=<ratio>: Eden 영역과 Survivor 영역의 비율
-XX:TargetSurvivorRatio=<percentage>: 대상 생존자 비율
Parallel GC-XX:+UseParallelGC-XX:ParallelGCThreads=<n>: GC를 위해 사용할 스레드 수
-XX:MaxGCPauseMillis=<ms>: 최대 GC 중단 시간 목표
-XX:GCTimeRatio=<ratio>: 애플리케이션 실행 시간과 GC 시간의 비율
-XX:+UseParallelOldGC: 병렬로 Old Generation 수집
CMS GC-XX:+UseConcMarkSweepGC-XX:CMSInitiatingOccupancyFraction=<n>: CMS가 시작되는 힙 사용률
-XX:+UseCMSInitiatingOccupancyOnly: 지정된 힙 사용률에서만 CMS 시작
-XX:+CMSScavengeBeforeRemark: 리마크 전에 스카벤지 수행
-XX:+CMSParallelRemarkEnabled: 병렬 리마크 단계 사용
G1 GC-XX:+UseG1GC-XX:MaxGCPauseMillis=<ms>: 최대 GC 중단 시간 목표
-XX:G1HeapRegionSize=<size>: G1 힙 영역 크기
-XX:InitiatingHeapOccupancyPercent=<percent>: GC를 트리거할 힙 점유율
-XX:ConcGCThreads=<n>: 동시 GC 스레드 수
-XX:ParallelGCThreads=<n>: 병렬 GC 스레드 수
-XX:G1ReservePercent=<percent>: 힙 영역의 비상 예약 비율
ZGC-XX:+UseZGC-XX:ZCollectionInterval=<seconds>: 두 GC 사이의 최소 시간 간격
-XX:ZFragmentationLimit=<percent>: 조각화를 제한하기 위한 힙 사용률
-XX:ZUncommitDelay=<seconds>: 힙 메모리를 해제하기 전의 대기 시간
-XX:ZGenerational: 세대 구분 ZGC사용
-XX:UseNUMA: NUMA메모리 할당 지원.
Shenandoah GC-XX:+UseShenandoahGC-XX:ShenandoahGCHeuristics=<heuristic>: GC 휴리스틱 선택 (balanced, compact, etc.)
-XX:ShenandoahUncommitDelay=<seconds>: 힙 메모리를 해제하기 전의 대기 시간
-XX:ShenandoahAllocationThreshold=<percent>: 젊은 세대가 얼마나 채워졌을 때 GC를 시작할지 결정하는 비율

실전: 메모리 할당과 회수 전략

객체 메모리 할당은 개념적으로 힙에 할당한다는 뜻이다. 가장 기본적인 메모리 할당 정책을 이해하고, 코드를 이용해 검증해보자.

객체는 먼저 에덴에 할당된다.

대부분의 경우 객체는 신세대의 에덴에 할당된다. 에덴의 공간이 무족해지면 마이너 GC를 시작한다.

큰 객체는 곧바로 구세대에 할당된다.

큰 객체란 커다란 연속된 메모리 공간을 필요로 하는 자바 객체를 말한다. 이러한 객체가 신세대 -> 구세대로 옮겨가는 것은 심각한 오버헤드임으로 -XX:PretenureSizeThreshold매개 변수를 설정해 설정값보다 큰 객체를 곧바로 구세대에 할당할 수 있다. 해당 변수의 목적은 에덴과 두 생존가 공간 사이의 대규모 복사를 줄이는 데 있다.

해당 옵션은 시리얼과 파뉴 신세대 컬렉터만 적용된다. 신세대 컬렉터들은 이 변수를 지원하지 않는다.

나이가 차면 구세대로 옮겨진다.

신세대 컬렉션들은 대부분 세대 단위 컬렉션을 활용한다. 이런 구세대로 승격하는 나이를 -XX:MaxTenuringThreshold매개 변수로 정할 수 있다.

공간이 비좁으면 강제로 승격시킨다.

나이가 적어도 구세대로 승격시키기도 한다. 이는 생존자 공간 점유율이 바로 그 조건이다. 기본값은 50%라서 생존 객체 전체의 크기 총합이 생존자 공간의 절반이 넘어서면 모든 객체를 구세대로 옮긴다.

결론

가비지 컬렉터는 많은 경우에 시스템의 일시 정지 시간과 처리량에서 중요한 요인으로 작동한다. 실제 어플리케이션에서는 수많은 실험을 통해 컬렉터와 매개 변수 조합을 찾아내야 한다. 그 과정에서 위에 적혀있는 지식과 노하우는 도움이 될 것이다.

Ref

profile
강단있는 개발자가 되기위하여

0개의 댓글