본문 바로 가기

자바 Serial Minor GC 동작 방식

들어가며

GC는 JVM이 더 이상 사용하지 않는 객체를 메모리에서 자동으로 제거하여, 개발자가 직접 메모리를 관리하는 부담을 덜어주는 핵심 기능입니다.

 

오늘 우리는 그 GC 알고리즘 중 가장 기본적인 Java Serial GC에 대해 알아 보도록 하겠습니다. Serial GC는 다른 복잡한 GC 알고리즘들의 기반이 되는 개념들을 가장 명확하게 보여줍니다. 특히, Serial GC 중에서도 Young Generation 에서 일어나는 Minor GC가 어떻게 동작하는지, 그리고 실제 소스코드를 통해 그 내부를 들여다볼 예정입니다.

🛫 가비지 컬렉션의 기본 원리: Mark & Sweep vs. Mark & Copy

본격적으로 Serial GC를 알아보기 전에, GC 알고리즘의 가장 기본적인 두 가지 원리인 Mark & SweepMark & Copy에 대해 짚고 넘어가겠습니다. 이 두 가지 방식은 모든 GC 알고리즘의 근간이 됩니다.

Mark and Sweep

GC를 수행할 때 가장 기초적인 형태의 알고리즘입니다. 이름 그대로 두 단계로 나뉘어 진행됩니다.

  1. Mark (마킹): 메모리 탐색을 시작하여, 현재 프로그램에서 더 이상 참조되지 않는 객체(Unreachable Object)들을 찾아 표시(Mark)합니다. 참조되지 않는다는 것은 앞으로도 사용될 일이 없다는 뜻이겠죠?
  2. Sweep (삭제): 마킹된 객체들을 한 번에 메모리에서 해제합니다.

이 방식의 가장 큰 문제점은 그림에도 명확히 나타나듯이 메모리 파편화(Memory Fragmentation)입니다. 객체들이 불규칙하게 제거되면서 메모리에 작은 빈 공간들이 여기저기 생기게 됩니다. 이렇게 파편화된 공간은 당장 활용하기 어렵고, 새로운 큰 객체가 할당될 때 사용 가능한 연속된 공간을 찾기 어려워 성능 저하로 이어질 수 있습니다.

Mark And Copy

Mark and Sweep의 메모리 파편화 문제를 해결하기 위해 등장한 방법이 바로 Mark and Copy 알고리즘입니다. 이 방식은 접근 방식 자체가 다릅니다.

  1. Mark (마킹): 그래프 탐색을 통해 도달 가능한(Reachable) 객체, 즉 현재 사용 중인 객체들을 식별합니다.
  2. Copy (복사): 마킹된 객체들을 새로운 메모리 영역으로 모아서 복사합니다. 이 과정에서 자연스럽게 파편화된 빈 공간은 사라지고, 복사된 객체들은 새로운 영역에 연속적으로 배치됩니다. 기존 영역은 모두 비어있는 공간으로 간주되어 초기화됩니다.

이렇게 되면 메모리 파편화 문제를 깔끔하게 해결하고, 이후 새로운 객체 할당을 선형적으로(linearly), 즉 순서대로 다음 빈 공간에 채워 넣는 방식으로 매우 빠르게 수행할 수 있게 됩니다.

 

하지만 단점도 명확합니다. 객체를 복사할 새로운 공간이 필요하기 때문에, 전체 메모리의 절반만 효율적으로 사용할 수 있다는 점입니다.

🎖 Serial GC

자바의 GC는 몇 가지 중요한 가설을 기반으로 알고리즘을 설계했습니다.

  1. 약한 세대 가설: 대부분의 객체는 생성된 지 얼마 되지 않아 금방 도달 불가능한 상태가 되어 죽는다. (즉, 일찍 죽는다.)
  2. 강한 세대 가설: 객체는 살아남을수록(오래될수록) 앞으로도 계속 살아남을 확률이 더 높다.

이러한 가설을 바탕으로 Serial GC는 힙(Heap) 메모리 공간을 Young GenerationOld Generation으로 분리했습니다. 대부분의 객체가 일찍 죽기 때문에, Young Generation 공간만 자주 살펴봐도 효율적인 GC가 가능하다고 판단한 것입니다.

Serial GC의 Young Generation은 앞서 설명한 Mark and Copy 방식을 기반으로 합니다. 하지만 메모리 절반만 사용할 수 있다는 단점을 극복하기 위해 약간의 변형된 형태를 띠고 있습니다.

Young Generation의 메모리 구조

Young Generation 공간을 총 세 가지로 구분합니다.

  • Eden : 새로운 객체가 처음 할당되는 공간입니다. '대부분의 객체가 일찍 죽는다'는 가설에 따라, Young Generation 전체에서 가장 많은 비중을 차지합니다.
  • Survivor1 : Eden 영역에서 살아남은 객체들이 일시적으로 머무는 공간 중 하나입니다.
  • Survivor2 : Survivor1과 마찬가지로 살아남은 객체들이 머무는 공간으로, Survivor1과 번갈아 가며 사용됩니다.

Young GC (Minor GC) 동작 방식

Minor GC는 Young Generation에서 일어나는 GC를 의미합니다.

  1. 객체 할당: 새로운 객체는 항상 Eden 영역에 할당됩니다. Eden 영역이 가득 차면 Minor GC가 발생합니다.
  2. Marking & Copying: GC가 진행될 때, Eden 영역과 현재 활성화된 하나의 Survivor 영역(예: Survivor1)에서 살아있는 객체들을 식별합니다. 이 살아있는 객체들은 다른 비활성화된 Survivor 영역(예: Survivor2)으로 복사됩니다.
  3. 영역 초기화: 객체들이 복사되고 나면, 기존의 Eden 영역과 비활성화된 Survivor 영역(예: Survivor1)은 모두 깨끗하게 비워집니다. 이 공간에 남아있던 객체들은 모두 도달 불가능한 객체이므로 다음 객체 할당 시 덮어쓰기됩니다.
  4. 역할 교체: 다음 Minor GC 때는 Survivor2가 활성화된 Survivor 영역이 되고, Survivor1이 비활성화된 영역으로 역할이 교체됩니다. 이런 식으로 Eden -> Survivor1/Survivor2 -> Eden -> Survivor2/Survivor1 식으로 번갈아 가며 복사가 일어납니다.

Serial GC의 또 다른 중요한 특징은, 모든 GC 과정이 사용자 스레드를 전부 정지시키고 진행된다는 점입니다. 이를 Stop-the-World (STW)라고 부릅니다. STW가 발생하면 애플리케이션은 잠시 멈추게 되며, GC 작업이 완료될 때까지 어떤 작업도 수행할 수 없습니다. Serial GC는 단일 스레드로 GC 작업을 수행하기 때문에 GC 시간이 길어지면 애플리케이션 지연(latency)이 매우 커질 수 있습니다. 이 때문에 주로 적은 양의 메모리를 사용하는 단일 코어 환경에서 사용됩니다.

💧 코드와 함께 살펴보는 Young GC 동작 과정

이제 OpenJDK 17 소스 코드를 통해 Serial GC의 Young Generation GC가 실제로 어떻게 구현되어 있는지 살펴보겠습니다. 실제 GC 코드는 굉장히 복잡하고 여러 예외 상황을 고려하지만, 우리는 핵심적인 동작 원리에 집중하여 단순화해서 설명드리겠습니다.

 

Young Generation의 Serial GC 관련 코드는 defNewGeneration.cpp 파일에서 찾아볼 수 있습니다.
소스코드

void DefNewGeneration::collect(bool   full,
                               bool   clear_all_soft_refs,
                               size_t size,
                               bool   is_tlab) {

      // 사용할 survivor 영역 클리어: GC가 완료된 후 객체들이 복사될 Survivor 영역을 초기화합니다.
      to()->clear(SpaceDecorator::Mangle);

      // 클로저들 선언: 객체를 스캔하고 처리할 여러 '클로저(Closure)' 객체들을 선언합니다.
      DefNewScanClosure       scan_closure(this);
      DefNewYoungerGenClosure younger_gen_closure(this, _old_gen);
      CLDScanClosure cld_scan_closure(&scan_closure);
      FastEvacuateFollowersClosure evacuate_followers(heap,&scan_closure,&younger_gen_closure);

    // 루트 객체 탐사: GC Root에서 직접 참조되는 객체들을 탐색하고 처리합니다.
      heap->young_process_roots(&scan_closure,&younger_gen_closure,&cld_scan_closure);

    // 루트 객체가 참조하고 있는 객체들 탐색 및 복사 시작 (팔로워 처리)
     evacuate_followers.do_void();

    // 메모리 초기화: GC가 끝난 후 사용되지 않는 Eden과 From Survivor 영역을 비웁니다.
     eden()->clear(SpaceDecorator::Mangle);
     from()->clear(SpaceDecorator::Mangle);

    // To와 From Survivor 영역을 바꿔준다 (역할 교체)
    swap_spaces();
}

위 코드에서 from()to()는 Survivor 영역들을 의미합니다. 두 영역이 번갈아 사용되기 때문에, 현재 시점에서 살아있는 객체들이 존재하는 Survivor 영역이 from()이고, 객체들이 복사될 비어 있는 공간이 to()입니다.

💫 heap->young_process_roots(): GC 루트 스캔

heap->young_process_roots(&scan_closure,&younger_gen_closure,&cld_scan_closure);

이 코드는 GC Root에서 직접 참조되는 객체들에 대해 GC를 진행합니다. GC Root란 스택 프레임의 지역 변수, 정적 변수, JNI (Java Native Interface) 관련 객체, 모니터 객체 등 JVM이 직접 관리하는, 반드시 살아있어야 하는 객체들을 의미합니다. GC는 이 Root로부터 시작하여 객체 참조 그래프를 탐색합니다.

 

여기서는 가장 일반적인 로컬 변수와 지역 변수에 대한 GC 내용에 집중하겠습니다.

 

young_process_roots는 여러 함수를 거쳐 현재 존재하는 스택 프레임을 전부 탐색하면서, 참조하고 있는 객체들에 대해 GC를 진행합니다. 핵심은 DefNewScanClosure scan_closure가 Young Generation 객체를 스캔하는 '클로저'라는 점입니다. 결국, 코드는 살아있는 객체별로 DefNewScanClosuredo_oop 함수를 실행하게 됩니다.

 

DefNewScanClosureFastScanClosure를 상속하고 있으며, 최종적으로 아래 함수가 실행됩니다.
소스코드

inline void FastScanClosure<Derived>::do_oop_work(T* p) {
  // 객체의 주소
  oop obj = CompressedOops::decode_not_null(heap_oop);

  // 새로운 주소를 얻는다
  oop new_obj = obj->is_forwarded() ? obj->forwardee(): : _young_gen->copy_to_survivor_space(obj);

  // 객체 참조에 새로운 주소 할당: 현재 참조하는 객체의 주소를 새로 복사된 객체의 주소로 업데이트합니다.
  RawAccess<IS_NOT_NULL>::oop_store(p, new_obj);
}

이 한 줄이 핵심입니다:
oop new_obj = obj->is_forwarded() ? obj->forwardee() : _young_gen->copy_to_survivor_space(obj);

 

이 코드는 현재 탐색하고 있는 객체(obj)가 이미 클로저에 의해 Survivor 영역으로 옮겨졌는지(is_forwarded()) 확인합니다. 만약 이미 옮겨졌다면, 옮겨진 객체의 새로운 주소(forwardee(), 즉 전방 포인터)를 가져옵니다. 아직 옮겨지지 않았다면 _young_gen->copy_to_survivor_space(obj)를 호출하여 Survivor 영역으로 객체를 복사합니다.

 

클로저는 객체를 Survivor 영역으로 이동시킨 후, 원래 객체의 Mark Word 부분에 이동된 객체의 새로운 주소(전방 포인터)를 저장해 둡니다. 이는 같은 객체를 여러 참조가 가리킬 때, 중복해서 복사하는 것을 방지하고 모든 참조가 올바른 새 주소를 가리키도록 하기 위함입니다.

copy_to_survivor_space(): 객체 복사 및 할당

이제 copy_to_survivor_space 코드를 잠시 살펴보겠습니다.
소스코드

oop DefNewGeneration::copy_to_survivor_space(oop old) {

  size_t s = old->size(); // 객체의 크기를 가져옵니다.

  // To Survivor 영역으로 메모리를 할당시킨다.
  oop obj = to()->allocate(s);

  // 객체의 age (나이)를 증가시킨다.
  obj->incr_age();

  // Mark Word에 새로운 객체 주소를 표기한다 (forwarding pointer).
  old->forward_to(obj);

  return obj;
}

이 함수는 비어 있는 to() Survivor 영역으로 allocate 함수를 통해 새로운 메모리를 할당시키고, 기존 객체의 내용을 복사하는 것을 볼 수 있습니다. 또한 obj->incr_age()를 통해 객체의 '나이'를 증가시킵니다. 이 나이는 Minor GC를 몇 번 견뎌냈는지를 의미하며, 특정 임계값을 넘으면 해당 객체는 Young Generation이 아닌 Old Generation으로 승격(Promotion)됩니다.

 

그럼 allocate 함수는 어떻게 동작할까요?
소스코드

inline HeapWord* ContiguousSpace::allocate_impl(size_t size) {

      HeapWord* obj = top(); // 현재 메모리 할당 포인터(top)를 가져옵니다.

    HeapWord* new_top = obj + size; // 객체 크기만큼 포인터를 이동시킵니다.
    set_top(new_top); // 새로운 top 포인터를 설정합니다.

    return obj; // 할당된 메모리 시작 주소를 반환합니다.
}

top()은 해당 Survivor 영역에서 현재까지 사용하고 있는 메모리 주소의 마지막 부분을 의미합니다. 따라서 새 객체 할당은 그 주소(top())부터 객체의 크기(size)만큼만 포당 포인터를 증가(obj + size)시켜주면 됩니다. 이는 위에서도 설명했듯이, 메모리 파편화 문제를 해결하고 나서 Bump-the-Pointer (포인터 증가 방식)라는 매우 간단하고 효율적인 방식으로 메모리를 할당할 수 있다는 장점을 보여줍니다.

 

여기까지 봤다면, 스택 프레임에서 직접 참조되고 있는 객체들(GC Root)이 어떻게 Survivor 영역으로 복사되는지 확인할 수 있었습니다. 하지만 스택 프레임에서 직접 참조되고 있는 대상이 아니라, 참조의 참조 형태, 즉 객체 그래프의 나머지 부분들은 어떻게 처리될까요?

🚜 evacuate_followers.do_void(): 참조 그래프 탐색

evacuate_followers.do_void()에서 나머지 부분들, 즉 루트 객체로부터 참조되는 객체들(Followers)에 대해 GC 처리를 해줍니다.

 

Follower들을 전부 탐색하려면 현재 살아있는 객체들에 대한 정보가 있어야 합니다. Serial GC는 이 문제를 어떻게 해결할까요? 바로 to() 영역의 top 포인터를 통해 해결합니다. evacuate_followers.do_void()가 실행되는 시점에서, to() 영역에는 young_process_roots가 처리하여 보낸 살아있는 루트 객체들만 존재하게 됩니다. 이제 그 객체들을 시작으로 마치 너비 우선 탐색(BFS)처럼 그래프 탐색을 해나가는 코드가 존재합니다.

 

do_void()는 내부적으로 아래 함수를 반복적으로 실행합니다.
소스코드

void DefNewGeneration::FastEvacuateFollowersClosure::do_void() {
  do {
    // to() 영역에 새로 복사된 객체들 내부에 있는 참조들을 스캔합니다.
    _heap->oop_since_save_marks_iterate(_scan_cur_or_nonheap, _scan_older);
  } while (!_heap->no_allocs_since_save_marks()); // 새로 할당된 객체가 없을 때까지 반복합니다.
}

이 코드를 보면, Serial GC가 BFS와 유사한 형태로 객체 간 참조 그래프를 탐색해나간다는 점을 알 수 있습니다.

 

처음 oop_since_save_marks_iterate를 실행하면, 루트 객체들이 참조하는 객체를 to() space로 이동시킵니다. !_heap->no_allocs_since_save_marks()는 "만약 이동된 객체가 있다면(즉, to() 영역에 새로 할당된 객체가 있다면), 또 그 객체들에 대해 다음 참조를 탐색하게 된다"는 의미로, 탐색을 이어가는 역할을 합니다.

oop_since_save_marks_iterate

oop_since_save_marks_iterateto() 영역에 대해 탐색을 진행하는 코드입니다.
소스코드

void DefNewGeneration::oop_since_save_marks_iterate(OopClosureType* cl) {
  to()->oop_since_save_marks_iterate(cl);
}

그리고 이 함수는 ContiguousSpaceoop_since_save_marks_iterate를 호출합니다.
소스코드

template <typename OopClosureType>
void ContiguousSpace::oop_since_save_marks_iterate(OopClosureType* blk) {
  HeapWord* t;
  HeapWord* p = saved_mark_word(); // 이전에 스캔을 멈췄던 지점부터 시작합니다.

  const intx interval = PrefetchScanIntervalInBytes;
  do {
    t = top(); // 현재 to() 영역의 최상단(가장 최근에 할당된 객체의 끝)을 가져옵니다.

    while (p < t) { 
      p += p->oop_iterate_size(blk); // 객체 내부의 모든 참조 필드를 스캔하고, 다음 객체로 이동합니다.
    }

  } while (t < top()); 

  set_saved_mark_word(p); // 스캔이 끝난 지점을 저장하여 다음 호출 시 이어서 시작할 수 있도록 합니다.
}

이 코드는 현재 to() 영역에 있는 객체 하나하나에 대해 그래프 탐색을 시작하는 역할을 합니다. saved_mark_word()는 이전에 스캔이 완료된 위치를 기록해두는 "저장된 마커" 역할을 합니다. oop_iterate_size 함수는 결국, 아까 young_process_roots의 마지막 부분인 do_oop_work를 다시 호출하면서 마무리가 됩니다. 즉, to() 영역에 복사된 객체들이 참조하는 객체들을 또다시 to() 영역으로 복사하는 과정을 반복하게 됩니다.

 

이러한 saved_mark_wordtop을 이용한 반복적인 스캔은, to() 영역이 항상 살아있는 객체들로만 채워지며, 그 객체들이 연속적으로 배치되어 있다는 Mark and Copy의 장점 덕분에 가능합니다.

😾 그림 요약

코드로만 보면 복잡할 수도 있는 전체 과정을 그림으로 시각화 해보았습니다.

 

마무리

지금까지 Java Serial GC의 Young Generation GC가 어떻게 일어나는지 이론과 실제 코드를 통해 깊이 파고들어 보았습니다. 핵심 내용을 다시 한번 요약하자면 다음과 같습니다.

  • Mark and Copy 변형: Serial Young GC는 Mark and Copy 알고리즘을 기반으로 하며, Eden, From Survivor, To Survivor라는 3개의 공간을 활용하여 메모리 효율성을 높였습니다.
  • Bump-the-Pointer 할당: GC가 완료되어 메모리가 압축된 후에는, 새로운 객체 할당이 매우 빠르고 효율적인 Bump-the-Pointer 방식으로 이루어집니다.
  • GC Roots와 그래프 탐색: GC는 young_process_roots를 통해 GC Roots를 먼저 처리하고, evacuate_followers를 통해 Roots로부터 참조되는 모든 객체들을 BFS 방식으로 탐색하며 to() Survivor 영역으로 복사합니다.
  • Stop-the-World: 모든 GC 과정이 사용자 스레드를 멈추는 STW 방식으로 진행된다는 중요한 특징을 가지고 있습니다.

Reference

https://imasoftwareengineer.tistory.com/103
https://velog.io/@mooh2jj/Garbage-Collection%EC%9D%98-Mark-and-Sweep-%EC%95%8C%EA%B3%A0%EB%A6%AC%EC%A6%98
https://nobilitycat.tistory.com/entry/Mark-and-Sweep
https://github.com/openjdk/jdk17/blob/master/src/hotspot/share/gc/serial/defNewGeneration.cpp
https://github.com/openjdk/jdk17/blob/master/src/hotspot/share/gc/shared/genOopClosures.inline.hpp
https://github.com/openjdk/jdk17/blob/master/src/hotspot/share/gc/serial/defNewGeneration.inline.hpp