Garbage Collector 이란 무엇일까?
automatically manages the application's dynamic memory allocation requests.
애플리케이션의 동적 메모리 할당을 자동으로 관리해주는 JVM의 구성 요소이다.
동작 방식
1. 운영체제로부터 메모리를 할당 받고, 반환한다.
2. 애플리케이션이 요청할 때 메모리를 제공한다.
3. 애플리케이션에서 메모리의 어떤 부분이 사용되고 있는지 확인한다.
4. 사용되지 않는 메모리를 반환한다.
자바에서 자동으로 메모리를 관리하는 기능으로, 사용하지 않는 객체들을 자동으로 메모리에서 제거해, 메모리 누수를 방지한다.
따라서 개발자가 메모리를 수동으로 관리할 필요없이, 메모리 누수와 이중 해제 등 메모리 관련 버그를 줄여준다.
Garbage Collection의 과정을 알아보자.
stop-the-world
GC를 실행하기 위해 JVM의 실행을 멈추는 것으로, stop-the-world가 발생하면 GC를 실행하는 스레드를 제외한
나머지 스레드는 모두 작업을 멈춘다. 그리고 GC 작업이 완료된 후 중단했던 작업들을 다시 재개한다.
대개 GC 튜닝은 stop-the-world 시간을 단축시키는 것을 의미한다.
위에서 말했듯이 Garbage Collector은 더 이상 사용되지 않는(참조되지 않는) 객체를 알아서 지워주는 작업을 한다.
이 작업은 2가지의 전제를 두고 동작한다.
<weak generational hypothesis>
- 대부분의 객체는 금방 unreachable 상태가 된다.
- 오래된 객체에서 젊은 객체로의 참조는 아주 적게 일어난다.
이 전제를 위해 Hotspot VM에서는 크게 2개의 공간인 Young, Old 영역으로 나누었다.
![](https://blog.kakaocdn.net/dn/btGn5Y/btsLGQFgrdo/KVEisABvkKqKbzegQdhd2K/img.png)
그림에서 보듯이 듯이 Young 영역에서 살아남은 객체들이 Old 영역으로 이동하는 흐름이다.
Old 영역 아래의 Permanent 영역(Perm)은 클래스의 메타데이터를 저장하는 공간으로, 이 영역에서도 GC가 발생할 수 있다.
Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우가 있을 때에는 어떻게 처리될까
이런 경우를 위해 Old 영역에는 Card Table이 존재한다. Old 영역의 객체가 Young 영역의 객체를 참조하는 정보가 표시되는 테이블로,
Young 영역에서 GC를 실행할 때, 카드 테이블을 확인 후 GC의 대상인지 확인할 수 있다.
이제 JVM Heap 메모리 영역에 대해 더 자세하게 알아보자.
![](https://blog.kakaocdn.net/dn/cUWttq/btsLG7M1fY3/sMICrpUwa078eclK4eODhk/img.png)
Hotspot JVM의 Heap 영역이다.
크게 Young, Old, Permanent로 구성된다.
Young Generation
- 새롭게 생성된 객체가 할당되는 영역
- 대부분 객체가 금방 Unreachable 상태가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다.
- Young 영역에 대한 가비지 컬렉션(Garbage Collection)을 Minor GC라고 부른다.
Young 영역은 또 3개의 영역으로 나뉜다.
- Eden 영역
- Survivor 0
- Survivor 1
동작순서를 알아보자.
![](https://blog.kakaocdn.net/dn/cQpJ66/btsLIAgxF4f/Bwo2iWJoNGxtUK3350krQ1/img.png)
1. 새로 생긴 객체는 Eden 영역에 위치한다.
2. 객체가 계속 생성되어 Eden 영역이 가득 차게되면 Minor GC가 실행된다. 이때 살아남은 객체들은 s0으로 이동한다.
3. 1~2번의 과정을 반복하다가, s0영역이 가득 차게되면, s0에서 살아남은 객체들과 eden에서 살아남은 객체들을 S1로 이동시킨다. (반드시 하나의 Survivor 영역은 빈 영역이 되어야 한다!!!)
4. eden과 Survivor 영역(S0, S1)을 번갈아 사용하면서 객체들을 옮겨가며, 객체가 일정 횟수이상 살아남으면 Old 영역으로 이동시킨다.
이 과정에서 왜 스왑이 필요할까
s0, s1은 두 개의 공간을 번갈아 가며 객체를 저장하는 방식이다. Eden 영역에서 살아남은 객체들을 복사하고, 그 후 다음 GC에는 이전에 살아남은 객체들이 다른 영역으로 이동하도록 하기 위해서 이다. 이를 통해 각 영역에서 빈 공간을 최소화할 수 있다.
Survivor 공간이 너무 작으면, 객체가 Survivor 공간에서 Old 영역으로 바로 넘어가고, 반대로 Survivor 공간이 너무 크면 공간이 사용되지 않고 비어 있게 된다. JVM은 객체가 Old 영역으로 이동하기 전에 Survivor 영역을 얼마나 이동할 수 있는지 결정하는 임계값을 설정한다. 이 임계값은 Survivor 공간을 반 정도 채울 수 있도록 조정된다.
-Xlog:gc,age 옵션을 사용하면, 이 임계값과 객체 age 확인할 수 있는데, 이를 통해 애플리케이션의 객체 생애 주기나 메모리 사용 패턴을 분석할 수 있다.
Minor GC
Young 영역이 가득 찾을 때 동작하며, Eden 영역과 Suvivor 영역의 더 이상 사용되지 않는 객체를 제거한다.
Young 영역은 객체 생성이 빈번하게 일어나기 때문에 Minor GC도 비교적 자주 실행된다.
Eden에서 살아남은 객체들은 Survivor 영역으로 이동하고, 일정 횟수 이상 살아남은 객체들은 Old Generation으로 이동한다.
Old Generation
- Young 영역에서 Reachable 상태를 유지하여 살아남은 객체가 복사되는 영역
- Young 영역보다 크게 할당되며, 영역의 크기가 큰 만큼 가비지는 적게 발생한다.
- Old 영역에 대한 가비지 컬렉션을 Major GC 또는 Full GC라 한다.
이 영역에서는 데이터가 가득 차면 GC를 실행한다. GC 방식에 따라 처리 절차가 달라진다.
Major GC (Old Generation GC, Full GC)
Old 영역이 가득 찬 경우, Perm/metaspace 영역에서 메모리 부족이 발생할 때 동작하며, Old 영역에서 참조되지 않는 객체를 제거한다.
Minor GC 보다 시간이 오래 소요되며, 전체 애플리케이션을 잠시 멈추는 Stop-the-world 상태가 발생한다.
Full GC:
s0와 s1에서 일정 횟수 이상 살아남은 객체들이 Old 영역으로 이동하는데, Old 영역에 여유가 없다면 Full GC가 발생한다.
Old 영역, Young 영역 모두 청소하는 GC로, Minor GC보다 비용이 크고 성능에 영향을 많이 미친다.
Permanent Generation
- JVM에서 클래스 메타데이터(클래스와 메소드, 필드 등의 정보)를 저장하는 곳
- 자바8이후 메타 스페이스로 대체된다.
- 클래스 로더는 클래스 파일을 읽어들여서 이 영역에 클래스 메타데이터를 저장한다.
- JVM 실행 도중에 변경되지 않고, JVM 종료 시까지 유지된다.
자바8이후 메타 스페이스로 대체된 이유는 무엇일까
한계
PermGen의 크기는 고정적이며, JVM 옵션으로 크기를 제한했다. 그래서 클래스 메타데이터가 많아지면 OutOfMemoryError이 발생하게 된다. 그리고 JVM 힙에 속해, 클래슷 로딩 및 언로딩 시 빈번한 메모리 할당과 해제로 성능 저하 문제가 발생한다.
대체된 이유
클래스 메타데이터를 PermGen 대신 Native Memory에 저장하여, 메모리 크기가 제한되지 않고 운영체제의 가용 메모리를 활용 가능.
메타데이터 저장 공간을 JVM이 아닌 운영체제에서 관리하여 PermGen처럼 제한된 공간의 문제를 해결.
Metaspace는 더 효율적인 메모리 관리와 클래스 로딩/언로딩을 지원.
즉, PermGen의 문제를 해결하고 더 유연한 메모리 관리를 위해 Metaspace가 도입되었다.
Metaspace
Perm 영역에 저장되던 클래스의 메타 정보들이 저장된다.
네이티브 메모리 영역에 위치하여, JVM이 아닌 OS 레벨에서 관리된다.클래스 메타데이터와 리플렉션을 사용하는 애플리케이션에서 사용하는 일부 메모리를 저장한다.
흐름
1. 객체 생성:
- 새로운 객체가 Young Generation의 Eden 영역에 생성된다.
2. Minor GC 실행:
- Eden 영역이 꽉 차면 Minor GC가 실행되어, 사용 중인 객체는 Survivor 영역으로 이동되고, 나머지 객체는 삭제된다.
3. Old Generation 이동:
- 여러 번의 Minor GC를 거친 객체는 Old Generation으로 이동한다.
4. Major GC 실행:
- Old Generation이 가득 차거나, 메모리가 부족할 때, Major GC가 실행된다.
- Old Generation 내에서 사용되지 않는 객체를 제거한다.
5. Full GC 실행:
- 전체 메모리가 부족하거나 Old Generation과 Young Generation 모두를 대상으로 메모리를 정리할 때 Full GC가 발생한다.
GC의 대상인지는 어떻게 판별할까?
도달성(Reachability)을 통해 가비지의 대상이 되는지 판단한다.
- Reachable : 객체가 참조되고 있는 상태
- Unreachable : 객체가 참조되고 있지 않은 상태로 GC의 대상이 된다.
객체들은 실제로 Heap 영역에 생성되고, 스태틱(메서드) 영역과 스택 영역에서는 생성된 객체의 주소를 참조하는데, Heap 영역 객체의 참조 변수가 삭제되면, Unreachable가 발생한다. 이런 참조되지 않는 객체는 가비지 컬렉션의 대상이 된다.
JVM의 런타임 데이터 영역을 보자.
![](https://blog.kakaocdn.net/dn/CR41Y/btsLI2KfBVY/WlFVlwJP2JKkkiHazqIPbk/img.png)
힙에 있는 객체에 대한 참조는 다음과 같은 종류가 있다.
- 힙 내의 다른 객체에 의한 참조
- Java 스택, 즉 Java 메서드 실행 시에 사용하는 지역 변수와 파라미터들에 의한 참조
- 네이티브 스택, 즉 JNI(Java Native Interface)에 의해 생성된 객체에 대한 참조
- 메서드 영역의 정적 변수에 의한 참조
이 중 힙 내에서 다른 객체에 의한 참조를 제외한 나머지 3개가 root set으로, reachability를 판가름하는 기준이 된다.
![](https://blog.kakaocdn.net/dn/MwxHI/btsLGM3VHfe/C6sHGhmch9kZ9hJ7eUWKq0/img.png)
- root set으로부터 시작한 참조 사슬에 속한 객체들이 reachable 객체이고,
- 이 참조 사슬과 무관한 객체들이 unreachable 객체로 GC 대상이다.
우측 하단의 객체처럼 reachable 객체를 참조하더라도, 다른 reachable 객체가 참조하지 않는다면 unreachable 객체이다.
위의 참조는 모두 java.lang.ref 패키지를 사용하지 않은 일반적인 참조로, strong reference라 한다.
어떤 Garbage Collector를 선택하느냐도 중요한 문제이다.
가비지 컬렉션으로 인한 짧고 빈번한 일시 중지가 있어도 성능에 큰 영향을 받지 않는 애플리케이션도 있지만, 다량의 데이터(수 기가바이트), 다수의 스레드, 높은 트랜잭션 비율을 처리하는 애플리케이션에서는 가비지 컬렉터 선택이 중요하다.
각 GC의 방식에 대해 알아보자.
- Serial GC
- Parallel GC
- Parallel Old GC
- CMS (Concurrent Mark & Sweep GC)
- G1 GC
Serial GC (-XX:+UseSerialGC)
Old 영역의 GC는 mark-sweep-compact이라는 알고리즘을 사용한다.
Old 영역에 살아 있는 객체를 식별(Mark)하여, 힙(heap)의 앞 부분부터 확인하여 살아 있는 것만 남긴다(Sweep).
남은 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채워 객체가 존재하는 부분과 객체가 없는 부분으로 나눈다(Compaction).
애플리케이션의 데이터셋이 작고 (약 100MB 이하), 성능 목표에 엄격한 지연 시간 요구사항이 없다면, Serial GC(-XX:+UseSerialGC)이 적합하다. 그리고 단일 프로세서에서 실행되며 지연시간이 중요치 않거나, 1초 이상의 지연이 허용될 때 사용할 수 있다.
Parallel GC (-XX:+UseParallelGC)
Serial GC와 기본적인 알고리즘은 같지만, Parallel GC는 GC를 처리하는 스레드가 여러 개이다. 그래서 더 빠르게 객체를 처리할 수 있다. 메모리가 크고 코어의 개수가 많은 때 유리하며, Throughpuh GC라고도 한다.
![](https://blog.kakaocdn.net/dn/wlBG6/btsLHoIhEio/jlo1KVk0OCZdLt1Wo5nsa0/img.png)
최대 성능이 우선이고, 짧은 지연 시간이 필요하지 않거나 1초 이상의 지연이 허용되는 경우 사용할 수 있다.
이런 경우 VM이 자동으로 선택하도록 하자.
Parallel Old GC(-XX:+UseParallelOldGC)
JDK 5 부터 6까지 사용한 방식으로 Old 영역의 GC 알고리즘만 다르다.
Mark-Summary-Compaction 단계를 거치는데, Summary 에서는 앞서 GC를 수행한 영역에 대해 별도로 살아있는 객체를 식별한다는 부분에서 Sweep과는 다르다.
CMS GC (-XX:+UseConcMarkSweepGC)
Stop-The-World 시간을 최소화하도록 설계되었다.
멀티 스레드를 활용해 애플리케이션 스레드와 동시에 작업을 수행하며, 응답 시간에 민감한 애플리케이션에 적합하다.
크게 4 단계로 진행된다.
Initial Mark 단계에서는 클래스 로더와 가까운 살아있는 객체만 확인하기 때문에 짧은 STW가 발생한다.
Concurrent Mark에서는 방금 확인된 객체의 참조를 따라가 확인 작업을 하는데, 이 작업은 다른 스레드가 실행 중인 상태에서 동시에 실행된다. 그리고 Remark 단계에서는 Concurrent Mark 중 추가되거나 참조가 끊긴 객체를 확인한다.
마지막으로, Concurrent Sweep 단계에서는 참조되지 않는 객체를 제거하는데, 이 작업도 다른 스레드가 실행되고 있는 상황에서 진행된다.
애플리케이션과 GC 작업이 병렬로 실행되기 때문에 CPU 와 메모리를 많이 사용하며, Compaction 단계를 제공하지 않기 때문에 메모리 단편화가 발생할 수 있다. 단편화로 인해 Compaction을 실행하면 STW시간이 다른 GC보다 길어질 수 있기 때문에 CMS GC를 사용할 때는 메모리 단편화와 자원 사용량을 모니터링해야 한다.
G1(Garbage First) GC
G1GC는 자바7에서 도입된 GC 알고리즘으로, Java9의 기본 알고리즘이다.
Young의 3영역에서 Old영역으로 이동하는 단계가 사라진 방식으로, CMS CG를 대체하기 위해 나왔다.
Low Latency, High Throughput(처리량)을 목표로 대규모 힙을 효과적으로 관리하도록 설계되었다.
G1 GC는 힙 메모리를 여러 개의 작은 리전(Region)으로 나누어 관리한다.
각 리전은 독립적으로 동작하며, 메모리를 동적으로 할당하고 해제할 수 있어 메모리 관리가 용이하다는 특징이 있다.
또한, GC Pause Time을 통해 예측 가능한 응답 시간을 보장하며, 개발자가 설정한 시간 내에 가비지 컬렉션을 완료하도록 최적화된다.
가비지가 많은 리전을 우선적으로 수집하여 메모리 회수 효율을 높이고 애플리케이션 성능을 향상시킨다.
객체 수집은 Mark-and-Sweep 방식을 사용하며, 동시성 수집(Concurrent Marking)을 통해 애플리케이션 실행 중에도 백그라운드에서 가비지를 처리하여 Stop-The-World 시간을 최소화한다.
이러한 특징으로 G1 GC는 대용량 힙을 사용하는 애플리케이션이나 응답 시간이 중요한 환경에 적합한 GC 알고리즘입니다.
ZGC
확장성이 뛰어난 저지연 GC
애플리케이션 스레드를 멈추지 않고 비싼 작업을 모두 동시적으로 수행하여, 최대 몇 밀리초의 짧은 지연 시간을 제공하기 때문에, 응답 시간이 중요한 애플리케이션에 적합하다.
ZGC는 힙 크기에 관계없이 일정한 지연 시간을 유지하고, 힙 크기는 8MB에서 16TB까지 지원한다.
ZGC는 -XX:+UseZGC 옵션을 사용하여 활성화할 수 있다.
하지만 처리량이 감소할 수 있고, 일부에서는 오버헤드가 발생할 수 있다.
![](https://blog.kakaocdn.net/dn/b0B1J5/btsLHSWm3Nx/z0KGYtA4snqhcWUHLMvBF0/img.png)
- 이상적인 시스템에서 가비지 컬렉션만이 확장성에 영향을 미치는 상황을 모델링한 그래프이다.
- 빨간 선:
- 단일 프로세서에서 가비지 컬렉션 시간이 1%인 경우.
- 32 프로세서로 확장 시 20% 이상의 처리량 손실 발생.
- 자홍색 선:
- 단일 프로세서에서 가비지 컬렉션 시간이 10%인 경우.
- 32 프로세서로 확장 시 처리량의 75% 이상 손실 발생.
- 빨간 선:
즉, 가비지 컬렉션 시간이 적어도, 시스템 확장 시 성능 손실이 크게 증가한다는 것을 알 수 있다.
작은 시스템에서는 성능 문제가 거의 없지만, 큰 시스템으로 확장하면 병목현상이 발생할 수 있다. 따라서, 작은 최적화도 큰 시스템에서는 성능 향상을 크게 가져올 수 있다.
작은 애플리케이션(100MB 이하 힙 사용)에는 Serial GC로 충분하지만, 다른 가비지 컬렉터는 더 복잡하거나 오버헤드가 있지만, 특정 상황에서 필요한 동작을 제공한다.
대규모, 멀티스레드 애플리케이션, 대용량 메모리와 다수의 프로세서를 가진 서버 환경에서는 보통 G1 GC가 사용된다.
리전 기반 구조의 장점
- 일관된 크기와 수명 관리
- 리전은 일정한 크기로 할당되며, 비슷한 수명과 용도를 갖는 객체들이 같은 리전에 관리된다.
- 따라서, 메모리 할당 및 해제가 예측이 가능하여, 특정 객체에 대한 메모리 패턴을 개발자가 쉽게 이해하고 관리할 수 있다.
- 간단한 메모리 할당과 해제
- 객체를 리전에 할당할 때 메모리 블록을 검색할 필요가 없기 때문에, 메모리 할당이 더 간단하고 빠르다.
- 특정 리전이 더 이상 필요하지 않은 경우, 전체 리전을 한 번에 해제할 수 있다.
- 동적 메모리 조정
- 메모리 사용 패턴에 따라 동적으로 리전 할당 및 해제할 수 있다.
- 특정 영역에서 메모리를 조정할 필요가 없으며, GC가 자동으로 이를 조정한다.
- 가비지 객체 수집 우선
- 가비기 객체가 많은 리전을 우선적으로 수집하여 불필요한 메모리 회수를 줄인다.
- 이로 인해 개발자는 소스 코드에 메모리 최적화 로직을 추가할 필요가 없다.
- GC 간격 예측
- G1 GC와 같은 리전 기반 GC는 GC간격을 예측할 수 있어, 성능 예측이 가능하다.
- 예측 가능한 GC 간격은 성능 튜닝을 용이하게 한다.
G1GC는 메모리 관리의 복잡성을 줄이고 성능을 안정적으로 유지하는 데 중점을 두어 개발자는 GC의 세부 조정보다는 비즈니스 로직에 집중할 수 있다.
주의할 부분
- Stop-the-world는 GC 중 애플리케이션 실행을 멈추는 현상으로, Major CG에 큰 영향을 미치기 때문에 성능 최적화가 필요한 실시간 시스템에서 문제가 될 수 있다.
- GC 자체도 메모리 및 CPU 리소스를 소모하는 작업으로, 성능저하가 발생할 수 있다.
- 메모리가 언제 해제되는지 예측이 힘들다.
Java HotSpot VM의 목표
Maximum Pause-Time, Throughput 중 하나를 우선적으로 만족하도록 설정한다.
목표를 만족하면 다른 목표도 도달하려고 시도한다.
힙의 크기가 작으면 애플리케이션이 메모리를 더 빨리 소모하기 때문에 GC가 더 자주 발생한다. 그러면 SWT 시간이 많아지기 때문에 응답 속도에 악영향을 줄 수 있다. 반대로 힙의 크기가 크면 GC의 빈도가 줄기 때문에 애플리케이션이 메모리를 더 오래 사용할 수 있다.
1. 최대 정지 시간
힙의 크기가 크면 GC가 발생했을 때 처리해야 할 객체의 수가 많아지기 때문에 정지 시간이 길어질 수 잇다. 따라서 최대 정지 시간 목표를 충족하기 위해서는 힙의 크기를 조절해야 한다.
-XX:MaxGCPauseMillis=<nnn> 옵션으로 최대 정지 시간을 설정할 수 있다. 힙 크기를 조정하고 GC의 빈도를 높일 수 있지만, 처리량이 감소할 수 있다.
2. 처리량
힙의 크기가 클 수록 애플리케이션은 메모리 할당과 GC의 간섭을 덜 받기 때문에 처리량이 증가한다.
하지만 너무 크게 설정하면 메모리가 낭비될 수 있고, GC 처리 시간이 늘어날 수 있다.
애플리케이션 실행 시간 대비 GC 시간이 차지하는 비율을 최소화하는 것으로, -XX:GCTimeRatio=nnn 옵션으로 설정할 수 있다. 예를 들어 -XX:GCTimeRatio=19는 전체 시간의 5%를 가비지 컬렉션에 사용하는 목표를 의미한다.
3. 메모리 사용량
힙의 크기를 너무 크게 설정하게 되면 물리적인 메모리를 넘어 Swapping이 발생할 수 있는데, 이러면 애플리케이션의 성능이 저하된다.
따라서, 처리량과 최대 정지 시간 목표를 달성하면 힙의 크기를 줄여 메모리 사용량을 최적화하자.
힙 크기는 -Xms=<nnn>와 -Xmx=<mmm> 옵션으로 최소 및 최대 크기를 설정할 수 있다. 하지만 그러나 설정된 목표가 서로 충돌할 수 있어, 힙 크기가 계속 변할 수 있다.
GC 튜닝 전략
먼저 처리량 목표를 만족할 수 있는 힙 크기를 설정하자.
정지 시간이 너무 길면 최대 정지 시간 목표를 추가로 설정하자. 하지만 정지 시간 목표를 설정하면 처리량이 감소할 수 있기 때문에, 애플리케이션에 적합한 균형을 찾아야 한다. 애플리케이션의 요구사항에 따라 목표를 조정하면서 최적의 성능을 확보해야 한다.
참고자료
'Java' 카테고리의 다른 글
🙋♂️ 왜 enum 생성자는 자동으로 private로 설정될까? (1) | 2025.02.07 |
---|---|
Stack 사용을 왜 지양해야 할까? (0) | 2025.01.20 |
자바의 실행과 JIT(Just-In-Time) 컴파일러 (1) | 2025.01.08 |
JVM 내부 동작 원리 (1) | 2025.01.08 |
SQL Injection (0) | 2025.01.07 |