Java는 C와 달리 개발자가 메모리를 직접 할당하고 해제하지 않는다. 대신 참조를 잃은 객체가 Garbage collector에 의해 주기적으로 삭제되는데, 이 과정에서 모든 어플리케이션 스레드를 멈추는 stop-the-world 방식을 사용하기에 GC가 일어나는 것은 API 응답 성능에 악영향을 미치게될 수 있다.
보통 모니터링을 수행한다고 할 때 CPU, Memory, 쿼리시간 정도를 지표로 두고 관찰해왔는데, 인스턴스 생성으로 인한 잦은 GC가 성능 저하를 일으킬 것이라고 예상되는 경우 추가로 관찰해도 좋겠다는 생각이 들었다. GC가 일어나는 시점, 방식 그리고 모니터링 방식까지 정리해보자.
1. JVM Heap의 기본구조
자바에서 ArrayList, Integer와 같은 참조타입 객체를 생성하면, Stack에는 객체를 가르키는 포인터가, Heap에는 실제 인스턴스가 저장된다. 이때 JVM에서는 힙 영역을 객체의 나이에 따라 아래와 같이 나누어 관리한다.


각 영역의 역할은 아래와 같다.
- Eden : 한번도 GC를 겪지 않은 인스턴스 저장공간
- S0 / S1 : 번갈아가면서 age 1 이상의 인스턴스 저장공간
- Old generation : Survivor 영역에서 오래 살아남은 인스턴스 저장공간
구체적인 예시를 통해 프로그램 실행 중 각 영역의 활용 방식을 알아보자.


새로 생성된 Instance는 항상 Eden 영역에 할당된다.
Eden 영역이 가득 차면 Minor GC가 발생하며, 이 과정에서 살아남은 객체만 Survivor 영역으로 이동한다.
또한 객체는 GC를 한 번 통과할 때마다 age값이 1씩 증가한다. 이 age는 객체 헤더에 기록되며, 객체의 생존 횟수를 의미한다.


Survivor 영역은 S0와 S1, 두 개의 공간으로 구성된다. 두 영역 중 하나만 사용되며, GC가 발생할 때마다 살아남은 객체는 비어 있는 다른 Survivor 영역으로 복사된다. 이러한 swap 과정을 통해 메모리 단편화를 방지하고, 항상 연속된 공간에 인스턴스를 저장한다.

Survivor 영역에서 Old Generation으로 객체가 이동하는 것을 승격된다고 하는데, 이 조건이 상당히 복합적이다.
1) MaxTenuringThreshold 값을 넘긴 경우
2) Survivor 영역이 가득 찬 경우 -> 조기승격 발생
3) 한번에 많은 객체가 생성되어 survivor 영역이 모자랄 것이라고 예상되는 경우 -> Dynamic Tenuring 전략 적용
1번 조건은 가장 기본적인 조건으로, Survivor 영역에서 일정 횟수 이상 생존한 객체는 Young Generation에서 Old Generation으로 승격(promote)된다. 이때 기준이 되는 값이 -XX:MaxTenuringThreshold 옵션이다. Java 프로그램 실행시 지정할 수 있다.
임계값이 있긴 하지만, Survivor 영역이 가득 차는 경우 Tenuring Threshold 나이에 도달하지 않았더라도 Old Generation으로 승격된다. promotion은 항상 Unreachable 객체를 정리한 후 발생하므로, Survivor 객체를 조기승격 시키는 것이 이상한 흐름은 아니다.
이에 더해, HotSpot JVM에서는 3번 조건인 Dynamic Tenuring 전략도 사용한다.
Survivor 영역에 객체가 과도하게 쌓일 경우, 일부 객체는 threshold에 도달하기 전이라도 Old Generation으로 조기 승격될 수 있다.
이때의 기준은 “동일한 age를 가진 객체들이 Survivor 영역에서 차지하는 비율”이며, 해당 비율은 -XX:TargetSurvivorRatio=<Percent> 옵션을 통해 설정할 수 있다.
2.G1GC으로 보는 Minor GC와 Major GC
G1GC는 JDK9 이상에서 기본으로 적용되는 GC 알고리즘이다.
Heap의 크기가 크고, 활성 데이터가 50% 이상인 환경에서 pause time을 최소화하는데에 특화되어있다.
위에서 살펴본 Eden, Survivor, Old generation의 개념은 그대로 가져가지만 세부 동작에 약간의 차이가 있다.
또한 프로그램이 실행되는 동안 Young-only phase와 Space Reclamation phase를 반복하는데, 해당 시점에 Heap에서 일어나는 일도 뒤에서 알아볼 것이다.
2-1. G1GC의 Heap Layout

G1GC는 전체 Heap을 균일한 크기의 Region 단위로 분할하여 관리한다.
각 Region은 상황에 따라 Eden, Survivor, Old Generation 역할을 동적으로 할당받는다.
또한 크기가 큰 객체의 경우, 하나의 Region에 담기 어려우면 여러 Region을 연속적으로 할당받아 하나의 논리적 영역처럼 사용한다.
위 사진의 파란색 H 영역이 그 예시이다. 이러한 Humongous object는 바로 Old generation에 할당되며, GC 로그에 이러한 특이사항이 기록된다.
Region 사용으로 인해 전통적인 JVM의 s0, s1 Survivor 영역 구분은 존재하지 않는다.
하지만 Survivor 간 객체 이동 개념 자체는 유지되며, GC 이후 새로운 Region으로 객체를 복사 이동하게 된다.
이 과정을 G1GC에서는 Evacuation이라고 부른다.

또 전통적인 Minor GC, Major GC로 GC를 구분하는 대신, Old generation에 대한 정리가 필요한지 판단하고, 예상 결과를 계산하는 Young-only phase와 Space Reclamation Phase로 GC 라이프사이클을 구분한다.
각 사이클의 특징과, Full GC의 긴 실행시간을 보완하기 위해 일어나는 Mixed GC에 대해서도 아래에서 알아보자.
2-2. Young-only phase
A) Normal Young Collection
Young-only phase는 Young Generation 영역에 대한 GC가 반복적으로 일어나는 단계이다.
위에서 살펴본대로 Eden 영역이 임계값 이상으로 커지면 Survivor 영역의 Unreachable 객체를 삭제하고, 살아남은 객체들을 새로운 Survivor region으로 이동시킨다. 일반적으로 이 작업을 Minor GC라고 부른다.
Minor GC 작업이 반복되면서 Old generation으로 승격된 객체들이 많아지고 점유율이 임계값이 도달하면 Space Reclamation phase를 위한 준비를 시작한다.
B) Concurrent Start
: Old Generation 전체를 대상으로 Live 객체를 식별하기 위한 마킹 작업을 시작하는 단계이다. 이 과정은 Minor GC가 일어나는 것을 방해하지 않고 병렬적으로 수행된다.
C) Remark
: Concurrent Marking 과정에서 누락될 수 있는 객체를 보정하기 위한 단계이다.
이 단계에서는 다른 작업이 멈춘다. Old generation에 대한 마킹 작업이 완료되고, 전역 참조 처리 및 클래스 언로딩이 수행되며, 완전히 비어 있는 영역을 회수하고 내부 데이터 구조를 정리한다. 다음 단계에서 어느 정도의 공간을 확보할 수 있는지도 이 단계에서 계산된다.
이때 SATB 알고리즘을 활용하는데, 초기 마킹 상태의 스냅샷을 기록하고 그 기록을 기준으로 마킹을 하는 것이다. 회수 효율은 조금 낮아질 수 있지만, 일시정지 시간을 줄이는데 도움이 된다.
D) Cleanup
: Remark에서 얻은 정보를 기반으로 Young-only phase를 끝낼지 말지 결정한다. 이 단계에서도 다른 작업이 멈춘다.
2-3. Space Reclamation phase
Young generation 및 Old generation region에 대해 모두 정리를 수행한다.
이때 전체 힙을 압축하는 Full GC 대신, Mixed GC라는 방식을 사용한다.
Mixed GC 방식에서는 회수 효율이 좋은 region을 골라 청소를 시작하며, 설정된 최대 시간 안에서 반복적으로 GC를 수행해서 공간을 확보한다.
하지만 객체 할당 속도가 너무 빨라서 evacuation이 실패하는 상황이 되면, 전체 힙을 압축할 수 있도록 Full GC를 수행한다. Full GC는 전체 Heap을 대상으로 Mark-Sweep-Compact 방식으로 수행되며, 가장 소요시간이 긴 방식이다.
이때 Full GC가 일어나지 않도록 간간히 코드상에서 System.gc()를 호출해주면 어떨까 하는 생각이 들 수 있다.
하지만 오히려 System.gc() 호출시 Full GC가 실행되므로, 해당 함수 호출은 실험적인 목적이 아니라면 지양하자. System.gc() 호출이 반드시 필요한 상황이라면 -XX:+ExplicitGCInvokesConcurrent 실행옵션으로 해당 함수 호출이 Full GC 대신 Cuncurrent GC를 트리거하도록 변경하자.
참고한 자료
'프로그래밍 언어 > Java' 카테고리의 다른 글
| JVM(3) - jstat으로 GC 동작 관찰하기 (0) | 2026.04.11 |
|---|---|
| JVM(1) - JVM 작동 과정 (0) | 2026.04.05 |