Garbage Collection(GC)는 자바의 메모리 관리 방법 중 하나로 JVM의 Heap 영역에서 동적으로 할당했던 메모리 중 더이상 필요없는 메모리 객체(garbage)를 모아 주기적으로 제거(메모리 해제)해주는 프로세스이다.
C/C++ 언어에서는 GC가 없어 프로그래머가 수동으로 메모리 할당/해제를 해주어야 했지만, Java는 JVM의 GC가 알아서 메모리 관리를 해주기 때문에 편리하다는 장점이 있다.
다음 코드를 보자.
for(int i=0; i<100000; i++) {
Object obj = new Object();
}
위 코드를 보면 반복문을 돌며 100000개의 새로운 인스턴스를 만드는데, for문이 한번 끝날때 obj 변수도 같이 사라지기 때문에 해당 Object 인스턴스에 접근할 수가 없다. 이렇게 많이 만들어졌지만 쓰이지 않는 객체들이 메모리 공간을 차지하고 있다면 메모리의 낭비가 발생할 수밖에 없다. 이렇게 더이상 사용하지 않는 인스턴스의 메모리 공간을 회수해주는 것이 GC이다.
GC는 알아서 메모리 공간을 관리해주기에 편리하지만 단점도 존재한다.
1. 프로그래머는 GC가 언제 작동하는지 알 수 없다.
2. GC가 실행될 때, GC 관련 스레드를 제외한 다른 스레드들이 모두 멈추기 때문에 성능상의 문제가 생길 수 있다. 이를 Stop-the-World라고 한다.
Full GC가 무엇인지는 아래에서 더 설명해보고자 한다.
Stop The World(STW)
GC가 작동하는 동안 GC 관련 스레드를 제외한 모든 스레드가 실행을 멈추는 현상이다. 이 시간이 길어지면 성능에 문제가 생길 수 있으므로 이 시간을 최소화 시키는 최적화가 필요하다.
STW라는 단점 때문에 GC가 너무 자주 실행되거나 오랫동안 실행되면 성능 문제가 생길 수 있다. 따라서 GC 실행을 최적화하는 작업이 필요한데, 이를 GC 튜닝이라고 한다.
GC는 메모리 중 힙(Heap) 메모리에 저장된 객체만을 대상으로 한다. 그럼 힙 영역에 저장된 객체 중 어떤 것을 지우는 것일까?
GC는 힙 영역 객체들 중 지울 것을 판단하기 위해 Reachability(도달성)이라는 개념을 사용한다.
자바는 참조 자료형 변수와 인스턴스를 저장할 때, 그림고 같이 인스턴스는 heap 영역에, 그 인스턴스를 참조하는 참조 변수는 stack 영역(메소드 내의 변수는 method area)에 저장한다. 이런 상황에서 stack 영역에 있는 참조 변수가 메모리에서 제거 되어서 그 인스턴스에 더이상 접근 불가능한 경우가 생기는데 이것을 Unreachable(도달 불가능)이라고 한다.
정리하자면
GC는 Unreachable한 객체들을 메모리에서 제거하는 역할을 한다.
GC는 기본적으로 'Mark and Sweep' 방식으로 동작한다.
GC의 청소 대상이 될 객체들을 식별(Mark)하고, 청소(Sweep)하고 메모리의 빈공간을 채우는 과정(Compaction)으로 이뤄져있다.
GC의 Root Space
Heap 메모리 영역을 참조하는 Method Area, Stack, Native Method Stack 등이 있다.
GC가 어떻게 동작하는 것인지 알기 위해서는 JVM의 힙 메모리가 어떤 구조로 이뤄져 있는지를 알아야한다.
Heap 영역은 'Weak Generational Hypothesis'라는 가정을 전제로 설계가 되었다.
Weak Generational Hypothesis
1. 대부분의 객체는 금방 Unreachable 상태가 된다.
2. 오래된 객체에서 새로운 객체로의 참조는 아주 드물게 발생한다.
위 가정을 요약하자면, 대부분의 객체는 일회성으로 사용이 끝난다는 것이다. 즉 오랫동안 남아있는 객체는 드물것이라는 말이된다. 이러한 특성에 따라, Heap 영역은 생긴지 얼마 안된 객체가 저장되는 Young Generation, 오래된 객체가 저장되어 있는 Old Generation으로 분리하여 설계되었다.
Young Generation
Old Generation
Young 영역보다 Old 영역이 더 크게 할당된 이유는 Young 영역의 수명이 짧은 객체들은 그렇게 큰 공간을 차지하지 않으며, 크기가 큰 객체들은 Young 영역이 아닌 바로 Old 영역으로 할당되기 때문이다.
참고: Metaspace 영역
Java 7까지는 힙 영역에 'Permanant'라는 영역이 존재했다. Permanant란 영구적인 세대라는 의미로 생성된 객체들의 정보의 주소값이 저장되는 영역이다. Permanant는 Java 7까지 힙 영역에 존재하다가 Java 8부터는 'Metaspace'라는 이름으로 Native method stack에 편입되었다.
Young Generation 영역은 다시 세 부분으로 나눠진다.
Eden 영역
Survival 영역
객체는 처음 생성될 때 Young Generation에서 생성된다.
Young Generation에서 동작하는 GC를 Minor GC라고한다.
Young Generation이 Old Generation보다 메모리의 크기가 작기 때문에, Minor GC의 수행시간이 Major GC의 수행시간보다 더 짧다.
Minor GC의 동작은 아래와 같다.
객체들은 처음 생성될 때 Eden 영역에 할당된다.
객체가 계속 생성되어서 Eden 영역이 꽉 차게 되면 Minor GC가 발생한다.
Mark 과정을 통해 Reachable 객체들을 탐색한다.
Reachable 객체들은 s0으로 이동한다. (s0, s1 중 하나는 비어있어야 하므로, s0이 아닌 s1으로 이동할 수도 있다.)
Eden 영역에 남아있는 객체들을 제거한다(Sweep)
살아남은 객체들의 age값이 1씩 증가한다.
age란?
Survival 영역에서, 객체가 살아남은 횟수를 의미하며 Object header에 기록된다. age 값이 임계값에 다다르면 Old 영역으로 이동(Promotion)된다. 참고로 HotSpot JVM의 경우, Object Header에 age를 기록하는 부분이 6 bit로 돼있기 때문에 임계값이 31이다.
다시 Eden 영역이 꽉차게 되면 Minor GC가 발생한다. (Eden과 s0를 탐색하며 Mark)
마킹된 객체는 s1으로 이동하며, unreachable 객체들은 제거된다.
살아남아서 s1으로 이동한 객체들은 age가 1씩 증가한다.
Old Generation에서 발생하는 GC를 Major GC(Full GC)라고 한다. Old Generationd은 오래 살아남은 객체들이 위치하는 곳으로, Young 영역에서 age가 임계값에 도달해 Promotion 된 객체들이 존재한다.
Major GC는 객체들이 계속 Promotion 되어서 Old 영역의 메모리가 부족해지면 발생한다.
Major GC의 과정은 아래와 같다.
Survival 영역에 있는 객체들 중 age가 임계값에 도달한 객체들이 발생한다. (이 그림에서는 임계값=8)
이 객체들은 Old Generation으로 이동한다. (Promotion)
Old Generation의 메모리가 부족해지면 Major GC(Full GC)가 발생한다. Major GC는 Old Generation 전체를 검사하여 Unreachable Object를 제거한다.
Old 영역은 Young 영역에 비해 메모리의 크기가 크기 때문에 Major GC의 실행시간이 길다.
Minor, Major GC 모두 STW(Stop-the-World)가 발생하지만, Minor GC는 수행 시간이 보통 0.5~1초 정도이기 때문에 크게 문제되지 않는다.
하지만 Major GC는 Minor GC의 수행시간의 10배 이상의 시간을 소요하기 때문에, cpu에 많은 부하를 주고 긴 STW 시간 동안 애플리케이션이 버벅거리는 문제가 생긴다.
따라서 개발자들은 GC의 수행 횟수와 수행 시간을 줄이기 위해 여러 종류의 GC를 개발해왔다.
GC가 자동으로 메모리를 관리해주는 것은 큰 장점이지만, STW 현상으로 애플리케이션의 성능에 문제가 될 수 있다는 단점이 있다. 이를 보완하고 효율적으로 GC를 사용하기 위해 개발자들은 많은 GC 알고리즘을 개발하였다. 아래에서 소개하는 여러 종류의 GC들은 Java에서 설절을 통해 적용이 가능하며, 상황에 따라 가장 효율적인 GC를 선택하여 사용할 수 있다.
자바 프로그램을 실행할 때 '-XX:+UseSerialGC' 옵션으로 serial GC를 설정할 수 있다.
java -XX:+UseSerialGC -jar Application.java
[-XX:+UseParallelGC] 옵션을 통해 적용 가능하다. parallel GC에서 GC 스레드는 기본적으로 cpu 개수만큼 할당된다. 설정을 통해 그 값을 바꿀 수 있다.
참고: Mark-Summary-Compact
Mark 단계: Old 영역을 region 별로 나눈다. region별로 참조되는 객체들을 mark한다. 이때 여러 스레드가 각각의 region을 병렬적으로 검사한다.
Summary 단계: Mark 단계에서 알아본 region 별 정보로 살아남은 객체들의 밀도가 높은 부분이 어디까지인지 dense prefix를 정한다. 오랜 기간 참조된 객체는 앞으로 사용할 확률이 높다는 가정 하에 dense prefix를 기준으로 Compact 영역을 줄인다.
Compact 단계: compact 영역을 destination과 source로 나누어, 살아남은 객체는 destination으로 이동하고 살아남지 않은 객체는 제거한다.
[-XX:+UseParallelOldGC] 옵션으로 Parallel Old GC를 적용 가능하다.
G1 GC가 효율적인 이유는 G1 GC는 이전 GC들처럼 일일히 메모리를 탐색해서 객체들을 제거하지 않는다. 대신 메모리가 많이 차있는 리젼을 인식해서 그 리젼에 우선적으로 GC를 실행한다. 즉, 힙 전체에 GC가 발생하는 것이 아닌, 영역(region, 리젼)별로 GC가 발생한다.
G1 GC는 [-XX:+UseG1GC] 옵션으로 적용 가능하다.
[-XX:+UseShenandoahGC] 옵션으로 적용가능하다.
[-XX:+UnlockExperimentalVMOptions -XX:+UseZGC] 옵션으로 적용가능하다.
https://imasoftwareengineer.tistory.com/103
https://velog.io/@devnoong/JAVA-Stack-%EA%B3%BC-Heap%EC%97%90-%EB%8C%80%ED%95%B4%EC%84%9C
https://velog.io/@guswlsapdlf/Java-GC-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98