GC는 메모리 관리 기법 중 하나로, 동적으로 할당했던 메모리 영역 중 필요 없게 된 영역을 자동으로 해제해주는 기법이다.
동적으로 할당했던 메모리 영역은 Heap 영역을 말하고, 필요 없게 된 영역은 어떤 변수도 가리키지 않게 된 영역을 말한다.
C와 C++의 경우, Heap 영역의 메모리를 관리하기 위해 코드 레벨에서 할당받고 해제해야 했다. 이 경우, 할당받은 메모리 영역을 제대로 해제하지 않아 Memory Leak(메모리 누수)이 발생하기도 한다.
수동으로 메모리를 관리하던 것에서 비롯된 에러를 방지할 수 있다.
이런 특성에 따라 실시간성이 매우 강조되는 프로그램의 경우 GC에게 메모리 관리를 맡기는 것이 알맞지 않을 수 있다.
GC를 구현하는 대표적인 알고리즘 2가지를 소개한다.
스택 변수, 전역 변수 등 heap 영역 참조를 담은 변수라고 생각하면 된다.
Heap 영역에 선언된 객체들이 각각 Reference count라는 별도의 숫자를 가지고 있다. 이 refrence count는 몇 가지 방법으로 해당 객체에 접근할 수 있는지를 뜻한다.
해당 객체에 접근할 수 있는 방법이 하나도 없다면, reference count는 0이 되고 가비지 컬렉션의 대상이 된다.
Root space에서 Heap Space로의 참조를 모두 끊어내도, 객체가 서로를 참조하고 있는 경우를 말한다. 이 경우 reference count가 1로 유지되어서 사용하지 않는 메모리 영역이 해제되지 못하고 Memory Leak이 발생한다.
이 알고리즘은 Reference Counting의 순환 참조 문제를 해결할 수 있다.
루트에서부터 해당 객체에 접근 가능한지를 해제의 기준으로 삼는다. 루트부터 그래프 순회를 통해 연결된 객체들을 찾아내고(Mark), 연결이 끊어진 객체들은 지우는(Sweep) 방식이다.
루트로부터 연결된 객체는 Reachable, 연결되지 않았다면 Unreachable이라고 부른다.
또한, 메모리를 예쁘게 정리하여 메모리 파편화를 막는 Compaction을 해주기도 한다. 다만, Compaction은 필수 과정은 아니다.
자바와 자바 스크립트가 바로 이 방식으로 메모리를 관리한다.
즉, 실행 중인 어플리케이션이 GC에게 컴퓨터 리소스들을 내줘야 한다.
따라서, 어플리케이션의 사용성을 유지하면서 효율적이게 GC를 실행하는 것이 꽤나 어려운 최적화 작업이다.
💡 JAVA 8 기준.
크게 세 가지의 영역으로 구성된다.
바이트 코드를 읽고, 클래스 정보를 메모리의 Heap/Method Area에 저장한다.
실행 중인 프로그램의 정보가 올라가 있는 메모리이다.
JVM은 OS로부터 메모리를 할당 받은 후, 해당 메모리를 용도에 따라 여러 영역으로 나누어서 관리한다. 총 다섯 가지의 영역으로 나누어지는데, 크게 2가지로도 나눌 수 있다.
프로그램의 클래스 구조를 메타 데이터처럼 가지며, 메소드의 코드들을 저장한다.
어플리케이션 실행 중에 생성되는 객체 인스턴스를 저장하는 영역이다. Garbage Collector에 의해 관리되는 영역이기도 하다.
메소드 호출을 스택 프레임이라는 블록으로 쌓으며, 로컬 변수와 중간 연산 결과들이 저장되는 영역이다.
스레드가 현재 실행할 스택 프레임의 주소를 저장하고 있다.
C/C++ 등의 Low level 코드를 실행하는 스택이다.
바이트 코드를 네이티브 코드로 변환시켜 주고, GC를 실행하는 실행 엔진이다.
JVM의 GC는 기본적으로 Mark-and-sweep 방식으로 돌아가는데, 이 방식은 루트에서부터 해당 객체의 접근이 가능한지가 해제의 기준이다.
JVM의 Root-Space는 아래와 같다.
Mark-and-sweep 방식의 특징은 의도적으로 GC를 실행시켜야 한다는 점이다. 따라서, JVM GC에게는 어느 시점에 GC를 실행시킬지에 대한 기준이 있다.
이 시점을 알기 위해서는 Heap 영역을 조금 더 들여다 봐야 한다.
Heap 영역은 크게 두 영역으로 나뉜다.
Young generation에서 발생하는 GC를 minor GC, Old generation에서 발생하는 GC를 major GC라고 한다.
Eden은 새롭게 생성된 객체들이 할당되는 영역이고, survival 영역은 minor GC로부터 살아남은 객체들이 존재하는 영역이다.
이 survival 영역에는 특별한 규칙이 하나 있다. survival 0 혹은 survival1 둘 중 하나는 꼭 비어있어야 한다는 점이다.
첫번째 Minor GC 발생 시
Eden 영역이 꽉 차면 minor GC가 발생한다. 이 GC는 앞서 말한 Mark-and-sweep 방식으로 진행된다. 루트로부터 Reachable이라 판단된 객체들은 survival 0 영역으로 옮겨진다.
이때, survival 0으로 옮겨지면서 age-bit가 1로 증가한다. 이 age-bit는 Minor GC에서 살아남을 때마다 1씩 증가한다.
두번째 Minor GC 발생 시
그리고 시간이 흘러 Eden 영역이 꽉 차서 minor GC가 발생하면, Eden 영역과 survival0 영역에서 Reachable이라고 판단된 객체들이 survival1 영역으로 이동된다. 이때, survival0 영역은 완전히 비워지게 된다.
세번째 Minor GC 발생 시
그리고 또다시 Eden 영역이 꽉 차서 minor GC가 발생한다. Eden 영역과 survival1 영역에서 Reachable이라고 판단된 객체들은 survival0 영역으로 이동한다. 이때, survivla1 영역은 완전히 비워진다.
이렇게 survival0 영역으로 이동된 객체 중 하나가 오래 살아남아 age-bit가 3이 되었다고 하자. JVM GC에서는 일정 수준의 age-bit를 넘어가면 “오래도록 참조될 객체”라고 생각하고 Old Generation 영역으로 넘긴다. 이 과정을 Promotion 이라고 한다.
Java 8에서는 Parallel GC 방식 사용 기준 age-bit가 15가 되면 promotion이 진행된다.
Old Generation 영역이 꽉 차면 Major GC가 발생한다. 마찬가지로 Mark-and-sweep 방식을 통해 필요 없는 메모리를 비워준다.
이 Major GC는 Minor GC보다 훨씬 오랜 시간을 소모하게 된다.
💡 Old Generation은 Young Generation에 비해 상대적으로 큰 공간을 가지고 있어, 이 공간에서 메모리 상의 객체 제거에 많은 시간이 걸리게 된다.
예를들어 Young 영역은 일반적으로 Old 영역보다 크키가 작기 때문에 GC가 보통 0.5초에서 1초 사이에 끝난다. 그렇기 때문에 Minor GC는 애플리케이션에 크게 영향을 주지 않는다.
하지만 Old 영역의 Major GC는 일반적으로 Minor GC보다 시간이 오래걸리며, 10배 이상의 시간을 사용한다.
GC 설계자들이 어플리케이션들을 분석해보니 대부분의 객체가 수명이 짧다는 것을 알게 되었다. GC도 결국 비용인데, 메모리의 특정 부분만을 탐색하며 해제하면 더 효율적이다.
어차피 대다수의 객체가 금방 사라지니, Young Generation 안에서 최대한 처리하도록 하는 것이다.
GC를 실행하기 위해 JVM이 어플리케이션 실행을 멈추는 것이다. 앞서 어플리케이션의 사용성을 유지하면서 효율적으로 GC를 실행하는 것이 꽤나 어려운 최적화 작업이라고 했다. 바로 이 Stop The World 시간을 최소화하는 것이 어려운 최적화 작업인 것이다.
Concurrent-Mark-Sweep의 줄임말이다.
Garbage First의 줄임말이다.
💡 네이버 D2의 아티클 참고.
GC 튜닝은 성능 개선의 최종 단계이다.
즉, 객체 생성 자체를 줄이려는 코드 레벨에서의 개선이 선행되어야 한다. 예를 들어, String 대신 String Builder를 쓰는 것이 있다.
즉, Major GC를 적게 발생시키거나 Major GC를 빠른 시간 내에 끝내는 것이 목표이다.
따라서 한정된 Heap 영역에 Young Generation과 Old Generation을 각각 얼마만큼 할당하는 것이 적당한지를 판단해야 한다.
메모리가 너무 크다면 GC는 가끔 일어나겠지만 오래 걸릴 것이고, 메모리가 너무 작다면 GC는 자주 일어나지만 금방 끝날 것이다.
따라서 어플리케이션의 구조 및 특성에 따라 적당한 메모리 크기를 주어야 한다.
GC 튜닝을 진행하기 위해서는 아래 사항들을 진행해야 한다.
먼저, java -XX:+PrintCommandLineFlags - version 명령어로 GC 설정을 확인한다.
InitialHeapSize와 MaxHeapSize를 얼만큼 사용하도록 설정되어 있는지, 현재 사용하고 있는 GC 방식은 무엇인지 알 수 있다.
JDK 설치 시 기본으로 제공되는 툴로, JVM을 모니터링할 수 있다. 각 영역의 할당률, Minor GC와 Major GC가 일어난 횟수와 걸린 시간을 확인할 수 있다.
이 명령을 통해 프로세스가 heap 영역을 얼마나 사용 중인지 정확한 수치를 알 수 있다.
위와 같이 모니터링 후, JVM의 Option을 다르게 설정해볼 수 있다.
우테코: 조엘의 GC