자바 가비지 컬렉션 알고리즘

Jeongmin Yeo (Ethan)·2021년 2월 14일
4

Java Performance

목록 보기
3/3
post-thumbnail

자바 가비지 컬렉션 알고리즘에 대해 정리합니다.

학습할 내용은 다음과 같습니다.

  • 가비지 컬렉션 알고리즘 개요
  • 처리율 컬렉터 이해하기
  • CMS 컬렉터 이해하기
  • G1 컬렉터 이해하기

Reference


1. 가비지 컬렉션 알고리즘 개요

이전에 쓴 글인 가비지 컬렉션에서는 가비지 컬렉터의 일반적인 동작과 기본적인 튜닝인 힙 크기, 제너레이션 크기를 설정하는 방법을 알아봤습니다.

대부분의 상황에서 가비지 컬렉션의 기본 튜닝으로 충분합니다.

그러나 특정한 상황에선 가비지 컬렉션의 특정 동작을 이해하고 개별적인 컬렉터의 튜닝을 적용해 애플리케이션의 성능을 올려야 하는 경우도 있습니다.

개별 컬렉터를 튜닝하는 데 필요한 주요 정보는 GC(가비지 컬렉터) 로그에서 얻는 데이터를 기반으로 합니다.

이 글에서는 로그를 디테일하게 분석하기 보단 이해하기 쉽게 결과 중심으로 쓰겠습니다.


2. 처리율 컬렉터 이해하기

자바 처리율 컬렉터(Throughput Collector)은 두 가지의 기본동작을 수행합니다.

첫번째는 영 컬렉션으로 에덴이 가득 찼을 때 수행합니다. 영 컬렉션은 에덴 영역에 있는 객체를 GC 수행 후 에덴 밖으로 옮깁니다.

일부는 서바이버 스페이스로 일부는 올드 제너레이션 영역으로 옮깁니다. 그러므로 이 영 컬렉션 이후에 올드 제너레이션 영역은 더 많은 객체를 가지게 됩니다.

그리고 당연히 영 컬렉션 이후에 많은 객체는 더는 참조되지 않기 때문에 폐기됩니다.

두번째는 풀 GC로 이 GC 이후에는 영 제너레이션 외에 모든걸 해제합니다.

풀 GC 이후에 남아있는 올드 제너레이션 영역에는 유효한 참조를 가진 객체만 남아있습니다.

그리고 올드 제너레이션에 남아 있는 객체는 모두 압축되어 앞 부분을 점유하고 있습니다.

적응과 정적 힙 크기 튜닝

처리율 컬렉터의 튜닝은 전체 힙 사이즈와 영 / 올드 제너레이션 크기의 균형유지와 중단 시간에 대해서만 고려하면 됩니다.

그러므로 여기서 고려할 트레이드 오프는 두 가지가 있습니다.

첫 번째로 시간과 공간에 대한 트레이드 오프로 더 큰 힙을 사용하면 즉 더 많은 메모리를 사용한다면 어느 범위까지는 애플리케이션 처리율이 높아질 것 입니다.

두 번째는 GC를 수행하는 데 걸리는 시간의 길이와 관련이 있습니다. 풀 GC 중단 횟수는 힙 크기를 늘리면 줄일 수 있지만 그로인해 한번의 GC가 더 오래 걸려 평균 응답 시간이 길어져 왜곡된 영향을 미칠 수 있습니다.

그리고 이와 비슷하게 풀 GC 중단 시간은 올드 제너레이션 보다 영 제너레이션 크기를 올려서 줄일 수 있지만 풀 GC 횟수는 증가할 것입니다.

이런 트레이드 오프로 인한 한 실험 결과를 보면 결과적으로 힙 크기가 늘어나면 어느 지점까지는 처리율이 빠르게 증가합니다.

하지만 그 지점 이후로는 수확 체감의 법칙에 따릅니다. 즉 메모리를 추가할 수록 처리율은 점점 조금씩 증가합니다.

그리고 힙이 너무 커지게 되면 어느 시점 이후에는 처리율이 감소 되기 시작합니다. 이 트레이드 오프는 많은 메모리로 인한 긴 GC 주기 때문입니다.

기본적으로 JVM은 힙 적응 크기 적용을 사용합니다. 이를 통해 애플리케이션에서 실험을 하고 힙과 제너레이션 크기를 정합니다.

이 플래그는 -XX:MaxGCPauseMillis=N과 -XX:GCTimeRatio=N 이 있습니다.

MaxGCPauseMillis 플래그는 애플리케이션에서 허용하는 수준의 최대 중단 시간을 명시합니다. 이 값은 마이너 & 풀 GC 모두 적용됩니다.

그러므로 여기에 매우 작은 값으로 설정하고 싶을 수도 있습니다.

하지만 매우 작은 값을 사용하면 그 정도로 작은 올드 제너레이션 영역을 사용할 것입니다. 즉 매우 자주 풀 GC를 수행할 것이고 성능은 낮아질 것입니다.

GCTimeRatio 플래그는 애플리케이션이 GC에 써도 무방할 정도의 시간을 명시합니다. 이 값은 비율을 말하고 기본 값으로 0.99가 나옵니다.

이 값이 말하는 의미는 애플리케이션 처리에 99%를 쓰고 GC에 1%의 시간을 쓰도록 하겠다 입니다.

처리율 목표를 정하는 방정식은 다음과 같습니다.


처리율 목표 = 1 - (1 / (1 + GCTimeRatio)

주의할 점은 GCTimeRatio가 95라는 말은 GC가 5%의 시간을 쓴다는 의미는 아닙니다. 이 방정식대로 계산을 한다면 GC에 수행하는 시간은 1.94%로 나옵니다.

GCTimeRatio에 19의 값을 넣어야 GC에 5%의 시간을 쓴다는 결과가 나옵니다.

JVM은 초기 힙 크기 플래그인 -Xms와 최대 힙 크기 플래그인 -Xmx 사이의 범위에서 힙을 조정하기 위해 위 두 플래그를 사용합니다.

MaxGCPauseMillis 플래그가 GCTimeRatio 플래그보다 우선순위가 더 높습니다. 이 플래그가 설정된다면 영과 올드 제너레이션 영역은 중단 시간 목표를 이루기 위해 조정됩니다.

그 후 시간 비율 목표를 이룰때까지 조정됩니다. 최적의 설정은 애플리케이션 목표에 따라 다르지만 특정 목표가 없다면 보통 GC 시간 비율을 5%라고 설정해도 잘 작동합니다.


3. CMS 컬렉터 이해하기

CMS 컬렉터는 세 가지 기본 동작을 합니다.

  • 모든 애플리케이션 스레드를 멈추고 영 제너레이션을 수집합니다.

  • 올드 제너레이션의 영역을 제거하기 위해 동시 병렬 주기를 수행합니다.

  • 필요하다만 풀 GC를 수행합니다.

CMS의 영 컬렉션은 처리율 영 컬렉션과 매우 유사합니다. 데이터는 에덴에서 서바이버 스페이스와 올드 제너레이션 영역으로 이동합니다.

CMS의 동시 병렬 주기는 힙의 점유율을 기반으로 시작합니다. 힙이 충분히 찼다면 JVM은 백그라운드 스레드를 시작해서 힙을 훑고 객체를 제거합니다.

CMS의 동시 병렬 주기는 압축(Compaction) 작업을 하지 않으므로 메모리 단편화 현상이 일어납니다.

이 영역은 영 컬렉션이 일어날 때 에덴에서 올드 제너레이션으로 객체를 옮길 때 사용됩니다.

CMS의 동시 병렬 주기는 여러 단계가 있고 이 과정을 좀 더 살펴보겠습니다.

CMS의 동시 병렬 주기는 초기 표시 단계에서는 애플리케이션 스레드가 잠시 짧게 멈추는 현상이 일어나고 이 단계에서는 살아있는 객체를 찾기 위해 힙 내의 모든 GC 루트 객체를 찾습니다.

다음 단계는 표시 단계로 해제할 객체를 표시하는 단계입니다. 이 과정에서는 애플리케이션 스레드를 멈추지 않습니다.

이 과정중에 영 컬렉션이 발생할 수 있고 그러므로 올드 제너레이션 영역은 좀 더 증가할 수 있습니다.

다음은 전처리 단계(preclean phase)로 애플리케이션 스레드를 동시 병렬로 실행합니다. 그 후에는 재표시(remark) 단계가 일어납니다.

다음으로 수거 단계(sweep phase)가 일어나고 애플리케이션 스레드를 동반해서 수행합니다. 이 단계가 진행되는 경우에도 영 컬렉션이 발생할 수 있습니다.

마지막으로 재설정 단계가 오고 이 과정에서 CMS 주기는 완료됐고 올드 제너레이션 내에서 발견된 미참조 객체는 해제됩니다.

CMS 동시 병렬 주기는 작업이 대부분 제대로 이뤄지지만 실패할 수도 있습니다.

첫 번째 실패는 영 컬렉션이 발생하고 올드 제너레이션으로 승격(Promotion)할 공간이 부족할 때 발생합니다. 이때 기본적으로 CMS는 풀 GC를 수행하고 이는 단일 스레드로 동작합니다.

이 과정에서는 모든 애플리케이션 스레드가 멈추고 올드 제너레이션 영역에서 죽은 객체들을 제거하는 과정이 발생합니다.

두 번째 실패는 승격된 객체를 유지할 공간은 올드 제너레이션 영역에 충분하지만 메모리 단편화 현상때문에 승격에 실패할 때 발생합니다.

결과적으로 영 컬렉션이 일어나는 도중에 CMS는 전체 올드 제너레이션 영역을 수집하고 압축합니다. 이 과정에서는 힙이 압축되기 때문에 첫 번째 실패보다 더 많은 시간이 걸립니다.

앞의 두 과정은 CMS 컬렉터의 성능을 낮추는 중요한 이슈입니다. CMS 컬렉터의 성능을 올리기 위해선 동시 병렬 실패를 해결해야 합니다.

마지막 실패는 퍼머넌트 제너레이션 영역이나 자바 8 기준으로는 메타스페이스의 크기즐 조절할 때 발생할 수 있습니다.

동시 병렬 모드 실패를 해결하기 위한 튜닝

CMS가 충분히 빠르게 올드 제너레이션 영역을 정리하지 못하면 동시 병렬 모드 실패가 발생합니다.

영 제너레이션에서 컬렉션을 수행할 때가 오면 CMS는 올드 제너레이션으로 이 객체들을 승격시킬 수 있는 충분한 공간이 있는지 계산하고 공간이 부족하다면 올드 제너레이션을 수집합니다.

올드 제너레이션은 초기에 객체를 연속적으로 위치해서 채우고 올드 제너레이션은 특정 수준(디폴트 기준으로는 70%)까지 차면 동시 병렬 주기가 발생합니다.

백그라운드 CMS 스레드는 가비지를 찾기 위해 올드 제너레이션 영역을 검사하고 영 컬렉션은 올드 제너레이션으로 객체를 옮깁니다.

CMS 올드 제너레이션 영역이 해제 되는것보다 차는 것이 더 빠르다면 동시 별렬 모드 실패를 경험할 것입니다.

이 실패를 피하기 위해 시도할만한 방법은 다음과 같습니다.

  • 영 제너레이션의 비율을 올드 제너레이션으로 옮기거나 힙 공간을 추가해 올드 제너레이션 영역을 더 크게 만드는 것

  • 백그라운드 스레드를 더 자주 실행하는 것

  • 백그라운드 스레드를 더 많이 사용하는 것

메모리를 더 많이 사용할 수 있다면 힙의 크기를 늘리는 편이 좋은 해결책이 될 수 있습니다. 그렇지 않다면 백그라운드 스레드가 동작하는 방식을 바꿔야 합니다.

백그라운드 스레드가 더 자주 수행하기

CMS 백그라운드 스레드가 영 컬렉션 경쟁에서 이기게 하는 방법은 동시 병렬 주기를 더 빠르게 시작하는 것입니다.

올드 제너레이션 영역이 70% 찼을 때 시작하는 것보다 50% 찼을 때 병렬 주기를 시작한다면 동시 병렬 모드 실패를 경험할 확률이 더 적습니다.

이걸 이루기 위해서는 -XX:CMSInitiatingOccupancyFraction=N과 -XX:+UseCmsInitiatingOccupancyOnly 플래그 두 개를 설정해서 가능합니다.

UseCmsInitiatingOccupancyOnly 플래그의 디폴트는 false고 이때 CMS는 백그라운드 스레드 시작할 시기를 결정하기 위해 더 복잡한 알고리즘을 사용합니다.

백그라운드 스레드를 더 빨리 시작할 필요가 있다면 UseCmsInitiatingOccupancyOnly 플래그를 true로 설정해서 가능한 더 간단한 방식으로 사용하는 편이 낫습니다.

CMSInitiatingOccupancyFraction 플래그에 값은 동시 병렬 주기가 시작되는 타이밍을 말합니다. 디폴트는 70으로 이는 올드 제너레이션이 70% 점유됐을 때 CMS 주기가 시작됨을 말합니다.

이 값을 튜닝할 땐 동시 병렬 모드가 실패한 때를 찾고 그 후 가장 최근에 CMS 주기가 시작된 시기를 찾아보면 됩니다. 왜냐하면 실패한 후 부터 백그라운드 스레드 시작 시기가 조정되기 때문입니다.

여기서 CMSInitiatingOccupancyFraction 값을 아주 작은 값으로 설정하고 싶을 수도 있습니다. 이런 경우 트레이드 오프에 대해 정확하게 알고 있어야 합니다.

첫 번째 트레이드 오프는 CPU 시간입니다. CMS 백그라운드 스레드는 끈임없이 수행되고 많은 양의 CPU를 소비할 것입니다.

두 번째 트레이드 오프는 중단 시간입니다. 앞서 살펴본 CMS 동시 병렬 주기를 살펴보면 특정 단계에서 애플리케이션 스레드를 멈추는 단계가 있습니다.

즉 CMS 주기를 필요 이상으로 자주 실행시킨다면 역효과를 낳을 수 있습니다.

그러므로 CMSInitiatingOccupancyFraction 값은 적어도 10% ~ 20% 이상의 값을 설정하는게 좋습니다.


4. G1 컬렉터 이해하기

G1 컬렌터는 힙 내의 개별 영역에서 동작하는 동시 병렬 컬렉터입니다. 각 영역은 디폴트로 2048개가 있고 올드 제너레이션 영역이나 신규 제너레이션 영역에 속할 수 있습니다.

G1 컬렉터는 여러 영역으로 이뤄진만큼 가비지가 있는 영역만 집중적으로 처리할 수 있습니다. 그러므로 가비지 우선 방식이라고 불리며 Garbage First로 G1 컬렉터로 불립니다.

G1은 네가지의 주요 동작을 수행합니다.

  • 영 컬렉션

  • 백그라운드, 동시 병렬 주기

  • 혼합 컬렉션

  • 필요한 경우 풀 GC

이 그램에서 각각의 사각형은 G1 영역을 나타냅니다. 해당 영역에 있는 문자는 속해있는 영역을 말합니다.

빈 영역은 어떠한 제너레이션에도 속하지 않습니다. G1이 필요하다고 여기면 제너레이션의 종류를 구분하지 않고 독단적으로 사용합니다.

에덴이 가득 차면 (이 경우에는 영역 7개가 찬 후) G1 영 컬렉션이 촉발됩니다. 영 컬렉션이 일어난 후에는 모두 서바이버 스페이스나 올드 제너레이션 영역으로 이동합니다.

일반적인 경우에 서바이버 스페이스에 있는 객체는 올드 제너레이션으로 이동하며 서바이버 스페이스가 가득 찬 경우라면 에덴에서 바로 올드 제너레이션 영역으로 승격될 수 있습니다.

G1의 동시 병렬 주기의 경우에는 크게 세가지 관찰 포인트가 있습니다.

첫 번째는 동시 병렬 주기동안 영 컬렉션이 적어도 한번이 발생할 것입니다. 그러므로 영 제너레이션의 비율은 달라질 수 있습니다.

두 번째로는 가비지가 포함된다고 표시될 지역은 G1에서 따로 마킹합니다.

마지막으로는 올드 제너레이션 영역은 동시 병렬 주기 이후에 더 많이 점유될 수 있습니다. 이건 표시 주기 동안 영 제너레이션에서 컬렉션이 일어나면서 올드 제너레이션으로 승격시키기 때문입니다.

G1의 동시 병렬 주기 단계를 자세하게 살펴보겠습니다.

G1의 동시 병렬 주기에는 몇가지 단계가 있고 이 중 일부는 애플리케이션 스레드를 전부 멈춰야 합니다.

첫 번째 단계는 초기 표시 단계(initial-mark phase)로 부분적으로 영 컬렉션도 수행합니다. 이 말의 뜻은 초기 표시 단계에서 애플리케이션 스레드를 멈춰야 하므로 G1은 이 작업을 하기 위해 영 GC 주기를 이용합니다.

이로인한 트레이드 오프는 영 GC에 초기 표시 단계를 추가한 것이므로 중단 시간이 약간 더 길어지게 됩니다.

다음으로는 이 표시에서 루트 영역을 살핍니다.

이 작업은 애플리케이션 스레드를 멈추지 않고 백그라운드 스레드만 이용합니다. 하지만 이 단계는 영 컬렉션에 의해 중단될 수 없으므로 백그라운드 스레드를 위한 가용 CPU 확보가 중요합니다.

루트 영역을 살펴보는 동안 영 제너레이션이 가득 차게 되면 영 컬렉션은 루트 조사가 끝날 때까지 대기해야 합니다. 그러므로 영 컬렉션이 평소보다 긴 중단을 할 수 있습니다.

이런 일이 자주 발생한다면 G1 컬렉터는 튜닝할 필요가 있습니다.

루트 영역 조사 후 G1은 동시 병렬 표시 단게에 접어듭니다. 완벽히 백그라운드에서 수행되며 이 단계 이후에는 재표시 단계(remarking phase)와 정상 소거 단계(normal cleanup phase)가 옵니다.

이제 정규 G1 주기(normal G1 cycle)이 끝나고 메모리가 환원됩니다. 이 과정에서 대부분 G1이 한 일은 올드 제너레이션 영역에서 가비지를 표시한 영역을 구분한 것입니다.

이 주기 이후 G1은 혼합 GC를 수행할 수 있습니다. 이 혼합 GC는 영 컬렉션을 수행하지만 백그라운드 조사에서 표시된 가비지 영역 몇 군데서도 수집도 하기 때문에 홉합 GC라고 불립니다.

이 과정이후 살아있는 객체는 영 제너레이션에서 올드 제너레이션으로 이동하는 것처럼 다른 영역으로 옮겨집니다. 이처럼 객체를 이동하면서 G1은 힙을 압축하므로 G1은 결국 CMS보다 덜 단편화된 힙을 유지할 수 있습니다.

동시 병렬 모드 실패

G1은 표시 주기를 시작하지만 올드 제너레이션이 주기가 완료되기 전에 꽉 차는 경우라면 G1은 표시 주기를 중단합니다.

이 실패는 힙 크기를 늘리거나 G1 백그라운드 처리를 좀 더 빨리 시작하거나 주기가 더 빨리 수행하도록 튜닝해야 함을 말합니다.

승격 실패

G1은 표시 주기를 완료하고 올드 제너레이션 영역을 치우기 위해 혼합 GC를 수행하는 데 올드 제너레이션이 충분한 메모리를 환원하기 전에 올드 제너레이션의 크기가 부족한 경우 혼합 GC에 이어 풀 GC가 바로 발생합니다.

이 실패는 혼합 GC가 좀 더 빨리 수행해야 함을 말합니다. 그리고 각 영 컬렉션에서 올드 제너레이션 내에서 더 많은 영역을 처리할 필요가 있습니다.

비우기 실패

영 컬렉션이 수행되고 있을 때 서바이버 스페이스와 올드 제너레이션 내에 살아남은 객체를 전부 유지하기에 공간이 충분하지 않을 때 발생합니다.

이 현상은 힙이 가득 찼거나 단편화 됐다는 걸 말해줍니다. 이 문제를 해결하기 위해 G1은 풀 GC를 합니다.

이 현상이 일어나지 않도록 하는 가장 단순한 방법은 힙 사이즈를 늘리는 것입니다.

대규모 할당 실패

매우 큰 객체를 할당하는 애플리케이션은 G1에서 다른 종류의 풀 GC를 발생시킬 수 있습니다.

풀 GC가 명확한 이유 없이 발생한 것이라면 대규모 할당 때문일 수 있습니다.

G1 튜닝하기

G1을 튜닝하는 목적은 풀 GC가 일어나지 않도록 하기 위함입니다.

풀 GC를 방지하기 위해 사용되는 기술은 루트 영역 조사가 완료되길 기다려야 하는 영 GC의 빈도가 잦을 때도 사용할 수 있습니다.

풀 GC를 방지하기 위한 옵션은 다음과 같습니다.

  • 힙 공간 전체를 늘리거나 제너레이션 간의 비율을 조정해서 올드 제너레이션의 크기를 늘립니다.

  • CPU가 충분하다면 백그라운드 스레드 개수를 늘립니다.

  • G1 백그라운드 스레드의 작업을 더 자주 수행합니다.

  • 혼합 GC 주기 내에서 할 작업의 양을 늘립니다.

G1 백그라운드 스레드 튜닝하기

가용 CPU가 충분히 있다면 백그라운드 표시 스레드를 늘리도록 해 튜닝할 수 있습니다.

G1 스레드를 튜닝하는 건 CMS 스레드를 튜닝하는 것과 유사합니다.

ParallelGCThreads 옵션은 애플리케이션 스레드가 멈출 때 사용될 스레드의 개수를 말하며 ConGCThreads 플래그는 동시 병렬 단계에서 사용되는 스레드의 개수를 말합니다.

ConGCThreads의 디폴트 값은 다음과 같이 정의됩니다.

ConGCThreads = (ParallelGCThreads + 2) / 4 

더(혹은 덜) 자주 수행되도록 G1 튜닝하기

G1이 더 빨리 수집되기 시작하면 풀 GC를 방지할 수 있습니다. G1의 주기는 디폴트 값이 45인 -XX:InitiatingHeapOccupancyPercent=N으로 명시된 점유율의 비율과 힙이 같아질 때 시작됩니다.

CMS와의 차이점은 올드 제너레이션 뿐 아니라 전체 힙의 사용률을 기반으로 합니다.

InitiatingHeapOccupancyPercent 값이 너무 높게 설정되어 있으면 동시 병렬 주기가 완료되기 전에 힙이 꽉 찰 확률이 높습니다.

반면 InitiatingHeapOccupancyPercent 값이 너무 낮게 설정된다면 백그라운드 처리를 수행할 CPU 확보도 중요하고 동시 병렬 주기가 더 많이 수행되므로 더 많은 중단 시간을 가질 수 있습니다.

그러므로 InitiatingHeapOccupancyPercent 값을 설정할 땐 GC 로그를 통해 동시 병렬 주기 후에 힙 사이즈를 확인한 후 그보다 큰 값을 설정하면 됩니다.

G1 혼합 GC 튜닝하기

동시 병렬 주기 후에 G1은 올드 제너레이션 내에 표시된 영역이 전부 수집되기 전까지 새로운 동시 병렬 주기를 수행할 수 없습니다.

따라서 혼합 GC 주기에서 더 많은 영역을 처리하도록 하는 것이 좋습니다. 대신에 중단 시간이 길어지는 트레이드 오프가 있습니다.

혼합 GC가 처리하는 일은 세 가지 요소에 의존합니다.

첫 번째는 우선 가비지가 있다고 표시될 영역의 개수 입니다. 직접적으로 영향을 줄 순 없지만 기본적으로 35%가 가비지라면 혼합 GC 동안 해당 영역은 컬렉션 대상으로 선택됩니다.

두 번째 요소는 XX:G1MixedGCCount Target=N 플래그 값입니다. 이 값은 혼합 GC 주기동안 최대 처리할 개수 입니다.

디폴트 값은 8입니다. 만약 이 값을 줄인다면 혼합 GC 주기 동안 중단 시간은 더 길어지겠지만 더 빨리 환원하므로 승격 실패를 극복하는데 도움을 줄 수 있습니다.

마지막으로 세 번째 요소는 혼합 GC에 대한 최대 중단 시간 설정입니다. MaxGCPauseMilis 플래그 값으로 설정할 수 있으며 이 값이 늘어나면 더 많은 중단시간을 가지므로 더 많은 올드 제너레이션 영역이 수집됩니다.

그러므로 G1은 동시 병렬 주기를 더 빨리 시작할 수 있습니다.

profile
좋은 습관을 가지고 싶은 평범한 개발자입니다.

0개의 댓글