Garbage Collection in Java Hotspot VM


C, C++와 같은 언어에서 동적 할당을 해 본 적 있는가? 동적으로 메모리를 사용할 때 반드시 지켜야 하는 사항, 배울 때 귀에 딱지가 얹도록 들었던 말이 바로 얻어온 메모리를 OS에 다시 돌려주는 것이다. 제때 반환하지 않으면 OS로부터 동적으로 할당받을 수 있는 메모리가 줄어들게 된다memory leak . 최악의 경우에는 OS가 더이상 메모리를 더이상 할당해주지 못해 프로그램이 뻗어버리는 상황이 발생할 수 있다. 🤯

초심자들의 통곡의 벽이었던 포인터와 동적 할당을 Java에서는 걱정할 필요가 없다. Java는 ‘우리가 알아서 해 줄게, 너는 개발에만 집중해’라는 달콤한 말을 건넨다. 개발자가 동적 메모리의 생명주기를 신경쓰지 않게 되면서 생산성이 더욱 올라가게 되었다.

This completely eliminates some classes of errors related to memory management
at the cost of some additional runtime overhead.
작은 런타임 오버헤드를 통해서, 메모리 관리와 관련된 에러를 완벽하게 제거할 수 있다

이 글에서는 Java를 사용하는 개발자가 동적 메모리로부터 자유로울 수 있게 된 Garbage Collection에 대해서 알아본다. Java SE 7 구현체 문서, Java SE 17 구현체 문서(튜닝)를 기반으로 작성되었다.

Garbage Collector?

위에서 말한 목표를 충족하기 위해서는, 보이지 않는 곳에서 꾸준히 사용하지 않는 객체를 청소해주는 친구가 필요하다. 그 역할을 하는 것이 Java의 환경미화원이라고도 볼 수 있는 Garbage Collector이다. 단순히 치우는 일만 하는 게 아니라, 프로그램에서 전반적인 메모리 관리를 진행하는 녀석이다. 다음과 같은 네 가지 일을 진행한다.

  1. OS로부터 메모리를 할당받거나, 돌려준다.
  2. 어플리케이션이 요청하는 경우, 할당받은 메모리를 어플리케이션에게 준다.
  3. 어플리케이션이 아직 사용하고 있는 메모리 부분을 파악한다.
  4. 어플리케이션이 사용하지 않는 메모리는 재사용할 수 있도록 회수한다.

Collector라는 이름에서 메모리를 회수하는 역할만 담당하는 것처럼 보이지만, 전반적인 메모리를 관리하는 일을 한다. 메모리 관리를 수행하는 친구를 Garbage Collector, 일련의 메모리를 정리하는 과정을 통틀어 Garbage Collection이라고 한다.

Implementing GC

앞선 JVM 소개 글에서 JVM은 vendor의 구현에 따라 달라진다고 했다. 더 나아가, GC에 대한 내용은 없다시피하다. JVM 명세에서는 automatic storage management system 이라고 명시한다. 나아가 이 system 이 하는 일도 구체적으로 적혀있지 않았다. “Heap은 Garbage collector에 의해 회수되는 영역” 정도로만 명세돼 있고, 어떤 메모리 영역에서는 “간단한 구현의 경우 Garbage collector가 회수하지 않아도 괜찮다” 라고 쓰여 있다.

결국 vendor가 구현하기 나름이다. 어떻게 하면 효율적으로 메모리를 관리할 수 있도록 구현할 수 있을까? 간단하게 생각하면, GC가 담당하는 메모리에 할당되는 모든 객체에 대해서 직접 모니터링하는 방법을 떠올릴 수 있다. Reachable object를 탐색한다고도 하는데, Root set으로부터 참조를 타고 갈 수 있다면 Reachable, 그렇지 않으면 Unreachable로 객체를 나누는 것이다. Unreachable object는 누구에게도 참조되지 않으므로 메모리 회수 대상이 된다.

오? 그럼 매번 메모리가 모자랄 때마다 reachability를 계산하면 되는 것 아닌가? 🤔 라고 생각할 수 있겠지만, 이는 꽤나 시간이 드는 연산이다. 사용 중인 메모리를 확인하기 위해서 JVM 위의 모든 스레드를 중단stop-the-world event 해야 하기 때문이다. 보다 나은 방법은 없을까?

Weak Generational Hypothesis

Oracle사의 Java Hotspot Virtual Machine에서는 몇 가지 관찰을 통해 메모리를 효율적으로 관리해낼 방법을 찾아내게 된다. 그들의 발자취를 따라가 보면서 이해해 보자.

할당된 메모리의 크기에 따른, 살아남은 메모리의 양

위 그래프는 Java 프로그램에서의 할당된 바이트 수에 따른 살아남은 바이트의 크기를 나타냈다. 즉, 파란 공간은 일반적인typical 객체의 수명에 대한 분포이다. 왼쪽의 짧은 피크 이후 뚝 떨어지는 절벽은 할당된 직후에 회수될 수 있는 객체를 의미한다.

거의 모든 객체majority of objects 가 일찍 수명을 다한다는 점에 초점을 맞추면 효율적인 회수가 가능하지 않을까? 이를 가설로 내세운 것이 weak generational hypothesis 이다. ZGC를 제외한 모든 Java Hotspot VM의 GC들은 위 가설을 기반으로 최적화한 generational collection 방식을 사용한다.

Generational Collection

앞서 설명한 가설을 활용하기 위해서는 객체를 세대generation 별로 분류해야 한다. Generational collection에서는 객체를 young generation, old generation으로 분류한다. 메모리 또한 두 부분으로 나뉘며, 메모리 회수는 어느 한 쪽 generation이 가득 찰 때마다 실행된다.

Heap의 구조를 Young/Old generation으로 나눈다

프로그램이 가동된 뒤, 최초에는 Survivor 영역이 비어 있다. 양 쪽의 Virtual 영역은 JVM이 논리적으로만 가지고 있는 영역이다. 필요하다면 OS로부터 할당받아 추가적인 매핑을 진행하게 된다(JVM은 가상 머신임을 잊지 말자!).

  1. 객체가 할당되면 대부분의 경우 Young generation의 Eden 영역에 저장된다.
  2. 어느 순간, 새로운 객체가 할당되었으나 Eden 영역이 가득 차 할당할 수 없는 상황이 발생한다.
  3. <minor collection> GC는 Young generation을 돌면서 참조되지 않은 객체를 회수한다.
  4. <minor collection> Eden 영역의 참조된 객체들은 Survivor 영역으로 이동한다.
  5. 두 Survivor 영역의 역할이 바뀐다. 다음 minor collection에는 비어 있는 survivor 영역으로 이동한다.
  6. (1~5)를 반복한다.
  7. <Aging / Promotion> minor collection이 일어날 때, Survivor 영역에서 반대쪽 survivor 영역이 가득 차 이동하지 못하거나, 특정 횟수threshold 이상 survivor 영역에서 살아남은 객체는 old generation으로 이동한다.
  8. <major collection> Old 영역이 가득 차는 순간, 전체 힙에 대한 Garbage collection이 일어난다.

위와 같은 방식으로 GC가 작동한다. 메모리 효율을 위해 compacting이라는 기법을 사용할 수도 있다.

Selecting/Tuning Garbage Collector

ZGC를 제외한 다양한 GC들 (G1, Parallel, Serial, …)은 위와 같은 방식으로 참조되지 않는 객체들을 청소한다. 그렇다면 이제 다양한 GC들을 적재적소에 고르거나, GC의 파라미터를 수정하는 방식으로 나의 어플리케이션에 최적화된 GC를 만들 수 있겠다는 생각이 든다.

모든 스레드가 멈추더라도 상관없는 어플리케이션이 존재할 수도 있고, 많은 스레드와 트랜잭션을 가진 어플리케이션의 경우에는 조금의 딜레이도 용납할 수 없을 것이다. 이 경우에는 어떤 방식으로 GC를 골라야 할까?

암달의 법칙 (Amdahl’s Law), 병렬 처리라고 무조건 빠른 것은 아니다

위 그래프는 프로세서가 많아질수록 실제 비즈니스 로직이 실행되는 시간의 비율을 나타낸 그래프이다. 전체 시간의 1%만을 GC로 사용하는 프로그램도 30개 이상의 멀티코어로 실행되는 순간, GC에만 전체 실행 시간의 20%의 시간이 소모된다는 의미이다. 소규모 시스템에서는 무시할 수 있는 처리량이, 확장되는 경우 병목 현상bottleneck effect 을 일으킬 수 있다.

만약 소규모 프로그램이라면 직렬 GC (Serial Collector)를 고려하는 것도 좋다. 여타 멀티코어에 기반한 GC는 특수한 여러 동작에 대한 trade-off를 가지고 있기 때문이다. 두 개 이상의 프로세서를 사용하거나, 대량의 메모리에서 어플리케이션이 실행되는 경우 기본적으로 G1(Garbage-first) GC가 설정된다.

그 외에도 Parallel, Z 등 다양한 GC 알고리즘이 존재한다. 무엇보다 중요한 건 나의 어플리케이션이 어떤 상황에 있고, 그 상황에서 최적의 GC를 선택하는 것이 될 것 같다. 튜닝에 관심이 많다면 이곳을 확인해 보자!

Categories