Published on

Optimizing Java Chap7

Authors
  • avatar
    Name
    ywj9811
    Twitter

가비지 수집 고급

트레이드오프와 탈착형 수집기

자바에 가비지 수집기가 있음에도 자바/JVM 명세서에는 GC를 구현하는 방법에 대해서는 전혀 설명되어있지 않다.

심지어는 실제로 자바 구현체 중에는 가비지 수집 기능이 전혀 없는 것도 있다.

GC는 탈착형 서브시스템으로 취급되는데, 같은 자바 프로그램이라도 코드 변경 없이 여러 가지 가비지 수집기에서 돌려볼 수 있다.

이렇게 탈착형으로 수집기를 사용하는 건 GC가 아주 일반적인 컴퓨팅 기법인데다 같은 알고리즘이라도 모든 워크로드 유형에 다 적합한건 아니기 때문이다.

개발자는 GC 선정 시 다음 항목을 충분히 고려야해야 한다.

  • 중단 시간 (중단 길이 또는 기간)
  • 처리율 (어플리케이션 런타임 대비 GC 시간 %)
  • 중단 빈도 (수집기 때문에 어플리케이션은 얼마나 자주 멈추는가?)
  • 회수 효율 (GC 사이클 당 얼마나 많은 가비지가 수집되는가?)
  • 중단 일관성 (중단 시간이 고른 편인가?)

→ 최고 관심사는 중단 시간이다. 하지만 어플리케이션 종류별로 고려할 우선 순위가 달라질 수 있다.

동시 GC 이론

범용 가비지 수집기는 중단 결정을 효과적으로 내리는 데 참고할 만한 도메인 지식이 전혀없다.

더욱이, 메모리 할당은 불확정성을 유발하는 직접적인 원인으로, 실제로도 많은 자바 응용 시스템에서 들쑥날쑥한 양상을 보인다.

최신 GC 이론은 불확정적 STW(GC 사이클이 발생하여 가비지를 수집하는 동안에는 모든 어플리케이션 스레드가 중단되는 것) 중단 문제를 일단 해결하려고 시도한다.

동시(적어도 부분적으로) 수집기를 써서 어플리케이션 스레드의 실행 도중 수집에 필요한 작업 일부를 수행해서 중단 시간을 줄이는 것도 한가지 방법이다.

본격적으로 동시 수집기를 살펴보기 이전에 최신 가비지 수집기의 특성과 로직을 이해하는데 필수적인 GC 용어 및 기술을 알아보자.

JVM 세이프포인트

핫스팟 병렬 수집기에서 STW 가비지 수집을 실행하기 위해서는 어플리케이션 스레드를 모두 중단시켜야 한다.

그럼 JVM은 이런 작업을 어떻게 수행할까.

JVM은 순수한 협동적 환경 (cooperative operation)은 아니다.

OS는 언제나 선제(preemptive) 개입을 할 수 있다. 가령 한 스레드가 자신에게 할당된 시간을 다 쓰거나 스스로 wait() 상태가 되면 그렇게 선점할 수 있다.

이러한 OS 코어 기능처럼 JVM 또한 뭔가 조정 작업을 할 필요가 있는데, 이를 위해 어플리케이션 스레드마다 세이프포인트(safe point) 라는 특별한 실행 지점을 둔다.

이는 스레드 내부 자료 구조가 훤히 보이는 지점으로, 여기서 어떤 작업을 하기 위해 스레드는 잠시 중단될 수 있다.

예를 들어, 풀 STW 가비지 수집기는 작동하려면 안정된 객체 그래프가 필요하기 때문에 전체 어플리케이션을 반드시 중단시켜야 한다.

GC 스레드가 OS에게 무조건 어플리케이션 스레드 중단을 요청할 수 없기 때문에 (JVM 일부로 실행중인) 어플리케이션 스레드는 반드시 서로 공조해야 한다.

그리고 JVM은 다음 두가지 규칙에 따라 세이프포인트를 처리한다.

  • JVM은 강제로 스레드를 세이프포인트 상태로 바꿀 수 없다.
  • JVM은 스레드가 세이프포인트 상태에서 벗어나지 못하게 한다.

따라서 세이프포인트 요청을 받았을 때 그 지점에서 스레드가 제어권을 반납하게 만드는 코드(배리어)가 VM 인터프리터 구현체 어딘가에 있어야 한다.

그리고 JIT 컴파일한 메소드에도 생성된 기계어 안에 이런 배리어가 꼭 들어가있어야 한다.

아래는 세이프포인트 상태로 바뀌는 몇가지 일반적인 경우이다.

  1. JVM이 전역 ‘세이프포인트 시간’ 플레그를 세팅

    이 경우 모든 어플리케이션 스레드는 반드시 멈춰야 한다. 일찍 멈춘 스레드는 느리게 멈추는 다른 스레드를 기다린다.

  2. 각 어플리케이션 스레드는 폴링(검사) 하면서 이 플래그가 세팅됐는지 확인

    일반 어플리케이션 스레드는 인터프리터에서 바이트코드 2개를 실행할 때마다 체크를 하는데, 컴파일드 코드에서는 보통 컴파일 메소드 밖으로 나가거나 분기가 루프 처음으로 회귀하는 지점에 JIT 컴파일러가 세이프포인트 폴링 코드를 삽입한다.

  3. 어플리케이션 스레드는 일단 멈췄다가 다시 깨어날 때까지 대기

그렇다면 구체적인 세이프포인트 사례는 무엇이 있을까, 아래와 같은 경우에는 자동으로 스레드는 세이프포인트 상태가 된다.

  • 모니터에서 차단된다.
  • JNI 코드를 실행한다.

하지만 아래와 같은 경우에는 스레드가 꼭 세이프포인트 상태가 되는 것은 아니다.

  • 바이트코드를 실행하는 도중(인터프리트 모드일때)
  • OS가 인터럽트를 걸었을 때

삼색 마킹

가비지 수집 이론에서 중요한 위치를 차지하는 삼색 마킹 알고리즘의 작동 원리는 다음과 같다.

  • GC 루트를 회색 표시한다.
  • 다른 객체는 모두 흰색 표시한다.
  • 마킹 스레드가 임의의 회색 노드로 이동한다.
  • 마킹 스레드가 흰색 표시된 자식 노드가 있는 노드를 만나면, 먼저 그 자식 노드를 모두 회색 표시한 뒤 해당 노드를 검색을 표시한다.
  • 회색 노드가 하나도 남지 않을 때까지 위 과정을 반복한다.
  • 검은색 객체는 모두 접근 가능한 것으로 살아남는다.
  • 흰색 노드는 더 이상 접근 불가능한 객체로 수집 대상이 된다.

image

동시 수집은 SATB(일단 스냅샷 뜨기) 이라는 기법을 적극 활용한다.

즉, 수집 사이클을 시작할 때 접근 가능하거나 그 이후에 할당된 객체를 라이브 객체로 간주한다.

그래서 삼색 표시 알고리즘은 사소하지만 몇 가지 단점이 있다.

가령, 어플리케이션 스레드가 수집을 하는 도중에는 검은색 상태, 수집을 안하는 동안에는 흰색 상태로 새객체를 생성할 수 있을 것이다.

삼색 마킹 알고리즘에서 실행 중인 어플리케이션 스레드가 변경한 것 때문에 라이브 객체가 수집되는 현상을 방지하려면 몇가지 로직이 추가되어야 한다.

동시 스레드가 삼색 알고리즘을 실행하는 도중에도 어플리케이션 스레드가 계속 객체 그래프를 변경하는데, 이미 검은색으로 표시한 객체를 마킹 스레드가 나중에 백색 객체를 참조하도록 바꾸는 상황이 연출될 수 있다.

(위의 그림에서 검은 표시가 흰 표시를 참조하는 상황)

이때 새 흰색 객체를 가리키는 회색 객체의 레퍼런스를 모두 삭제하면 흰색 객체는 아직 접근이 가능하나 이 알고리즘 규칙에 따라 발견되지 않을 것이다.

이러한 부분은 여러 방법으로 해결할 수 있는데, 검은색 객체를 회색으로 바꾸고 어플리케이션 스레드가 업데이트하며 처리할 노드 세트에 추가하는 것도 가능하다.

이렇게 ‘쓰기 배리어’ 를 호라용하는 방법은 전체 마킹 사이클 동안 삼색을 그대로 유지할 수 있으니 알고리즘 측면에서 훌륭하다.

혹은, ‘동시 마킹 도중에는 절대로 검은색 객체 노드가 흰색 객체 노드를 가리킬 수 없다.’ 라는 삼색 불변 원칙을 고려할 수 있는데 삼색 불변의 원칙을 위배할지 모를 모든 변경 사항을 큐 형태로 넣어두고, 주(main phase) 단계가 끝난 다음 부차적인 ‘조정(fix)’ 단계에서 바로잡는 방법도 가능하다.

CMS

CMS 수집기는 중단 시간을 아주 짧게 하려고 설계된, 테뉴이드(올드) 공간 전용 수집로 보통, 영 세대 수집용 병렬 수집기 (Pharallel GC)를 변형한 수집기(ParNew)와 함께 쓴다.

CMS는 중단 시간을 최소화하기 위해 어플리케이션 스레드 실행 중에 가급적 많은 일을 하는데, 마킹은 삼색 마킹 알고리즘에 따라 수행하므로 수집기가 힙을 탐색하는 도중에도 객체 그래프가 변경될 수 있다.

따라서 CMS는 가비지 수집의 두번째 원칙 (아직 살아있는 객체를 수집하면 안된다)를 위반하지 않도록 반드시 레코드를 바로잡아야 한다.

그러다 보니, 수행단계는 다음과 같이 병렬 수집기보다 복잡하다.

  1. 초기 마킹 (STW)

    해당 영역 내부에 위치한 확실한 GC 출발점(GC 루트와 같은 것으로 내부 포인터라 한다)을 얻는 것

  2. 동시 마킹

    삼색 마킹 알고리즘을 힙에 적용하면서 나중에 조정해야 할지 모르는 변경 사항을 추적한다.

  3. 동시 사전 정리

    재마킹 단계에서 가능한한 STW 시간을 줄이는 것을 목표로 한다.

  4. 재마킹 (STW)

    카드 테이블을 이용해 변경자 스레드가 동시 마킹 단계 도중 영향을 끼친 마킹을 조정한다.

  5. 동시 스위프

  6. 동시 리셋

1, 4 두 단계 동안 모든 어플리케이션 스레드가 멈추고, 나머지 단계에서는 어플리케이션 스레드와 병행하여 GC를 수행한다. 이는 전체적으로 한번 긴 STW를 일반적으로 짧은 두번의 STW 중단으로 대체한 것이다.

이러한 CMS를 워크로드에 적용하면 다음과 같은 효과가 있다.

  1. 어플리케이션 스레드가 오랫동안 멈추지 않는다.
  2. 단일 풀 GC 사이클 시간이 더 길다.
  3. CMS GC 사이클이 실행되는 동안, 어플리케이션 처리율은 감소한다.
  4. GC가 객체를 추적해야 하므로 메모리를 더 많이 쓴다.
  5. GC 수행에 훨씬 더 많은 CPU 시간이 필요하다.
  6. CMS는 힙을 안착하지 않으므로 테뉴어드 영역은 단편화될 수 있다.

보면 장점이 있으면 단점이 있음을 알 수 있다.

즉, 만병 통치 GC 솔루션은 없으니 알맞는 선택지를 잘 찾아야 한다는 것이다.

CMS 작동 원리

CMS는 어플리케이션 스레드와 동시에 작동하는데, 기본적으로 가용 스레드 절반을 동원해 GC 동시 단계를 수행하고 나머지 절반은 어플리케이션 스레드가 자바 코드를 실행하는데 쓴다.

그러다보니 새로운 객체가 할당되는데, 만약 CMS 실행 도중 에덴 공간이 꽉차버리면 어떻게 될까?

그러면 당연히 실행이 중단되고, 영 GC (STW)가 일어날 것이다.

하지만 이 영 GC는 코어 절반만 사용(나머지 절반은 CMS가 사용)하기 때문에 병렬 수집기의 영 GC보다 더 오래걸릴 것이다.

게다가 일부 객체가 테뉴어드로 승격될텐데 CMS가 실행되는 동안 승격된 객체는 테뉴어드로 이동시켜야 하는데, 이러한 이유 때문에 두 수집기간에 긴밀한 조정이 필요하게 될 것이다.

따라서 CMS는 조금 다른 영 수집기를 사용한다.

  • 평상시

    평상시에는 영 수집 이후 극히 일부 객체만 테뉴어드로 승격되고, CMS 올드 수집을 하면 테뉴어드 공간이 정리될 것이다.

    그런 다음 어플리케이션은 다시 정상 모드로 돌아가 전체 코어를 이용해 다시 처리할 것이다.

  • 할당률이 급증한 경우

    하지만 할당률이 급증한 경우 영 수집 시 조기 승격이 일어날 것이다. 급기야 영 수집 후 승격된 객체가 너무 많아 테뉴어드 공간조차 부족한 사태가 벌어질 수 있을 것이다.

    image

    이러한 현상을 동시 모드 실패 (CMF) 라고 하며 JVM은 어쩔 수 없이 풀 STW를 유발하는 ParallelOld GC 수집 방식으로 돌아가게 된다.

따라서 이러한 CMF가 자주 일어나지 않게 하려면 CMS가 수집 사이클을 적절하게 개시하여 테뉴어드가 꽉차는 것을 막아야 한다.

그리고, 힙 단편화 또한 CMF를 유발하는 또 다른 원인으로 CMS는 테뉴어드 압착을 하지 않기에 발생할 수 있다.

CMS는 내부적으로 프리 리스트를 이용해 사용 가능한 빈 공간을 관리하는데, 동시에 스위프 단계에서 스위퍼 스레드가 여유 공간을 더 큰 덩어리로 만들어 단편화로 인해 CMF가 발생하지 않도록 연속된 빈 블록을 하나로 뭉친다.

하지만 스위퍼는 어플리케이션과 동시에 작동하므로 스레드가 서로 적절히 동기화되지 않으면 새로 할당된 블록이 잘못 삭제될 수 있다. 이런 일이 없도록 스위퍼 스레드는 작업 도중 프리 리스트를 잠군다.

G1

G1(가비지 우선)은 병렬 수집기, CMS와는 전혀 스타일이 다른 수집기이다.

이는 다음과 같은 특성이 있다.

  • CMS보다 훨씬 튜닝하기 쉽다.
  • 조기 승격에 덜 취약하다.
  • 대용량 힙에서 확장성(특히, 중단 시간)이 우수하다.
  • 풀 STW 수집을 없앨 수 있다.

G1 힙 레이아웃 및 영역

G1 힙은 영역(region)으로 구성된다.

영역은 디폴트 크기가 1MB인 메모리 공간으로 영역을 이용하면 세대를 불연속적으로 배치할 수 있고, 수집기가 매번 실행될 때마다 전체 가비지를 수집할 필요가 없다.

image

위와 같은 그림을 가지는 G1 알고리즘에서는 1, 2 ,4 ~ 64 MB의 영역을 사용할 수 있으며, 2048~4095개의 영역이 있다.

이 개수에 따라 영역 크기도 조정된다.

영역 개수 = 힙 크기 / 영역 크기

G1 알고리즘 설계

G1 수집기는 다음과 같은 일을 한다.

  • 동시 마킹 단계를 이용
  • 방출 수집기임
  • ‘통계적으로 압착’ 한다.

G1 수집기는 워밍업을 하는 동안 그리고 GC 사이클이 한번 돌 때마다 얼마나 많은 ‘일반’ 영역에서 가비지를 수집할 수 있는지 그 수치를 보관한다.

제일 마지막에 GC가 발생한 이후로 새로 할당된 객체를 감당하기에 충분한 메모리를 수집할 수 있다면, G1은 할당보다 뒤쳐지지 않는 것이다.

TLAB 할당, 서바이버 공간으로 방출, 테뉴어드 영역으로 승격 등의 개념은 앞서 살펴본 다른 핫스팟 수집기와 대동소이하다.

G1 에서도 에덴, 서바이버 영역으로 이루어진 영 세대 개념은 같지만, 세대를 구성하는 영역이 연속되어 있지 않다는 차지엄이 있다.

ParallelOld GC는 ‘올드 객체가 영 객체를 참조하는 일은 거의 없다’ 와 핫스팟 병렬/CMS 수집기는 카드 테이블**(**Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 정보가 표시된다. Young 영역의 GC를 실행할 때에는 Old 영역에 있는 모든 객체의 참조를 확인하지 않고, 이 카드 테이블만 뒤져서 GC 대상인지 식별한다.) 장치를 활용한다는 것을 확인했다.

G1 수집기 또한 **기억 세트(RSet)**라는 비슷한 장치로 영역을 추적한다.

RSet은 영역별로 하나씩, 외부에서 힙 영역 내부를 참조하는 레퍼런스를 관리하기 위한 장치다.

이러한 RSet과 카드 테이블은 부유 가비지라는 GC 문제를 해결하는데 유용하다.

G1 단계

G1의 수집 단계는 앞서 보았던 CMS 같은 수집기와 비슷하다.

  1. 초기 마킹 (STW)

  2. 동시 루트 탐색

    초기 마킹 단계의 서바이버 영역에서 올드 세대를 가리키는 레퍼런스를 찾는 단계로 반드시 다음 영 GC 탐색을 시작하기 전에 끝내야 한다.

  3. 동시 마킹

  4. 재마킹 (STW)

    마킹 단계 완료 시점이자 레퍼런스를 처리하고 SATB 방식으로 정리하는 작업을 진행한다.

  5. 정리 (STW)

    어카운팅(에덴 영역에서 재사용 준비를 마친 영역을 식별하기) 및 RSet ‘씻기’ 태스크를 수행하며 대부분 STW를 일으킨다.

셰난도아

레드햇 진영에서 제작한 자체 수집기이다.

간단하게 알아보자면 이는 G1처럼 목표는 중단 시간 단축으로 이를 달성하고자 동시 압착을 진행한다.

그리고 셰난도아의 특징은 브룩스 포인터로 이는 객체당 메모리 워드를 하나 더 써서 이전 가비지 수집 단계에서 객체가 재배치 되었는지 여부를 표시하고 새 버전 객체 콘텍스트를 가리킨다.

동시 압착

어플리케이션 스레드와 동시 실행중인 GC 스레드는 다음과 같이 방출한다.

  1. 객체를 TLAB로 복사한다.
  2. CAS로 브룩스 포인터가 추측성 사본을 가리키도록 수정한다.
  3. 이 작업이 성공하면 압착 스레드가 성공한 것으로 이후 이 버전의 객체는 모두 브룩스 포인터를 경유하여 액세스하게 된다.
  4. 이 작업이 실패하면 압착 스레드가 실패한 것으로, 추측성 사본을 원상복구하고 성공한 스레드가 남긴 브룩스 포인터를 따라간다.

C4 (아줄 징)

아줄 시스템 사는 두가지 자바 플랫폼을 출시했다.

그중 줄루(zulu)는 다중 플랫폼에서 사용 가능한 OpenJDK 기반의 자유오픈소스 솔루션이다.

그리고 징(zing)은 리눅스에서만 쓸 수 있는 상용 플랫폼으로, OpenJDK에 있는 자바 클래스 라이브러리를 사용하지만 완전히 다른 갓아 머신이다.

이 징 VM은 C4 (연속 동시 압착 수집기) 가비지 수집기를 비롯해 레디나우와 팰콘 컴파일러등 신박한 소프트웨어 기술을 가지고 있다.

이 또한 간단하게 짚고 넘어가자면, C4는 셰난도아 처럼 동시 압착 알고리즘을 사용하지만, 브룩스 포인터 대신 64비트 워드 하나로 이루어진 객체 헤더를 쓴다.

밸런스드(IBM J9)

J9는 IBM이 제작한 JVM으로 현재 오픈 소스화 단계를 밟고 있다.

J9에는 핫스팟 디폴트 병렬 수집기와 비슷한, 처리율이 높은 수집기를 비롯해 여러가지 수집기가 내장되어 있다.

그 중에서 밸런스드 수집기를 마찬가지로 간단하게 살펴보자.

  • 대용량 자바 힙에서 중단 시간이 길어지는 현상 개선
  • 중단 시간이 최악인 경우를 최소화
  • 불균일 기억장치 액세스 (NUMA)성능을 인지하여 활용

이러한 특징을 가지고 있다.

이밖에 존재하는 레거시 핫스팟 수집기

이들은 핫스팟 초기 버전에 존재하던 다양한 버전으로 그냥 있다고 보기만 하고 사용하지 않는 것이 좋다고 한다.

Serial 및 Serial Old

이는 Parrallel/ParrallelOld GC와 작동 원리는 거의 같지만, CPU 한 코어만 이용하여 GC를 수행하는 것이다.

증분 CMS(iCMS)

보통 iCMS라고 줄여 쓰는 증분 CMS는 예전에 동시 수집을 시도했던 수집기로 CMS에 도입하려 했던 일부 아이디어는 훗날 G1에도 영향을 주었다.

추가 : Java 버전에 따른 변화

Java 버전 별 Default GC 알고리즘

Java 8 (JDK 8)의 Default

Java 7,8 에서의 디폴트 GC 알고리즘은 Parallel GC이다.

Java 11 (JDK 11)의 Default

Java9,10,11 에서의 디폴트 GC 알고리즘은 G1 GC이다.

Java 11에서 추가

Epsilon GC

JDK 11부터 Epsilon GC 라는 No-Op Garabge Collection을 도입해 가능한 가장 낮은 GC 오버헤드를 약속하는 GC를 선보였다.

이는 ‘작동하지 않는 가비지 수집기’로 어플리케이션에서 사용 가능한 힙이 충분하다고 알고 있는 경우 굳이 JVM의 리소스를 사용해 GC 작업을 실행하는 것을 원치 않을 때 사용한다.

하지만 어플리케이션이 사용할 힙 용량이 충분하지 않을 경우(사용 가능한 힙 공간이 없을 경우), Elipson GC는 지금까지 본 GC들과 달리 OutOfMemoryException을 발생시킨다.

→ 메모리 회수 코드가 구현되지 않았기 때문이다. 즉, 테스트 환경에서는 적합하지만 프로덕션에서는 예외가 터져 응용 프로그램이 종료되기 때문이다.

→ 하지만, 정확한 성능 테스트 결과를 제공한다.

ZGC GC

이는 ‘빠른 어플리케이션 응답 속도를 요구하는 상황에서 가장 알맞는 GC, 하지만 메모리가 많이 여유로운 상황에서 이용하는 것을 권장한다.’

이는 기존의 GC가 GC가 발생하면 영역이 변경되며 기존 객체가 새로운 빈 공간을 찾아 재할당 되는 과정을 거치는 것과 달리, 새로운 영역을 할당해 그곳에 객체를 이동시키는 전략을 사용하기 때문이다.

JDK8, 11, 17 별 각 GC 성능 평가 - 참고

https://kstefanj.github.io/2021/11/24/gc-progress-8-17.html

  • 버전이 업그레이드 될수록 GC의 성능이 개선되는 것을 확인할 수 있다.
  • 시스템의 일시 중지 시간을 크게 고려할 경우 ZGC GC 선택을 고려가능
  • ZGC GC는 메모리 관리 측면에서는 G1 GC보다는 뒤떨어지는 단점이 존재
  • 종합적으로 보았을 때 G1 GC의 성능이 가장 뛰어납니다.