Published on

Optimizing Java Chap6

Authors
  • avatar
    Name
    ywj9811
    Twitter

가비지 수집 구현체는 어떤 원칙을 가지고 있을까

모든 가비지 수집 구현체는 두가지 기본 원칙을 가지고 있다.

  1. 알고리즘은 반드시 모든 가비지를 수집해야 한다.

  2. 살아있는 객체는 절대로 수집해서는 안된다.

    → 2번 원칙이 가장 중요하다.

마크 앤 스위프

가비지 수집의 기초 알고리즘은 마크 앤 스위프 알고리즘이다.

그렇다면 GC 알고리즘의 기본 개념과 이를 응용해 메모리를 어떻게 자동 회수 하는지 알아보자.

물론 알고리즘을 단순화 해서 이해하는 것이기 때문에 실제 JVM에서는 좀 다르게 진행될 수 있다.

아래와 같은 가정을 가지고 가자.

  • 마크 앤 스위프 알고리즘 할당
  • 아직 회수되지 않은 객체를 가리키는 포인터를 포함한 할당 리스트 사용. (allocated list)

전체적인 GC 알고리즘은 아래와 같다.

  1. 할당 리스트를 순회하면서 마크 비트를 지운다.
  2. GC 루트부터 살아 있는 객체를 탐색한다.
  3. 찾은 객체마다 마크 비트를 세팅한다.
  4. 할당 리스트를 순회하면서 마크 비트가 세팅되지 않은 객체를 찾는다.
    1. 힙에서 메모리를 회수해 프리 리스트에 되돌린다. (메모리가 할당되지 않은 영역들의 연결 리스트)
    2. 할당 리스트에서 객체를 삭제한다.

이 방식에서 살아 있는 객체는 대부분 깊이 우선 방식 탐색으로 찾는다.

이렇게 해서 생성된 객체 그래프를 라이브 객체 그래프라고 하며, 접근 가능한 객체의 전이 폐쇄 라고도 한다.

가비지 수집 용어

GC 알고리즘 설명의 용어는 헷갈리는 경우가 많다.

따라서 용어 몇개를 살펴보면 용이할 수 있다.

  • STW

    GC 사이클이 발생하여 가비지를 수집하는 동안에는 모든 어플리케이션 스레드가 중단되는 것이다.

    따라서 어플리케이션 코드는 GC 스레드가 바라보는 힙 상태를 무효화 할 수 없다.

    단순 GC 알고리즘에서는 대부분 이럴 때 STW가 일어난다.

  • 동시

    GC 스레드는 어플리케이션 스레드와 동시(병행) 실행될 수 있다는 것이다.

    하지만 이는 계산 비용 면에서 아주, 아주 어렵고 비싼 작업이며 100% 동시 실행을 보장하는 알고리즘은 없다.

    → 핫스팟의 CMS (동시 마크 앤 스위프) 또한 사실상 준 동시 수집기라고 봐야 한다.

  • 병렬

    여러 스레드를 동원해서 가비지 수집을 한다는 것이다.

  • 정확

    정확한 GC 스킴은 전체 가비지를 한방에 수집할 수 있게 힙 상태에 관한 충분한 타입 정보를 지니고 있다.

  • 보수

    보수적인 스킴은 정확한 스킴의 정보가 없다.

    따라서 리소스를 낭비하는 일이 잦고, 근본적으로 타입 체계를 무시하기 때문에 비효율적이다.

  • 이동

    이동 수집기에서 객체는 메모리를 여기저기로 오갈 수 있다.

    즉, 객체 주소가 고정된게 아닌 것으로 (C++처럼) 맨포인터로 직접 액세스하는 환경은 이동 수집기와 잘 맞지 않다.

  • 압착

    할당된 메모리(즉, 살아남은 객체들)는 GC 사이클 마지막에 연속된 단일 영역으로 (대게 이 영역 첫 부분부터) 배열되며, 객체 쓰기가 가능한 여백의 시작점을 가리키는 포인터가 있다.

    압착 수집기는 메모리 단편화 (memory fragment)를 방지한다.

  • 방출

    수집 사이클 마지막에 할당된 영역을 완전히 비우고 살아남은 객체는 모두 다른 메모리 영역으로 방출 된다.

핫스팟 런타임 개요

위에서 GC 용어에 대한 설명을 작성해 놓았는데, 구현체에 특정한 용어도 있다.

우리는 자바를 사용하기에 JVM의 핫스팟 내부도 적당히 알아야 한다.

우선, 자바 언어에서는 다음 두가지 값만 사용한다.

  1. 기본형 (byte, int… 등)
  2. 객체 레퍼런스

객체를 런타임에 표현하는 방법

핫스팟은 런타임에 oop라는 구조체로 자바 객체를 나타난다.

oop는 평범한 객체 포인터의 줄임말로, C 언어 느낌이 물씬 풍기는 순수 포인터이다.

oop는 참조형 지역 변수 안에 위치하는데, 자바 메소드의 스택 프레임으로 부터 자바 힙을 구성하는 메모리 영역 내부를 가리킨다.

이때 oop를 구성하는 자료구조 중 하나인 instanceOop는 자바 클래스의 인스턴스를 나타낸다.

instanceOop 의 메모리 레이아웃은 모든 객체에 대해 기계어 워드 2개로 구성된 헤더로 시작한다.

Mark 워드 (인스턴스 관련 메타데이터를 가리키는 포인터) 가 먼저 나오고 그 이후, Klass 워드 (클래스 메타데이터를 가리키는 포인터) 가 나온다.

자바 8 부터는 Klass가 자바 힙의 주 영역 밖으로 빠지게 되었고 따라서 객체 헤더가 필요없다.

oop는 대부분 기계어 워드라서, 예전 32비트 프로세서는 32비트, 요즘 프로세서는 64비트이다.

하지만 이런 구조를 사용하게 되면, 메모리가 크게 낭비될 우려가 있기에 핫스팟은 이러한 낭비를 절약하기 위해

압축 oop 라는 기법을 제공한다. 이는 힙에 있는 다음 oop를 압축한다.

  • 힙에 있는 모든 객체의 Klass 워드
  • 참조형 인스턴스 필드
  • 객체 배열의 각 원소

그리고, 핫스팟 객체 헤더는 일반적으로 다음과 같이 구성된다.

  • Mark 워드 (32비트 환경은 4바이트, 64비트 환경은 8바이트)
  • Klass 워드 (압축됐을 수도 있음)
  • 객체가 배열이면 length 워드 (항상 32비트)
  • 32비트 여백 (정렬 규칙 때문에 필요할 경우)

그리고 객체 인스턴스 필드는 헤더 바로 다음에 나열된다.

아래는 압축oop 의 메모리 레이아웃이다.

Untitled

자바에서 배열은 객체이기 때문에 JVM의 배열도 oop로 표시되며, 배열은 Mark 워드, Klass 워드 다음에 길이를 나타내는 length 워드가 붙는다.

JVM 환경에서 자바 레퍼런스는 instanceOop 혹은 null 을 제외한 어떤 것도 가리킬 수 없는데, 저수준에서는 아래와 같은 의미를 가진다.

  • 자바 값은 기본형 혹은 instancesOop 주소(레퍼런스)에 대응되는 비트 패턴이다.
  • 모든 자바 레퍼런스는 자바 힙의 주 영역에 있는 주소를 가리키는 포인터라고 볼 수 있다.
  • 자바 레퍼런스가 가리키는 주소에는 Mark 워드 + Klass 워드가 들어있다.
  • KlassOop와 Class 인스턴스는 다른 것으로 (KlassOop는 힙의 메타데이터 영역에) KlassOop를 자바 변수 안에 넣을 수 없다.

GC 루트 및 아레나

GC 루트는 핫스팟에 관한 블로그 글이나 기사에서 자주 나오는 말이다.

GC 루트는 메모리의 ‘고정점’ 으로 메모리 풀 외부에서 내부를 가리키는 포인터이다.

즉, 이는 외부 포인터로 같은 메모리 풀 내부에서 다른 메모리 위치를 가리키는 내부 포인터와 정반대의 의미이다.

GC 루트는 종류가 매우 다양한데, 힙에 있는 객체를 가리키는 참조형 지역 변수도 말하자면 가장 단순한 형태의 GC 루트이다.

핫스팟 GC는 아레나 라는 메모리 영역에서 작동하는데, 이는 사실 매우 저수준이기에 일반적으로 다루지는 않지만 간단한 개념과 용어를 알아두면 이후에 어쩌면 도움이 될지도 모른다.

중요. 핫스팟은 자바 힙을 관리할 때 시스템 콜을 하지 않는다.

핫스팟은 유저 공간 코드에서 힙 크기를 관리하기 때문에 단순 측정 값을 통해 GC 서브시스템이 어떤 성능 문제를 일으키고 있는지 알 수 있다.

할당과 수명

자바 어플리케이션에서 가비지 수집이 일어나는 주된 원인은 다음 두가지다.

  • 할당률

    일정 기간 새로 생성된 객체가 사용한 메모리량이다.

  • 객체 수명

    제대로 파악하기 어렵기 때문에 이것이 핵심적인 요인이다.

가비지 수집은 객체가 생성된 후 잠시 존재하고 그 상태를 보관하는 데 사용한 메모리를 다시 회수한다는 발상이 핵심이다.

약한 세대별 가설

이는 소프트웨어 시스템의 런타임 작용을 관찰한 결과 알게 된 경험 지식으로, JVM 메모리 관리의 이론적 근간을 형성한다.

→ JVM 및 유사 소프트웨어 시스템에서 객체 수명은 이원적 분포 양상을 보인다. 거의 대부분의 객체는 아주 짧은 시간만 살아 있지만, 나머지 객체의 기대 수명은 훨씬 길다.

즉, 단명 객체를 쉽고 빠르게 수집할 수 있게 설계하며, 장수 객체와 단명 객체를 완전히 떼어 놓는 것이 가장 좋다 라는 것이다.

핫스팟은 몇 가지 메커니즘을 응용하여 약한 세대별 가설을 십분 활용한다.

  • 객체마다 ‘세대 카운트(객체가 지금까지 무사 통과한 가비지 수집 횟수)’ 를 센다.
  • 큰 객체를 제외한 나머지 객체는 에덴 공간에 생성한다. 여기서 살아남은 객체는 다른 곳으로 옮긴다.
  • 장수했다고 할 정도로 충분히 오래 살아남은 객체들은 별도의 메모리 영역(올드 혹은 테뉴어드 세대)에 보관한다.
Untitled

이와 같이 오래 살아남게 되면 테뉴어드 세대로 승격되는데, 이들 영역은 처음부터 자연스럽게 연속된 공간이다.

이렇게 세대별 수집 목적에 다라 메모리를 상이한 영역으로 나누면 핫스팟의 마크 앤 스위프 수집의 구현에 따라서 그 결과가 더 세분화 된다.

여기서 중요한 것은 young세대 내부를 가리키는 포인터를 계속 추적하는 기법이다.

핫스팟은 카드 테이블 이라는 자료 구조에 늙은 객체가 젊은 객체를 참조하는 정보를 기록하는데, 이 테이블은 JVM이 관리한는 바이트 배열로, 각 원소는 올드 세대 공간의 512 바이트 영역을 가리킨다.

이것의 핵심 로직은 다음과 같다.

  • 늙은 객체 o에 있는 참조형 필드값이 바뀌면 o에 해당하는 instanceOop가 들어있는 카드를 찾아 해당 엔트리를 더티 마킹한다.

  • 핫스팟은 레퍼런스 필드를 업데이트 할때마다 단순 쓰기 배리어를 이용한다.

  • 필드 저장이 끝나면 cards[*instanceOop >> 9] = 0 이 동작한다.

    → 카드에 더티 표시를 한 값이 0이고, 카드 테이블이 512바이트이기 때문에 9비트 우측 시프트한다.

핫스팟의 가비지 수집

자바는 OS를 이용해 동적으로 메모리를 관리하지 않는다.

대신, 일반 프로세스가 시작되면 JVM은 메모리를 할당하고 유저 공간에서 연속된 단일 메모리 풀을 관리한다.

이 메모리 풀은 앞에서 설명한 것과 같이 각자 자신의 목적에 따라 서로 다른 영역으로 구성되며, 객체는 보통 에덴 영역에 생성된다.

수집기가 줄곧 객체를 이동시키기 때문에 객체가 차지한 주소는 대부분 시간이 흐르면서 빈번하게 바뀐다.

이렇게 객체를 이동시키는 것을 방출 이라고 한다.

즉, 핫스팟 수집기는 대부분 방출 수집기이다.

스레드 로컬 할당

JVM은 성능 강화하여 에덴을 관리한다.

에덴은 대부분의 객체가 탄생하는 장소이고 단명 객체는 다른 곳에는 위치할 수 없으니 특별히 관리를 잘해야 하는 영역이다.

JVM은 에덴을 여러 버퍼로 나누어 각 어플리케이션 스레드가 새 객체를 할당하는 구역으로 활용하도록 배포한다.

이렇게 하면 각 스레드는 다른 스레드의 버퍼에 객체를 할당할 수 없다. 그리고 이 구역을 **스레드 로컬 할당 버퍼(TLAB)**라고 한다.

이때 스레드가 자신의 TLAB를 배타적으로 제어한다면 이것을 JVM 스레드의 할당 복잡도가 O(1)이라고 한다.

Untitled

이와 같은 방식으로, 각각 TLAB에 할당되게 된다.

그리고, 스레드가 버퍼를 다 채우면 JVM은 새 에덴 영역을 가리키는 포인터를 내어준다.

반구형(방출) 수집

반구형 (방출) 수집기는 두 공간을 사용하는 독특한 방출 수집기이다.

실제로 장수하지 못한 객체를 임시 수용소에 담아 두자는 아이디어로, 단명 객체가 테뉴어드 세대를 어지럽히지 않게 하고 풀 GC 발생 빈도를 낮출 수 있다.

이는 아래 두가지 기본적인 특성을 가진다.

  • 수집기가 라이브 반구를 수집할 때 객체들은 다른 반구로 압착시켜 옮기고 수집된 반구는 비워서 재사용한다.
  • 절반의 공간은 항상 완전히 비운다.

물론, 이 경우 수집기 반구 내부에 실제로 보관 가능한 메모리 공간보다 2배를 더 사용하게 된다는 점이 있지만, 공간이 너무 크지 않다면 유용하게 사용할 수 있다.

핫스팟은 이 반구형 기법과 에덴 공간을 접목시켜 영 세대 수집을 진행한다.

핫스팟은 영 힙의 반구부를 서바이버(생존자) 공간이라고 부른다.

일반적으로 이 서바이버 공간은 에덴보다 작으며 이 공간의 역할은 각 영 세대 수집을 교환하는 것이다.

병렬 수집기

자바 8 이전까지 JVM 디폴트 가비지 수집기는 병렬 수집기였다.

이는 처리율에 최적화 되어있고, 영 GC, 풀 GC 모두 풀 STW를 일으킨다. 어플리케이션 스레드를 모두 중단시킨 다음, 가용 CPU 코어를 총동원해 가능한 한 재빨리 메모리를 수집한다.

Parallel GC, ParNewGC, ParallelOld GC 등등 병렬 수집기도 여러가지 있다.

이 중에서 많이 쓰이는 두 수집기의 차이점을 살펴보도록 하자.

영 세대 병렬 수집

영 세대 수집은 가장 흔한 가비지 수집 형태로 스레드가 에덴에 객체를 할당하려는데, 자신이 할당받은 TLAB 공간은 부족하고, JVM은 새 TLAB을 할당할 수 없을 때 영 세대 수집이 발생한다.

영 세대 수집이 일어나면 JVM은 어쩔 수 없이 전체 어플리케이션 스레드를 중단시키는데 이렇게 되면 핫스팟은 영 세대 → (에덴 및 현재 비어 있지 않은 서바이버 공간) 을 뒤져서 가비지 아닌 객체를 골라낸다. 이때 GC 루트를 병렬 마킹 스캔 작업의 출발점으로 삼는다.

그리고 Parallel GC는 살아남은 객체를 현재 비어있는 서바이버 공간으로 모두 방출한 후, 세대 카운트를 늘려 한 차례 이동했음을 기록하고 에덴과 이제 막 객체들을 방출시킨 서바이버 공간을 재사용 가능한 빈 공간으로 표시하고 어플리케이션 스레드를 재시작하여 TLAB을 어플리케이션 스레드에 배포하는 프로세스를 재개한다.

영 세대 수집

영 세대 수집

영 세대 방출

영 세대 방출

위의 그림을 보면 에덴에서 살아남은 것들을 서바이버에 보낸 후 0을 1로, 그리고 기존의 1, 2는 2, 3으로 올린다.

→ STW 시간을 족므이라도 단축해 가비지를 효율적으로 수집하기 위한 것

올드 세대 병렬 수집

ParallelOld GC 는 현재 디폴트 올드 세대 수집기이다.

Parallel GC와 상당 부분이 비슷하지만 Parallel GC는 객체를 방출하는 반구형 수집기 이지만, ParallelOld GC는 하나의 연속된 메모리 공간에서 압착하는 수집기라는 근본적인 차이가 있다.

올드 세대에 더 이상 방출할 공간이 없으면, 병렬 수집기는 올드 세대 내부에서 객체들을 재비치 하여 늙은 객체가 죽고 빠져 버려진 공간을 회수하려고 한다.

이를 통해 메모리 사용 면에서 아주 효율적이고, 메모리 단편화가 일어날 일도 없다.

→ 풀 GC 사이클 내내 CPU를 점유하는 대가로 메모리는 아주 효율적으로 배치하는 것이다.

Untitled

이는 방출과 압착을 나타낸 것으로

영 세대 수집은 단명 객체 처리가 목적이기 때문에 영 공간의 점유 상태는 GC 이벤트가 발생할 때마다 급격히 변하지만, 올드 공간은 눈에 띄는 변화가 없다.

영 세대 객체가 승격되거나, 올드/풀 수집이 일어나 객체를 재탐색 후 다시 배치하는 등의 수집이 일어날때만 변한다.

병렬 수집기의 한계

세대 전체 콘텐츠를 대상으로 한번에, 가능한 한 효율적으로 가비지를 수집하는데, 단점도 존재한다.

  • 풀 STW를 유발한다.

    약한 세대별 가설에 따르면 극소수 객체만 살아남기 때문에 영 수집에서는 STW가 문제 되지 않는다.

    → 영역 내 살아있는 객체 수 만큼 마킹 시간도 늘어난다.

    → 즉, 힙 크기에 거의 비례하여 STW 시간이 증가한다.

할당의 역할

자바의 가비지 수집 프로세스는 정해진 일정에 일어나는 것이 아닌, 순전히 그때그때 필요한 경우 발생한다.

즉, GC 사이클은 하나 이상의 힙 메모리 공간이 꽉 채워져 더 이상 객체를 생성할 공간이 없을 때 일어난다.

그렇다면 할당은 왜 중요할까, 예시를 위해 간단한 사례를 살펴보자 (힙 매개변수 값을 설장하고 이 값은 변하지 않는다고 가정한다.)

Untitled
Untitled
  • 이렇게 되어있을 때 에덴은 4초면 다 채워지니 정상 상태에서는 4초에 한번씩 영 GC가 발생한다.
  1. GC0(4초) 20MB가 에덴에서 SS1 (서바이버 공간)으로 방출된다.

    에덴의 객체는 대부분 사망하지만, 살아남은 객체는 서바이버 공간으로 방출된다.

  2. GC1(8초) 20MB가 에덴에서 SS2로 방출된다.

    그리고 또 4초가 지나면 GC1이 발생하는데, 이번에는 SS1에서 생존한 객체가 SS2로 보내져야 하지만, 200ms 가 있었으니 모두 죽었고, 단순히 에덴에서 20MB가 또 SS2로 방출된다.

  3. GC2(12초) 20MB가 에덴에서 SS1으로 방출된다.

    또 4초가 지나면 GC2에서 또 에덴에서 SS1으로 20MB만 보내지고 SS2의 객체는 죽을 것이다.

    이 모양을 보면, 이상적인 단순 모형에서는 어느 객체도 테뉴어드 세대로 승격될 수 없고, 이 공간은 실행하는 동안 계속 빈 상태일 것이다.

→ 이는 굉장히 이상적인 상태이며, 할당률이 치솟을 일도 없을 것이다. (현실과 거리가 멀다.)

Untitled

만약 이러한 상태라고 가정하면 어떨까

실제로는 이와 같이 아주 심하게 변하거나 갑자기 확 치솟기도 한다.

  • 처음 정상 상태로 2초 동안 200메가바이트의 객체가 에덴에 할당됐다. 이떄 장수 객체가 없다면 이 메모리에 있는 모든 객체의 수명은 100ms이다.
  • 그리고 할당률이 확 치솟아 불과 0.2초만에 200메가바이트가 에덴에 추가 할당된다.
  1. GC0(2.2초) 100MB가 에덴에서 테뉴어드로 보내진다. (테뉴어드 100MB)

    따라서 나이가 100밀리초 이하인 객체는 다 합쳐서 100메가바이트이다.

    이때 살아남은 객체 용량이 서바이버 공간보다 크기 때문에 어쩔 수 없이 바로 테뉴어드로 보낸다.

  2. GC1(2.6초) 200MB가 에덴에서 테뉴어드로 보내진다. (테뉴어드 300MB)

    이때 할당률이 가파르게 상승해 에덴에서는 또 100MB 객체가 살아남았지만, 이 모형에서 ‘생존자’ 전원은 금장 죽은 객체가 되므로 테뉴어드 세대는 지저분해진다.

    → 가비지 풀 수집이 일어나기 전까지 회수되지 않는다.

  3. GC2(3초) 200MB가 에덴에서 테뉴어드로 보내진다. (테뉴어드 500MB)

    마찬가지로 계속해서 들어오게 되는데, 막상 살아있는 객체는 별로 없다.

이와 같이 가비지 수집기는 일정한 주기마다 실행되는 것이 아닌, 필요에 따라 그때마다 실행되는 것을 알 수 있다.

할당률이 높으면 GC는 더 자주 발생하며, 너무 높으면 객체는 바로 테뉴어드로 승격될 것이다.

→ 이러한 현상을 조기 승격이라고 한다.