GC 진짜 쉽게 이해해보기

Minuuu·2025년 4월 15일
0

Java

목록 보기
20/23

이전의 글 G1 GC 깊게 파보기는 G1 GC의 용어나 개념에 포커스를 뒀습니다.
다만, 오히려 어려운 자료구조나 용어들로 인해 이해를 돕기엔 어렵다는 점을 느꼈습니다.
그래서 좀 더 본질에 집중한 글을 작성해보고자 합니다.

가비지 컬렉션이란 안쓰는 메모리를 JVM 내부 GC가 회수해주는 작업을 의미합니다.
GC의 과정에 대해 간단하게 말씀드리겠습니다.
1. GC가 루트를 잡아 모든 살아있는 객체를 마킹
2. 마킹된 객체를 메모리에 재정렬(죽은 객체 처리 x) (단편화 방지)

실제로 많은 분들이 놓치시는 부분이 죽은 객체를 처리한다고 오해합니다.
하지만 실제로는 살아 있는 객체들을 단순히 재정렬만 수행하고, 그 외 메모리 공간은 비어있는 공간이라고 인식만 하는 것이지 죽은 객체는 그대로 존재하고 이후에 덮어쓰는 식으로 구현됩니다.
(죽은 객체는 실제로 "처리되지 않는다"기보다는, 살아있는 객체를 재배치한 후 그 공간을 사용 가능한 것으로 표시합니다.)

이는 G1 GC 이전의 GC를 사용할 때에 heap의 모습입니다. (perm은 이 글의 맥락과 중요하지 않아 무시해주세요.)

여기서보시면 Young과 Old 영역으로 구분되어 있는데 이는 GC의 두가지 가설을 토대로 설계되었습니다.

  1. 대부분의 객체는 곧 도달하지 못한다. (Most Objects soon become unreachable)
  2. 오래 살아있는 객체가 젊은 객체를 참조하는건 적다 (Reference from "old" objects to "young objects" only exist in small numbers)

즉, 대부분의 객체는 도달할 수 없는 객체(GC의 대상인 죽은 객체)가 되기에 젊은 객체는 Young, 오래된 객체는 Old로 구분하여 Young 영역만 빠르고 많이 GC를 수행하자!라는 설계를 생각했습니다.
실제로 Young 영역의 GC를 수행하며 특정 GC 주기동안 살아남았다면 Old로 승격하게 됩니다.
그래서 Young을 주로 GC를 수행하면서 old가 가끔씩 가득찬다면 old 영역을 비우도록 합니다.

다만 이 과정에서 문제가 생겼습니다.

Old GC를 처리하는게 Young GC에 비해 너무 비싼데... 어떻게 해야하지?

실제로 어플리케이션이 실행되는 도중에 GC가 일어나게되면, 어플리케이션이 멈추는 과정(Stop The World)이 일어나게 됩니다.
Minor GC (young): 일반적으로 짧은 STW 시간 (밀리초 단위) -> 크게 신경쓰지 않아도 된다.
Major GC (old): 더 긴 STW 시간 (경우에 따라 초 단위까지) -> 진짜 신경써야하는 부분
그래서 현대의 GC는 이러한 Major GC부분을 비롯한 Full GC(old + young) 에 드는 멈추는 시간을 줄이고자 노력하였습니다.


왜 Major GC와 Full GC는 비쌀까요?
1. Old 영역이 우선 young에 비해 2배이상 크다는 문제가 있었습니다.
2. Young GC는 자주 일어난다는 특성으로 인해 Surv 공간으로 메모리 압축 시간을 줄일 수 있었습니다(쉽게 말해 최적화)

가장 큰 문제는 결국, 큰 Old 영역을 전체를 모든 GC의 단계(mark & sweep & compact)를 수행해야한다는 점이였습니다.

그래서 G1 GC는 이에 대한 아이디어를 내놓습니다.

Old가 처리할 때 모든 Old를 처리하는 것이 아니라 Old의 필요한 부분만 처리할 수 있도록 할 순 없을까?
-> 앞으로의 설명은 Young GC가 아닌 Old GC 관점에서 설명하겠습니다.

G1 GC (Garbage First GC)


위는 G1 GC의 경우 Heap의 구조인데요. 이전과 달리 Eden, Survivor 영역이 여러 특정 구간으로 쪼개어진걸 볼 수 있습니다. 또한 이전의 GC 설계의 두가지 가설에 따라 Eden과 Survivor을 그대로 채택한 점을 볼 수 있습니다.

G1 GC는 "리전"이라는 개념으로 답을 두었는데요.
만약 Old까지의 GC 처리가 필요하다면, Old 전체를 처리하는게 아니라, 일부의 Old영역만 선택해 처리하는 방식입니다.(전체 young + 일부의 old 리전만 선택!)
실제로 각 리전의 크기와 가비지의 개수 등을 따라 내부 알고리즘(Garbage first)을 통해 가비지가 많은 곳을 중점으로 판단하게 됩니다.
구체적인 단계를 보고 싶으시다면 GC 자세한 글을 참고해주세요!

이렇게 리전으로 쪼갠것의 가장 큰 장점은 STW 시간을 조절할 수 있다는 것입니다.
이전의 GC는 모든 Old에 GC 처리를 해줘야하기 때문에 STW 시간을 조절하기 어려웠습니다.
하지만, G1 GC는 내부 알고리즘으로 특정 리전의 STW 시간을 예측할 수 있었기 때문에 어플리케이션 상황에 맞게 튜닝이 가능해졌습니다.

-XX:MaxGCPauseMillis=200

위와 같이 STW 시간을 설정할 수 있게 되었습니다.

또한 그외에도 G1 GC 이전의 GC들은 마킹단계에서 Stop the World를 모두 걸어줬는데요.
G1 GC에서는 마킹 초기에 STW를 걸어 스냅샷을 찍고, STW를 풀고 어플리케이션과 마킹을 동시에 처리합니다.
이후 마킹 마지막에 STW를 다시 한번 걸어 스냅샷과 바뀐 참조에 대해서 동기화하여 마킹을 처리합니다.
이런 방식으로 STW를 최소화 하는 작업을 통해 지연시간을 최소화 할 수 있었습니다.

결국 G1 GC는 다음과 같습니다.

  1. Heap을 리전이라는 단위로 나누어 전체 Heap을 처리하지 않고, 일부의 리전만 처리
  2. 마킹 단계를 쪼개어 STW 시간 최소화
    리전마다 가비지에 대한 계산을 통해 예측 가능해져서 STW 시간을 조절할 수 있다.

이상입니다.
사실 이해를 돕기 위해 전체 Heap을 처리하지 않는다고 설명했지만
실제로는 마킹 단계에선 G1 GC도 전체 Heap을 마킹합니다.
다만 재정렬이라는 단계에서는 전체 Heap이 아닌 리전의 단위에 맞게 처리하기 때문에 위와 같이 설명했습니다.
실제로는 재정렬이 굉장히 비싼 작업이기 때문입니다. 아무튼 헷갈리지 않으셨으면 좋겠습니다.
다들 GC에 대한 올바른 이해 가지셨으면 좋겠습니다 :)

profile
꾸준히 한걸음씩 나아가려고 하는 학부생입니다 😄

0개의 댓글