Java에서는 개발자가 따로 메모리 관리에 신경써도 되지 않는다는 장점은 많이들 알고 있다. 이것이 가능한 이유는 JVM의 Garbage Collector가 Garbage Collection(GC)이라는 기법으로 알아서 메모리 관리를 해주기 때문이다. 이 글에서는 GC에 대한 개념과 JVM에서 GC를 구현한 다양한 모델들에 대해 정리해보았다. 메모리 관리를 하지 않아도 되는 것이 장점이지만 GC와 JVM의 특성을 잘 알면 어떻게 해야 더 메모리를 잘 관리할 수 있는지, 어떻게 GC 튜닝을 할지 쉽게 이해할 수 있다.
Garbage Collection이란?
GC는 메모리 관리 기법 중 하나로, 프로그램이 동적으로 할당했던 메모리 영역 중에서 필요없게 된 영역을 해제하는 기법이다. 필요없게 된 영역이란 더 이상 다른 어떤 객체도 참조하고 있지 않은 영역을 의미한다. Java에서는 JVM의 힙 영역을 대상으로 GC를 실행한다.
C언어를 배웠다면 동적으로 배열을 선언할 때 malloc()으로 직접 메모리를 할당하고, 프로그램이 종료되기 전에 반드시 free()를 호출해서 사용이 끝난 메모리에 대해서는 반환해야 한다고 들은 기억이 있을 것이다. Java에서는 그럴 필요가 없는데 이 과정을 GC가 다 해 준다. Java 외에도 GC가 존재하는 언어들에서는 아래와 같은 장단점이 있다.
장점
- 개발자가 메모리 관리에 대해 신경쓸 필요없이 기능 개발에만 신경 쓸 수 있다.
- 유효하지 않은 주소에 접근, 이중 메모리 해제와 같은 버그를 줄이거나 완전히 막을 수 있다.
단점
- GC는 시간과 비용이 드는 작업이다. 게다가 상당히 큰 오버헤드가 드는 작업이라 GC가 존재하는 언어는 그렇지 않은 언어에 비해 느리다.
- GC 작업이 시작하는 타이밍이나 할당된 메모리가 해제되는 시점을 예측하기 어렵다. 후술하겠지만 GC 작업 시 Stop-The-World라는 현상이 일어난다. 이 때 프로그램의 모든 작업이 일시적으로 정지되므로, 실시간성이 중요한 프로그램에서는 문제가 될 수 있다.
GC 작동원리
GC가 해야 하는 일은 다음과 같다.
- 메모리 할당하기
- 참조되는 객체가 메모리에 존재함을 보장하기
- 다른 어떤 객체로부터 참조되지 않는 객체에 할당된 메모리 해제하기
이 때 다른 객체로부터 참조되는 객체를 살아있다(live)라고 표현하며, 참조되지 않는 개체를 죽었다(dead)라고 표현한다. 그리고 이 죽은 영역을 garbage라고 하며 GC의 메모리 해제 대상이 된다.
따라서 GC는 메모리 관리 영역을 탐색하여 garbage들을 찾기 위해 다양한 방법을 사용한다. GC를 구현할 때 보편적으로 Tracing 방식이 사용되므로, GC 알고리즘이라고 하면 Mark-and-Sweep이나 Tri-Color-Marking과 같은 Tracing 방식을 가르킨다. 이외에도 Reference Counting, Escape Analysis 등의 알고리즘이 있으니 궁금하면 찾아보자.
Mark-and-Sweep
1. Mark
JVM에서는 동적으로 객체를 생성할 때 힙 영역에 생성한다. 그리고 Method Area나 실행 중인 스레드들의 스택 내부에서 이 객체들을 참조하고 있다. JVM은 여러 개의 GC Root들을 시작으로 참조되지 않는 객체들을 찾아낸다. 위 그림에서는 객체 C와 E가 garbage이다.
2. Sweep
Sweep은 garbage 객체에게 할당된 메모리를 해제하는 단계이다. 이로써 가용 가능한 메모리 영역이 확보된다.
3. Compaction
Sweep을 진행하면 힙 영역에 fragmentation이라는 빈 공간이 생기게 된다. 이 때 live 객체들을 전부 연속된 주소로 옮길 수 있는데 이를 compaction이라고 한다. 이 작업을 하는 이유는 다음 번에 새로운 객체에 메모리를 할당할 때 쉽고 빠르게 할 수 있기 때문이다. GC 구현체에 따라 Mark-and-Sweep 후 compaction을 할 수도 있고, 하지 않을 수도 있다.
Stop-The-Word (STW)
모든 메모리 영역의 참조 관계를 파악하기 위해서는 실행 중인 프로그램의 스레드를 모두 멈추고 GC 작업만을 위한 스레드만 실행되어야 한다. GC 작업 동안 프로그램의 실행이 멈추게 되는데 이 현상을 STW라고 한다. 이 시간을 최소화하기 위해 다양한 방법이 개발되었고, 개발자들이 GC 튜닝을 고려해야 하는 경우가 생길 수 있다.
Tri-Color-Marking
Mark-and-Sweep 방식은 STW 때문에 프로그램이 멈추는 현상이 생기고, 할당된 메모리 구역 전체를 최대 2번까지 순회해야 한다는 단점이 있다. 이를 개선하기 위한 알고리즘이 Tri-Color-Marking이며 CMS GC 등에서 사용된다. Mark-and-Sweep 방식과 달리 프로그램 실행과 동시에 수행할 수 있다.
이 알고리즘에는 세 가지의 색이 존재한다.
- 흰색 (White set, Condemned set) : 어떤 객체에게도 참조되지 않는 객체. GC 수집 대상이 된다.
- 검은색 (Black set) : GC Root로부터 접근이 가능하고, 흰색 객체를 참조하지 않는 객체. GC의 수집 대상이 되지 않는다.
- 회색 (Grey set) : GC Root로부터 접근이 가능하지만, 흰색 객체를 참조하는지 확인이 되지 않은 객체. GC의 수집 대상이 되지 않고 흰색 객체에 대한 참조 여부가 확인된 후 검은색이 된다.
알고리즘 과정은 다음과 같다.
- 모든 객체를 흰색으로 표시하고, GC Root로부터 접근 가능한 객체를 회색으로 표시한다.
- 회색 객체들 중 하나를 선택한다.
- 선택한 객체 A가 참조하는 모든 흰색 객체를 회색 객체로 표시한다
- A를 검은색으로 표시한다
- 1-4 과정을 회색으로 표시된 객체가 없을 때까지 반복한다.
Generational Garbage Collector
좀 더 효율적인 GC를 위해 메모리 영역을 세대별로 나누는 기법을 사용한다. 이 방법이 효율적인 이유는 일반적인 객체의 생성시기에 따른 생명주기에 근거한다.
GC는 살아있는 모든 객체를 조회해야 하기 때문에 살아있는 객체가 많을수록, 힙 영역의 크기가 클수록 STW가 길어진다. Figure 5를 보면 객체 대부분이 생성되고 얼마 있지 않아 dead 상태가 된다 (die young) 는 것을 알 수 있다. 또 다른 특성으로 오래된 객체가 젊은 객체를 참조하는 경우는 드물다는 것이 있다.
이 특성들을 활용하여 생성되지 얼마되지 않은 객체(Young Generation)에 대해서는 잦지만 비용은 적게 드는 GC 작업을 하고, 오래된 객체(Old Generation)에 대해서는 가끔이지만 비용이 큰 GC 작업을 한다. 이 때 Young Generation에 대한 작업을 Minor GC, Old Generation에 대한 작업을 Major GC라고 한다. 또한 각 영역에서 사용하는 알고리즘이 달라지기도 한다. 몇 번의 Minor GC에도 사라지지 않은 객체를 Old Generation으로 옮기게 되는데, 이를 Promotion이라고 한다.
- Young Generation 새롭게 생성된 객체가 할당되는 영역. Minor GC는 자주 발생하기 때문에 해당 영역에서의 GC 알고리즘은 보통 속도를 기준으로 선택한다.
- Old Generation Minor GC에서 살아남거나 크기가 큰 객체가 할당되는 영역. Young Generation보다 크기 때문에 Major GC는 적게 발생한다. Major GC가 진행될 때는 메모리에 남은 빈 공간이 적기 때문에 해당 영역에서의 GC 알고리즘은 공간 효율적인 방법을 사용한다.
- Card Table Old Generation에서 Young Generation을 참조하는 경우는 적지만 존재할 수 있다. 그런 경우 Minor GC가 진행될 때 Old Generation의 객체들도 조회해야 하는데, generational gc의 이점이 사라지게 된다. 따라서 그런 경우는 따로 Card Table에 저장하여, Old Generation 영역을 살펴볼 필요 없이 Card Table만 조회하면 충분하도록 설계할 수 있다.
HotSpot JVM Garbage Collector
HotSpot JVM에서는 Generational Garbage Collector를 좀 더 세분화시켜서 구현했다. 이를 통해 객체에 대한 메모리 할당을 좀 더 빠르고 효율적으로 할 수 있다.
우선 Young Generation이 Eden과 2개의 작은 Survivor 영역으로 이루어져있다. 처음 객체가 생성될 때는 Eden 영역에 할당된다. Survivor 영역은 한 번 이상의 Minor GC 이후에도 살아 있는 젊은 객체들이 할당된다. 이 때 2개의 Survivor 영역 중 하나는 반드시 사용되어야 하고, 하나는 반드시 비어있어야 한다. Survivor 영역에서 충분히 오래된 객체들은 Old Generation으로 promotion된다.
Minor GC
프로그램 실행 중에 Eden 영역이 가득 찾을 때 Minor GC 작업이 시행되는데 과정은 다음과 같다.
- Eden 영역에 있는 live 상태의 객체를 To Survivor 영역으로 복사한다. 이 때 해당 객체의 Age bit를 1 늘린다. 만약 해당 객체가 Survivor 영역에 담기에 크다면 바로 Old Generation 영역으로 복사한다.
- From Survivor 영역에 있는 live 상태의 객체 중 아직 젊은 객체는 To Survivor 영역으로 복사한다. (Age bit로 판정)
- From Survivor 영역에 있는 live 상태의 객체 중 충분히 늙은 객체는 Old Generation으로 복사한다. (Age bit로 판정)
- Eden, From Survivor 영역의 메모리를 해제한다.
- Minor GC가 종료된 후 두 Survivor 영역의 역할이 바뀐다.
한 번의 Minor GC가 시행되면 Figure 7에서 Figure 8의 상태가 된다.
Major GC
Major GC는 Old Generation 영역이 가득 찼을 때 시행한다. Young Generation에 비해 영역이 크고 Minor GC가 여러 번 시행된 이후에 가득 차므로 훨씬 적은 빈도로 시행된다. Major GC가 시작되면 일반적으로 먼저 Young Generation에 대해 특화된 알고리즘을 사용하여 GC를 시행한다. 그 후 Old Generation에 대해서도 역시 특정한 알고리즘을 사용하여 GC를 시행한다.
만약 promotion으로 인해 Old Generation의 공간이 부족하다면 Young Generation에 대한 GC가 먼저 시행되는 것이 아닌, 전체 힙 영역에 대해 Old Generation GC 알고리즘이 시행된다. (Full GC) 단, CMS GC에서는 Major GC가 시행될 때 Young Generation에 대해 GC를 시행하지 않으므로 해당하지 않는다. (CMS 알고리즘은 후술)
Fast Allocation
HotSpot JVM에서는 객체의 빠른 할당을 위해 bump-the-pointer 라는 기술을 활용한다. 직전에 생성된 객체가 생성된 주소를 기억하면, 다음 객체가 생성될 때 해당 주소로부터 충분한 연속적인 메모리 공간이 있는 걸 확인한 후 새로운 객체에게 빠르게 메모리를 할당할 수 있다.
그런데 멀티 스레드 프로그램의 경우 bump-the-pointer 를 활용하기 위해서는 모든 스레드가 공유하는 글로벌 락을 사용해야 한다. 이는 전체적인 프로그램의 병목이 되고, 멀티 스레드 프로그램의 이점을 해칠 수 있다. 그래서 HotSpot JVM은 Thread-Local Allocation Buffers (TLABs) 라는 기술도 사용한다. 각 스레드별로 Eden에 생성할 수 있는 객체의 영역인 버퍼를 할당하고, 버퍼 내에서 bump-the pointer 를 활용할 수 있다. 단 스레드가 자신에게 할당된 TLAB를 다 채우고 새 버퍼를 할당받아야 할 때는 동기화 작업이 필요하다.
GC 알고리즘
Serial GC
GC 과정이 한 개의 스레드를 사용하여 순차적으로 실행된다. 따라서 GC 작업을 하는 동안 반드시 프로그램이 멈추는 STW 현상이 발생한다. 실제 서비스를 운영한다면 STW 시간이 너무 길기 때문에 사용하면 안 된다. 실행하는 환경의 CPU 코어가 하나일 때 사용하기 위해 만든 방식이다.
Young Generation에서 Serial GC를 사용할 때는 위의 Mark-and-Sweep 방식으로 실행한다. Old Generation에서 Serial GC를 사용할 때는 거기에 compaction 과정을 추가하여 Mark-Sweep-Compact 알고리즘을 사용한다. Compaction은 추후에 객체를 할당할 때 bump-the-pointer 기술을 더 빠르게 사용할 수 있도록 해준다.
Parallel GC
Young Generation에서 Serial GC 과정을 한 개의 스레드가 아닌 여러 스레드를 통해 진행한다. 자연스럽게 STW 시간이 Serial GC와 비교해 짧아진다. 실행환경의 메모리가 충분하고 CPU 코어가 많을 때 유리하다. Old Generation에서는 Serial GC와 동일하게 동작한다.
Parallel Compacting GC
Parallel GC와 비교해 Old Generation에서 사용할 때만 차이가 있다. Mark-Sweep-Compact 대신 Mark-Summary-Compact 방식을 사용하여 Old Generation에서의 GC도 멀티 스레드로 처리한다.
Concurrent Mark-Sweep (CMS) GC
Old Generation에서의 GC가 긴 STW 시간을 가지는 것을 보완하기 위해, 높은 throughput을 일부 포기하는 대신 낮은 응답속도를 목표로 개발된 GC이다. 따라서 Old Generation 영역에 대한 GC에서만 차이가 있다.
Low Latency GC라고도 불리지만 그에 따른 단점도 존재한다. 다른 GC 방식과 비교해 많은 CPU 자원을 필요로 하며, fragmentation(단편화 현상) 이 많이 발생한다. 다른 Old Generation GC와 달리 CMS GC는 Old Generation이 가득 찼을 때 시행하지 않고, 힙 영역이 모자라는 것을 방지하기 위해 Major GC를 자주 시행하게 된다. 즉, Major GC의 수행 시간과 빈도에 따른 trade-off가 있다. 또한 fragmentation 때문에 따로 compaction 작업을 하는 경우 그에 따른 STW 역시 고려해야 한다.
CMS GC 과정은 다음과 같다.
- Initial Mark : 어플리케이션 코드에서 바로 참조하고 있는 live 객체들을 마킹한다
- Concurrent Marking : 프로그램을 실행하는 동시에 처음에 마킹된 객체들에서 도달 가능한 모든 객체를 마킹한다.
- Remark : Concurrent Marking 작업을 프로그램이 실행되는 동시에 했기 때문에, 모든 live 객체를 찾았다는 보장이 없다. 따라서 프로그램을 잠시 멈추고 (STW) 수정된 객체들을 확인한다.
- Concurrent Sweep : 프로그램을 실행하면서 이전 단계들에서 찾아낸 garbage들을 정리한다.
G1 GC (Garbage-First GC)
Java 9 이후 버전부터 디폴트로 적용되는 GC이다. Garbage-First라는 이름이 붙은 이유는 GC를 효율적이고 빠르게 진행하기 위해, garbage가 많은 부분부터 GC를 진행하기 때문이다. G1 GC는 CMS GC를 대체하기 위해 설계되었는데 아래와 같은 특징을 가진다
- CMS GC와 같이 프로그램을 실행하는 동시에 작업이 가능하다
- 긴 STW 없이 compaction을 수행한다
- GC로 인해 멈추는 최대 시간을 예상가능하게 해 준다 (보통 최대 100ms)
- Throughput 성능을 크게 해치지 않고 짧은 STW가 가능하게 한다
- 많은 힙 공간을 필요로 하지 않는다.
G1 힙 구조
G1 GC는 힙 영역을 고정된 크기를 가지는 region들로 나눈다. Region은 메모리 할당과 해제의 단위이다. 각 region의 크기는 1, 2, 4, 8, 16, 32Mb 중 전체 힙 영역을 2048로 나눈 값을 반올림 했을 때 가장 가까운 값을 사용한다.
힙 영역은 논리적으로 연속적인 Eden, Survivor, Old Generation, Humongous 영역으로 나뉜다. 각 영역의 역할을 다른 GC에서와 동일하다. 새롭게 등장한 Humongous 영역은 region 크기의 절반 이상인 객체를 저장하기 위한 영역이다. 해당 크기의 객체는 Young Generation에 할당되지 않고 바로 Humongous 영역에 할당된다. 각 영역의 크기가 고정되어 있었던 다른 GC들과 달리 G1 GC에서는 변할 수 있으며, 이는 유연한 메모리 사용을 가능하게 한다.
Tip
논리적으로 연속적이라는 의미는 가상 메모리(Virtual Memory) 기술을 사용하여 컴퓨터가 연속적으로 접근할 수 있도록 하는 것이다. 각 영역에 속한 region들의 실제 물리적인 위치는 위 그림과 같이 떨어져 있다.
GC 과정
G1 GC는 크게 나누었을 때 두 단계를 번갈아가며 수행한다. 위 그림은 GC를 진행할 때 수행하는 단계와 그로 인해 발생할 수 있는 STW를 원의 크기로 나타낸다.
간단하게 설명하자면 Young-Only 단계에서는 반복적으로 Minor GC를 반복하면서, 효율적으로 GC가 가능하도록 region liveness를 기록하는 구간이다. Space-Reclamation 단계는 충분한 메모리 공간이 확보될 때까지 Minor GC와 함께 liveness 정보를 기반으로 Major GC를 반복하는 구간이다.
Young-Only
Minor GC가 수행되며 live 객체들을 Old Generation으로 promotion한다. Young-Only 단계에서 Space-Reclamation 단계로의 전환은 Old Generation 공간의 점유율이 특정 임계값이 도달했을 때 일어난다. 해당 임계값을 Initiating Heap Occupancy라고 하며 G1 GC가 동적으로 조절한다. 초기값이나 동적 조절 여부는 개발자가 설정할 수도 있다. 전환이 시작되면, G1이 Minor GC 대신 Concurrent Start를 실행한다.
- Concurrent Start Minor GC를 진행하는 동시에 프로그램이 실행하면서 힙 영역 내의 모든 live 객체들을 마킹한다. CMS GC에서의 Initial Mark + Concurrent Marking이라고 생각하면 된다. 완벽히 모든 live 객체를 판별하기 위해서는 뒤의 Remark, Cleanup 단계가 수행되어야 한다.
- Remark 이 단계에서는 다른 스레드들을 멈추고 마킹을 마무리한다. (Snapshot-At-The-Beginning 알고리즘 사용) 완전히 빈 Old Generation 영역에 대해 메모리를 해제한다. 또한 모든 영역에 존재하는 region에 대해서, region에서 live 객체가 차지하는 정도를 의미하는 liveness를 계산한다.
- Cleanup 가장 낮은 liveness를 가지는 region을 선택해 compaction을 진행해 unused region을 확보한다. 그 후 실제로 Space-Reclamation이 진행될지 여부가 결정된다.
Space-Reclamation
Young Generation 영역에 대한 다수의 Minor GC와 Old Generation 영역의 live 객체들을 대피하는(evacuate) 작업이 진행된다. 두 영역에 대해 동시에 작업을 진행해 Mixed Collections라고도 한다. Space-Reclamation 단계는 대피 작업을 반복해도 충분한 여유 공간이 생기지 않을 때 끝난다.
Space-Reclamation 단계 종료 후, 다시 Young-Only 단계가 시작된다. 만약을 위해, liveness 정보를 수집하는 동안 메모리가 부족할 경우, STW 상태에서 전체 힙 영역에 대한 GC(Full GC) 를 진행한다.
Tip
G1 GC는 작업의 효율성을 위해 Remembered Set와 Collection Set이라는 자료 구조를 활용한다.
- Remebered Set (RSet) Rset은 Region 하나당 한 개가 존재하며, region 내부의 객체 참조를 추적한다. Rset은 region과 독립적이고 병렬적인 GC를 가능하게 해 준다.
- Collection Set (CSet) GC 중에 메모리를 확보하기 위한 region들의 집합이다. CSet 내부의 live 객체들은 GC 동안 다른 region으로 옮겨진다.
Z GC
KB부터 TB까지 이르는 영역에서 작동가능한 GC이다. 그보다 더 큰 힙 영역에 대해서도 GC로 인한 지연 시간을 10ms 이내로 보장한다. Java 11에 처음 공개되었으며, 21부터는 generational GC까지 지원한다.
ZGC 힙 영역은 ZPages라는 영역으로 나눈다. ZPages는 동적으로 생성되고 파괴되며, 2MB의 배수의 크기를 가진다. 또한 다른 GC와 달리 실제 힙 영역보다 더 큰 주소 공간을 매핑할 수 있다. 따라서 메모리 단편화 문제를 해결하기 위해 compaction을 실행해야 하는 다른 GC와 달리, ZGC에서는 문제가 되지 않는다.
ZGC는 높은 Throughput과 낮은 Latency를 위해 더 많은 메모리(footprint)를 사용한다. ZGC가 압도적인 성능을 가진 것 같지만 디폴트 GC가 아닌 이유가 이런 이유 때문인 것 같다.
마치며
GC 튜닝도 정리하여 추가하려 했지만, 예제 상황까지 만들려니 너무 시간이 오래 걸릴 거 같아 포기했다. 실제 서비스에서도 GC 튜닝은 늦은 단계에 고려되는 방법이니 혹시 궁금해지면 공부해야겠다. HotSpot JVM이라면 오라클 공식문서에서 자바 버전별로 GC 튜닝 가이드 문서도 잘 작성되어 있기도 하고…
참고 자료
https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf (Figure 9, 10 출처)
https://en.wikipedia.org/wiki/Garbage_collection_(computer_science)
https://en.wikipedia.org/wiki/Tracing_garbage_collection
https://d2.naver.com/helloworld/1329
https://hub.packtpub.com/getting-started-with-z-garbage-collectorzgc-in-java-11-tutorial/
'JAVA' 카테고리의 다른 글
[Java] JVM이란? (0) | 2024.07.09 |
---|---|
자바 레벨에서의 동시성 (1) | 2024.01.19 |
DateTime 자동 저장 시 OS에 따른 Time Precision 문제 (0) | 2024.01.17 |
[Effective Java] equals를 재정의하려거든 hashCode도 재정의하라 (0) | 2023.04.02 |
[Effective Java] equals는 일반 규약을 지켜 재정의하라 (0) | 2023.03.26 |