[Java] GC(Garbage Collection) 이해하기
들어가며
Java의 가비지 컬렉터는 다양한 종류가 있지만 공통적으로 다음의 2가지 작업을 수행한다.
- 힙(Heap) 내의 객체 중 Garbage를 찾아낸다.
- 찾아낸 Garbage 객체를 반환하여 메모리를 회수한다.
최초의 Java는 Garbage Collection 작업에 사용자가 관여하지 않도록 구현되었었지만, JDK 1.2 부터는 java.lang.ref 패키지를 통하여 GC와 어느 정도 상호작용을 할 수 있게 되었다. Reference에 대해서는 해당 글을 참고하면 된다.
참고 : https://jangjjolkit.tistory.com/31
Garbage Collection의 특징
객체들은 일단 생성되면 위 그림과 같이 힙 영역에 생성된다. 힙에 새로운 객체를 생성했을 때 공간이 부족하다면 JVM은 Out Of MemoryGC를 뿌리게 된다. GC는 위에서 말했듯이 Root Set of References(유효한 최초의 참조)가 이루어지지 않는 객체 Unrechable Objects들을 수거한다. GC는 객체를 메모리에서 제거하기 전에 해당 객체의 finalize() 메소드를 호출한다. 이렇게 보면 GC를 자주 호출해주어 불필요한 메모리를 제거해주고 싶지만 안타깝게도 GC는 사용자가 강제로 수행할 수 없다. 언제 일어나는지도 정확히 할 수 없다.
GC 대상이 되는 객체
모든 객체의 참조가 모두 null일 경우 GC 대상이 된다. 하지만 원형 참조(A가 B를 B가 A를 참조하는 형태)인 경우에는 참조로 간주하지 않는다. 객체 A, B에 다른 살아있는 객체의 참조가 없다면 또한 GC의 대상이다.
일반적으로 다음과 같은 경우에 GC의 대상이 된다.
- 모든 객체 참조가 null인 경우
- 객체가 블록 안에서 생성되고 블록이 종료된 경우
- 부모 객체가 null이 된 경우, 자식 객체는 자동적으로 GC 대상이 된다.
- 객체가 Weak 참조만 가지고 있을 경우
- 객체가 Soft 참조이지만 메모리 부족이 발생한 경우
자동 Garbage Collection?
이제부터 소개하는 내용은 Oracle에서 제공하는 Java Garbage Collection Basics 번역을 참고한 것이다. Automatic GC는 힙 메모리에서 어떤 객체가 사용되고, 사용되지 않는지를 찾는 과정이다. 오브젝트를 사용하거나, 오브젝트가 참조된다는 것은 우리의 프로그램에 여전히 오브젝트에 대한 포인터가 남아있다는 것이다. 사용되지 않거나, 참조되지 않는 오브젝트는 더 이상 프로그램에 관여하지 않는다. 그러므로 메모리에 부하가 오는 것이다.
프로그래밍 언어인 C에서는 메모리의 할당, 반환을 수동으로 하지만, 자바에서는 GC에 의해 자동으로 수행된다.
Step 1 : Marking (Mark)
프로세스 마킹을 호출한다. 이것은 GC가 사용 중인 객체와 사용 중이지 않은 객체를 식별한다. 참조되는 객체는 파란색으로, 참조되지 않는 객체는 주황색으로 보인다. 모든 오브젝트는 마킹 단계에서 결정을 위해 스캔된다. 모든 오브젝트를 스캔하기 때문에 매우 많은 시간을 소모한다.
Step 2 : Normal Deletion (Sweep)Marking 단계에서 식별된 사용하지 않는 객체를 수거한다. 메모리 Allocator는 반환되어 비어진 블록의 참조 위치를 저장해 두었다가 새로운 오브젝트가 선언되면 할당되도록 한다.
Step 3 : Compacting (Compact)퍼포먼스를 향상시키기 위해, 살아남은 객체들을 연속된 영역으로 배열한다. 이들을 묶음으로서 공간이 생기므로 새로운 메모리 할당 시에 더 쉽고 빠르게 진행할 수 있다.
Generational Garbage Collection?위에서 언급했듯이, 모든 객체를 mark & compact 하는 JVM은 비효율적이다. 오브젝트를 할당할 때마다 GC 시간에 오브젝트들의 리스트를 읽는 시간은 점점 길어진다. 그러나 경험적 분석에 의하면 대부분의 객체는 짧게 생존한다. 위의 데이터를 보면 알 수 있다. Y축은 할당된 바이트 수이고 X축은 바이트가 할당될 때의 시간이다. 보시다시피 시간이 길수록 적은 객체만 남는다.
세대적 GC는 다음 두 가정 하에 만들어졌다.
- 대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.
- 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.위의 경험으로부터 퍼포먼스를 향상시켰다. 힙을 작은 파트(세대; Generation)로 나눈 것이다. 각각 Young, Old or Tenured, Permanent Generation으로 나누었다.
1) Young Generation (Young 영역)
새롭게 생성한 객체의 대부분이 여기에 위치한다. 가득 차게 되면 Minor GC이 일어난다. 대부분 객체가 금방 사라지기 때문에 많은 객체가 이곳에서 사라진다.
2) Old Generation (Old 영역)
접근 불가능 상태가 되지 않고 Young 영역에서 살아남은 객체들이 복사된다. Young 영역보다 크기가 크게 할당되고 큰 만큼 GC는 적게 발생한다. 이 영역에서 사라질 때 Major GC가 일어난다.
3) Permanent 영역
Method Area라고도 한다. JVM이 클래스들과 메소드들을 설명하기 위해 필요한 메타데이터들을 포함하고 있다.
JVM 힙 메모리의 구조위에서 설명한 JVM 힙 메모리의 상세 구조이다. Java 8 이전에는 Metaspace 영역이 아닌 Permanent 영역이 존재했다. Java 8 버전부터는 기존의 Permanent 영역이 Native 영역으로 이동하여 Metaspace 영역으로 변경되었다.
Eden Space (Young Generation)
위에서 설명한 Young 영역 안에 해당하는 공간이다. JVM에 의해 정해진 임계치에 도달하면 Minor GC가 수행된다. Micor GC가 수행되면 참조되지 않는 객체들은 Eden Space에서 제거되고, 살아남은 객체들은 From 영역에서 To 영역의 Survivor 영역으로 이동한다. GC가 끝나고 나면 From과 To Survivor 영역의 역할이 서로 바뀐다. (From은 To가 되고, To는 From이 된다.)
Survivor 1 (From)
이 영역은 에덴 영역으로부터 살아남은 객체가 담긴다. 이전의 GC 과정에서는 To 역할을 하던 영역이다.
Survivor 2 (To)
이 영역은 GC가 수행될 때, 에덴 영역과 From의 역할을 하던 Survivor 영역에서 살아남은 객체들이 담긴다.
Tenured (Old Generation)
Survivor 영역의 객체가 Minor GC에서 살아남아 다른 Survivor 영역으로 이동할 때마다 객체의 Age가 증가한다. 이 Age가 일정 이상이 되면 Tenured 영역으로 이동하게 된다. (Promotion)
Promotion의 기준이 되는 Age는 -XX:MaxTenuringThreshold 옵션으로 설정할 수 있다. Java SE 8 에서의 default 값은 15이며, 설정 가능한 범위는 0 ~ 15이다.
Generational Garbage Collection의 과정
그렇다면 왜 힙을 다른 세대로 분리하였는지, 그것이 어떻게 동작하고 상호작용을 하는지 알아보도록 하겠다.
1. 먼저, 어떠한 새로운 오브젝트가 생성되면 Eden Space에 할당된다.
2. Eden Space가 가득 차면, Minor GC가 수행된다.
3. 참조되는 오브젝트들은 첫 번째 survivor(S0)로 이동되고, 비 참조 객체는 Eden Space가 clear 될 때 반환된다.
4. 다음 Minor GC 때, Eden Space에서는 같은 일이 일어난다. 비 참조 객체는 삭제되고, 참조 객체는 survivir space로 이동한다. 그러나 이 케이스에서 참조 객체는 두 번째 survivor space로 이동하게 된다. 게다가 최근 Minor GC에서 첫 번째 survivor space로 이동된 객체들도 age가 증가하고 S1 공간으로 이동하게 된다. (From과 To의 교체) 한번 모든 surviving 객체들이 S1으로 이동하게 되면 S0와 Eden 공간은 clear 된다. 이제 우리는 다른 aged 객체들을 survivor space에 가지게 되었다는 것이다.
5. 다음 Minor GC 때 같은 과정이 반복된다. 이 번에도 survivor space들은 서로 바뀐다. (From과 To의 교체) 참조되는 객체들은 S0으로 이동한다. 살아남은 객체들은 aged된다. 그리고 Eden과 S1 공간은 Clear된다.
6. 이 그림은 Promotion을 보여준다. Minor GC 후 aged 오브젝트들이 일정한 age threshold를 넘게되면 Young 영역에서 Old 영역으로 Promotion 되어진다. 여기서는 8을 예로 들었다.
7. Minor GC가 계속되고 계속해서 객체들이 Old 영역으로 이동한다.
8. 위에서 설명한 전 과정을 보여준다. 결국 Major GC가 Old 영역에서 수행되고, Old 영역은 Clear 되고, 공간이 Compact 되어진다.
STW (Stop The World)
STW란 GC를 실행시키기 위해 JVM이 모든 어플리케이션 쓰레드를 중단시키는 것이다. STW가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈춘다. GC 작업이 완료된 이후에야 중단했던 작업을 다시 시작한다. 보통의 GC 튜닝이란 STW 시간을 줄이는 것이다.
HotSpot의 가비지 수집
자바 프로세스가 시작되면 JVM은 메모리를 할당(또는 예약)하고 사용자 공간에서 연속된 단일 메모리 풀을 관리한다.
이 메모리 풀은 각자의 목적에 따라 서로 다른 영역으로 구성되며, 객체는 보통 Eden Space에 생성된다. 수집기가 줄곧 객체를 이동시키기 때문에 객체가 차지한 주소는 대부분 시간이 흐르면서 아주 빈번하게 바뀝니다. 이처럼 객체를 이동시키는 것을 '방출'이라고 하는데, 핫스팟 수집기는 대부분 방출 수집기이다.
bump-the-pointer
새로 생성되는 대부분의 객체는 Eden Space에 할당된다. bump-the-pointer는 에덴에 할당된 마지막 객체를 추적한다. 새로 생성되는 객체가 있으면 해당 객체가 Eden Space에 할당하기 적합한지 확인한다. 만약 해당 객체 크기가 Eden Space에 할당되기 적당하면 Eden Space에 할당하고 포인터가 마지막 위치를 가르키도록 업데이트한다. 따라서, 새로운 객체를 생성할 때마다 마지막에 추가된 객체만 점검하면 되므로 매우 빠르게 메모리 할당이 이루어진다.
하지만 멀티 쓰레드 환경에서는 Thread-Safe를 고려해주어야 한다. 여러 쓰레드에서 사용하는 객체를 Eden Space에 저장하려면 Lock이 발생할 수 밖에 없고, Lock-Connection 때문에 성능이 매우 떨어지게 된다.
HotSpot VM에서 이를 해결하기 위한 것이 TLAB(Thread-Local Allocation Buffer)라고 한다.
TLAB(Thread-Local Allocation Buffer)
JVM은 Eden Space를 여러 버퍼로 나누어 각 어플리케이션 쓰레드가 새로운 객체를 할당하는 구역으로 활용하도록 배포한다. 이렇게 하면 각 쓰레드는 혹여 다른 쓰레드가 자신의 버퍼에 객체를 할당하지는 않을까 염려할 필요가 없다.
Garbage Collector의 종류
Serial GC (-XX:+UseSerialGC)
Serial GC는 싱글 쓰레드를 활용해서 GC 작업을 수행한다. 이 방법은 쓰레드 사이의 커뮤니케이션 오버헤드가 없으므로 상대적으로 효율적이다. 다만 멀티 프로세서 하드웨어의 장점을 살릴 수 없기 때문에 적은 메모리와 싱글 프로세스 머신 환경에 적합한 방식이다.
Parallel GC (-XX:+UseParallelGC)
Parallel GC는 Throughput GC라고도 부르며, 기본적인 알고리즘은 Serial GC와 같다. 하지만 Serial GC는 싱글 쓰레드로 GC를 처리하지만, Parallel GC는 GC를 처리하는 쓰레드가 여러개이다. Minor GC를 병렬로 수행하므로 오버헤드를 현저히 줄일 수 있다.
Parallel GC는 메모리가 충분하고 프로세서의 개수가 많을수록 유리하다.
Parallel Old GC (-XX:+UseParallelOldGC)
Parallel Old GC는 Young 영역과 Old 영역 모두 GC가 멀티 쓰레드로 처리된다는 것을 제외하면 Parallel GC와 같다.
CMS (Concurrent Mark and Sweep GC, -XX:+UseConcMarkSweepGC)
CMS 수집기는 STW 시간을 최소화하려고 설계되었다. 마킹은 삼색 마킹(tri-color marking) 알고리즘에 따라 수행하므로 수집기가 힙을 탐색하는 도중에도 객체 그래프가 변경될 수 있다. 따라서 CMS 가비지 수집의 두 번째 원칙(아직 살아있는 객체를 수집하면 안된다)을 위반하지 않도록 반드시 레코드를 바로잡아야 한다.
CMS의 수행 단계는 병렬 수집기보다 더 복잡하다.
- 초기 마킹 (Initial Mark) - STW
- 동시 마킹 (Concurrent Mark)
- 재마킹 (Remark) - STW
- 동시 스위프 (Concurrent Sweep)
1단계의 초기 마킹, 3단계의 재마킹 단계는 모든 어플리케이션 쓰레드가 멈추고, 나머지 단계에서는 어플리케이션 쓰레드와 병행하여 GC를 수행한다.
1. 초기 마킹(Initial Mark) 단계의 목적은 해당 영역 내부에 위치한 확실한 GC 출발점(내부 포인터, Internal Pointer 라고 하며 수집 사이클 목적상 GC 루트와 동등함)을 얻는 것이다. 출발점에서 참조 Tree상 가장 가까운 객체만 1차적으로 찾아가며 해당 객체가 GC 대상인지 판단한다. 탐색의 깊이가 얕기 때문에 STW 시간이 매우 짧다.
2. 동시 마킹(Concurrent Mark) 단계에서는 STW가 발생하지 않으며, 삼색 마킹 알고리즘을 힙에 적용하면서 나중에 조정해야 할지 모를 변경 사항을 추적한다. 동시 마킹 단계에서는 어플리케이션 쓰레드와 병렬로 동작한다. 즉, STW가 발생하지 않는다.
3. 재마킹(Remark) 단계는 동시 마킹 단계에서 새로 추가되거나 참조가 제거되었는지 객체를 확인한다. 재마킹 단계에서는 STW가 발생하며, STW 시간을 줄이기 위해 멀티 쓰레드로 동작한다.
4. 동시 스위프(Concurrent Sweep) 단계는 GC 대상인 객체들을 메모리에서 제거한다.
CMS는 STW 시간이 매우 짧으므로 어플리케이션 응답 속도가 중요할 때 적합하다. 하지만 STW 시간이 매우 짧다는 장점에 비해 다음과 같은 단점이 존재한다.
- 다른 GC 방식보다 CPU와 메모리를 많이 사용
- Compaction 단계를 진행하지 않으므로 Tenuered 영역의 단편하가 발생할 수 있음
CMS는 실행 도중 Old 영역의 할당률이 너무 높거나 단편화가 심해져서 Young 영역으로부터 승격된 객체를 Old 영역에서 수용하지 못할 지경에 이르면 CMF(Concurrent Mode Failure)가 발생한다. 이런 경우에 JVM은 어쩔 수 없이 풀 STW를 유발하는 (압착 수집기인)Parallel Old GC 수집 방식으로 돌아가게 된다.
조각난 메모리가 많아 Compaction을 수행하게 되면 다른 GC 방식보다 STW 시간이 길기 때문에 CMS를 사용할 때는 Compaction 작업을 얼마나 자주, 오랫동안 수행되는지 확인해야 한다.
G1 (Garbage First, -XX:+UseG1GC)
G1은 앞서 살펴본 GC들과는 다른 방식으로 힙 메모리를 관리한다. 병렬/CMS 수집기와는 달리 세대마다 경계가 뚜렷한, 연속된 메모리 공간이 없고 반구형 힙 레이아웃 방식과도 무관하다.
G1은 논리적 단위로 나누어진 리전(Region)으로 구성된다. 리전을 이용하면 Generation을 불연속적으로 배치할 수 있고, 수집기가 매번 실행될 때마다 전체 가비지를 수집할 필요가 없다.
G1 GC의 특징
- 큰 메모리를 가진 멀티 프로세서 시스템에서 사용되기 위해 개발된 GC
- CMS보다 훨씬 튜닝하기 쉽다
- 조기 승격에 덜 취약하다 (조기 승격 : 할당률이 너무 높아서 Old 영역으로 너무 빨리 승격되는 문제)
- 대용량 힙에서 확장성(특히, 중단 서버)이 우수
- 자바 9부터는 default GC
G1의 힙은 리전(Region)으로 구성된다. 리전을 이용하면 세대를 불연속적으로 배치할 수 있고, 수집기가 매번 실행될 때마다 전체 가비지를 수집할 필요가 없다.
G1 수집기는 RSet(Remembered Set)을 통해 어떤 객체가 어떤 리전에 저장되어 있는지 추적한다. 덕분에 G1은 영역 내부를 바라보는 레퍼런스를 찾으려고 전체 힙을 다 뒤질 필요 없이 RSet만 꺼내보면 된다.
G1의 수집 단계는 다음과 같다.
- Initial Mark - SWT : Old 영역에서 존재하는 객체들이 참조하는 Survivor 영역을 찾는다. SWT가 발생한다.
- Root Region Scanning : Initial Mark 단계에서 식별한 Survivor 영역에서 Old 영역을 가리키는 레퍼런스를 식펼한다.
- Concurrent Mark : 힙 전체에 걸쳐 접근 가능한 살아있는 객체를 찾는다.
- Remark - STW : Concurrent Mark 단계를 검증하고, 최종적으로 살아남을 객체들을 식별한다. 이 단계에서는 SATB(Snapshot-At-The-Beginning) 알고리즘이 사용된다. STW가 발생한다.
- Cleanup - STW : 어플리케이션을 멈추고(STW) 살아있는 객체가 가장 적은 리전에 대한 미사용 객체를 제거한다. 이후 STW를 끝내고, 앞서 GC 과정에서 완전히 비워진 리전을 FreeList에 추가하여 재사용할 수 있게 한다.
- Copy : GC 대상 리전이었지만 Cleanup 과정에서 완전히 비워지지 않은 리전의 살아남은 객체들을 새로운 리전에 복사하여 Compaction 작업을 수행한다.
참고