시스템에 있는 모든 객체의 수명을 정확히 몰라도 런타임이 대신 객체를 추적하며
쓸모없는 객체를 알아서 제거하는 것
GC의 2가지 기본 원칙
- 알고리즘은 반드시 모든 가비지를 수집해야 한다.
- 살아 있는 객체는 절대로 수집해선 안된다.
2번째 원칙이 더 중요하다.
살아 있는 객체를 수집했다간 세그먼트 폴트가 발생하거나 프로그램 데이터가 나도 모르게 변형될 것이다.
따라서 GC 알고리즘은 프로그램이 사용 중인 객체를 절대 수집해선 안된다.
STW(Stop-The-World)
GC가 실행되어 힙의 메모리를 반환하는 동안에는 GC에 할당된 스레드를 제외한 모든 스레드가 멈추게 된다.
이러한 상태를 Stop the World라고 하고 어떠한 GC 알고리즘을 사용하더라도 STW 상태에 부딪히게 된다.
따라서 GC 튜닝이라고 하면 STW에 걸리는 시간을 최소화하는 것을 의미한다.
마크 앤 스위프(Mark & Sweep)
GC 알고리즘의 기초이다.
가장 기초적인 마크 앤 스위프 알고리즘은
할당된 상태지만 회수되지 않은 객체를 가리키는 포인터를 가지는 할당 리스트(allocated list)를 사용한다.
전체적인 GC 알고리즘은 다음과 같다.
- 할당 리스트를 순회하면서 마크 비트(mark bit)를 지운다.
- GC Root로부터 살아있는 객체를 찾는다.
- 이렇게 찾은 객체마다 마크 미트를 세팅한다.
- 할당 리스트를 순회하면서 마크 비트가 세팅되지 않은 객체를 찾는다.
- 힙에서 메모리를 회수해 프리 리스트(free list)에 되돌린다.
- 할당 리스트에서 객체를 삭제한다.
GC Root는 말 그대로 GC의 Root라는 뜻이며, 힙 외부에서 접근할 수 있는 변수나 객체를 의미한다.
바로 다음 단락에서 GC Root를 설명한다.
살아있는 객체는 대부분 DFS(깊이 우선 탐색) 방식으로 찾는다.
이렇게 해서 생성된 객체 그래프를 라이브 객체 그래프(live object graph)라고 하며, 접근 가능한 객체의 전이 폐쇄(transitive closure of reachable objects)라고도 한다.
전이 폐쇄(transitive closure)는 이산 수학에서 나오는 용어로, 여기서는 살아있는 객체 그래프의 어느 지점에서 출발하든 접근 가능한 모든 지점의 집합을 의미한다.
GC Root
GC Root는 메모리의 고정점(anchor point)으로, 메모리 풀 외부에서 내부를 가리키는 포인터이다.
메모리 풀 내부에서 같은 메모리 풀 내부의 다른 메모리 위치를 가리키는 내부 포인터와 정반대인 외부 포인터라고 할 수 있다.
GC Root의 종류
- 스택 프레임(stack frame)
- 하나의 메서드를 호출하는데 필요한 메모리 덩어리
- 메서드 하나당 하나의 스택 프레임이 존재
- JNI(Java Native Interface)
- java에서 C/C++ 로 작성된 모듈을 호출할 수 있게 해주는 기능
- 레지스터
- 코드 루트
- 전역 객체
- 참조형 지역 변수
- 로드된 클래스의 메타데이터
JVM의 객체 런타임 표현 방법
자바는 다음 두 가지 값만 사용한다.
- primitive(기본형) : byte, int, long 등
- 객체 레퍼런스
자바는 C++와 다르게 주소를 역참조하는 일반적인 메커니즘이 없다. (& 연산자가 없다는 것이다.)
오직 오프셋 연산자( . 연산자) 만으로 필드에 액세스 하거나 객체 레퍼런스의 메서드를 호출할 수 있다.
그리고 자바는 call-by-value 방식으로만 메서드를 호출한다.
물론, 객체 레퍼런스의 경우 복사된 값은 힙에 있는 객체의 주소가 된다.
JVM은 런타임에 oop(ordinary object pointer)라는 구조체로 자바 객체를 나타낸다.
OOP(Object Oriented Programming) 아님
oop는 참조형 지역 변수 안에 위치한다.
그래서 자바 메서드의 스택 프레임으로부터 자바 힙을 구성하는 메모리 영역 내부를 가리킨다.
oop를 구성하는 자료 구조는 여러 가지가 있는데 그중 instanceOop는 자바 클래스의 인스턴스를 나타낸다.
instanceOop의 메모리 레이아웃은 모든 객체에 대해 기계어 워드 2개로 구성된 헤더로 시작한다.
- Mark 워드 : 인스턴스 관련 메타데이터를 가리키는 포인터
- Klass 워드 : 클래스 메타데이터를 가리키는 포인터
Class가 아니라 Klass인 이유
자바 Class<?> 객체를 나타내는 instanceOop와 구분하기 위함. 둘은 전혀 다르다.
oop는 대부분 기계어 워드라서, 컴퓨터의 프로세서의 비트수에 큰 영향을 받을 수 있다.(32비트, 64비트)
이런 구조로는 메모리가 크게 낭비될 우려가 있기 때문에 JVM은 조금이라도 메모리를 절약할 수 있게 압축 oop라는 옵션을 제공한다. (자바 7 이후 디폴트)
- 힙에 있는 모든 객체의 Klass 워드
- 참조형 인스턴스 필드
- 객체 배열의 각 원소
JVM 객체 헤더는 일반적으로 다음과 같이 구성된다.
- Mark 워드(32비트 환경은 4바이트, 64비트 환경은 8바이트)
- Klass 워드(압축됐을 수도 있음)
- 객체가 배열이면 length워드 (항상 32비트)
- 32비트 여백(정렬 규칙 때문에 필요한 경우)
객체 인스턴스 필드는 헤더 바로 다음에 나열된다.
다음은 oop의 메모리 레이아웃이다.
자바에서 배열은 객체이다. 그래서 JVM의 배열도 oop로 표시되며, 배열은 Mark 워드, Klass 워드 다음에 배열 길이를 나타내는 Length 워드가 붙는다. 자바 배열 인덱스가 32비트 값으로 제한되는 건 이 때문이다.
이렇게 배열의 길이를 따로 메타데이터에 넣어 관리하므로 C언어처럼 배열의 길이를 매개변수로 따로 넘겨야 할 일은 없다.
JVM 환경에서 자바 레퍼런스는 instanceOop(또는 null)를 제외한 어떤 것도 가리킬 수 없다.
- 자바 값은 primitive 기본형 값 또는 instanceOop 주소(레퍼런스)에 대응되는 비트 패턴이다.
- 모든 자바 레퍼런스는 자바 힙의 주 영역에 있는 주소를 가리키는 포인터라고 볼 수 있다.
- 자바 레퍼런스가 가리키는 주소에는 Mark 워드 + Klass 워드가 들어있다.
Weak Generational Hypothesis (약한 세대별 가설)
- 대부분의 객체는 생명 주기가 짧다. (빠르게 접근 불가능 상태(unreachable)가 된다.)
- 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.
위 전제조건은 많은 사람들이 런타임 시스템을 관찰한 결과이자 객체 지향 워크로드를 상대로 실제로 실험을 하여 얻어낸 정보이다.
결국 '가비지를 수집하는 힙 메모리는 생명 주기가 짧은 객체(단명 객체)를 빠르게 수집할 수 있게 설계해야 하며, 생명 주기가 긴 객체(장수 객체)와 단명 객체를 완전히 떼어놓는 게 가장 좋다'는 결론이 나왔다.
이러한 결론을 통해 JVM은 자신의 힙을 크게 2개의 물리적 공간으로 나누었다.
바로 Young 영역과 Old 영역이다.
그리고 객체마다 세대 카운트(Generation count)(객체가 지금까지 GC로부터 살아남은 횟수)를 센다.
Young 영역
- 새롭게 생성한 객체의 대부분이 여기에 위치한다.
- 대부분의 객체가 금방 접근 불가능 상태가 되기 때문에 매우 많은 객체가 Young 영역에 생성되었다가 사라진다.
- 이 영역에서 객체가 사라질 때 Minor GC가 발생한다고 말한다.
Old 영역(Tenured 영역)
- 살아남은 Young 영역의 객체가 여기로 복사된다.
- 대부분 Young 영역보다 크게 할당하며, 크기가 큰 만큼 Young 영역보다 GC는 적게 발생한다.
- 이 영역에서 객체가 사라질 때 Major GC(Full GC)가 발생한다고 말한다.
Minor GC
Young 영역에 GC가 발생할 경우 이를 Minor GC라고 한다.
Minor GC의 동작 순서를 이해하기 위해서 Young 영역의 구조를 세부적으로 알아야 한다.
- Eden 영역 : 새로 생성된 객체가 할당되는 영역이다.
- Survivor 영역(2개) : Survivor 1, Survivor 2로 구분되며, 최소 1번의 GC에서 살아남은 객체가 존재하는 영역이다.
Minor GC 동작 순서
- 새로 생성된 객체가 Eden 영역에 할당된다.
- 객체가 계속 생성되어 Eden 영역이 가득 차게 되고 Minor GC가 발생한다.
- JVM이 STW(Stop the World) 상태로 들어간다.
- 가비지 여부 확인을 위해 Young 영역 전체에 대해 Mark 작업을 시작한다(Mark & Sweep).
- 살아남은 Eden 영역 객체와 Survivor 영역의 객체는 다른 Survivor 영역으로 할당된다.
- 기존의 Survivor 영역과 Eden 영역은 클리어 된다.
- 1~2번의 과정이 반복된다. (1개의 Survivor 영역은 반드시 빈 상태가 된다.)
- 이러한 과정이 반복되며 일정 횟수 이상 살아남은 객체는 Old 영역으로 이동(Promotion)된다.
객체의 생존 횟수를 카운트하기 위해 Minor GC에서 객체가 살아남은 횟수를 의미하는 age를 Object Header에 기록한다.
그리고 Minor GC때 Object Header에 기록된 age를 보고 Promotion 여부를 결정한다.
Major GC
Young 영역에서 오래 살아남은 객체는 Old 영역으로 Promotion 된다.
그러다가 객체들이 계속 Promotion 되어 Old 영역의 메모리가 부족해지면 Major GC가 발생한다.
Old 영역은 Young 영역보다 디폴트 크기 자체가 7배 정도 된다.
그리고 살아있는 객체 수만큼 Mark 시간도 늘어난다. 심지어 Old 영역의 객체는 장수 객체이므로 Major GC를 해도 이들 중 상당수는 살아남을 가능성이 크다는 것이다.
만약 Old 영역의 객체가 Young 영역의 객체를 참조하는 현상이 발생한다면?
이를 위해서 Old 영역에는 512바이트의 덩어리로 되어있는 Card Table이 존재한다.
카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 정보가 기록된다.
따라서 Minor GC를 실행할 때에는 Old 영역에 있는 모든 객체의 참조를 확인하지 않고, 카드 테이블만 뒤져서 GC의 대상인지 식별한다.
성능상 이점
- Young 영역은 Old 영역보다 사이즈가 작고, GC가 전체 영역을 처리하는 것보다 시간이 덜 걸린다.
즉, STW로 애플리케이션이 중지되는 시간이 짧아진다.
실제로 2GB짜리 JVM에서 Young 영역 Minor GC 시간은 대부분 10ms 이하이다. - Young 영역을 한 번에 모두 비우기 때문에 Young 영역에 해당하는 연속된 여유 공간이 만들어진다.
-> 압축이 이루어지며 메모리 파편화를 방지할 수 있다.
Ref.
http://www.yes24.com/Product/Goods/72161685
https://mangkyu.tistory.com/118
https://d2.naver.com/helloworld/1329
https://www.holaxprogramming.com/2013/07/20/java-jvm-gc/
'Lang > Java' 카테고리의 다른 글
옵저버 패턴 (Observer Pattern) (3) | 2022.10.21 |
---|---|
전략 패턴 (Strategy Pattern) (3) | 2022.10.04 |
[Java] Lambda 특징과 활용 (1) | 2022.10.03 |
함수형 프로그래밍과 Java #1 (6) | 2022.09.08 |
[디자인 패턴] 싱글톤 패턴 (Creational) (0) | 2022.01.10 |