Programming Summary

Java) G1 GC를 어떻게 뜯어볼까? 본문

CS 공부

Java) G1 GC를 어떻게 뜯어볼까?

쿠키롱킹덤 2024. 4. 16. 01:41

들어가기에 앞서 용어 정리

  • Chunk : 보통 메모리 할당 및 해제를 위한 블록
  • HeapWord : heap의 최소 단위
  • ArchiveRegion : GC 시작시 초기화된 Region
  • SafePoint : 다른 vm thread의 동작이 정지된 상황. STW가 발생함.
  • Mutator : 가비지 컬렉션을 수행하는 스레드가 아닌, 일반적인 응용 프로그램 코드
  • Collector : 메모리 관리를 위해 가비지를 식별하고 회수하는 스레드
  • OOP : Ordinary Object Pointer의 약자로, JVM에서 객체를 가리키는 포인터
  • 참조 종류
강한 참조(strong reference): 객체를 참조하는 것으로, 참조하는 동안 가비지 컬렉션의 대상이 되지 않음. 일반적인 객체 참조는 이 유형에 해당 
소프트 참조(soft reference): 일반적으로 가비지 컬렉션의 대상이 되지 않지만, 메모리 부족 상황에서는 가비지 컬렉션의 대상이 될 수 있음. 메모리 부족 상황에서 가비지 컬렉터가 메모리를 해제할 때, 소프트 참조를 가진 객체는 메모리에서 해제될 수 있음. 
약한 참조(weak reference): 가비지 컬렉션의 대상이 되는 참조로, 해당 객체가 가비지 컬렉션의 대상이 될 수 있음. 일반적으로 약한 참조는 객체의 수명을 연장하는 데 사용됨. 약한 참조를 가진 객체는 다른 강한 참조가 없을 때 가비지 컬렉션의 대상이 됨 
파생 참조(phantom reference): 가비지 컬렉션 후 객체가 해제되기 전에 수행해야 하는 작업을 정의하는 데 사용됨. 가비지 컬렉션 시 해당 객체의 파생 참조가 큐에 추가되어 알림을 받을 수 있음.
  • gc 종류
Full GC (전체 가비지 컬렉션): 전체 힙(heap)을 대상으로 하는 가비지 컬렉션을 의미
Major GC (주요 가비지 컬렉션): Full GC와 동의어로 사용되는 경우가 많음
Minor GC (소규모 가비지 컬렉션): Young 영역을 대상으로 하는 가비지 컬렉션
Mixed GC (혼합 가비지 컬렉션): 전체 힙을 대상으로 하는 것이 아닌 여러 영역(Region) 중 일부 영역에 대해서만 가비지 컬렉션을 수행

 

아니 아직도 G1 GC를 이해못했어?

분노의 G1 GC 뜯어보기

이번 Java 22 버전에서 G1 GC에 Region Pinning 기술이 적용되어 STW 시간을 줄였다고 한다..
솔직히 이 글보고 짜증이 났다.
Z GC나 Shenandoah GC에 대한 이해는 바라지도 않는다. 하지만 G1 GC, Java 9버전부터 Default GC로 활용되고 있는 G1 GC에 대해서는 이해하고 있어야 할 것 아닌가? 여러 블로그 글을 읽었지만 와닿지 않고 이에 대해 설명하지 못할 것 같았다. 결국 완벽하게 이해하기 위해 G1 GC를 나름대로 뜯어보기로 결심하였다.

Visual GC를 통해 G1 GC 살펴보기

Visual GC는 Garbage Collector의 흐름을 직관적으로 볼 수 있는 매우 유용한 플러그인이다.
도움이 될까 Visual GC를 사용해 G1 GC의 동작과정을 살펴보았다.

Visual GC

구체적인 리전에 관한 정보는 나와있지 않다.. 하단의 히스토그램이 혹시 리전인가 싶었지만,
Tenuring Threshold인걸 보니 Young generation에 있는 객체들의 나이를 표현한 것이었다.

Eden 영역에서 Survior 1 영역으로 승격할 때 GC Time 발생

다만 Eden Space 영역이 꽉 차 Survivor space로 객체가 이동할 때 "G1 Evacuation Pause"라는 원인으로 STW 시간이 잠깐 발생한 것을 확인할 수 있었다.

G1 GC 매커니즘 설명

해당 STW를 그림으로 표현하면 작은 파란 원 해당하는 부분(minor GC)에 해당하는 것으로 추측했다.
사실 시각화 도구를 써도 아직 잘 와닿지 않는다. 이럴 때는 로그를 찍어보는게 더 직관적일 수 있다.

로그를 통해 G1GC 살펴보기

-verbose: gc VM 옵션을 통해 Spring Boot Application을 실행시켜 GC 로그를 확인했다.

-verbose:gc

개발자라 그런지 모르겠지만 시각화 툴보다 이게 더 보기 편하다. 로그에서는 보다 다양한 정보들을 확인할 수 있는데, Pause 원인과 그에 따른 STW 시간, GC와 Spring Boot Application이 concurrent하게 실행되는 것을 볼 수 있다. 또한 해당 로그를 통해 Concurrent Mark Cycle 부분이 비동기적으로 동작하며 STW 시간을 최소한으로 가져가기 위한 노력을 한 모습이 보인다. 다만 CMS GC도 똑같이 동작하기 때문에 G1GC만의 위 사이클 그림을 설명하지 못하겠다. 결국 JVM 코드를 살펴봐야겠다.

코드를 통해 G1GC 살펴보기

JVM 선정

뜯어보기로 선택한 JVM은 오픈 소스로 공개된 OpenJDK이며, 최신 Java22버전의 마지막 태그인 Java22 + 36 버전을 뜯어보기로 결정하였다.

jdk-22+36버전 g1GC 깃허브 레포지토리 링크
 
그리고 G1 GC 파일들을 하나하나 살펴보려 했지만..

cpp파일만 100..

엄.. 너무 많다. cpp 파일들만 해도 100개가 넘어간다. 어디서부터 손대야 할지도 모르겠고, 이거 분석하다가 내 개발자 삶이 마감되는게 더 빠를 것 같아서 중요한 파일들만 살펴보기로 했다. 난 전체적인 흐름이 궁금했기 때문에 메모리 관리를 담당하는 g1CollectorHeap.cpp와 Full GC를 담당하는 g1FullCollector.cpp, Minor GC를 담당하는 g1YoungCollector.cpp 파일을 위주로 분석하였다.

g1CollectorHeap.cpp

G1CollectorHeap은 G1 GC의 메모리를 heapLock을 활용해 힙 영역에 할당하는 클래스이다.(각 스레드별 공간에 할당되는 것이 아님을 파일 상단에 강조하고 있다) 주요 기능은 humongous region 메커니즘과  힙에 할당하는 스케쥴링 매커니즘, 그리고 gc 발생 시 Collection과 Compaction 작업 등이 명시되어 있다. 다음은 예시인 do_full_collection 메서드다.

bool G1CollectedHeap::do_full_collection(bool clear_all_soft_refs,
                                         bool do_maximal_compaction) {
  assert_at_safepoint_on_vm_thread();

  const bool do_clear_all_soft_refs = clear_all_soft_refs ||
      soft_ref_policy()->should_clear_all_soft_refs();

  G1FullGCMark gc_mark;
  GCTraceTime(Info, gc) tm("Pause Full", nullptr, gc_cause(), true);
  G1FullCollector collector(this, do_clear_all_soft_refs, do_maximal_compaction, gc_mark.tracer());

  collector.prepare_collection();
  collector.collect();
  collector.complete_collection();

  // Full collection was successfully completed.
  return true;
}

 
위 코드는 full collection 요청 시 해당 스레드가 safe point에 있는지 확인하고, G1FullCollector 객체를 생성한다. 그리고 collector의 prepare_collection(), collect(), complete_collection() 메서드를 순차적으로 호출한다.
 
※ Full Collection도 concurrenct 인지 아닌지, soft refs까지 전부 지울 지, maximal_compaction 할 지 등으로 여러가지로 나뉜다

g1FullCollector.cpp

(추후 작성 예정)

g1YoungCollector.cpp

(추후 작성 예정)

Java22버전에서 변경된 점?

Region Pinning 기술이 적용되어 GC 도중에도 JNI가 Critical Section에 접근 가능하게 되었다. 커밋 내용을 살펴보면 G1GC의 GCLocker에 대한 의존성을 제거함으로 이를 구현하였다. 다음은 GCLocker에 있는 jni_lock이라는 함수이다.

void GCLocker::jni_lock(JavaThread* thread) {
  assert(!thread->in_critical(), "shouldn't currently be in a critical region");
  MonitorLocker ml(JNICritical_lock);
  // Block entering threads if there's a pending GC request.
  while (needs_gc()) {
    // There's at least one thread that has not left the critical region (CR)
    // completely. When that last thread (no new threads can enter CR due to the
    // blocking) exits CR, it calls `jni_unlock`, which sets `_needs_gc`
    // to false and wakes up all blocked threads.
    // We would like to assert #threads in CR to be > 0, `_jni_lock_count > 0`
    // in the code, but it's too strong; it's possible that the last thread
    // has called `jni_unlock`, but not yet finished the call, e.g. initiating
    // a GCCause::_gc_locker GC.
    ml.wait();
  }
  thread->enter_critical();
  _jni_lock_count++;
  increment_debug_jni_lock_count();
}

위를 살펴보면 gc 진행 중에 임계 구역에 접근하지 못하고, 끝났을 때 접근 가능한 모습을 볼 수 있다. 그렇기에 GCLocker에 대한 의존성 제거가 GC 진행 중 JNI의 Critical Section 접근에도 연관되어 G1 GC를 개선했다는 것이다.

느낀점

노력한 것에 비해 속 시원하지는 않은 느낌이다. 생각보다도 G1 GC는 복잡하게 동작했고, 내 실력도 많이 미숙해 그 중에서도 일부분만 살펴볼 수 있었다. 그래도 많은 도움이 되었다. 해당 경험을 하며 JVM에 얼마나 많은 개발자들이 노력을 쏟았는지 느낄 수 있었으며, 이후에도 궁금한 점이 생겼을 때 어떠한 흐름으로 분석을 해볼 수 있을지에 대해 배워가는 시간이었다. 그리고 무엇보다도 코드를 더듬더듬이라도 보고 나니 진짜 이해가 안되던 g1GC 관련 포스트, 레퍼런스들이 읽히기 시작했다!! 나중에 실력을 더 기르고 좀 더 뜯어볼 수 있으면 좋겠다.


참고