Post

가비지 수집 기초

6. 가비지 수집 기초

모든 가비지 수집 구현체는 다음 두 가지 기본 원칙을 준수해야 함

  • 알고리즘은 반드시 모든 가비지를 수집해야 함
  • 살아 있는 객체는 절대로 수집해선 안 됨

6.1 마크 앤 스위프(mark and sweep)

전체적인 GC 알고리즘

  1. 할당 리스트를 순회하면서 마크 비트(mark bit)를 지운다
  2. GC 루트부터 살아 있는 객체를 찾는다
  3. 찾은 객체마다 마크 비트를 세팅
  4. 할당 리스트를 순회하면서 마크 비트가 세팅되지 않은 객체를 찾는다
    1. 힙에서 메모리를 회수해 프리 리스트(free list, 동적 메모리 할당을 위해서 계획적으로 사용된 자료 구조로, 메모리의 할당되지 않은 영역들을 연결 리스트로 연결해서 운용함)
    2. 할당 리스트에서 객체 삭제

살아 있는 객체는 대부분 깊이-우선(depth-first) 방식으로 찾음

이렇게 해서 생성된 객체 그래프를 라이브 객체 그래프(live object graph)라고 하며, 접근 가능한 객체의 전에 폐쇄(transitive closure of reachable objects)라고도 함

힙 상태는 시각화하여 보기 어렵지만 jmap -histo로 타뼐로 할당된 바이트 수와 그만큼의 메모리를 차지한 전체 인스턴스 개수 확인 가능

GUI 화면은 VisualVM의 Samping 탭으로 볼 수 있다.

6.1.1 가비지 수집 용어

STW

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

동시

GC 스레드는 애플리케이션 스레드와 동시(병행) 실행 가능

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

병렬

여러 스레드를 동원해서 가비지 수집

정확

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

보수

보수적인 스킴은 정확한 스킴의 정보가 없다. 그래서 리소스를 낭비하는 일이 잦고 근본적으로 타입 체계를 무시하기 때문에 비효율적

이동

이동 수집기에서 객체는 메모리를 여기저기 오갈 수 있음. 즉, 객체 주소 고정 X

압착

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

압착 수집기는 메모리 단편화(memory fragmentation) 방지

방출

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

6.2 핫스팟 런타임 개요

자바는 C++과 달리 주소를 역참조하는 일반적인 메커니즘이 없고, 오직 오프셋 연산자(offset operator) 만으로 필드에 액세스하거나 객체 레퍼런스의 메서드를 호출 가능

또, 자바는 call-by-value 방식으로만 메서드 호출

객체 레퍼런스의 경우 복사된 값은 힙에 있는 객체의 주소

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

핫스팟은 런타임에 oop라는 구조체로 자바 객체르 나타냄

oop는 평범한 객체 포인터(ordinary object pointer)의 줄임말

oop는 참조형 지역 번수 안에 위치

oop를 구성하는 자료 구조는 여러가지

instanceOop는 자바 클래스의 인스턴스를 나타냄

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

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

자바7까지는 instanceOop의 Klass워드가 자바 힙의 일부인 PermGen이라는 메모리 영역을 가리켰다

자바8부터는 Klass가 자바 힙의 주 영역 밖으로 빠지게 되어 객체 헤더가 필요 없습니다

압축 oop(compressed oop) 기법 제공

1
-XX:UseCompressedOops

위의 옵션을 주면, 힙에 있는 다음oop가 압축됨(자바 7 버전 이상, 64비트 힙은 디폴트)

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

핫스팟 객체 헤더는 일반적으로 다음과 같이 구성됨

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

객체 인스턴스 필드는 헤더 바로 다음에 나열됨

자바에서 배열은 객체. 그래서 배열은 Mark 워드, Klass 워드 다음에 배열 길이를 나타내는 Length워드가 붙음.

JVM 환경에서 자바 레퍼런스는 instanceOop(또는 null)를 제외한 어떤 것도 가리킬 수 없음

  • 자바 값은 기본형 값 또는 instanceOop 주소(레퍼런스)에 대응되는 비트 패턴
  • 모든 자바 레퍼런스는 자바 힙의 주 영역에 있는 주소를 가리크는 포인터라고 볼 수 있다
  • 자바 레퍼런스가 가리키는 주소에는 Mark 워드 + Klass워드가 들어 있다
  • klassOop와 Class<?> 인스턴스는 다르며, klassOop를 자바 변수에 넣을 수 없다

런타임에 oop구조체를 이용해서 한 포인터는 클래스 메타데이터를, 다른 포인터는 인스턴스 메타데이터를 가리킴

6.2.2 GC 루트 및 아레나

GC 루트는 메모리의 고정점(anchor point)로, 메모리 풀 외부에서 내부를 가리키는 포인터

GC 루트는 종류가 다양함

  • 스택 프레임
  • JNI
  • 레지스터(hoist 된 변수)
  • 코드 루트
  • 전역 객체
  • 로드된 클래스의 메타데이터

힙에 있는 객체를 가리키는 참조형 지역 변수도 가장 단순한 형태의 GC 루트.

핫스팟 GC는 아레나라는 메모리 영역에서 작동함

핫스팟은 자바 힙을 관리할 때 시스템 콜을 하지 않음

핫스팟은 유저 공간 코드에서 힙 크기를 관리하므로 단순 측정값을 이용해 GC 서브시스템이 어떤 성능 문제를 일으키고 있는지 파악 가능

6.3 할당과 수명

자바 애플리케이션에서 가비지 수집이 일어나는 주된 원인

  • 할당률
  • 객체 수명

할당률은 일정 기간 새로 생성된 객체가 사용한 메모리량

객체 수명은 대부분 측정하기 어려움

객체 수명이 할당률보다 더 핵심적인 요인

가비지 수집은 메모리를 회수해 재사용하는 일. 객체는 대부분 단명하므로 가비지 수집에서 핵심 전제는 동일한 물리 메모리 조각을 몇 번이고 계속 다시 쓸 수 있는가

6.3.1 약한 세대별 가설(Weak Generation Hyposis)

소프트웨어 시스템의 런타임 작용을 관찰한 결과 알게 된 경험 지식

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

결론은 가바지를 수집하는 힙은 단명 객체를 쉽고 빠르게 수집할 수 있게 설계해야 하며, 장수 객체와 단명 객체를 완전히 떼어놓는 게 가장 좋다

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

  • 객체마다 세대 카운트(generation count, 객체가 지금까지 무사 통과한 가비지 수집 횟수)를 센다

  • 큰 객체를 제외한 나머지 객체는 에덴 공간에 생성. 여기서 살아남은 객체는 다른 곳으로 옮김

  • 장수했다고 할 정도로 충분히 오래 살아남은 객체들은 별도의 메모리 영역(old 또는 Tenuered)에 보관

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

늙은 객체가 젊은 객체를 참조할 일은 거의 없다는 게 약한 세대별 가설의 두 번째 포인트

핫스팟은 카드 테이블(card table)이라는 자료 구조에 늙은 객체가 젊은 객체를 참조하는 정보를 기록

카드 테이블은 JVM이 관리하는 바이트 배열로, 각 원소는 올드 세대 공간의 512 바이트 영역을 가리킴

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

핫스팟은 레퍼런스 필드를 업데이트할 때마다 단순 쓰기 배리어(wrtie barrier, 늙은 객체와 젊은 객체의 관계가 맺어지면 카드 테이블 엔트리를 더티 값으로 세팅하고, 반대로 관계가 해제되면 더티 값을 지우는 실행 엔진에 포함된 작은 코드 조각)를 사용

cards[*instanceOop >> 9] = 0

카드에 더티하다고 표시한 값이 0이고, 카드 테이블이 512바리트라서 9비트 우측 시프트

6.4 핫스팟의 가비지 수집

자바는 C/C++ 계열의 환경과달리 OS를 이용해 동적으로 메모리를 관리하지 않음

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

객체를 이동시키는 것을 ‘방출’이라고 하는데, 핫스팟 수집기는 대부분 방출 수집기

6.4.1 스레드 로컬 할당

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

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

이 구역을 스레드 로컬 할당 버퍼(Thread-local Allocation Buffer)라고 함

핫스팟은 애플리케이션 스레드에 발급한 TLAB 크기를 동적으로 저정함. 따라서 한 스레드가 메모리를 엄청나게 소모하고 있으면 스레드에 버퍼를 내주는 오버헤드를 줄이기 위해 더 큼지막한 TLAB를 건네줌

애플리케이션 스레드가 자신의 TLAB를 배타적으로 제어한다는 건 JVM 스레드읨 할당 복잡도가 O(1)

6.4.2 반구형 수집(hemispheric evacuating collector)

(보통 크기가 같은) 두 공간을 사용하는 독특한 방출 수집기

단명 객체가 테뉴어드 세대를 어지럽히지 않게 하고 풀 GC 발생 빈도를 줄일 수 있다

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

이 방법을 따르면 수집기 반구 내부에 실제로 보관 가능한 메모리 공간보다 2배를 더 사용하게 되어 낭비지만, 공간이 너무 크지 않다면 유용한 기법

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

핫스팟에서는 형 힙의 반구부를 서바이버(survivor) 공간이라고 함

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

6.5 병렬 수집기

자바 8 이전까지 JVM 디폴트 가비지 수집기는 병렬 수집기(parallel collector)

병렬 수집기는 처리율에 최적화되어 있고, 영 GC, 풀 GC 모두 풀 STW를 일으킴

애플리케이션 스레드를 모두 중단시킨 다음, 가용 CPU 코어를 총동원해 가능한 한 재빨리 메모리를 수집

  • Parallel GC: 가장 단수한 영 세대용 병렬 수집기
  • ParNew GC: CMS 수집기와 함께 사용할 수 있게 Parallel GC를 조금 변여형한 것
  • ParallelOld GC: 올드 세대용 병렬 수집기

여러 스레드를 이용해 가급적 빠른 시간 내에 살아 잇는 객체를 식별하고 기록 작업을 최소화하도록 설계된 점은 비슷함

6.5.1 영 세대 병렬 수집

스레드가 에덴에 객체를 할당하려는데 자신이 할당받은 TLAB 공간은 부족하고 JVM은 새 TLAB을 할당할 수 없을 때 영 세대 수집이 발생

영 세대 수집이 일어나면 JVM은 어쩔 수 없이 전체 애플리케이션 스레드를 중단

전체 애플리케이션 스레드가 중단되면 핫스팟은 영세대를 뒤져서 가비지 아닌 객체를 골라냄

이때 GC 루트와 카드 테이블을 병령 마킹 스캔 작업의 출발접으로 삼음

살아남은 객체를 현재 비어 있는 서바이버 공간으로 모두 방출한 후, 세대 카운트를 늘려 한 차례 이동했음을 기록함

방출한 서바이버 공간을 재사용 가능한 빈 공간으로 표시하고, 애플리케이션 스레드를 재시작해 TLAB를 애플리케이션 스레드에 배포하는 프로세스 재개

6.5.2 올드 세대 병렬 수집

자바 8 기준 디폴트 올드 세대 수집기

Parallel GC는 객체를 방출하는 반구형 수집기이지만, ParallelOld GC는 하나의 연속된 메모리 공간에서 압착하는 수집기

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

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

영 세대 수집은 단명 개게 처리가 목적이기 때문에 영 공간의 점유 상태는 GC 이벤트가 발생할 때마다 메모리 할당 및 소거가 일어나면서 급격하게 변함

올드 공간은 크게 눈에 띄는 변화 X

6.5.3 병렬 수집기의 한계

풀 STW를 유발함

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

영 병렬 수집기는 설계상 죽은 객체는 절대로 건드리지 않기 때문에 마킹 단계의 소요 시간은 생존 객체 수에 비레

전체 힙에서 영 영역을 작게 구성한 설계는 기본적으로 대부분의 워크로드에서 영 수집에 따른 중단 시간이 매우 짧다고 가정한 것

실제로, 최신 2기가바이트짜리 JVM에서 영 수집 중단 시간은 거의 대부분 10밀리초 이하

그러나 올드 세대는 디폴트 크기 자체가 영 새대의 7배

그리고, 영역 내 살아 있는 객체 수만큼 마킹 시간도 늘어남(올드 객체는 장수한 객체이므로 상당수는 살아남을 확률이 큼)

올드 수집의 가장 큰 약점은 STW 시간이 힙 크기에 거의 비례한다는 점

6.6 할당의 역할

GC 사이클은 예측 가능한 일정에 맞춰 발생하는 게 아니라, 순전히 그때그때 필요에 의해 발생

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

할당률은 실제로 아주 심하게 변하거나 확 치솟기도 함

할당률이 높을수록 GC는 더 자주 발생

할당률이 높으면 객체는 어쩔 수 없이 테뉴어드로 곧장 승격 -> 조기 승격(premature promotion)

참조

  1. Optimizing Java(자바 최적화)
  2. https://cheese10yun.github.io/intellij-visual-vm/
  3. https://visualvm.github.io/index.html
This post is licensed under CC BY 4.0 by the author.