Java & Kotlin

자바의 Garbage Collection 이해와 성능 최적화 방법

백엔드 유성 2023. 7. 30. 21:46


1. Garbage Collection이란?

Garbage Collection(GC)는 동적으로 할당된 메모리 중 필요하지 않은 부분을 해제하는 JVM의 기능입니다.

더 이상 사용하지 않는 객체의 메모리 자원을 해제하여 메모리의 여유 공간을 만들게 됩니다.

객체는 메모리 중 힙 영역에 할당되고, 할당된 객체 즉, 인스턴스가 더 이상 참조되지 않는다면 garbage로 간주되며, 이를 수집하여 메모리를 해제하는 역할이 바로 GC의 역할입니다.

이와 같은 자동 메모리 관리는 프로그래머의 부담을 줄이고, 메모리 누수나 불필요한 메모리 사용 등의 문제를 방지하는데 큰 도움이 됩니다.


2. GC의 동작 원리

GC의 기본적인 동작 원리는 'Mark and Sweep' 알고리즘에 기반합니다.

 

Mark 단계에서는 Root로부터 시작하여 각 객체가 참조하고 있는 객체를 따라가며 살아있는 객체를 마킹합니다.

이때 Root는 GC가 수행되는 시점에서 실행 중인 Thread가 스택과 레지스터에 접근 가능한 오브젝트를 의미합니다.

 

BFS, DFS 알고리즘을 사용합니다.


Sweep 단계에서는 Mark 되지 않은, 즉 필요 없어진 객체들을 메모리에서 제거합니다.


3. 자바의 GC 구조

 

자바의 메모리 구조는 크게 Young Generation, Old Generation, 그리고 Permanent Generation으로 분류되며, 각 영역에 따라 가비지 컬렉션(GC)의 동작 방식이 다릅니다.

GC 동작 방식은 크게 Minor GC, Major GC 로 나뉘며 Major GC는 Full GC로 표현하기도 합니다.

 

Young Generation

Young Generation은 새롭게 생성된 객체들이 배치되는 영역입니다.

해당 영역이 가득차면 Minor GC가 시작되고 GC스레드를 제외한 JVM상의 애플리케이션은 모두 일시 중단합니다.

이를 Stop-The-World라고 표현하는데 이는, GC를 수행하는 동안 객체의 참조 상태가 변경되지 않도록 하기 위함입니다.

이러한 절차를 통해 GC는 객체들 사이의 참조 관계를 정확하게 파악하고, 필요 없어진 객체들을 효과적으로 제거할 수 있습니다.

Young Generation을 좀 더 깊이 살펴보겠습니다. Young Generation은 Eden 영역과 Survivor 영역(S0, S1) 2개로 나누어집니다.

Eden 영역은 객체가 처음 생성되어 배치되는 공간입니다. Eden 영역이 가득 차게 되면 Minor GC가 발생하며, 이 과정에서 살아남은 객체들은 Survivor에서도 살아남은 객체와 함께 Survivor S0 또는 S1으로 영역으로 이동하게 됩니다.

그러므로 S0 또는 S1중 하나는 비어있는 공간이 됩니다.

(아마 그림을 보시면 이해가 되실 겁니다.. ㅎㅎ;; 설명하기 어렵네요.)

그림은 2개의 상황을 타나냅니다.

왼쪽은 S1이 비어있고, Eden이 가득 찼을 때

오른쪽은 S0가 비어있고, Eden이 가득 찼을 때입니다.

S0에서 S1로, 또는 그 반대로 객체를 복사하는 이유는 메모리 단편화를 방지하기 위한 것입니다. 이 과정을 통해, 살아남은 객체들이 항상 연속적인 메모리 공간에 배치되게 되어 메모리 관리의 효율성이 향상됩니다.

이동(Copy)이 모두 끝났으면 나머지 공간을 모두 비워줍니다.

Minor GC는 Mark -> Copy -> Clear 과정으로 모두 알아보았습니다.

 

 

Minor GC는 Eden 영역과 Survivor 영역에서 발생하므로 비교적 빠르게 수행되지만, GC가 실행되는 동안은 애플리케이션의 모든 동작이 멈추게 됩니다. 따라서 GC의 실행 시간을 최소화하는 것이 중요합니다. 이를 위해, 자주 사용되지 않는 객체는 빠르게 해제하는 것이 좋습니다. 이렇게 하면 Young Generation 영역에서 메모리를 빠르게 회수할 수 있습니다.

 



객체가 계속해서 살아남고, 일정 횟수 이상의 Minor GC를 거친 경우, 해당 객체는 Old Generation 영역으로 이동하게 됩니다. 이러한 프로세스를 통해 Young Generation 영역에서의 빠른 객체 회수와 Old Generation 영역으로의 객체 이동을 관리하게 됩니다.

 

Old Generation

Old Generation: Young 영역에서 살아남은 객체들이 이동되는 영역으로, 이 영역이 가득 차게 되면 Major GC(Full GC)가 발생합니다.

 

Full GC는 JVM에서 사용하는 전체 힙 영역을 대상으로 하므로 Minor GC와는 다소 다릅니다.

 

우선 Minor GC와 동일하게 Root로 시작하여 객체들을 Mark 하고, 제거합니다.

이때 메모리에 빈 공간인 프래그먼트가 생깁니다. 위 메모리 단편화에서 본 작은 메모리 조각입니다.

 

그리고 Minor GC와는 다르게 Compaction이 시작됩니다. 메모리에 생긴 빈 공간을 제거하고, 살아남은 객체들을 메모리의 한쪽으로 이동시키는 과정이며, 이를 통해 메모리 단편화를 방지합니다.

 

Major GC의 경우 처리해야 하는 객체가 많고, 전체 힙 영역을 대상으로 하기 때문에 수행 시간이 길다는 특징이 있습니다.

그래서 Major GC가 발생하면 애플리케이션의 성능이 저하될 수 있으므로, 이를 최적화하고 효율적으로 관리하는 것이 중요합니다.

 

Permanent Generation

Permanent Generation은 Java 8 이전의 JVM에서 메서드 메타데이터, static 변수 등을 저장하는 메모리 영역이었습니다.

 

하지만 Java 8부터 Permanent Generation 영역은 Metaspace라는 새로운 메모리 영역으로 대체되었습니다.

Metaspace는 기본적으로 OS의 네이티브 메모리를 사용하므로, JVM 힙과는 별개의 공간에서 운영됩니다.

 

그러나 Full GC가 발생하면 해당 영역에서도 더 이상 참조되지 않는 클래스나 메서드의 메타데이터를 삭제합니다.

Permanent Generation과 다르게 Metaspace의 최대 크기는 기본적으로 제한되어 있지 않습니다. 이는 Metaspace가 필요에 따라 동적으로 확장될 수 있기 때문입니다. 그러나 이것이 무제한이라는 의미는 아니며, 메모리 부족 문제를 완전히 해결한 것은 아닙니다. 필요에 따라 Metaspace의 크기를 제한하거나 조절할 수 있는 JVM 옵션들이 있습니다. 

결론적으로, Permanent Generation 영역은 JVM 메모리 구조의 중요한 부분이었지만, 현재는 Metaspace 영역으로 대체되어 더 효율적인 메타데이터 관리를 가능하게 하였습니다.

 

 

 

4. GC의 성능 최적화 방법

GC의 성능을 최적화하는 방법은 주로 GC 튜닝에 의존합니다.

 

1. 적절한 GC 알고리즘 선택: 애플리케이션의 특성에 맞게 알고리즘을 선택합니다.

  • Serial GC: 작은 힙 크기의 프로그램에 적용하는 단일 스레드 GC 알고리즘입니다.
  • Parallel GC: GC를 병렬로 처리하는 알고리즘입니다.
  • Concurrent Mark Sweep (CMS) GC: CMS GC는 Old Generation 영역에서 "stop-the-world" 이벤트를 최소화하여 애플리케이션의 응답 시간을 개선하려고 하는 알고리즘입니다. CMS GC는 애플리케이션과 병행하여 동작하며, 대형 힙을 가진 애플리케이션에 적합합니다.
  • Garbage-First (G1) GC: G1 GC는 대용량 힙에서 높은 처리량과 예측 가능한 일정한 GC 일시 중지 시간을 동시에 제공하는 것을 목표로 하는 알고리즘입니다. G1 GC는 힙을 여러 개의 동일한 크기의 영역으로 나누며, 각 영역에 대해 병렬 및 동시 GC를 수행합니다.
  • Z Garbage Collector (ZGC): ZGC는 최신 GC 알고리즘 중 하나로, 특히 멀티테라 바이트 크기의 힙을 가진 시스템에 대한 'Stop-The-World' 이벤트를 최소화합니다. 이 GC는 최대한 일시중지 시간을 줄이려고 하는 확장 가능한 저지연 알고리즘입니다. 이는 애플리케이션 실행 도중에 메모리를 회수하므로, 거의 모든 GC 작업을 병렬화하고 동시에 실행할 수 있습니다.
  • Shenandoah GC: Shenandoah GC는 OpenJDK에서 개발된 저지연 GC로, GC 일시중지 시간을 힙의 크기와는 독립적으로 유지합니다. 이것은 즉, 크고 복잡한 데이터셋이 있는 애플리케이션에 이상적입니다. 대부분의 작업이 애플리케이션 스레드와 동시에 수행되므로 "Stop-The-World" 시간이 크게 줄어듭니다.

찾아보니 GC 종류가 상당히 많네요.. 사용자에게 빠른 응답을 주고 싶으면 ZGC를 사용하면 되겠습니다. 물론 테스트는 해야겠지만요..

 

 

2. 힙 사이즈 조정

  • 힙 사이즈를 늘리면 GC의 발생 빈도를 낮출 수 있습니다.
  • 반면에, 힙 사이즈를 늘리면 GC의 지연 시간이 길어집니다.
  • 가능한 경우 JMX, VisualVM 등을 사용하여 애플리케이션의 메모리 사용 패턴을 분석해 보는 것이 좋을 것 같습니다.

 

3. JVM 파라미터 조정

  • Young Generation과 Old Generation의 비율을 조절하거나, Survivor 영역의 크기를 조절합니다.

 

4. 이 외의 방법

  • 애플리케이션 코드 최적화: 지역 변수를 사용하여 생명주기를 조절합니다.
  • 프로파일링 도구 활용: JDK에 있는 도구도 있다고 하며, dump를 떠서 분석기를 돌리 수 있습니다.