가비지 컬렉션(GC)
가비지 컬렉션(GC)
가비지 컬렉션을 공부하고 정리한 글입니다.
가비지 컬렉션(GC) 이란?
가비지 컬렉터에서 가비지란 유효하지 않은 메모리 주소를 말한다. 코드를 통해 살펴보면,
public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("hello");
list.add("bye");
List<String> list = new ArrayList<>();
}
위 코드에서 ArrayList 인스턴스를 생성하고 난후 hello
와 bye
를 추가했다. 그리고 변수 list
에 새로운 ArrayList 인스턴스를 생성하여 대입했다.
그렇다면 hello
, bye
를 담았던 리스트는 어떻게 되는가? 를 생각해볼 수 있다. 이 리스트의 레퍼런스를 갖고있던 변수가 사라지게 되어 더 이상 접근할 수 있는 방법이 없게되었고 유효하지 않은 메모리 주소가 된 것이다. 프로그래밍 언어에서는 이를 Dangling Object 라고 부르고 자바에서는 Garbage라고 부른다.
추가로 앞으로 사용하지 않고 메모리를 계속 갖고만 있는 객체 역시 Garbage에 포함된다.
가비지 컬렉터(Garbage Collector)는 메모리가 부족할 때 이런 가비지들을 메모리에서 해제시켜 다른 용도로 사용할 수 있도록 해주는 프로그램을 말한다.
C/C++ 같은 언어에서는 사용하지 않을 객체의 메모리를 개발자가 직접 해제해주어야 하지만 자바에서는 GC가 잡아주어 편리하다. 하지만 모든 메모리의 누수를 잡는 것이 아니므로 항상 주의해야한다.
가비지 컬렉션 과정
이제 가비지 컬렉션이 어떤 과정으로 이루어지는 지 살펴보자.
stop-the-world
GC에 대해 알아보기 전에 알아야 할 용어로 stop-the-world가 있다.
stop-the-world 란,
GC를 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 것이다.
stop-the-world가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈춘다. GC 작업을 완료한 이후에야 중단했던 작업을 다시 시작한다. 어떤 GC 알고리즘을 사용하더라도 stop-the-world는 발생한다. 대개의 경우 GC 튜닝이란 이 stop-the-world 시간을 줄이는 것이다.
Java는 프로그램 코드에서 메모리를 명시적으로 지정하여 해제하지 않는다. 명시적으로 해제하려는 시도로 객체를 null로 지정하거나 System.gc()
메서드를 호출하는 경우가 있는데, null로 지정하는 것은 큰 문제가 안되지만 System.gc()
메서드를 호출하는 것은 시스템 성능에 매우 큰 영향을 끼친다. 절대 사용하면 안된다.
따라서 Java에서는 가비지 컬렉터가 더 이상 필요없는 객체를 찾아 지우는 작업을 한다. 이 가비지 컬렉터는 두 가지 전체 조건(가설)에서 만들어졌다. 이를 week generational hypothesis라 한다.
- 대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.
- 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.
이 가설을 최대한 살리기 위해 HotSpot VM에서는 크게 2개로 물리적 공간을 나누었다. 바로 Young 영역과 Old영역이다.
Young 영역(Young Generation 영역)
새롭게 생성한 객체의 대부분이 여기에 위치한다.
대부분의 객체가 금방 접근 불가능 상태가 되기 때문에 매우 많은 객체가 Young 영역에서 생성되었다가 사라진다.
이 영역에서 객체가 사라질 때 Minor GC가 발생한다.
Old 영역(Old Generation 영역)
접근 불가능 상태로 되지 않아 Young 영역에서 살아남은 객체가 여기로 복사된다.
대부분 Young 영역보다 크게 할당하며 크기가 큰 만큼 Young 영역보다 GC는 적게 발생한다.
이 영역에서 객체가 사라질 때 Major GC(or Full GC)가 발생한다고 말한다.
그림으로 살펴보면 다음과 같다.
GC 영역과 데이터 흐름
위 그림의 Permanent Generation 영역은 Method Area라고도 한다. 객체나 억류된 문자열 정보를 저장하는 곳이다. (Old 영역에서 살아남은 객체가 영원히 남아있는 곳은 절대 아니다.) 이 영역에서 GC가 발생할 수도 있는데, 여기서 GC가 발생해도 Major GC의 횟수가 포함된다.
(자바 8 부터는 Permanent Generation 영역은 제거된다. 대신에 Metaspace 영역이 Native 영역에서 생기고 OS가 자동으로 조정하거나 옵션을 주어 크기를 조정할 수 있게되었다. 사이즈 제한을 없앤것이다. 그리고 Static Object, String Object 상수는 Heap 영역에 남게된다.)
Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우가 있을 때에는 Old 영역에는 512바이트의 덩어리(chunk)로 되어 있는 카드 테이블이 존재한다.
카드 테이블에는 Old 영역에 있는 객체가 Young 영역의 객체를 참조할 때마다 정보가 표시된다. Young 영역의 GC를 실행할 때에는 Old 영역에 있는 모든 객체의 참조를 확인하지 않고, 이 카드 테이블만 살펴보고 GC 대상인지 식별한다.
Young 영역의 구성과 GC
Young 영역은 3개의 영역으로 나뉜다.
- Eden
- Survivor (2개)
Survivor 영역이 2개이기 때문에 총 3개의 영역으로 나뉘는 것이다. 각 영역의 처리 절차를 순서에 따라 나타내면 다음과 같다.
- 새로 생성한 대부분의 객체는 Eden 영역에 위치한다.
- Eden 영역에서 GC가 한 번 발생한 후 살아남은 객체는 Survivor 영역 중 하나로 이동한다.
- Eden 영역에서 GC가 발생하면 이미 살아남은 객체가 존재하는 Survivor 영역으로 객체가 계속 쌓인다.
- 하나의 Survivor 영역이 가득 차게되면 그 중에서 살아남은 객체를 다른 Survivor 영역으로 이동한다. 그리고 가득 찬 Survivor 영역은 아무 데이터도 없는 상태로 된다.
- 이 과정을 반복하다가 계속해서 살아남아 있는 객체는 Old 영역으로 이동하게 된다.
그림으로 살펴보면 다음처럼 표현할 수 있을 것이다.
Minor GC 전과 후
이 절차를 확인해 보면 알겠지만 Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아 있어야한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 두 영역 모두 사용량이 0이라면 시스템이 정상적인 상황이 아니라고 생각하면 된다.
참고로, HotSpot VM에서는 보다 빠른 메모리 할당을 위해서 두 가지 기술을 사용한다. 하나는 bump-the-pointer라는 기술이며, 다른 하나는 TLABs(Thread-Local Allocation Buffers)라는 기술이다.
Old 영역과 GC
일반적으로 Old 영역의 크기는 Young 영역의 두 배정도여서 GC 시간이 더 많이 걸린다.
Old 영역은 기본적으로 데이터가 가득 차면 GC를 실행한다. GC 방식에 따라서 처리 절차가 다르다. GC 방식은 JDK 7을 기준으로 5가지 방식이 있다. (자바 9 기준 G1GC 가 default 이다.)
- Serial GC
- Parallel GC
- Parallel Old GC(Parallel Compacting GC)
- Concurrent Mark & Sweep GC(이하 CMS)
- G1(Garbage First)GC
이 중에서 운영 서버에서 절대 사용하면 안되는 방식이 Serial GC다. Serial GC는 데스크톱의 CPU 코어가 하나만 있을 때 사용하기 위해서 만든 방식이다. Serial GC를 사용하면 애플리케이션의 성능이 많이 떨어진다.
Serial GC(-XX:+UseSerialGC)
Old 영역의 GC는 mark-sweep-compact 이라는 알고리즘을 이용한다.
- 이 알고리즘의 첫 단계는 Old 영역에 살아 있는 객체를 식별(Mark)하는 것이다.
- 그 다음에는 힙(heap)의 앞 부분부터 확인하여 살아 있는 것만 남긴다(Sweep).
- 마지막 단계에는 각 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 객체가 존재하는 부분과 객체가 없는 부분으로 나눈다.
Parallel GC(-XX:+UseParallelGC)
Parallel GC는 Serial GC와 기본적인 알고리즘은 같다. 그러나 Serial GC는 GC를 처리하는 스레드가 하나인 것에 비해, Parallel GC는 GC를 처리하는 쓰레드가 여러 개이다. 그렇기 때문에 Serail GC보다 빠르게 객체를 처리할 수 있다.
Parallel GC는 메모리가 충분하고 코어의 개수가 많을 때 유리하다. Parallel GC는 Throughput GC라고도 부른다.
Serial GC vs Parallel GC
CMS GC(-XX:+UseConcMarkSweepGC)
먼저 Serial GC와 CMS GC의 절차를 비교한 그림을 살펴보면 CMS GC는 지금까지의 GC 방식보다 더 복잡하다.
Serial GC vs CMS GC
초기 Initial Mark 단계에는 클래스 로더에서 가장 가까운 객체 중 살아있는 객체만 찾는 것으로 끝낸다. 따라서, 멈추는 시간은 매우 짧다.
Concurrent Mark 단계에서는 방금 살아있다고 확인한 객체에서 참조하고 있는 객체들을 따라가면서 확인한다.
이 단계의 특징은 다른 스레드가 실행 중인 상태에서 동시에 진행된다는 것이다.
그 다음 Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인한다.
마지막으로 Concurrent Sweep 단계에서는 쓰레기를 정리하는 작업을 실행한다. 이 작업도 다른 스레드가 실행되고 있는 상황에서 진행한다.
이러한 단계로 진행되는 GC 방식이기 때문에 stop-the-world 시간이 매우 짧다. 모든 애플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용한다. Low Latency GC라고도 부른다.
하지만 stop-the-world 시간이 짧은 장점에 비해 다음과 같은 단점도 존재한다.
- 다른 GC 방식보다 메모리와 CPU를 더 많이 사용한다.
- Compaction 단계가 기본적으로 제공되지 않는다.
따라서, CMS GC를 사용할 때에는 신중히 검토한 후 사용해야한다. 그리고 조각난 메모리가 많아 Compaction 작업을 실행하면 다른 GC 방식의 stop-the-world 시간보다 더 긴 stop-the-world 시간을 갖게되기 때문에 Compaction 작업이 얼마나 자주, 오랫동안 수행되는 지 확인해야한다.
G1 GC
메모리 할당을 위해 Young 영역에 메모리 할당을 시도했지만 실패하여 Young 영역에 GC가 발생하면, 살아남은 객체는 Old 영역으로 올라가게 될 것이다. 더 나아가 파편화된 Old 영역의 메모리 공간이 새롭게 Young 영역의 객체가 올라오기에 충분한 공간이 없다고 가정한다면, 이런 조건에서 Heap 영역을 compaction 작업과 함께 Full GC 작업이 발생할 것이다.
CMS GC를 사용하면 Full GC 작업은 병렬처리되지 않고 heap 메모리를 회수하고 compact하기 위한 시간동안 애플리케이션의 모든 스레드를 중지시키는 stop-the-world가 발생하게 된다. stop-the-world로 중지되는 시간은 heap 사이즈와 살아남은 객체의 숫자에 따라 달라지게 된다.
메모리 파편화를 방지하는 compaction 작업을 병렬로 처리한다 하더라도, Old 영역의 메모리 공간에 여유공간을 얻기 위해, Young과 Old를 포함한 Java의 모든 영역의 메모리를 Full GC를 수행해야하는 것은 변하지 않는다. 이는 Parallel Old Gc의 일반적인 사례이다. Parallel Old GC를 사용하면, Old 영역의 메모리 회수는 병렬로 Full GC를 처리하는데 stop-the-world가 발생한다. CMS GC와는 달리 Full GC는 점진적으로 처리되지 않고 애플리케이션의 실행 없이 하나의 큰 stop-the-world가 발생하게 된다.
G1 GC는 위에서 언급한 CMS GC나 Parallel Old GC에 비해서 스레드 정지가 예측 가능한 시간 안에 이루어지는 점진적으로 처리되는 병렬 compaction GC이다.
병렬성과 동시성, 다중화된 masking cycle로 인해 G1 GC는 최악의 경우의 수를 생각해도 괜찮은 정도 수준의 스레드 정지가 발생하기에 더 큰 Heap을 다룰 수 있게 된다.
G1 GC의 가장 기본 아이디어는 heap 메모리의 범위(heap 메모리의 크기를 -Xms로 최소치를, -Xmx로 최대치를 설정)와 현실적인 목표 스레드 정지 시간(-XX:MaxGCPauseMillis 옵션 사용)을 설정하고 GC가 작업을 할 수 있도록 만들어주는 것이다.
G1 GC를 이해하기 위해선 HotSpot VM에서 사용하는 전통적인 방식인 통으로 되어있는 자바 Heap 메모리를 Young 영역과 Old 영역으로 나누는 개념을 잊어야한다.
G1 GC에서는 region
이라는 개념을 도입했다. 큰 자바 Heap 공간을 고정된 크기의 region
들로 나눈다. 이 region
들을 free한 region들의 리스트 형태로 관리한다.
메모리 공간이 필요로 해지면 free region
을 young 영역이나 old 영역으로 할당한다. 이 region의 크기는 1MB ~ 32MB로 전체 Heap 사이즈 용량이 2048개의 region
으로 나누어 질 수 있도록 범위 내에서 결정된다. region
이 비어지면 이 region
은 다시 free region
들의 리스트로 돌아간다.
G1 GC의 기본 원리는 자바 Heap의 메모리를 회수할 때, 최대한 살아있는 객체가 적게 들어있는 region을 수집하는 것이다. (목표 정지시간에 최대한 부합하게 한다.) 가장 살아 있는 객체가 적을수록 가비지란 의미이고 따라서 이름도 가비지 우선 수집(Garbage First)이란 이름으로 붙게되었다.
G1 GC 힙 메모리
위에 그림에서 보다시피, G1 GC는 HotSpot VM의 기본적인 개념은 활용하지만 영역의 개념이 물리적으로 존재하지 않고 논리적으로만 존재함으로써 공간과 시간을 아낄 수 있다.
Reference
https://luavis.me/server/g1-gc
https://d2.naver.com/helloworld/1329
https://d2.naver.com/helloworld/329631
https://medium.com/naukri-engineering/garbage-collection-in-elasticsearch-and-the-g1gc-16b79a447181