Java Garbage Collector

dev_note·2022년 5월 1일
0

Garbage Collector

Garbage Collector. 즉, 객체를 추적해서 쓸모 없어진 Heap 영역의 객체들을 알아서 제거하는 프로그램이다. 가비지 수집기의 구현체는 다음과 같은 두가지 원칙을 꼭 준수해야한다.

  • GC 에 사용되는 알고리즘은 모든 가비지를 수집할 수 있는 알고리즘이어야 한다.
  • 살아있는 객체는 절대로 수집해서는 안된다.

위 두가지 원칙을 지키지 않으면 프로그램에 결함이 생긴다. 특히, 살아있는 객체를 수집한다면 더욱 치명적일 수 있다. 프로그램에 신뢰도가 깨지기 때문이다.

GC 가 알고리즘

(GC 는 Garbage Collector 와 Garbage Collection 을 혼용해서 부르는 말이다. 이 탭에서는 Garbage Collection 을 지침하는 단어로 생각해주세요.)
JVM 에서 돌아가고 있는 GC 는 Mark And Sweep 이라는 알고리즘을 기반으로 동작합니다.

Mark

GC Root 또는 다른 객체에 의해서 참조되고 있는 객체는 아직 살아있는 객체(live object) 이다. 따라서 이 객체를 GC 해서는 안된다. 이렇게 살아있는 객체를 찾고 표시 (Mark) 해두는 과정이 Mark 단계이다. GC Root 는 다음과 같은 것들이 될 수 있다.

  1. 실행중인 스레드
  2. 전역 변수
    • 정적 변수
    • 멤버 변수
  3. 스택 프레임
    • 로컬 변수
  4. JNI 레퍼런스
  5. 로드된 클래스의 메타데이터

GC Root 의 참조사슬에 있는 객체는 GC 대상이 아니다. 즉, 실행중인 스레드나 변수, 그리고 JNI 레퍼런스 또는 객체 등등 의해 참조되고 있는 객체는 GC 대상이 아니다.
참조사슬이란 말은 GC Root 에 의해 직접적으로 참조되고 있는 객체 뿐만 아니라, live object 에 의해서 참조되는 객체 또한 포함하기 때문에 "사슬"이라는 말이 붙는다. 그림을 보면 명확하게 이해할 수 있다.

그림을 보면 GC Root 에 의해서 직접적인 참조는 없음에도 불구하고 Mark 된 객체들이 있다(M 으로 표시된 객체).

객체가 생성되고 참조가 일어날 때마다 객체 대한 참조정보가 저장되고 있기 때문에 Mark 알고리즘이 동작할 수 있는 것입니다.

객체를 정점이라고 보고 참조정보를 간선이라고 보면 이를 그래프라는 자료구조로 나타낼 수 있다. 그래프를 탐색하는 방법에는 대표적으로 두가지가 있다.

  • 깊이 우선 탐색
  • 너비 우선 탐색

Mark 알고리즘에서는 깊이 우선 탐색을 사용하고 있다.

직접 Mark 알고리즘을 구현한다면?

모든 객체의 정보를 Map에 담아두고 탐색을 통해서 발견되는 객체의 정보를 에 표시해둔다. Map 에 다음과 같은 형태도 정보를 저장한다.

Map<Integer, Boolean> mark

heap 의 모든 객체정보를 mark 변수에 담아둔다.
그리고 만약 hashcode 값이 123456 인 객체가 참조사슬에 포함되어 있다면 (살아있다면) true, 아니면 false 로 표시해둘 수 있다.

살아있는 객체 => mark.put(123456, true);
살아있지 않은 객체 => mark.put(123456, false);

그리고 Mark 단계가 끝나면 Map 을 반복하여 mark 여부가 false 인 객체를 메모리에서 해체하는 방법을 사용해서 구현할 수 있다. 실제로 Mark 알고리즘 구현에 유사한 방법을 사용하고 있다고 한다.

Sweep

객체를 순회하면서 Mark 되지 않은 객체를 메모리에서 해제하는 과정이다.

가비지 수집을 이해하기 위해 알아야 할 용어

STW (Stop The World)

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

동시

GC 스레드가 애플리케이션 스레드와 동시에 실행될 수 있는 알고리즘이 있다. 그러나 이는 아주아주 비싸고 어려운 작업이고 100% 동시 실행을 보장하는(STW 가 없는) 알고리즘은 없음. 따라서 애플리케이션과 GC 작업이 병렬적으로 일어난다고 말하는 알고리즘인 CMS (Concurrent Mark and Sweep) 알고리즘도 사실상 ‘준 동시 수집기'라고 해야 맞다. 즉, GC 에서 동시라는 개념은 완벽히 병렬 실행을 보장하는 알고리즘이 아니라는 말이다.

병렬

여러 스레드를 동원해서 가비지 수집을 할 수 있습니다.

스킴

GC 를 할 수 있도록 필요한 정보를 저장하는 공간을 스킴이라고 함. (더 깊은 개념이 있지만 이 정도까지만 알아두는 것이 정신건강에 좋을 것 같다..)

이동

객체의 메모리 주소는 항상 일정한 것이 아니라 때에 따라 달라질 수 있음. 즉, 원래 있던 자리에서 다른 자리로 "이동"이 일어날 수 있음. 이 때 단편화(조각조각 나뉜)된 메모리 사이사이를 채우는 작업이 일어날 수 있는데 이 때문에 메모리 단편화 문제를 해결할 수 있음. "압착"이라는 개념과 달리, GC 이후가 아니라, 프로그램 실행 중간에도 일어날 수 있는 동작

  • 메모리 단편화를 막을 수 있음
  • 한번 참조된 메모리와 인접한 주변 메모리는 다시 참조될 가능성이 높다는 원칙으로 주변 데이터를 캐싱 하는 것을 참조지역성의 원리라고 하는데 참조지역성의 원리에 따라서 "이동"이라는 작업이 일어나면 데이터가 캐싱될 확률이 높아지고, 성능 향상에 도움이 됨

압착 (Compaction)

GC 후에도 살아남은 객체는 메모리의 특정 영역에 나란히 배열된다. 이런 과정을 압착이라고 함.

방출

수집 사이클 마지막에, 할당된 영역을 비우고도 살아남은 객체를 다른 메모리 영역으로 이동시키는 것

자바 객체에 대한 이야기는 여기를 참고.

가비지 수집은 언제 발생할까?

가비지 수집을 일으키는 요인은 대표적으로 2가지가 있다.

  • 할당률
  • 객체 수명

할당률은 일정 기간동안 새로 생성된 객체가 사용한 메모리량이다.(단위는 보통 MB/s, 초당 메가바이트 사용).
JVM 이 이 할당률을 직접 기록하지는 않지만 우리는 이 값을 비교적 쉽게 측정할 수 있습니다. 센섬 같은 툴을 사용하면 정확하게 구할 수 있습니다.

반면 객체 수명은 대부분 측정하기가 너무나도 어렵습니다. 수동 메모리 관리 시스템에서 가장 논란이 되었던 것 중 하나가 실제 애플리케이션의 객체 수명을 정확하게 파악하기가 너무 어렵다는 점입니다.
할당률이 어느정도 이상 되었어도 객체 수명이 되지 않았으면 GC 대상이 아닙니다. 따라서 객체의 수명을 정확하게 측정하고 GC할 수 있는 방법이 필요합니다.

약한 세대별 가설

약한 세대별 가설은 소프트웨어 시스템이 런타임 중에 일어나는 동작들을 관찰 하면서 알게된 경험을 바탕으로 만들어졌습니다. 이 경험을 기반으로 JVM 이 어떻게 메모리의 객체를 관리해야할지 이론적인 근간을 만든 것입니다.

JVM 기반의 소프트웨어 시스템에서 객체 수명은 이원적 (bimodel, 낙타 등같은 모양의 그래프) 분포 양상을 보인다. 대부분의 객체가 아주 짧은 시간만 살아 있으나, 일단 살아남은 객체는 기대 수명이 훨씬 길다.

이런 경험을 토대로 생성된지 얼마 안된 객체와 오랫동안 살아남은 객체를 나누어 관리하자는게 약한 세대별 가설의 핵심입니다.

핵심이론

  • 방금 생성된 객체는 에덴(Eden, 메모리 영역) 공간에 생성한다. 여기서 살아남은 객체는 다른 곳으로 옮긴다.
  • 객체마다 세대 카운트 (객체가 지금까지 GC 에서 살아남은 회수, instanceOop 의 Mark 헤더에 age 비트 필드에 기록됨)를 센다
  • 장수했다고 할 정도로 충분히 오래 살아남은 객체는 별도의 메모리 영역 (Old 또는 Tenured[종신, 생명을 마쳤다는 뜻이지만 맥락상 장수를 의미하는 것 같음] 세대)에 보관한다.

  • 새로 생성한 대부분의 객체는 Eden 영역에 위치
  • Eden 영역이 꽉차면 GC 발생. 이 때 살아남은 객체는 Survivor 영역 중 하나로 이동
  • 하나의 Survivor 영역이 가득 차게 되면 그 중에서 살아남은 객체를 다른 Survivor 영역으로 이동한다. 이 때 가득 찬 Survivor 영역은 아무 데이터도 없는 상태
  • 다른 Survivor 영역의 GC 과정중에도 살아남은 객체는 Old 영역으로 이동한다.

GC 가 일어날 때 마다 모든 객체로 부터의 참조를 검사해야할까?

GC 를 발생시키기 위해서 GC Root 로 부터의 참조가 있는지 검사합니다. 그리고 이 참조 사슬 내에 객체가 있다면 GC 에서 살아남습니다. 이 말의 의미는 참조 사슬 내에 있는 GC root 가 아닌 객체에 의한 참조가 있는 경우에도 참조 사슬 내에만 있다면 살아남는다는 말입니다.

즉, GC Root 로 부터 직접적인 참조가 없어도 참조 사슬 내에 있는 객체이면 살아남습니다.

  • B 객체는 GC Root 로 부터 참조가 없지만 살아남습니다.

따라서 GC 대상인지 확인하기 위해서는 GC Root 가 아닌 객체로 부터의 참조가 있는지도 검사를 해야합니다.
그런데 모든 객체로 부터의 참조를 검사해야할까요?
약한 세대별 가설은 그럴 필요가 없다고 말합니다.

방금 생성된 객체가 장수한 객체를 참조하는 일은 있을 수 있어도 그 반대는 드물다.

즉, 젊은 객체(Young)들이 살아남았는지 확인하기 위해서 장수한 객체(Old)로부터의 참조가 있는지 확인할 필요는 없다는 의미입니다.

더 정확하게 말하면 참조가 거의 없기 때문에 있는 경우만 따로 기록해두면 Old 객체 전체를 검사할 필요는 없다는 말입니다.

이런 기록을 해두기 위해서 핫스팟 JVM은 카드테이블(card table) 라는 자료 구조를 이용합니다. 여기서는 Old 객체가 Young 객체를 참조하는 정보를 기록하는 공간입니다.

Java 8u40 버전부터 사용한 G1 GC 가 사용되고 이는 이전 버전에 대한 내용입니다.

  • GC 알고리즘의 종류와 GC 알고리즘이 지속적으로 개선되는 이유를 알고 싶으신 분은 여기 를 참고해주세요.
profile
having a better day than i did yesterday

0개의 댓글