DevSprout/optimizing-java

Chapter 06. 가비지 수집 기초

Opened this issue · 3 comments

Chapter 06. 가비지 수집 기초

끄적끄적

가비지 수집 두가지 원칙

  • 알고리즘은 반드시 모든 가비지를 수집해야 한다
  • 살아 있는 객체는 절대로 수집해선 안 된다
    • segmentation fault가 발생하거나, 프로그램 데이터가 조용히 더럽혀짐
  • 프로그래머가 저수준 세부를 일일이 신경쓰지 않는 대가로 저수준 제어권(가비지 수집)을 포기하고 JVM에 맡긴다 -> 자바는 블루칼라 언어

기본적인 GC 알고리즘

    1. 'Allocated List'를 순회하면서 'Mark Bit'를 지운다
    • 'Allocated List'란?:

    • 'Mark Bit'란?:

    1. GC Root부터 살아있는 객체를 DFS로 찾는다 (이렇게 생성된 객체 그래프를 'Live Object Graph' 라고 함)
    1. 찾은 객체마다 Mark Bit를 세팅한다
    1. Allocated List를 순회하면서 Mark Bit가 세팅되지 않은 객체를 찾는다
    • a) Heap에서 메모리를 회수해 'Free List'에 되돌려준다
      • 'Free List'란?: 메모리의 할당되지 않은 영역들을 연결 리스트로 연결해서 운용
    • b) Allocated List에서 객체를 삭제한다

GC 관련 용어

  • 이번 절 번역체 너무 심한데.... 맨포인터(raw pointer) ㄷㄷ...

  • 근데 원문도 설명이 그렇게 친절해보이진 않다

  • Stop-The-World
    • GC Cycle이 발생하여 가비지를 수집하는 동안에는 모든 Application Thread가 중단됨
  • Concurrent (동시): GC Thread는 Application Thread와 동시 실행될 수 있음
    • ex) CMS(Concurrent Mark and Sweep)는 사실상 '준 동시' 수집
  • Parallel (병렬): 여러 GC Thread를 사용해서 병렬로 수집할 수 있음
  • Exact(정확한): 정확한 GC Scheme은 힙 상태에 관한 충분한 타입 정보를 지니고 있음
    • Q. GC Scheme이란?

  • Conservative(보수적인): 보수적인 Scheme은 정확한 스킴의 정보가 없음. 그래서 리소스를 낭비하는 일이 잦고 근본적으로 타입 체계를 무시하기 때문에 훨씬 비효율적임
    • 이건 또 무슨 말이지..... '보수적인 Scheme'이라는 말만 들으면 뭔가 더 정확한 스킴을 제공할 것 같은데...

    • Q. 보수적인 Scheme이란?

  • Moving(이동): 이동 수집기에서 객체는 메모리를 여기저기 오갈 수 있음. raw pointer로 직접 액세스하는 환경은 이동 수집기와 잘 맞지 않음.
    • Q. 이동 수집기(moving collector)란?

      • Compaction 또는 Evacuation 시 객체를 이동시키는 앤가?

  • Compacting(압축): 메모리 단편화(Memory Fragment)를 방지하기 위해 조각모음
  • Evacuating(방출): 가비지 수집 후 살아남은 객체들을 다른 영역으로 이동시킴
    • ex. Eden -> Surv1 / Surv1 -> Surv2 / Surv2->Old / ...

Hotspot VM관련 용어

  • 이번 장은 굳이 볼 필요는 없을 듯
  • oop(Ordinary Object Pointer): Hotspot은 런타임에 oop라는 구조체로 자바 객체를 나타냄 (C언어 느낌의 순수 포인터)
    • Q. oop?

    • Q. 순수 포인터?

    • oop를 구성하는 자료구조는 여러가지 있음
      • instanceOop: 자바 클래스의 인스턴스를 나타내며, �machine word 2개로 구성된 헤더로 시작함
        • Mark word: 인스턴스 메타데이터를 가리키는 포인터
        • Klass word: 클래스 메타데이터(klassOop인 듯?)를 가리키는 포인터
          • Java7까진 PermGen을 가리켰고, Java8부턴 Metaspace 가리킴
            • Java8부터는 Klass word가 Java Heap 밖을 가리키므로 객체 헤더가 필요 없음
          • Q. JVM 메모리 영역 vs C 메모리 영역 (JVM Heap vs C Heap)

      • klassOop: JVM Class Loader가 로드한 Class 객체를 JVM 수준에서 나타낸 구조체
        • 앞에 k를 붙인 것은, 자바의 Class<?> 인스턴스를 나타내는 instanceOop와 구분하기 위함
        • klassOop vs Class<?>의 instanceOop

          • klassOop: 클래스의 메타데이터

          • Class의 instanceOop: Java에서 사용할 수 있는 인스턴스화된 Class

          • classMetadataOop, classInstanceOop로 네이밍했다면 좀 더 직관적이지 않았을까?

      • Compressed oop

GC Roots and Arenas

  • Hotspot VM의 GC는 Arena라는 메모리 영역에서 작동함
  • Hotspot VM은 Java Heap을 관리할 때 '시스템 콜'을 하지 않고 '유저 공간 코드'에서 힙 크기를 관리
    • 단순 측정값을 이용해 GC 서브시스템이 어떤 성능 문제를 일으키고 있는지 파악 가능
  • GC Root: External Pointer. 메모리의 고정점(Anchor point)
    • Internal Pointer: 메모리풀 내부에서 같은 메모리풀 내부의 다른 메모리 위치를 가리키는 포인터
    • External Pointer: 메모리풀 외부에서 메모리풀 내부를 가리키는 포인터
    • 종류
      • Stack Frames
      • JNI
      • Registers (Hoisted variable)
      • Registers (for the hoisted variable case)
      • Code roots (from the JVM code cache)
      • Globals
      • Class metadata from loaded classes

Allocation and Lifetime

  • 가비지 수집이 일어나는 주된 원인
    • 할당률: 일정 기간 새로 생성된 객체가 사용한 메모리량
    • 객체 수명:
      • 수동 메모리 관리 시스템에서 가장 논란됐던 부분 중 하나. 객체 수명을 제대로 파악하기가 너무 복잡함
  • Weak Generational Hypothesis
    • 거의 대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다
      • 단명 객체와 장수 객체를 완전히 떼어놓고, 단명 객체는 쉽고 빠르게 수집할 수 있게 설계하는 것이 좋다
      • 단명 객체/장수 객체를 나눈 덕분에 GC cycle에서 살아남은 젊은 객체들을 집어내느라 전체 객체 그래프를 뒤질 필요가 없음
    • 늙은 객체가 젊은 객체를 참조할 일은 거의 없다
      • Card Table: 늙은 객체가 젊은 객체를 참조하는 정보를 기록
        • 늙은 객체 o에 있는 참조형 필드값이 바뀌면 o에 해당하는 instanceOop가 들어있는 카드를 찾아 해당 엔트리를 Dirty Marking
    • 객체마다 generational count(age)를 센다
      • generational count: 객체가 무사 통과한 가비지 수집 횟수
    • 큰 객체(Humongous Object)를 제외한 나머지 객체는 Eden region에 생성하고, 여기서 살아남으면 다른(Survivor) region으로 옮긴다
      • G1GC에는 Humongous Region이 따로 있음

        • Humongous Region: Region size의 50%보다 큰 객체를 할당하는 공간

          • 보통 Region Size는 전체 Heap / 2024 개로 구성된다. (Heap이 8GB면 4MB) (-XX:G1HeapRegionSize=로 지정 가능)

    • 충분히 오래 살아남은 객체들은 별도의 region(Old (=Tenured) Region)에 보관
      • -XXMaxTenurintThreshold 값으로 수명 임계값 조정 가능 (default: 15)

      • 살아남은 객체 용량이 Survivor �space보다 크면 바로 전부 Old Gen으로 보내는 조기 승격(�Premature Promotion)이 일어남

Thread-Local Allocation

  • Eden을 여러 Buffer로 나누어 각 Application Thread가 새 객체를 할당하는 구역으로 활용
    • TLAB(Thread-Local Allocation Buffer) 덕분에 각 Thread는 다른 Thread�의 Buffer를 침범하지 않음

Parallel Collector

  • 병렬 수집기는 처리율에 최적화되어 있고, Young GC/Full GC 모두 STW를 일으킴 (가용 CPU core를 총동원해 최대한 빨리 메모리를 수집)
  • 종류
    • Parallel GC
    • ParNew GC
      • CMS Collector와 함께 사용할 수 있게 parallel GC를 조금 변형
    • ParallelOld GC: 하나의 연속된 메모리 공간에서 Compaction
      • Java8 기준 default Old Gen Collector (G1GC랑 같이 사용하는건가?)
      • STW 시간이 Heap 크기에 거의 비례
    • Q. 다른 GC랑 같이 사용한다는게 무슨 뜻이지?

  • Young Gen Parallel Collection
    • Thread가 Eden에 객체를 할당하려는데 자신이 할당받은 TLAB 공간은 부족하고 JVM은 새 TLAB를 할당할 수 없을 때 Minor GC 발생
  • Old Gen Parallel Collection
  • 한계
    • Full STW를 유발

인상 깊었던 것

  • 살아 있는 객체를 DFS로 찾는 거
  • KlassOop가 Java Heap Object로 저장하기 위한 Wrapper 역할을 한다는 것
  • 약한 세대별1에 의해 객체 영역을 두 개로 나누는 것 외에 장수하지 못한 객체를 임시 수용소에 담아 두자는 아이디어
  • '늙은 객체가 젋은 객체를 참조할 일은 거의 없다'라는 약한 세대별2. 하지만 예외 케이스를 커버하기 위해 Card Table 이용

정리

가비지 수집 Point: 시스템에 있는 모든 객체의 수명을 정확히 몰라도 런타임이 대신 객체를 추적하며 쓸모없는 객체를 알아서 제거

가비지 수집 구현체 기본 원칙

  1. 알고리즘은 반드시 모든 가비지를 수집해야 한다
  2. 살아 있는 객체는 절대로 수집해서는 안된다.
  • 살아 있는 객체를 수집했다간 Segmentation fault가 발생하거나 프로그램 데이터가 더럽혀짐

마크 앤 스위프(Mark and Sweep) 알고리즘

  1. 할당 리스트를 순회하면서 Mark bit를 지운다
  2. GC 루트부터 살아 있는 객체를 찾는다
  3. 이렇게 찾은 객체마다 Mark bit를 세팅한다
  4. 할당 리스트를 순회하면서 마크 비트가 세팅되지 않은 객체를 찾는다
    1. Heap에서 메모리를 회수해 free list에 되돌린다
    2. 할당 리스트에서 객체를 삭제한다

살아 있는 객체는 DFS로 찾는다.

HotSpot runtime 개요

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

instanceOop의 메모리 레이아웃: Mark 워드, Klass 워드라는 2개의 기계어 워드로 구성된 헤더로 시작.

  • Mark 워드: 인스턴스 관련 메타데이터를 가리키는 포인터
  • Klass 워드: 클래스 메타데이터를 가리키는 포인터.
    • Java 7까지는 instanceOop의 Klass 워드가 자바 힙의 일부인 PermGen이라는 메모리 영역을 가리켰다
    • Java 8부터는 Klass가 자바 Heap의 주 영역 밖으로 빠지게 되었다. 그래서 최신 버전의 자바는 Klass 워드가 자바 힙 밖을 가리키므로 객체 헤더가 필요없다

CompressedOops

-XX:+UseCompressedOops option을 주면, 힙에 있는 다음 oop가 압축된다 (Java 7 이상, 64비트 힙은 이 옵션이 디폴트)

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

GC 루트 및 아레나

  • GC 루트: 메모리의 고정점(Anchor Point)로 메모리 풀 외부에서 내부를 가리키는 포인터.
  • GC 루트의 종류: Stack Frame, JNI, Register, Code root, 전역 객체, 로드된 클래스의 메타데이터
  • 핫스팟 GC는 Arena라는 메모리 영역에서 작동한다.
  • 핫스팟은 자바 힙을 관리할 때 시스템 콜을 하지 않는다.

할당과 수명

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

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

  • 객체마다 Generational count를 센다.
  • 큰 객체를 제외한 나머지 객체는 Eden 공간에 생성한다. 살아남은 객체는 다른 곳으로 옮긴다
  • 오래 살아남은 객체들은 별도의 메모리 영역으로 보관한다.

약한 세대별 가설2: 늙은 객체가 젋은 객체를 참조할 일은 거의 없다.

  • Card Table이라는 자료구조에 늙은 객체가 젋은 객체를 참조하는 정보를 기록한다. GC Young generation을 수집할 때, Card Table을 이용해서 Old generation에서 참조하는 젋은 객체를 알아낼 수 있다. 만약 Card Table이 없다면, 모든 Old generation을 추적해야 하기 때문에 비효율적이다.
  • 더티 marking하는 식으로 관리한다.
    • GC에 의해 해제되거나 young object가 old generation으로 옮겨졌을 때도 더티 값을 지운다.

핫스팟의 가비지 수집

Thread-Local Allocation Buffer(TLAB): JVM은 Eden 영역을 여러 버퍼로 나누어 각 Application 스레드가 새 객체를 할당하는 구역으로 활용하도록 한다.

  • Application thread가 자신의 TLAB를 배타적으로 제어한다는 것은 JVM 스레드의 할당 복잡도가 O(1)이라는 뜻. 어떠한 동기화 작업도 필요없기 때문.
  • TLAB는 동적으로 조정한다.

반구형 수집기(Hemispheric evacuating collector): 장수하지 못한 객체를 임시 수용소에 담아 두자는 아이디어. 덕분에 단명 객체가 테뉴어드 세대를 어지럽히지 않게 하고 풀 GC 발생 빈도를 줄일 수 있습니다.

할당의 역할

  • GC는 불확정적으로, 불규칙적으로 발생한다. GC 사이클은 하나 이상의 힙 메모리 공간이 꽉 채워져 더 이상 객체를 생성할 공간이 없을 때 일어난다.

→ 그렇기에 GC 로그는 기존의 시계열 해석 방법으로 처리하기 어렵다. GC 이벤트 간의 규칙성이 거의 없어서 대다수 시계열 라이브러리로 쉽게 끼워맞출 수 없다.

  • GC가 발생하면 모든 Application thread가 멈춘다.
  • 할당률이 높을수록 GC는 자주 발생하고, 할당률이 너무 높으면 객체는 어쩔 수 없이 Tenured로 곧장 승격(조기 승격)

더 알아본 내용

GC!!

  • 알고리즘은 반드시 모든 가비지를 수집해야하며 절대 살아있는 객체를 수집하면 안된다.

Mark And Sweep

  • 할당 리스트(회수되지 않은 객체를 가르키는 포인터를 포함한 리스트)를 순회하며 마크 비트(아마도 이제 얘들을 지울거라는 뜻이 될 듯)를 지운다.
  • 살아있는 객체를 찾아서 마크 비트를 세팅한다.
  • 할당 리스트를 순회하며 마크 비트가 세팅되지 않은 객체들을 찾고 힙에서 메모리를 회수해서 프리 리스트(메모리 할당 안된 리스트들을 연결 리스트로 만들어서 운영함)에 되돌리고 할당 리스트에서 객체를 제거한다.
  • 살아있는 객체를 찾을 때는 DFS를 사용한다고 한다. (오옹!)

Hot-Spot Runtime

  • 핫스팟은 런타임에 oop라는 구조체로 자바 객체를 나타낸다.
  • oop가 오브젝트 오리엔티드 프로그래밍.. 그건 줄 알았는데 그건 아니고 ordinary object pointer의 줄임말이라고 한다. 하핫
  • instanceOop의 메모리 레이아웃은 모든 객체에 대해 기계어 워드 2개로 구성된 헤더로 시작한다고 한다.
  • Mark(인스턴스 관련 메타데이터를 가르키는 포인터)와 Klass(클래스 메타데이터를 가르키는 포인터)가 나온다고 한다.
  • 8버전부터는 klass가 자바의 힙 영역에서 네이티브 영역으로 나가게 되면서 자연스럽게 klassOop는 더 이상 사용되지 않는다고 한다. ( 원래는 7버전에서 PermGen 영역을 가르키는 헤더 포인터로써 사용되었다고 함)

GC 루트 및 아레나

  • GC 루트는 메모리의 Anchor point로 메모리 풀 외부에서 내부를 가르키는 포인터이다.
  • 핫스팟 GC는 arena라는 메모리 영역에서 작동한다고 한다. (오.. 신기;; )
  • 핫스팟은 자바 힙을 관리할 때 시스템 콜을 하지 않는다고 한다. (이야..!!)
  • 핫스팟은 유저 공간 코드에서 힙 크기를 관리하기에 이게 가능하다고 한다.

할당과 수명

  • 자바 애플리케이션에서 GC가 일어나는 원인은 두 가지이다.
  • 할당률 : 일정 기간 동안 새로 생성된 객체가 사용한 메모리량.
  • 수명 : 얼마나 오래 살아남았는가? -> 측정하기 매우 어려움. GC에서 핵심적인 요인
  • 약한 세대별 가설 : 대부분의 객체는 아주 짧은 시간만 살아있지만, 나머지 객체는 기대 수명이 훨씬 길다. ( 스프링은 왠지 더 그럴 것 같음 )

핫스팟 GC가 단명 객체와 장수 객체를 구분하는 방법

  • 객체마다 Geneartion Count라고 얼마나 GC에서 살아남았는지를 카운팅함.
  • 큰 객체를 제외한 나머지 객체는 Eden에 박아둔다. 살아남으면 다른 곳으로 옮김.
  • 장수했다고 판단될 정도로 오래 살아남은 객체들은 별도의 메모리 영역에 보관한다. ( Old 또는 Tenured..? 이런것도 있구나;; )

핫스팟의 가비지 수집

  • JVM은 에덴을 여러 버퍼로 나눠서 각 애플리케이션 스레드가 새 객체를 할당하는 구역으로 활용하도록 배포한다고 한다.
  • 각 스레드나 다른 스레드가 자신의 버퍼에 객체를 할당할 필요가 없다고 한다. 스레드 로컬 할당 버퍼라고 한다네.

hemispheric evacuating collector ( 반구형 수집 )

  • 내가 예전에 배운 서바이버 영역이 이 반구형 수집이라고 한다.
  • 장수하지 못한 객체를 서바이버 영역에 두고 테뉴어드 존으로 옮기지 않게 하는 것 같음.
  • 기본적으로 이 서바이버 영역은 둘 중 하나는 무조건 비워져있어야함.

Young Parallel GC

  • Young GC도 STW는 발생한다.

Old Parallel GC

  • 여기서는 하나의 연속된 메모리 공간에서 압착하는 수집기이다.
  • Old GC는 늙고 병든 객체들을 회수해서 메모리 사용이 효율적이고 단편화도 일어나지 않는다고 한다.

병렬 수집기의 한계

  • 병렬 수집기의 단점은 풀 STW가 유발되는데, 영 STW는 극소수 객체만 살아남으므로 크게 문제가 안된다.
  • 올드 GC에는 디폴트 크기 자체가 영의 7배라서 훨씬 더 시간이 길어지게 된다.
  • 올드 GC의 가장 큰 단점은, STW 시간이 힙 크기에 비례한다는 뜻인데, 힙이 계속 커질수록 올드 GC의 시간도 같이 늘어나게 된다.

할당의 역할

  • GC는 그때 그때 필요할 때 돈다. 정해진 규칙대로 동작하지 않음.

총평

  • GC를 다시 공부할 수 있어서 좋은 기회였고 애매모호했던, 몰랐던 개념들을 새롭게 배우고 정립할 수 있어서 무척 좋은 챕터였음!!