JAVA GC - G1GC

  1. G1GC
    1. G1GC를 이해하기 전에 Serial GC, Parallel GC의 한계부터 보기
      1. Stop-The-World(STW) 시간이 크다
      2. 왜 이것이 문제일까?
        1. Serial GC의 경우
        2. Parallel GC의 경우
      3. 그래서 G1GC는 무엇이 다른가?
      4. Young / Old 경계가 고정적이다
      5. 왜 이것이 문제일까?
      6. 실행 중 경계를 자유롭게 바꾸지 못한다
    2. 그래서 G1GC는 region 개념을 도입
      1. 고정된 Young/Old 경계 대신 Region으로 힙을 운영 하는 방법 알아보기
    3. G1GC는 왜 등장 했고, STW가 왜 문제이고, 특히 Old Generation 수집이 왜 부담일까
      1. STW는 왜 Young GC보다 Old GC에서 더 부담일까?
      2. Young GC는 왜 비교적 감당 가능한가?
      3. Old Generation 수집은 왜 더 어렵나?
      4. G1GC는 이 문제를 어떻게 접근했을까?
        1. 1. Old Generation 전체를 한 번에 다 수집하지 말자
        2. 2. Old Generation 수집에도 evacuation 방식을 활용하자
        3. 3. Old Generation을 위한 marking 작업은 애플리케이션과 겹쳐서 수행하자
      5. Young GC vs Old GC 비교
      6. 정리하자면…
    4. G1GC 핵심 동작 정리 - Young-Only Phase와 Mixed Collection은 어떻게 이어질까?
      1. G1GC 전체 흐름 한눈에 보기
        1. 1. Young-Only Phase
        2. 2. Concurrent Marking 시작
        3. 3. Remark / Cleanup
          1. Remark
          2. Cleanup
        4. 4. Space-Reclamation Phase = Mixed Collection
      2. 이런 방식으로 해서 이점은?
    5. Mixed Collection의 핵심
      1. 1. Young GC 때 Old Region 일부도 같이 처리한다
      2. 2. 점진적으로 evacuation 한다
      3. 3. 의미가 줄어들면 다시 Young-Only로 돌아간다
      4. marking의 핵심은 무엇일까?
      5. G1GC 전체 Flow 한 번에 이해하기 - Young-Only, Concurrent Marking, Mixed GC는 어떻게 이어지는 알아보자
      6. 전체 흐름 먼저 보기
    6. 보통의 Young GC - Young-Only phase
      1. Concurrent Start
      2. Young GC 후 evacuation
      3. 핵심
      4. Root Region Scanning
      5. Concurrent Marking
      6. Remark / Cleanup
      7. Remark
      8. Cleanup
    7. 핵심
      1. Space-Reclamation phase 시작
      2. 선택된 Old Region 회수
      3. 모든 garbage가 한 번에 사라지는 것은 아니다
    8. 흐름 핵심을 정리
      1. 1) 평소에는 Young GC 중심으로 간다
      2. 2) Old가 쌓이면 Concurrent Marking을 시작한다
      3. 3) marking은 concurrent 하게 진행한다
      4. 4) Mixed GC에서는 old 일부만 회수한다
      5. 5) old 전체를 한 번에 다 치우지 않는다
    9. G1GC Concurrent Mark 문제점
      1. 이미지 보기 전 marking 처리 관련 설명
      2. 1. 시작 상태
      3. 2. GC Root에서 1번 객체를 먼저 본다
      4. 3. 1번 탐색 완료, 3번과 2번 mark
      5. 4. 2번 객체 탐색 완료
      6. 5. 3번 객체 탐색 완료 직전
      7. 6. 애플리케이션이 참조를 바꾼다
      8. 7. 4번 탐색 완료
      9. 8. 최종적으로 발생할 수 있는 잘못된 판단
    10. 이 문제가 발생하기 위한 두 가지 조건
      1. 1. Black 객체가 White 객체를 향한 새로운 참조를 추가한다
      2. 2. 그 White 객체를 원래 참조하던 Grey 객체 또는 결국 Grey가 될 White 객체가 그 참조를 삭제한다
      3. 왜 이 두 조건이 같이 있어야 문제일까?
    11. G1GC는 이 문제를 어떻게 해결하려고 할까?
      1. SATB 관점에서 보면
      2. G1GC에서 SATB Buffer와 Mark Stack은 왜 필요할까? - Concurrent Mark 중 참조 변경이 있어도 객체를 놓치지 않는 방법
      3. 설명 전 용어 정리 해보자
        1. Mark Stack
        2. Local SATB Buffer
        3. Global SATB Buffer
        4. old value를 기록할까?
      4. 전체 흐름 확인 해보기
      5. 1. Marking 시작
      6. 2. Concurrent Start
      7. 3. Root Region Scan 이후 1번 pop
      8. 4. 2번, 3번 push
      9. 5. 2번 pop
      10. 6. 3번 pop, 4번 push
      11. 7. 애플리케이션이 참조를 바꿈
        1. 핵심
      12. 8. 4번 pop
        1. 핵심
      13. 9. Remark 단계 진입
        1. 핵심
      14. 10. SATB Buffer → Mark Stack 이동 시 필터링
        1. discard 조건
        2. 핵심
      15. 11. 5번만 mark stack에 들어감
      16. 12. 5번 pop 후 최종 marking
        1. 핵심
    12. 이 흐름의 핵심 의미 설명
      1. 1. Mark Stack은 “탐색 작업용”
      2. 2. SATB Buffer는 “참조 변경 보완용”
      3. 3. pre-write barrier가 old value를 기록한다
      4. 4. Remark에서 SATB Buffer를 비운다
      5. 5. 최종적으로 reachable 객체를 놓치지 않는다
      6. 정리해자면?
    13. G1GC Mixed GC에서 Remembered Set이 왜 필요할까?
      1. 수집 대상 Old Region 안의 살아 있는 객체를 놓치지 않는 방법
        1. 1. Mixed GC의 기본 대상
        2. 2. 이번 Mixed GC에서 수집할 Region 선택
        3. 3. 1번 객체 evacuation
        4. 4. 5, 6번 객체도 수집 대상이므로 이동
        5. 5. Young Generation Remembered Set을 통해 4번 객체 이동
        6. 6. 8번 Region이 수집 대상이 아니면 어떤 문제가 생길까?
        7. 7. 해결책 - Region마다 Remembered Set
        8. 8. 7번 객체를 정상적으로 evacuation
        9. 9. 수집 대상 Region을 안전하게 회수
      2. 왜 Remembered Set이 꼭 필요할까?
    14. G1GC에서 Humongous Object(거대 객체)란?
      1. 왜 특별 취급되고, 왜 eager reclamation이 필요한가
      2. Humongous Object는 왜 특별할까?
      3. Humongous Object가 실제로 할당되는 방식
      4. Humongous Object는 GC 중에 항상 이동할까?
      5. Humongous Object가 garbage가 되면 언제 회수될까?
      6. eager reclamation for humongous objects
      7. Remembered Set이 왜 여기서 다시 나오나?
      8. primitive 배열만 우선 대상으로 본 이유
      9. 정리 해보자
      10. Humongous Object vs 일반 Old Object 비교
      11. 핵심 차이 요약
    15. G1GC의 Evacuation Failure란?
      1. 살아 있는 객체를 옮기지 못하면 무슨 일이 벌어질까
      2. Evacuation Failure가 발생하는 대표 원인 2가지
        1. 1. Allocation 실패
        2. 2. Pinned 객체
      3. Evacuation Failure가 나면 G1은 어떻게 처리할까?
      4. 이미지 흐름으로 보는 Evacuation Failure
        1. 이미지 1
        2. 이미지 2
        3. 이미지 3
        4. 이미지 4
        5. 이미지 5
        6. 이미지 6
        7. 이미지 7
        8. 이미지 8
        9. 이미지 9
      5. 실패한 young region은 왜 old처럼 보일까?
      6. Free space가 전혀 생기지 않으면?
      7. 정리

G1GC

G1GC를 이해하기 전에 Serial GC, Parallel GC의 한계부터 보기

JVM GC를 공부하다 보면 보통 Serial GC, Parallel GC부터 먼저 접하게 됩니다. 이 두 GC는 구조가 비교적 직관적이고, Young / Old Generation 기반 동작을 이해하는 데도 도움이 됩니다. 하지만 실제 서비스 환경, 특히 응답 시간유연한 메모리 운영이 중요한 환경에서는 한계도 분명합니다.

G1GC가 왜 등장했고, 왜 많은 환경에서 주목받는지 이해하려면 먼저 Serial GC와 Parallel GC의 문제점을 짚고 넘어가는 것이 좋습니다.

Stop-The-World(STW) 시간이 크다

Serial GC와 Parallel GC의 가장 큰 특징 중 하나는 GC가 중요한 작업을 수행하는 동안 애플리케이션 스레드가 멈춘다는 점입니다. 즉, GC가 동작하는 순간에는 애플리케이션 코드가 함께 실행되지 못합니다. 이것이 바로 Stop-The-World(STW) 입니다.

왜 이것이 문제일까?

애플리케이션 입장에서는 GC가 끝날 때까지 요청 처리, 계산, 비즈니스 로직 수행이 멈추게 됩니다. 배치 프로그램처럼 전체 처리량이 더 중요한 경우에는 어느 정도 감수할 수 있지만, 사용자 응답 속도가 중요한 서버 애플리케이션에서는 이 pause가 치명적일 수 있습니다.

예를 들어

  • 어떤 API 서버가 1초에 수천 건의 요청을 처리하고 있는데
  • Full GC가 발생하면서 수백 ms 또는 수 초 동안 STW가 걸린다면

그 시간 동안 사용자는 응답 지연을 직접 체감하게 됩니다.


Serial GC의 경우

Serial GC는 이름 그대로 단일 GC 스레드로 동작합니다. GC 중에는 애플리케이션도 멈추고, GC 작업도 하나의 스레드가 처리합니다.

즉,

  • 애플리케이션도 멈춤
  • GC도 단일 스레드
  • 큰 힙에서는 pause가 길어질 가능성이 큼

이라는 특징이 있습니다.


Parallel GC의 경우

Parallel GC는 Serial GC보다 발전된 형태로, GC 작업 자체는 여러 GC 스레드로 병렬 처리할 수 있습니다.

그래서 GC 처리량(throughput) 은 더 좋아질 수 있습니다. 하지만 중요한 점은, Parallel GC도 여전히 STW 기반이라는 것입니다.

즉,

  • GC 중 애플리케이션은 멈추고
  • GC 작업만 여러 스레드가 병렬 수행됨

이라는 구조입니다. 결국 멀티 코어를 활용해 GC는 빨라질 수 있어도, 애플리케이션과 GC가 동시에 의미 있게 함께 일하는 구조는 아니다는 한계가 남습니다.


그래서 G1GC는 무엇이 다른가?

G1GC는 이 지점에서 차이를 보입니다.

특히 old generation을 다루는 과정에서 일부 작업을 concurrent, 즉 애플리케이션과 겹쳐서 수행하려는 방향으로 설계되었습니다.

물론 G1GC도 모든 단계가 완전히 pause 없이 돌아가는 것은 아니지만, 적어도 Serial GCParallel GC처럼 “GC가 본격적으로 돌면 애플리케이션이 계속 길게 멈춘다”는 문제를 줄이기 위한 방향으로 발전한 collector라고 볼 수 있습니다.

즉, 첫 번째 한계는 이렇게 정리할 수 있습니다.

Serial GC와 Parallel GC는 GC 중 애플리케이션이 멈추는 STW 구조를 가지며,
특히 응답 시간에 민감한 환경에서는 이것이 큰 부담이 될 수 있다.


Young / Old 경계가 고정적이다

한계는 메모리 영역의 유연성 부족입니다.

Serial GCParallel GC는 기본적으로 heap 안에서 Young Generation과 Old Generation이 나뉘어 있고, 실행 중에도 이 구조를 크게 벗어나지 않습니다.

즉, 애플리케이션이 순간적으로 young 영역을 많이 필요로 하거나 반대로 old 영역 압박이 커지는 상황이 생겨도, 이 두 영역을 완전히 유연하게 재구성하는 데는 한계가 있습니다.


왜 이것이 문제일까?

실제 애플리케이션의 객체 생성 패턴은 항상 일정하지 않습니다.

예를 들어 어떤 시점에는

  • 짧게 살 객체가 폭발적으로 많이 생성될 수 있고
  • 또 다른 시점에는 오래 살아남는 객체가 급격히 늘어날 수 있습니다

그런데 Young / Old의 경계가 비교적 정적인 구조라면 이런 변화에 즉각적으로 유연하게 대응하기 어렵습니다.

즉,

  • young가 부족하면 Minor GC가 너무 자주 일어나고
  • old가 압박되면 promotion 부담이 커지고
  • 전체적으로 메모리를 더 효율적으로 나눠 쓰고 싶어도 구조적 한계가 생길 수 있습니다

실행 중 경계를 자유롭게 바꾸지 못한다

Serial GC와 Parallel GC는 Young / Old라는 세대 구조를 사용하지만, 실행 중에 이 boundary 자체를 매우 유연하게 바꾸는 방식은 아닙니다.

이 말은 곧

“지금은 young가 더 필요하니 old 일부를 곧바로 young처럼 쓰자”
“지금은 old가 더 필요하니 영역을 세밀하게 다시 나누자”

같은 접근이 쉽지 않다는 뜻입니다. 즉 세대 기반 구조가 명확한 대신 영역 활용의 탄력성은 떨어질 수 있습니다.


그래서 G1GC는 region 개념을 도입

G1GC는 이런 한계를 줄이기 위해 heap 전체를 고정된 큰 Young / Old 덩어리로 보는 대신 더 작은 단위인 region 으로 나누어 관리합니다.

이 방식의 핵심은 다음과 같습니다.

  • heap을 잘게 나눈다
  • region이 상황에 따라 Eden, Survivor, Old 역할을 가질 수 있다
  • young, old generation 각각 구분 해서 연속된 주소 공간으로 잡히지 않는다
  • 즉 고정된 경계보다 더 유연하게 메모리를 운영할 수 있다

이 덕분에 G1GC는 애플리케이션의 객체 생성 패턴 변화에 더 탄력적으로 대응할 수 있는 기반을 갖게 됩니다.


고정된 Young/Old 경계 대신 Region으로 힙을 운영 하는 방법 알아보기

앞서 Serial GC, Parallel GCYoung GenerationOld Generation의 경계가 비교적 고정적이라는 점이 한계라고 정리했습니다. G1GC는 이 문제를 완화하기 위해 힙 전체를 하나의 큰 덩어리로 보지 않고, 동일한 크기의 여러 Region 으로 나누어 관리합니다.

G1GC에서는

  • 어떤 Region은 Eden 역할을 할 수도 있고
  • 어떤 Region은 Survivor 역할을 할 수도 있고
  • 어떤 Region은 Old 역할을 할 수도 있습니다

그리고 상황에 따라 이 역할이 계속 바뀔 수 있습니다.

이번 글에서는 제공해주신 G1GC Region Flow 이미지를 기준으로 G1GC가 RegionFree List를 어떻게 활용하는지 순서대로 정리 해보겠습니다.

G1GC-region-1

첫 번째 그림은 G1GC가 시작된 직후의 개념적인 초기 상태를 보여줍니다.

  • 전체 최대 힙(-Xmx)은 6G reserved
  • 시작 시 실제 사용 가능한 힙(-Xms)은 4G committed
  • committed 범위 안에 여러 Region이 존재
  • 아직 역할이 정해지지 않은 Region들이 Free List 에 들어 있음

그림에서는 Region 1 ~ 7 정도가 Free List에 들어 있는 형태로 표현되어 있습니다. 이 상태에서는 아직 Eden도, Survivor도, Old도 본격적으로 할당되지 않았습니다. 즉 “사용 가능한 Region들을 준비만 해 둔 상태” 라고 보면 됩니다.

G1GC는 힙을 처음부터 Young/Old로 딱 잘라 놓고 시작하지 않습니다. 먼저 Region 단위로 사용 가능한 공간을 준비하고 필요할 때 Free List에서 꺼내 역할을 부여합니다

G1GC-region-2

두 번째 그림에서는 Free List에서 일부 Region이 빠져나와 Eden Region 으로 사용됩니다.

  • Region 1, 2, 3 이 Eden Region으로 지정됨
  • Free List는 기존 1,2,3,4,5,6,7 에서 4,5,6,7 만 남음

즉 애플리케이션이 새 객체를 생성하기 시작하면 G1GC는 Free List에서 Region을 꺼내 Eden 역할을 부여합니다. 전통적인 Young Generation GC에서는 Eden이 하나의 연속된 큰 공간처럼 보이지만 G1GC에서는 Eden 역할을 하는 Region들의 집합으로 이해하는 것이 더 정확합니다.

Eden은 고정된 하나의 큰 덩어리가 아니라 현재 Eden 역할을 맡은 여러 Region의 묶음입니다

G1GC-region-3

세 번째 그림에서는 Minor GC 이후 살아남은 객체를 담기 위해 Survivor Region 이 새로 할당됩니다.

  • Eden에 있던 객체 중 살아남은 객체들이 Survivor로 이동
  • Region 4, 5가 Survivor 역할을 부여받음
  • Free List는 6,7 정도만 남음

즉 Eden의 객체가 모두 죽는 것이 아니라 일부가 살아남으면 G1GC는 Free List에서 새로운 Region을 꺼내 Survivor 역할을 주고 그곳으로 살아남은 객체를 복사합니다.

이것도 중요한 차이점입니다.

전통적인 GC에서는 Survivor 영역이 Young 내부에서 고정적으로 존재하는 느낌이 강하지만 G1GC에서는 필요할 때 Free Region을 Survivor로 바꿔 쓴다고 이해하는 편이 맞습니다.

Survivor도 고정 공간이 아니라 역할입니다. Free Region이 상황에 따라 Survivor Region이 됩니다.

G1GC-region-4

네 번째 그림은 Minor GC 이후 기존 Eden Region이 다시 비워지고 그 Region들이 Free List로 되돌아가는 흐름을 보여줍니다.

  • 기존 Eden이었던 Region 1, 2, 3은 이제 free 상태가 됨
  • Free List에 다시 1,2,3 이 추가됨
  • Survivor Region 4, 5는 계속 유지됨

그림의 Free List6,7,1,2,3 형태로 바뀌는 것은 이미 사용이 끝난 Eden Region이 다시 재활용 가능한 상태로 돌아왔다는 의미입니다. 즉 G1GC에서는 Region을 한번 Eden으로 썼다고 해서 영원히 Eden인 것이 아닙니다.

  • Eden으로 썼다가
  • GC 후 비워지면
  • 다시 Free List로 돌려놓고
  • 나중에 또 Eden / Survivor / Old 등 다른 역할로 재사용할 수 있습니다

G1GC의 Region은 역할이 고정되지 않습니다. Eden → Free → Survivor 또는 Eden → Free → Old 처럼 역할이 계속 바뀔 수 있습니다.

G1GC-region-5

다섯 번째 그림에서는 중요한 변화가 하나 더 생깁니다.

  • -Xms 기준 committed heap4G → 5G 로 증가
  • Region 6, 7이 Eden Region으로 사용됨
  • Free List는 8,9 정도만 남음

이 장면은 reserved heap은 이미 6G까지 확보되어 있지만 처음에는 4G만 commit되어 있었고, 이제 필요에 따라 5G까지 commit을 확장한 상황으로 이해할 수 있습니다.

즉 G1GC는 Free Region만 재활용하는 것이 아니라 필요하다면 아직 reserved만 되어 있고 commit되지 않았던 영역까지 실제 사용 가능 상태로 늘릴 수 있습니다.

이 그림은 다음 두 개념을 동시에 잘 보여줍니다.

  1. Free Region 재사용
  2. Committed Heap 확장

G1GC는 단순히 “남은 공간을 쓰는 것”만이 아니라 필요하면 heap commit 자체도 확장하면서 유연하게 동작합니다.

-Xmx 는 최대 예약 범위, -Xms 는 현재 committed 범위, G1GC는 필요 시 committed heap을 확장하면서 추가 Region을 활용할 수 있습니다.

G1GC-region-6

여섯 번째 그림에서는 Region 8이 Old Region 으로 사용됩니다.

  • Survivor에 있던 객체 중 충분히 오래 살아남은 객체 또는 Survivor에 다 담기 어려운 객체
  • 이런 객체들이 Region 8로 승격되어 Old Region이 됨
  • Free List는 9 정도만 남게 됨

이 장면은 G1GC에서 Old Region도 고정적으로 따로 존재하는 것이 아니라 필요할 때 Free List에서 Region을 하나 꺼내 Old 역할을 부여한다는 점을 보여줍니다.

즉 Young / Old 경계가 거대한 고정 경계로 미리 정해지는 것이 아니라 Region 단위로 조금씩 Old가 늘어날 수 있습니다.

이것이 앞서 Serial GC, Parallel GC의 한계로 언급했던 고정된 Young/Old 경계 문제를 완화하는 핵심 포인트입니다. Old 영역도 고정 덩어리가 아니라 Region 집합입니다. 살아남은 객체가 많아지면 Free Region이 Old Region으로 전환됩니다.

정리하면 G1GC는 힙을 고정된 Young / Old 두 구역으로 강하게 나누는 대신 동일 크기 Region들의 집합으로 보고 각 Region에 Eden / Survivor / Old 역할을 동적으로 부여하는 방식으로 동작합니다.

이번 그림 흐름을 따라가면 G1GC의 핵심은 아래처럼 요약할 수 있습니다.

  1. Free Region을 준비한다
  2. 일부를 Eden으로 쓴다
  3. 살아남은 객체는 Survivor로 복사한다
  4. 사용이 끝난 Eden은 다시 Free List로 돌린다
  5. 필요하면 committed heap을 확장한다
  6. 오래 살아남은 객체는 Old Region으로 승격한다

즉 G1GC의 본질은 “힙을 고정 구획이 아니라 동적으로 역할이 바뀌는 Region 집합으로 다룬다” 는 데 있습니다.

G1GC는 왜 등장 했고, STW가 왜 문제이고, 특히 Old Generation 수집이 왜 부담일까

GC가 문제로 느껴지는 가장 큰 이유는 GC가 동작하는 동안 애플리케이션이 멈출 수 있기 때문입니다. 특히 Stop-The-World(STW) 구간에서는 요청 처리, 비즈니스 로직, 사용자 응답이 모두 멈춥니다. G1도 일부 pause는 있지만, 다른 작업은 애플리케이션과 동시에(concurrently) 수행하도록 설계되어 긴 정지를 줄이려 합니다.

STW는 왜 Young GC보다 Old GC에서 더 부담일까?

핵심은 “얼마나 많은 쓰레기가 있느냐”보다 “얼마나 많은 살아 있는 객체를 따라가고 옮겨야 하느냐” 입니다. Oracle 문서도 Minor GC 비용은 1차적으로 수집 대상 중 살아 있는 객체 수에 비례한다고 설명합니다. 대부분의 객체는 금방 죽기 때문에 young generation은 비교적 효율적으로 수집할 수 있지만, old generation이 차면 major collection이 필요하고 이때는 훨씬 더 많은 객체가 관여하므로 보통 minor collection보다 오래 걸립니다.

Young GC는 왜 비교적 감당 가능한가?

세대별 GC는 약한 세대 가설(weak generational hypothesis) 을 전제로 합니다. 즉 대부분의 객체는 짧게 살고 young generation에서 죽습니다. 그래서 young generation만 수집하는 minor GC는 보통 효율이 좋고, 살아남은 일부 객체만 survivor나 old 쪽으로 이동시키면 됩니다. Oracle 문서도 young generation은 대부분 garbage가 빨리 회수되기 때문에 minor collection이 최적화되기 쉽다고 설명합니다.

Old Generation 수집은 왜 더 어렵나?

old generation에는 이미 오래 살아남은 객체가 많고, 영역도 상대적으로 큽니다. 그래서 old generation을 다루는 수집은 young보다 훨씬 무겁고, 특히 전체 old를 한 번에 크게 건드리는 방식은 긴 pause로 이어지기 쉽습니다.

G1GC는 이 문제를 어떻게 접근했을까?

G1GC의 방향은 세 가지로 요약할 수 있습니다.

1. Old Generation 전체를 한 번에 다 수집하지 말자

G1GC은 힙을 region으로 나누고 old generation도 그 region들의 집합으로 관리합니다. 그리고 old가 꽉 찰 때까지 기다렸다가 전체를 크게 수집하기보다 garbage가 많은 region부터 우선 회수하려고 합니다. Oracle 문서도 G1은 heap을 동일 크기 region으로 나누고, 가장 효율적인 영역부터 먼저 회수한다고 설명합니다. 또한 concurrent marking 이후에는 young-only 수집에서 mixed collection 단계로 넘어가 old region 일부를 함께 회수합니다.

2. Old Generation 수집에도 evacuation 방식을 활용하자

G1GC은 공간 회수를 주로 evacuation 즉 살아 있는 객체를 다른 region으로 복사하는 방식으로 수행합니다. 이 방식은 복사 과정에서 자연스럽게 compaction 효과를 얻을 수 있어서, 조각난 공간을 다시 정리하기 유리합니다.

3. Old Generation을 위한 marking 작업은 애플리케이션과 겹쳐서 수행하자

G1GC은 old generation의 live object를 찾는 global/concurrent marking 작업의 큰 부분을 애플리케이션과 동시에 수행합니다.
즉 “모든 무거운 작업을 긴 STW 안에서 한 번에 끝내는 방식” 대신, marking은 concurrent하게 진행하고, 그 결과를 바탕으로 나중에 mixed GC에서 old region 일부를 조금씩 회수합니다. Oracle 문서도 whole-heap marking 같은 작업은 애플리케이션과 동시에 수행하고, space reclamation은 incremental하고 parallel하게 나눠 처리한다고 설명합니다.

G1GC의 핵심은 단순합니다.

  • Young GC는 원래 효율적인 방식으로 유지
  • Old는 전체를 한 번에 크게 멈추지 말고
  • concurrent marking + region 단위 선택적 회수 + evacuation 으로 부담을 나누자

즉 G1GC는 “왜 old generation 수집이 길어지는가?”라는 문제에 대해 전체를 한 번에 하지 말고, 더 잘게 나누고, 일부는 애플리케이션과 동시에 처리하자는 방향으로 접근한 collector라고 볼 수 있습니다.

Young GC vs Old GC 비교

항목 Young GC Old GC
수집 대상 Young Generation (Eden, Survivor) Old Generation
기본 전제 대부분의 객체는 빨리 죽는다 오래 살아남은 객체는 앞으로도 계속 살아남을 가능성이 크다
영역 크기 상대적으로 작음 상대적으로 큼
살아 있는 객체 비율 보통 낮음 보통 높음
STW 영향 비교적 짧은 편 길어지기 쉬움
수집 비용이 커지는 이유 살아 있는 객체를 복사해야 해서 살아 있는 객체가 많고 영역도 커서
일반적인 체감 자주 일어나도 비교적 감당 가능 한 번 발생하면 서비스에 큰 부담이 될 수 있음
대표 알고리즘 예시 Mark-Copy / Evacuation Mark-Sweep-Compact, Mark-Summary-Compact 등
G1GC 관점 기존처럼 evacuation 중심으로 처리 전체를 한 번에 하지 않고 일부 region만 선택적으로 처리
왜 문제인가 pause는 있지만 보통 짧다 긴 STW pause의 핵심 원인이 되기 쉽다

정리하자면…

  • Young GC 는 보통 영역이 작고, 대부분의 객체가 빨리 죽기 때문에 비교적 빠르게 끝난다.
  • Old GC 는 영역이 크고 살아 있는 객체 비율도 높아서, STW 시간이 길어지기 쉽다.
  • 그래서 G1GC는 Old Generation 전체를 한 번에 수집하지 않고 garbage가 많은 region만 선택적으로 회수하는 방향으로 접근한다.

GC가 오래 걸리는 핵심 원인은 단순히 garbage가 많아서가 아니라 살아 있는 객체가 많고 그 객체들을 추적·복사·정리해야 하기 때문 입니다.


G1GC 핵심 동작 정리 - Young-Only Phase와 Mixed Collection은 어떻게 이어질까?

G1GC를 이해할 때 가장 중요한 포인트 중 하나는 Old Generation 전체를 한 번에 크게 수집하지 않는다는 점입니다.

기존 GC에서 가장 부담이 컸던 부분은 보통 Old Generation 수집 시 발생하는 긴 Stop-The-World(STW) 였습니다. G1GC는 이 문제를 완화하기 위해 평소에는 Young GC 중심으로 동작하다가 필요할 때만 Old Region 일부를 점진적으로 회수하는 방식을 사용합니다.

즉 G1GC의 핵심 전략은 다음 한 문장으로 요약할 수 있습니다.

Young GC는 계속 수행하되, 필요한 순간에는 Old Region 일부를 Young GC에 얹어서 같이 처리하자.


G1GC 전체 흐름 한눈에 보기

GarbageCollectionCycle

G1GC의 큰 흐름은 보통 다음 두 단계로 나눠 볼 수 있습니다.

  1. Young-Only Phase
  2. Space-Reclamation Phase (Mixed Collection Phase)

그림을 보면 원형 사이클 형태로 표현되어 있는데 평소에는 Young-Only 단계로 돌다가, Old 영역 사용량이 일정 임계치를 넘으면 Concurrent Marking을 시작하고 이후 Mixed Collection 단계로 넘어갑니다.


1. Young-Only Phase

이 단계에서는 말 그대로 Young GC만 수행합니다.

  • Eden이 차면 Young GC 발생
  • 살아남은 객체는 Survivor 또는 Old 쪽으로 이동
  • 이 과정은 일반적인 G1의 Young evacuation 방식으로 처리

즉 애플리케이션이 평소 동작하는 대부분의 시간에는 G1GC도 “Young Generation 중심”으로 동작한다고 이해하면 됩니다.

그런데 시간이 지나면서 Old Generation에 객체가 점점 쌓이게 됩니다. 그리고 Old 영역 점유율이 일정 임계치를 넘으면 G1GC는 다음 단계를 준비합니다.

  • 평소에는 Young GC 위주로 동작
  • Old가 어느 정도 차면 Concurrent Start 발생
  • Mixed Collection을 위한 marking 작업이 시작됨

2. Concurrent Marking 시작

Old Generation 점유율이 임계치를 넘으면 G1GC는 Mixed Collection을 준비하기 위한 marking 을 시작합니다.

이 단계의 핵심은 다음입니다.

어떤 Old Region에 garbage가 많이 쌓였는지 먼저 파악하자

즉 Old 전체를 무작정 수집하는 것이 아니라 먼저 “어디를 회수하면 효율이 좋은가?” 를 찾는 단계라고 보면 됩니다. 여기서 중요한 점은 이 marking 작업의 큰 부분이 애플리케이션과 동시에(concurrently) 진행된다는 것입니다.

즉, 예전처럼 “무거운 Old 작업을 전부 긴 STW 안에서 처리”하는 방식이 아니라 가능한 부분은 애플리케이션 실행과 겹쳐서 수행합니다.


3. Remark / Cleanup

Concurrent Marking이 끝나갈 즈음, G1GC는 RemarkCleanup 단계를 거칩니다.

Remark

Concurrent하게 marking 하는 동안 놓칠 수 있는 변경 사항을 최종적으로 정리하는 단계입니다.

Cleanup

Marking 결과를 바탕으로 어떤 Old Region을 Mixed Collection 대상으로 삼을지 확정하는 단계입니다.

즉 Cleanup 단계가 끝나면 G1GC는 이제 다음 질문에 답할 수 있게 됩니다.

  • 어떤 Old Region에 garbage가 많은가?
  • 어떤 Region부터 회수하는 것이 효율적인가?
  • Mixed Collection에서 어떤 Region들을 대상으로 삼을 것인가?

정리하자면

  • marking 결과를 마무리한다
  • 회수 가치가 높은 Old Region들을 선별한다
  • Mixed Collection 대상이 확정된다

4. Space-Reclamation Phase = Mixed Collection

이제 G1GC는 Space-Reclamation Phase, 즉 Mixed Collection 단계로 들어갑니다. 이 단계가 G1GC의 핵심입니다.

Mixed Collection에서는 Young GC가 발생할 때마다 Young Region만 처리하는 것이 아니라 선별된 Old Region 일부도 함께 evacuation 합니다.

  • Young GC는 원래 자주 발생하고
  • 그 pause 안에
  • garbage가 많은 Old Region 몇 개를 같이 처리하는 방식입니다

이걸 쉽게 표현하면

Young GC에 Old Region 회수를 슬쩍 얹어서 같이 처리하는 전략

이라고 볼 수 있습니다.


이런 방식으로 해서 이점은?

기존 방식에서는 Old Generation 전체를 크게 한 번 건드리면 pause가 길어지기 쉬웠습니다. 반면 G1GC는 다음처럼 접근합니다.

  • 어차피 Young GC는 자주 발생한다
  • 그렇다면 그 pause 안에서
  • garbage가 많은 Old Region 일부도 같이 처리하자
  • 그러면 Old 전체를 한 번에 크게 멈추지 않아도 된다

즉 G1GC는

  • Throughput
  • Pause Time

두 목표를 동시에 어느 정도 만족시키기 위해 Old 처리 작업을 잘게 나눠서 Young GC와 함께 분산시키는 전략을 사용합니다.


Mixed Collection의 핵심

Mixed Collection의 핵심은 아주 간단합니다.

1. Young GC 때 Old Region 일부도 같이 처리한다

Old 전체를 다루지 않고 garbage가 많은 일부 region만 선택적으로 회수합니다.

2. 점진적으로 evacuation 한다

한 번에 다 끝내는 것이 아니라 여러 번의 Young GC에 나눠서 조금씩 처리합니다.

3. 의미가 줄어들면 다시 Young-Only로 돌아간다

Old Region을 계속 Mixed Collection 대상으로 넣어도 회수 효율이 크지 않다고 판단되면, 다시 Young-Only Phase 로 전환합니다.

즉, Mixed Collection은 무한정 계속되는 단계가 아니라, 효율 좋은 Old Region을 어느 정도 회수하면 종료되는 단계입니다.


marking의 핵심은 무엇일까?

Mixed Collection을 하려면 먼저 알아야 할 것이 있습니다.

어떤 Old 객체가 살아 있고, 어떤 Region에 garbage가 많이 쌓였는가?

그래서 marking이 필요합니다. 여기서 G1GC의 생각은 이렇습니다.

  • 어차피 Young GC 때 살아 있는 객체를 계속 추적한다
  • 그렇다면 그 흐름을 활용해서
  • Old 쪽도 같이 보고
  • 가능하면 concurrent하게 marking 하자

marking이 끝나면 각 Old Region마다 얼마나 많은 garbage가 있는지 계산할 수 있습니다.

그러면 G1GC는 그 정보를 바탕으로 “회수 효율이 높은 Old Region” 을 골라서 Mixed Collection 대상에 넣을 수 있습니다.

즉 marking의 핵심은 단순히 live object를 찾는 것이 아니라

어떤 Old Region을 회수해야 가장 이득이 큰지 판단할 수 있게 만드는 것

입니다.


G1GC 전체 Flow 한 번에 이해하기 - Young-Only, Concurrent Marking, Mixed GC는 어떻게 이어지는 알아보자

G1GC를 이해할 때 가장 중요한 포인트는 하나입니다.

Old Generation 전체를 한 번에 크게 수집하지 않고 Young GC 흐름에 일부 Old Region 회수를 조금씩 얹어서 처리한다.

즉 G1GC는 평소에는 Young-Only phase 로 동작하다가 Old Generation에 객체가 어느 정도 쌓이면 Concurrent Marking 을 시작하고 그 결과를 바탕으로 Mixed GC(Space-Reclamation phase) 로 넘어갑니다.

이번 글에서는 제공된 이미지 15장을 순서대로 따라가면서 G1GC의 전체 흐름을 짧고 핵심만 정리해보겠습니다.


전체 흐름 먼저 보기

G1GC의 큰 흐름은 아래처럼 이해하면 됩니다.

  1. 평소에는 Young GC만 수행
  2. Old 점유율이 임계치를 넘으면 Concurrent Start
  3. Young GC와 연결된 흐름 속에서 Concurrent Marking 준비
  4. Root Region Scanning 수행
  5. Concurrent Marking 으로 살아 있는 Old 객체 탐색
  6. Remark / Cleanup 으로 마무리
  7. 회수 가치가 높은 Old Region을 골라 Mixed GC 수행
  8. 더 이상 의미가 크지 않으면 다시 Young-Only phase 로 복귀

보통의 Young GC - Young-Only phase

G1GC-FLOW-1

이 단계는 평소의 Young GC입니다.

  • Eden / Survivor 중심으로 Young 객체만 처리
  • marking 중에 young 객체가 old 객체를 참조하고 있어도
  • 그 old 객체까지 따라가지 않고
  • 그냥 다음 young 객체로 넘어갑니다

즉 아직은 Old Generation을 본격적으로 수집할 시점이 아니기 때문입니다.


Concurrent Start

G1GC-FLOW-2

Old Generation에 객체가 어느 정도 쌓이면 Young GC 중에 Concurrent Start 가 발생합니다.

이때 중요한 변화가 있습니다.

  • marking 중에 만난 GC Root가
  • Old 객체를 직접 참조하고 있으면
  • 그 old 객체를 mark stack에 push
  • 그리고 다음 young 객체로 넘어갑니다

즉 이제부터는 “Old를 어떻게 회수할지”를 준비하기 시작한 것입니다.


Young GC 후 evacuation

G1GC-FLOW-3

Young GC가 발생하면
Eden 영역은 비워지고, 살아남은 Young 객체는 Survivor Region 으로 복사됩니다.

즉, 일반적인 G1 Young evacuation 과정입니다.

  • Eden → Free Region
  • 살아 있는 young 객체 → Survivor Region 이동

핵심

  • Young는 기존처럼 mark-copy / evacuation
  • Eden은 회수되고 Survivor에 살아남은 객체만 남음

Root Region Scanning

G1GC-FLOW-4

Young GC가 끝난 뒤 Survivor Region에는 살아 있는 young 객체만 존재하게 됩니다.

이 Survivor Region들을 Root Regions 라고 보고 여기서 직접 참조되는 Old 객체를 다시 찾아 mark stack에 push 합니다.

이 과정을 Root Region Scanning 이라고 합니다.

그리고 중요한 점은

  • 이 과정이 다음 Young GC 전까지 concurrently 진행된다는 것입니다.

Concurrent Marking

이제부터는 mark stack을 이용해 살아 있는 Old 객체를 찾는 Concurrent Marking 단계입니다. 중요한 점은 이 과정이 애플리케이션과 동시에(concurrently) 진행된다는 것입니다.

G1GC-FLOW-5

  • 18번 객체 pop
  • 참조되는 객체 없음
  • 추가 push 없음

G1GC-FLOW-6

  • 7번 객체 pop
  • 참조하던 14번 old 객체 발견
  • 14번 push

G1GC-FLOW-7

  • 14번 객체 pop
  • 참조되는 객체 없음
  • 추가 push 없음

G1GC-FLOW-8

  • 12번 객체 pop
  • 참조 대상이 4번 young 객체
  • young 객체이므로 별도 old marking 진행 안 함

G1GC-FLOW-9

  • 6번 객체 pop
  • 참조 대상이 3번 young 객체
  • 마찬가지로 old marking 대상은 아님

G1GC-FLOW-10

  • 8번 객체 pop
  • 참조하던 15번 old 객체 발견
  • 15번 push

G1GC-FLOW-11

  • 15번 객체 pop
  • 참조되는 객체 없음
  • marking 종료 방향으로 진행

정리하자면

  • mark stack에서 old 객체를 꺼내며 탐색
  • old → old 참조만 따라가며 확장
  • young 객체를 만나도 굳이 young 전체를 다시 탐색하지 않음
  • 이 전체 흐름이 concurrent 하게 수행됨

Remark / Cleanup

G1GC-FLOW-12

Concurrent Marking이 끝나면 이제 Remark 단계로 들어갑니다.

Remark

  • 별도의 STW
  • concurrent marking 동안 남은 작업 마무리
  • 예: drain SATB buffer
  • 완전히 비었다고 판단된 old region은 바로 회수 가능

그 다음은 Cleanup 단계입니다.

Cleanup

  • 별도의 STW
  • CSet(Collection Set) candidate regions 확정
  • 이후 Space-Reclamation(mixed collection) 을 실제로 진행할지 결정

이미지의 검정 점선 원으로 표시된 Region들이 바로 CSet candidate regions 입니다.

핵심

  • Remark: marking 마무리
  • Cleanup: 어떤 old region을 mixed GC 대상으로 삼을지 확정

Space-Reclamation phase 시작

G1GC-FLOW-13

이제 Mixed GC 단계입니다. 이 단계의 핵심은 다음 한 문장으로 정리할 수 있습니다.

Young GC가 발생할 때, CSet candidate old region 일부도 같이 회수한다.

즉 Young GC에 Old Region 회수를 얹는 방식입니다. 이미지에서는 Young GC가 발생했고 Eden에서 살아남은 young 객체 1, 2, 3, 5 만 새로운 Survivor Region으로 옮겨집니다.

정리하자면

  • Mixed GC도 Young GC 흐름 안에서 진행
  • Young만 처리하는 것이 아니라 selected old region도 같이 처리

선택된 Old Region 회수

G1GC-FLOW-14

이제 CSet candidate old region들을 회수합니다.

  • 새로운 Old Region을 만들고
  • 살아 있는 old 객체들을 그쪽으로 evacuation
  • 이번 예시에서는 mixed GC 한 번에 3개의 region 회수라고 가정

이때 G1GC의 중요한 특징 중 하나가 나옵니다.

  • Old Region도 자신만의 Remembered Set을 가짐
  • 이를 바탕으로 이동 후 참조 위치를 갱신할 수 있음

즉 old도 region 단위로 독립적으로 다룰 수 있습니다.

정리하자면

  • old region 회수도 evacuation 기반
  • compact를 직접 하지 않고 살아 있는 객체만 다른 region으로 대피
  • remembered set으로 참조 갱신 가능

모든 garbage가 한 번에 사라지는 것은 아니다

G1GC-FLOW-15

마지막 이미지는 Mixed GC의 현실적인 특징을 잘 보여줍니다.

예를 들어 7번 old region 을 보면 이미 garbage가 되었더라도 이번 cycle의 회수 대상에 포함되지 않을 수 있습니다.

  • 이번 cycle에서는 건너뛸 수 있고
  • 다음 cycle에서 다시 candidate가 될 수 있습니다

이것이 바로 G1GC의 특징입니다.

Old 전체를 한 번에 다 치우는 것이 아니라 회수 효율이 높은 region부터 점진적으로 처리한다.

  • garbage라고 해서 무조건 이번 cycle에 다 회수되는 것은 아님
  • 회수 우선순위는 region 단위로 결정
  • 다음 cycle에 다시 대상이 될 수 있음

흐름 핵심을 정리

앞써 설명한 15장의 이미지가 보여주는 G1GC의 핵심은 아래와 같습니다.

1) 평소에는 Young GC 중심으로 간다

Young-Only phase에서는 Young 객체만 빠르게 처리합니다.

2) Old가 쌓이면 Concurrent Marking을 시작한다

Old 전체를 당장 멈춰서 처리하지 않고,
먼저 살아 있는 Old 객체와 garbage 비율을 계산합니다.

3) marking은 concurrent 하게 진행한다

애플리케이션을 완전히 멈추는 시간을 줄이기 위해
가능한 작업은 concurrent 하게 수행합니다.

4) Mixed GC에서는 old 일부만 회수한다

young GC가 발생할 때 selected old region도 함께 evacuation 합니다.

5) old 전체를 한 번에 다 치우지 않는다

가장 회수 효율이 좋은 region부터 점진적으로 처리합니다.

G1GC는 평소에는 Young GC만 수행하다가, Old Generation 점유율이 높아지면 concurrent marking으로 회수 가치가 높은 old region을 찾고, 이후 Young GC에 일부 old region 회수를 얹는 Mixed GC 방식으로 점진적으로 공간을 회수한다.

G1GC Concurrent Mark 문제점

G1GC는 Old Generation을 한 번에 길게 멈추지 않고 애플리케이션과 동시에(concurrently) marking을 수행하려고 합니다. 그런데 concurrent marking에는 본질적인 어려움이 있습니다.

GC가 객체 그래프를 따라가며 marking하는 동안 애플리케이션이 참조 관계를 계속 바꿔버릴 수 있다.

GC는 “지금 보이는 객체 그래프”를 따라가고 있는데 애플리케이션은 그 순간에도 객체 참조를 추가하거나 삭제합니다. 이 때문에 살아 있는 객체를 죽었다고 잘못 판단하거나 반대로 이미 죽은 객체를 살아 있다고 잘못 판단할 수 있습니다.

이번 글에서는 제공된 그림 흐름을 기준으로 이 문제가 어떻게 발생하는지 정리해보겠습니다.


이미지 보기 전 marking 처리 관련 설명

Concurrent Mark를 설명할 때는 보통 객체를 3가지 색으로 구분 해보았습니다.

  • White: 아직 marking되지 않은 객체
  • Grey: marking은 되었지만, 아직 이 객체가 참조하는 다른 객체들을 다 탐색하지 않은 상태
  • Black: marking도 되었고, 이 객체가 참조하는 다른 객체 탐색까지 끝난 상태

이번 그림에서도 색 의미를 이렇게 이해하면 됩니다.

  • 연한 색: 아직 미탐색 또는 탐색 전
  • 진한 회색: 탐색 완료
  • 중간색: mark는 되었지만 아직 탐색 전

Concurrent Mark가 올바르게 끝나려면 최종적으로 도달 가능한 객체는 모두 black으로 수렴해야 합니다.


1. 시작 상태

Concurrent-Mark-Problem-1

처음 상태에서는 GC Root에서 시작해 객체 1 -> 3 -> 4 -> 51 -> 2 같은 참조 관계가 보입니다.

아직 아무 객체도 marking 되지 않았습니다. 이 상태는 단순히 “GC가 이제 Root부터 탐색을 시작하려고 한다” 정도로 이해하면 됩니다.


2. GC Root에서 1번 객체를 먼저 본다

Concurrent-Mark-Problem-2

GC Root가 가리키는 1 번 객체를 먼저 mark 합니다. 이 시점의 1mark는 되었지만, 아직 그 내부 참조(3, 2)를 다 따라가진 않은 상태이므로 grey로 볼 수 있습니다.

  • 1 은 탐색 대상이 되었고
  • 다음에는 1 이 가리키는 객체들을 따라가야 합니다

3. 1번 탐색 완료, 3번과 2번 mark

Concurrent-Mark-Problem-3

이제 1 의 참조를 따라가며 3, 2 를 mark 합니다. 그리고 1 은 자식 탐색을 끝냈기 때문에 black으로 바뀝니다.

  • 1 = black
  • 2, 3 = grey 또는 mark 되었지만 아직 완전 탐색 전

이 단계까지는 문제가 없습니다. GC는 정상적으로 객체 그래프를 따라가고 있습니다.


4. 2번 객체 탐색 완료

Concurrent-Mark-Problem-4

이번에는 2 객체를 탐색하고 완료합니다. 2 는 더 이상 따라갈 참조가 없으므로 black이 됩니다. 이제 남아 있는 주요 grey 객체는 3 입니다.


5. 3번 객체 탐색 완료 직전

Concurrent-Mark-Problem-5

이제 3 도 탐색 대상입니다. 3 이 가리키는 4 를 mark 하게 되고 점점 아래 방향으로 그래프 탐색이 진행됩니다.

이 시점까지도 GC는 “현재 보이는 참조 그래프”를 기준으로 옳게 동작하고 있습니다.


6. 애플리케이션이 참조를 바꾼다

Concurrent-Mark-Problem-6

이제 concurrent marking의 핵심 문제가 등장합니다. 애플리케이션이 실행되면서 원래 있던 참조 관계를 바꿉니다. 그림에서는 다음 변화가 일어납니다.

  • 1 -> 5 라는 새로운 참조가 추가
  • 1 -> 2 라는 참조가 삭제
  • 4 -> 5 라는 참조가 삭제

GC가 marking을 진행하는 도중에 객체 그래프가 바뀌고 있습니다. 바로 이 지점이 concurrent marking이 어려운 이유입니다.


7. 4번 탐색 완료

Concurrent-Mark-Problem-7

GC는 자신의 시점에서 4 까지 정상적으로 탐색 완료합니다. 문제는 GC가 보고 있는 그래프와 애플리케이션이 실제로 바꿔 놓은 최신 그래프 사이에 차이가 생길 수 있다는 점입니다.

즉 GC 입장에서는 원래 경로를 따라 탐색하고 있다고 생각하지만 실제 런타임에서는

  • 참조가 이미 삭제되었거나
  • 새 참조가 생겼을 수 있습니다

8. 최종적으로 발생할 수 있는 잘못된 판단

Concurrent-Mark-Problem-8

그림 마지막은 concurrent marking 문제를 아주 직관적으로 보여줍니다.

예를 들면

  • 원래는 garbage 대상이 아니었는데 살아 있다고 판단하지 못하는 경우
  • 반대로 garbage 대상인데도 살아 있다고 판단하는 경우

즉 concurrent marking 중 참조 변경이 겹치면 GC가 도달 가능성(reachability) 을 잘못 판단할 수 있습니다.


이 문제가 발생하기 위한 두 가지 조건

이 문제는 아무 때나 생기는 것이 아니라 보통 아래 두 조건이 동시에 맞아야 발생합니다.

1. Black 객체가 White 객체를 향한 새로운 참조를 추가한다

이미 탐색이 끝난 black 객체가 아직 marking되지 않은 white 객체를 새롭게 가리키게 되는 경우입니다.

GC 입장에서는 “black 객체는 이미 다 처리했으니 다시 안 봐도 된다”고 생각하는데 그 black 객체 안에 새 참조가 뒤늦게 생겨버린 상황입니다.


2. 그 White 객체를 원래 참조하던 Grey 객체 또는 결국 Grey가 될 White 객체가 그 참조를 삭제한다

원래는 어떤 경로를 통해 그 white 객체에 도달할 수 있었는데, 그 참조가 지워져 버리면 GC는 그 객체를 놓칠 수 있습니다.

원래 추적하던 경로는 사라지고 새로운 경로는 black 객체에 추가되었는데 그 black 객체는 이미 다시 스캔하지 않기 때문에 결국 그 white 객체를 marking하지 못하게 됩니다.


왜 이 두 조건이 같이 있어야 문제일까?

조금 더 쉽게 말하면 이렇습니다.

어떤 white 객체가 살아 있으려면 최소한 한 경로를 따라 GC가 도달할 수 있어야 합니다. 그런데 concurrent 환경에서는

  • 새 경로는 이미 탐색이 끝난 black 객체 안에 생기고
  • 옛 경로는 아직 탐색 중이던 쪽에서 사라지면

GC는 그 white 객체를 결국 발견하지 못할 수 있습니다.

살아 있는 객체인데도 marking에서 빠질 수 있다

는 것이 concurrent marking의 대표적인 위험입니다.


G1GC는 이 문제를 어떻게 해결하려고 할까?

질문에서 정리해주신 핵심이 바로 이것입니다.

G1GC는 2번 조건이 성립하지 않도록 해서 이 문제를 완화하려고 한다.

G1GC는 “원래 white 객체를 가리키던 참조가 사라져버리는 문제”를 그냥 없던 일처럼 보지 않도록 처리합니다. 이 아이디어는 SATB(Snapshot-At-The-Beginning) 관점으로 이해하면 쉽습니다.


SATB 관점에서 보면

G1GC의 concurrent marking은 “marking 시작 시점의 객체 그래프 스냅샷”을 기준으로 살아 있는 객체를 찾으려는 방향에 가깝습니다.

  • marking 도중 참조가 삭제되더라도
  • 그 삭제되기 전의 참조를 기록해 두어
  • GC가 나중에라도 그 객체를 다시 볼 수 있게 만듭니다

쉽게 말하면

참조가 끊기기 전에, 그 이전 값을 따로 기억해 두는 방식 입니다. 그러면 위의 2번 조건 “원래 따라가야 했던 경로가 완전히 사라져서 white 객체를 놓치는 상황”을 막을 수 있습니다.


G1GC에서 SATB BufferMark Stack은 왜 필요할까? - Concurrent Mark 중 참조 변경이 있어도 객체를 놓치지 않는 방법

G1GC의 Concurrent Mark는 애플리케이션과 동시에 동작합니다. GC가 객체 그래프를 탐색하는 동안에도 애플리케이션 스레드(mutator thread)는 참조를 계속 바꿀 수 있습니다.

문제는 여기서 발생합니다.

GC가 아직 보지 못한 객체를, 애플리케이션이 참조 관계 변경 때문에 놓칠 수 있다.

G1GC는 이 문제를 줄이기 위해 SATB(Snapshot-At-The-Beginning) 방식과 SATB Buffer, Mark Stack 을 함께 사용합니다.

핵심은 단순합니다.

  • 평소에는 Mark Stack 으로 그래프를 따라가며 marking 하고
  • concurrent marking 중 참조가 끊어질 때는
  • 예전 참조값(old reference)SATB Buffer 에 기록해 둔 뒤
  • 나중에 remark 단계에서 다시 mark stack으로 가져와 놓친 객체가 없는지 보완합니다

즉 SATB Buffer는 “concurrent marking 중 변경된 참조를 임시로 기록하는 보조 장치” 이고 Mark Stack은 “실제로 탐색할 객체 주소를 쌓아 두는 작업 스택” 이라고 이해하면 됩니다.


설명 전 용어 정리 해보자

Mark Stack

GC가 앞으로 탐색할 객체 주소를 담아 두는 스택입니다. GC thread는 여기서 객체를 pop 하며 그래프를 탐색합니다.

Local SATB Buffer

mutator thread가 참조 변경 시 삭제되기 전(old value) 참조를 잠시 기록하는 로컬 버퍼입니다.

Global SATB Buffer

Remark 단계에서 Local SATB Buffer에 있던 값들을 모아 두는 전역 큐/버퍼 개념입니다.

old value를 기록할까?

SATB의 핵심은 “marking 시작 시점에 살아 있던 객체는 끝까지 살아 있다고 보자” 라는 관점입니다. 그래서 concurrent marking 도중 참조가 끊어져도 끊기기 전 참조값을 따로 기억해 두었다가 다시 확인합니다.


전체 흐름 확인 해보기

이번 그림 흐름은 아래처럼 이해하면 됩니다.

  1. Concurrent Mark 시작
  2. Root에서 시작해 Mark Stack으로 객체 탐색
  3. 애플리케이션이 참조를 변경
  4. pre-write barrier가 old reference를 Local SATB Buffer에 기록
  5. Remark 단계에서 Local SATB Buffer 내용을 처리
  6. 조건에 맞는 객체만 Mark Stack으로 다시 넣음
  7. 최종적으로 놓칠 수 있었던 객체까지 marking 완료

즉 SATB Buffer는 Concurrent Mark 도중 참조 변경으로 인해 marking 누락이 생기지 않도록 보완하는 장치입니다.


1. Marking 시작

SATB-buffer&mark-stack-1

Concurrent Mark가 시작됩니다. 이 시점부터 애플리케이션도 동시에 실행됩니다. 아직 mark stack은 비어 있고 SATB Buffer도 비어 있습니다.


2. Concurrent Start

SATB-buffer&mark-stack-2

Young GC에 맞춰 Concurrent Start(STW) 가 발생합니다. 이때 GC Root에서 시작해 1번 객체를 mark stack에 push 합니다. 즉 본격적인 그래프 탐색의 시작점이 stack에 들어간 것입니다.

  • GC Root가 가리키는 시작 객체를 mark stack에 넣음
  • 이후부터 mark stack 기반 탐색 시작

3. Root Region Scan 이후 1번 pop

SATB-buffer&mark-stack-3

이번 예제에서는 Young Region이 없다고 가정하므로 Root Region Scan은 특별한 추가 작업 없이 넘어갑니다. 그 다음 concurrent marking 단계로 진입하고 mark stack에 있던 1번 객체를 pop 해서 탐색합니다.


4. 2번, 3번 push

SATB-buffer&mark-stack-4

1번 객체가 참조하는 객체를 보니 2번, 3번 객체가 있습니다. 따라서 이 둘을 mark stack에 push 합니다.

  • 1번은 탐색 완료 방향으로 가고
  • 다음 탐색 대상은 2, 3번이 됩니다

5. 2번 pop

SATB-buffer&mark-stack-5

이제 2번 객체를 pop 해서 탐색합니다. 하지만 2번이 참조하는 reachable 객체는 더 이상 없습니다. 추가 push 없이 끝납니다.


6. 3번 pop, 4번 push

SATB-buffer&mark-stack-6

다음은 3번 객체를 pop 합니다. 3번은 4번 객체를 참조하고 있으므로 4번 객체를 mark stack에 push 합니다.

7. 애플리케이션이 참조를 바꿈

SATB-buffer&mark-stack-7

여기서 concurrent marking의 핵심 상황이 나옵니다. 애플리케이션이 실행되면서 참조 관계를 바꿉니다.

  • 1 -> 2 참조를 끊음
  • 4 -> 5 참조를 끊음
  • 대신 1 -> 5 참조를 새로 추가함

이때 pre-write barrier 가 동작합니다. 중요한 점은, G1GC는 새 참조(new value) 가 아니라 끊기기 전 참조(old value) 를 기록한다는 것입니다.

그래서

  • 끊긴 old reference인 2
  • 끊긴 old reference인 5

이 두 객체 주소를 Local SATB Buffer 에 넣습니다.

핵심

  • 참조 변경이 생김
  • pre-write barrier가 old value를 Local SATB Buffer에 기록
  • 이번 예시에서는 2, 5가 기록됨

8. 4번 pop

SATB-buffer&mark-stack-8

이제 GC는 mark stack에 있던 4번 객체를 pop 합니다. 그런데 이미 애플리케이션이 참조를 바꿨기 때문에 4번은 더 이상 참조하는 객체가 없습니다. GC 입장에서는 여기서 탐색이 끝난 것처럼 보일 수 있습니다.

바로 이런 상황 때문에 SATB Buffer가 필요합니다. 원래는 4가 5를 가리키고 있었지만 그 참조가 끊겨서 그냥 현재 그래프만 보면 5를 놓칠 수도 있기 때문입니다.

핵심

  • 현재 시점의 그래프만 보면 5를 놓칠 수 있음
  • 그래서 old reference 기록이 필요함

9. Remark 단계 진입

SATB-buffer&mark-stack-9

이제 Remark 단계(STW) 로 들어갑니다.

Remark 에서는 concurrent marking 도중 남아 있던 작업을 마무리하는데 그중 중요한 것이 바로 SATB Buffer 처리입니다.

이 단계에서

  • 특정 mutator thread의 Local SATB Buffer 에 있던 값들
  • 예: 5, 2
  • Global SATB Buffer 쪽으로 enqueue 합니다

즉 concurrent 중에 임시로 기록해 둔 old reference들을 이제 GC가 정식으로 처리할 수 있는 단계로 넘기는 것입니다.

핵심

  • Remark는 STW
  • Local SATB Buffer의 주소들을 GC가 처리 가능한 위치로 넘김

10. SATB Buffer → Mark Stack 이동 시 필터링

SATB-buffer&mark-stack-10

이제 SATB Buffer에 있던 주소들을 다시 mark stack 으로 가져오려 합니다. 하지만 무조건 다 넣지는 않습니다. 가져올 때 아래 조건으로 필터링(discard) 합니다.

discard 조건

  1. TAMS 포인터 이후에 할당된 객체
  2. 이미 marking된 객체
  3. young 객체

즉 SATB Buffer에 들어 있다고 해서 무조건 mark stack으로 보내는 것은 아니고 정말 다시 볼 필요가 있는 객체만 남깁니다.

핵심

  • SATB Buffer는 보조 자료구조
  • mark stack으로 다시 넣기 전에 필요 없는 값은 걸러냄

11. 5번만 mark stack에 들어감

SATB-buffer&mark-stack-11

이번 예제에서는 Local SATB Buffer에 있던 5, 2 중에서 조건에 맞는 것은 5번 객체만 남습니다. 따라서 5번 객체만 mark stack에 dequeue 됩니다.

  • 2번은 이미 marking 되었거나, 다시 볼 필요가 없다고 판단
  • 5번은 아직 marking 누락 가능성이 있으므로 mark stack으로 보냄

12. 5번 pop 후 최종 marking

SATB-buffer&mark-stack-12

마지막으로 mark stack에 올라간 5번 객체를 pop 해서 marking 처리합니다.

이렇게 하면 concurrent marking 중 참조가 끊겼더라도 원래 살아 있었어야 할 객체를 놓치지 않고 최종적으로 marking 할 수 있습니다.

SATB Buffer와 Mark Stack이 함께 동작해서 concurrent 환경에서도 marking 누락을 줄이는 것입니다.

핵심

  • 5번 객체를 최종적으로 marking
  • concurrent 중 참조 변경이 있어도 reachable 객체 누락 방지

이 흐름의 핵심 의미 설명

이번 12장의 그림이 보여주는 핵심은 아래와 같습니다.

1. Mark Stack은 “탐색 작업용”

GC thread가 실제로 따라갈 객체를 쌓아 두는 곳입니다.

2. SATB Buffer는 “참조 변경 보완용”

애플리케이션이 concurrent 중 참조를 끊으면
old reference 를 잠시 저장하는 버퍼입니다.

3. pre-write barrier가 old value를 기록한다

새 참조가 아니라, 끊기기 전 참조값 을 기록합니다.

4. Remark에서 SATB Buffer를 비운다

Local / Global SATB Buffer의 내용을 정리하고,
필요한 객체만 다시 mark stack에 넣습니다.

5. 최종적으로 reachable 객체를 놓치지 않는다

즉, concurrent marking의 약점을
SATB Buffer + Remark + Mark Stack 보완 과정으로 해결합니다.

  • Mark Stack: GC가 지금부터 탐색할 객체 목록
  • Local SATB Buffer: mutator thread가 old reference를 임시 저장
  • Global SATB Buffer: remark 때 GC가 가져갈 버퍼
  • pre-write barrier: 참조 변경 전 old value 기록
  • remark: SATB Buffer를 비우고 필요한 객체를 다시 mark stack에 적재
  • 결과: concurrent 중 참조가 끊겨도 객체를 놓치지 않음

정리해자면?

G1GC는 concurrent marking 중 참조가 바뀌어도 reachable 객체를 놓치지 않기 위해, pre-write barrier로 old reference를 SATB Buffer에 기록하고 remark 단계에서 이를 mark stack으로 다시 옮겨 최종 marking한다.


G1GC Mixed GC에서 Remembered Set이 왜 필요할까?

수집 대상 Old Region 안의 살아 있는 객체를 놓치지 않는 방법

G1GC의 Mixed GCYoung Generation 전체 + 일부 Selected Old Region 만 골라서 수집합니다. Old Generation 전체를 한 번에 수집하지 않고 Collection Set(CSet) 으로 선택된 Region만 대상으로 삼습니다.

이 방식은 pause time을 줄이는 데 유리하지만 한 가지 문제가 생깁니다.

수집 대상 Region 안의 살아 있는 객체가 수집 대상이 아닌 다른 Old Region을 통해서만 도달 가능하다면 어떻게 할까?

이 문제를 해결하는 장치가 바로 Remembered Set 입니다. 9장의 이미지 통해 흐름을 따라가며 Mixed GC 에서 Remembered Set이 왜 필요한지 정리해보겠습니다.


Mixed GC는 다음처럼 동작합니다.

  • Young / Survivor Region 은 당연히 수집 대상
  • 여기에 Garbage가 많다고 판단된 일부 Old Region 을 추가로 선택
  • 선택된 Region(CSet)에 있는 살아 있는 객체는 다른 Region으로 evacuation
  • 이후 비워진 Region은 회수

그런데 문제는 선택된 Old Region 안에 있는 객체가 선택되지 않은 다른 Old Region을 통해서만 참조되고 있다면 그 객체를 놓칠 수 있다는 점입니다.

그래서 G1GC는 각 Region마다 외부에서 자신을 참조하는 정보Remembered Set으로 따로 관리합니다.


1. Mixed GC의 기본 대상

MixedGC–RememberedSet-1

Mixed GC는 기본적으로 다음을 수집합니다.

  • Young Generation
  • Survivor Region
  • 그리고 일부 Selected Old Region

그냥 Young GC가 아니라 Young에 Old Region 일부를 얹어서 같이 처리하는 방식입니다.

이미지에서는 각 Region 안에 객체 참조 관계가 그려져 있고 이제 이 중 일부 Region이 CSet 으로 선택될 준비를 하고 있습니다.


2. 이번 Mixed GC에서 수집할 Region 선택

MixedGC–RememberedSet-2

노란색 박스로 표시된 Region이 이번 Mixed GC의 수집 대상 Region(Collection Set) 입니다.

여기서 중요한 점은 두 가지입니다.

  1. Young / Survivor Region은 기본적으로 수집 대상
  2. Old Region도 일부만 선택적으로 수집 대상이 됨

이번 cycle에서는 선택된 Region 안에 있는 객체들만 evacuation 대상이 됩니다.


3. 1번 객체 evacuation

MixedGC–RememberedSet-3

이제 실제 evacuation이 시작됩니다. GC Root가 직접 참조하는 1번 객체가 있고 1번 객체는 이번 Mixed GC에서 수집 대상 Region 안에 있습니다.

따라서 1번 객체는 다른 Region에 새로 마련된 Survivor Region 으로 이동합니다. (1번 객체는 새 Survivor Region으로 evacuation) 즉 Mixed GC도 기본적으로는 살아 있는 객체를 새 위치로 복사(evacuation) 하는 방식입니다.


4. 5, 6번 객체도 수집 대상이므로 이동

MixedGC–RememberedSet-4

이제 1번 객체가 참조하는 객체를 따라갑니다. 1번 객체는 5번 객체를 참조하고 있고 5번 객체는 다시 6번 객체를 참조합니다.

그리고 중요한 점은

  • 5번, 6번이 속한 Region이 이번 Mixed GC의 수집 대상 Region 이라는 것입니다.

그래서 5번 객체만 보는 것이 아니라 그와 연결된 6번 객체까지 포함해서 새로운 Old Region 으로 함께 이동시킵니다.

평소 Young GC였다면 Old Region은 보통 그대로 두었겠지만 이번에는 Mixed GC 이기 때문에 선택된 Old Region도 진짜 수집 대상으로 다뤄집니다.


5. Young Generation Remembered Set을 통해 4번 객체 이동

MixedGC–RememberedSet-5

이제 Young Generation Remembered Set 을 봅니다. 그 정보를 따라가면 2번 객체, 그리고 이어서 4번 객체(Survivor Region) 를 찾을 수 있습니다.

그리고 4번 객체가 속한 Region 역시 이번 수집 대상이기 때문에 4번 객체도 새로 생성된 Survivor Region으로 이동합니다.

Mixed GC는 단순히 Root만 보는 것이 아니라 Young 쪽에서 들어오는 cross-region 참조도 함께 고려해야 합니다.


6. 8번 Region이 수집 대상이 아니면 어떤 문제가 생길까?

MixedGC–RememberedSet-6

여기서 문제가 드러납니다. 1번 객체는 8번 객체를 참조합니다. 그런데 8번 객체가 속한 Region은 이번 Mixed GC의 수집 대상이 아닙니다. 그러면 GC는 보통 이렇게 생각할 수 있습니다.

  • 8번 객체는 이번에 건드리지 않음
  • 그 뒤는 더 깊게 안 봄

그런데 이 경우 문제가 생깁니다.

실제 참조 흐름은 다음과 같을 수 있습니다.

  • GC Root -> 1 -> 8 -> 3 -> 7

7번 객체가 이번 수집 대상 Region 안에 있는데도 중간의 8번 Region이 수집 대상이 아니라는 이유로 그 경로를 제대로 따라가지 않으면 7번 객체를 살아 있는 객체로 찾지 못할 수 있습니다.

결국 7번 객체가 살아 있음에도 잘못 회수될 위험이 생깁니다.


7. 해결책 - Region마다 Remembered Set

MixedGC–RememberedSet-7

이 문제를 해결하기 위해 G1GC는 각 Region마다 자신을 참조하는 외부 참조 정보를 Remembered Set으로 유지합니다.

이미지에서 Remembered Set of 7 을 주목하면 이 정보 덕분에 7번 객체로 들어오는 외부 참조가 있다는 사실을 알 수 있습니다.

즉 GC가 굳이 전체 Old Generation을 전부 다시 스캔하지 않아도 “누가 이 Region을 참조하고 있는지” 를 Remembered Set을 통해 빠르게 찾을 수 있습니다.


8. 7번 객체를 정상적으로 evacuation

MixedGC–RememberedSet-8

이제 Remembered Set of 7 정보를 통해 7번 객체가 외부 Region에서 참조되고 있다는 사실을 알게 됩니다.

그리고 7번 객체가 속한 Region은 이번 Mixed GC의 수집 대상이므로 7번 객체도 이전에 생성된 새 Old Region 으로 정상적으로 이동시킵니다.

아까 이미지 6에서 생길 수 있었던 문제를 Remembered Set 덕분에 해결한 것입니다.


9. 수집 대상 Region을 안전하게 회수

MixedGC–RememberedSet-9

이제 수집 대상 Region 안의 살아 있는 객체들이 필요한 곳으로 모두 이동했습니다.

그 결과

  • 선택된 Region 내부는 비워지고
  • 안전하게 reclaim 가능해집니다
  • 그리고 무엇보다 7번 객체를 잘못 가비지로 처리하는 문제도 발생하지 않습니다

즉 Mixed GC에서 Remembered Set은 단순 최적화가 아니라 정확성을 지키기 위해 꼭 필요한 장치입니다.


왜 Remembered Set이 꼭 필요할까?

이 글의 핵심은 사실 한 문장으로 정리할 수 있습니다.

Mixed GC는 Old 전체를 다 스캔하지 않기 때문에 수집 대상 Region 안으로 들어오는 외부 참조를 Remembered Set으로 따로 알고 있어야 한다.

만약 Remembered Set이 없다면

  • 수집 대상이 아닌 Old Region을 통해 들어오는 경로를 놓칠 수 있고
  • 그 결과 수집 대상 Region 안의 살아 있는 객체를 못 찾을 수 있으며
  • 결국 살아 있는 객체를 잘못 회수할 수 있습니다

즉 Remembered Set은 “부분 수집(partial collection)을 안전하게 가능하게 만드는 핵심 장치” 입니다.


G1GC에서 Humongous Object(거대 객체)란?

왜 특별 취급되고, 왜 eager reclamation이 필요한가

G1GC에서 Humongous ObjectRegion 크기의 1/2 이상인 객체를 말합니다. 이 객체들은 일반적인 객체와 다르게 취급되는데, 이유는 크기가 너무 커서 일반적인 evacuation 흐름에 자연스럽게 녹여 넣기 어렵고, 힙 단편화와 pause time 문제에도 더 직접적인 영향을 줄 수 있기 때문 입니다.


Humongous Object는 왜 특별할까?

Humongous-Object-1

Humongous Object는 크기 자체가 크기 때문에, 보통의 작은 객체처럼 region 안에 자연스럽게 들어가지 못할 수 있습니다. 이런 객체는 연속된(contiguous) region들에 할당되며 배치 위치는 old generation 으로 취급 됩니다.

또한 객체의 시작은 첫 번째 region의 시작점에 놓이고 마지막 region에 남는 자투리 공간은 그 객체가 회수될 때까지 다른 할당에 쓰지 못 합니다.

Humongous Object는 단순히 “큰 객체”가 아니라, 여러 region을 한 번에 잡아먹고 마지막 region의 일부 공간도 버리게 만들 수 있는 객체 입니다. 그래서 힙 효율과 단편화 측면에서 부담이 커질 수 있습니다.


Humongous Object가 실제로 할당되는 방식

Humongous-Object-2

두번째 그림처럼 Humongous Object가 하나의 region에 다 들어가지 않으면 G1GC는 여러 개의 연속된 region 을 확보해서 그 객체를 배치 됩니다. 그리고 이 객체는 young가 아니라 old 쪽 문맥으로 취급 됩니다.

또 한 가지 중요한 점은 Humongous Object가 할당될 때마다 G1은 IHOP(initiating heap occupancy) 조건을 확인 한다는 것 입니다. 현재 old 쪽 점유율이 임계치를 넘었고 아직 marking 중이 아니라면 G1GC는 즉시 initial-mark young collection(concurrent start pause) 를 유발할 수 있습니다. 즉 큰 객체를 여러 번 할당하는 패턴은 marking cycle을 더 일찍 시작하게 만들 수 있습니다.


Humongous Object는 GC 중에 항상 이동할까?

보통은 아닙니다. humongous object는 대체로 end-of-marking의 Cleanup pauseFull GC 에서만 회수 대상이 되며 이동(move) 자체는 훨씬 더 제한적으로 일어납니다.

특히 G1은 humongous object를 일반 객체처럼 자주 evacuation 하지 않고 정말 마지막 수단에 가까운 Full GC 상황에서만 이동을 시도할 수 있는데 이 과정은 매우 느릴 수 있습니다.

즉 Humongous Object는 “살아 있으면 일단 거기 둔다”에 가깝고 그래서 한 번 차지한 공간이 꽤 오랫동안 힙에 남을 수 있습니다. 바로 이 점 때문에 humongous object가 많으면 G1이 불리해질 수 있습니다.


Humongous Object가 garbage가 되면 언제 회수될까?

기본적으로는 marking 종료 시점의 Cleanup pauseFull GC 에서 회수될 수 있습니다. humongous object는 일반적으로 Cleanup pause 또는 Full GC 에서 reclaim 됩니다.

하지만 G1GC 는 일부 humongous object에 대해서는 더 일찍 회수(eager reclamation) 하려는 최적화도 보입니다.


eager reclamation for humongous objects

Humongous-Object-3

G1GC 는 모든 humongous object를 무조건 remark/full GC까지 기다리지 않고 primitive type 배열 형태의 humongous object (예: bool[], 각종 정수 배열, 부동소수점 배열)은 “any kind of garbage collection pause” 에서도 opportunistically reclaim을 시도할 수 있습니다.

즉 경우에 따라 young GC pause 중에도 old generation 쪽 humongous object가 회수될 수 있습니다.

이 최적화가 필요한 이유를 설명 하자면 Humongous Object는 여러 region을 차지하고 자투리 공간까지 남길 수 있기 때문에, 빨리 회수되지 않으면 힙 일부를 계속 점유해 GC를 더 자주 유발할 수 있기 때문입니다.

humongous object가 많으면 예상보다 더 많은 메모리를 차지할 수 있고 Full GC나 allocation pressure 문제를 악화시킬 수 있습니다.


Remembered Set이 왜 여기서 다시 나오나?

eager reclamation을 하려면 그 humongous object가 정말 외부에서 거의 참조되지 않거나 이미 죽었는지를 빨리 판단할 수 있어야 합니다. G1GC 가 참조가 많지 않은 humongous object 를 대상으로 opportunistic reclaim을 시도 합니다.

이 판단을 위해 G1GC 는 region 단위 메타데이터와 remembered-set 계열 정보를 활용하는데 즉 humongous region도 평소 참조 관계를 전혀 모르는 상태로 방치되는 것이 아니라 필요할 때 빠르게 회수 가능성을 판단할 수 있도록 관리 됩니다.


primitive 배열만 우선 대상으로 본 이유

eager reclamation 대상을 primitive type 배열 예시 중심으로 우선 대상으로 보는데, eager reclaim 의 대표 대상이 primitive 배열 보다 reference 배열이 더 오래 살 가능성이 높다고 봐서 더 보수적으로 봤다는 것이 추측 됩니다.


정리 해보자

Humongous Object의 핵심 특징은 아래처럼 정리할 수 있습니다.

  • Region 크기의 1/2 이상이면 humongous object다.
  • 하나의 region에 안 들어가면 연속된 old regions 에 할당된다.
  • 마지막 region의 남는 공간은 보통 버려진다.
  • 할당 때마다 IHOP 를 확인하고 필요하면 marking 시작이 앞당겨질 수 있다.
  • 일반적으로는 Cleanup pause / Full GC 에서 회수되며, 이동은 매우 제한적이고 느릴 수 있다.
  • 다만 일부, 특히 primitive 배열 humongous object는 eager reclamation 으로 더 빨리 회수될 수 있다.

Humongous Object vs 일반 Old Object 비교

항목 일반 Old Object Humongous Object
크기 기준 일반적인 Old 영역 객체 Region 크기의 1/2 이상인 객체
할당 위치 Old Region에 일반 방식으로 배치 연속된(contiguous) Old Region들에 배치
객체 시작 위치 일반적인 객체 배치 규칙 따름 항상 첫 번째 Humongous Region의 시작점에 위치
마지막 Region 잔여 공간 다른 객체 할당에 재활용 가능 남는 공간은 객체 전체가 회수될 때까지 사용 불가
세대 관점 Old Generation 객체 G1에서 Old Generation 쪽으로 특별 취급
Mixed/Young GC 중 처리 선택된 Collection Set이면 evacuation 가능 보통 이동하지 않음, 주로 살아있는지만 확인
이동 시점 Mixed GC나 Full GC 등에서 evacuation 가능 매우 제한적, 사실상 마지막 수단의 Full GC 에서만 이동 시도
회수 시점 일반적인 marking / mixed GC / full GC 흐름에 따라 회수 보통 Cleanup pause 또는 Full GC 에서 회수
조기 회수(eager reclamation) 일반적으로 별도 개념 없음 일부, 특히 primitive 배열 humongous object 는 더 이른 GC pause에서도 회수 시도 가능
할당이 GC에 미치는 영향 일반적인 Old 점유율 증가 할당 때마다 IHOP 를 확인하고 필요하면 Concurrent Start pause 를 앞당길 수 있음
성능/운영상 부담 일반적인 Old 객체 관리 비용 큰 연속 공간 필요, 자투리 공간 손실 가능, 이동도 느려서 힙 압박과 단편화 부담이 큼

핵심 차이 요약

  • 일반 Old Object 는 Old Region 안에서 일반적인 evacuation/회수 흐름에 비교적 자연스럽게 들어간다.
  • Humongous Object 는 크기가 너무 커서 연속된 Region 을 차지하고, 마지막 Region의 남는 공간도 낭비될 수 있다.
  • Humongous Object는 보통 이동하지 않고, 주로 Cleanup pause / Full GC 에서 회수된다.
  • 다만 일부 primitive 배열 형태의 humongous object는 eager reclamation 으로 더 빨리 회수될 수 있다.

G1GC의 Evacuation Failure란?

살아 있는 객체를 옮기지 못하면 무슨 일이 벌어질까

G1GC의 기본 아이디어는 간단합니다. GC pause 동안 Collection Set(CSet)에 포함된 region의 살아 있는 객체를 다른 region으로 복사(evacuation) 하고 원래 region은 비워서 회수합니다. G1GC 의 pause에서는 live object를 source region에서 destination region으로 옮기고 참조를 다시 맞춥니다.

그런데 이 복사가 항상 성공하는 것은 아닙니다. G1GC의 GC 도중 객체를 옮기려 했지만 끝내 옮기지 못한 상황을 Evacuation Failure 라고 부릅니다.


Evacuation Failure가 발생하는 대표 원인 2가지

1. Allocation 실패

가장 흔한 경우는 옮겨 갈 목적지(to-space) 에 충분한 공간이 없는 경우입니다.

2. Pinned 객체

또 다른 경우는 Pinned 객체 입니다.
OpenJDK의 Region Pinning 관련 JEP 이슈는, G1에서 pinned region은 evacuate하지 않으며, 특히 young pinned region은 failed evacuation처럼 취급되어 old로 승격된니다. 즉 native/JNI 쪽 이유로 현재 위치를 유지해야 하는 객체가 있으면 evacuation이 막힐 수 있습니다.


Evacuation Failure가 나면 G1은 어떻게 처리할까?

evacuation failure가 발생하면 G1GC은 이미 이동한 객체는 새 위치에 그대로 두고, 아직 이동하지 못한 객체는 제자리(in-place) 에 남긴 채 현재 GC를 마무리 하려고 합니다. 즉 실패가 났다고 해서 그 pause가 바로 중단되는 것은 아니고 가능한 범위까지 마무리합니다.

또한 OpenJDK 이슈에서는 evacuation failure가 난 region을 old이면서 거의 꽉 찬 것으로 취급한다고 설명합니다. 그래서 직관적으로 보면, 이번에 비우지 못한 region은 이후 GC에서 다시 정리해야 할 부담으로 넘어간다고 이해하면 됩니다. 다만 “정확히 다음 GC에서 반드시 바로 회수된다”고까지 일반화하는 것은 버전/상황에 따라 조심할 필요가 있습니다.


이미지 흐름으로 보는 Evacuation Failure

이미지 1

Evacuation-Failure-1

현재 상태에서 Mixed GC 가 시작되었다고 가정합니다.

예제를 단순화하기 위해, 5번과 6번 객체가 있는 old region 이 이번 Mixed GC의 CSet candidate region으로 선택된 상황입니다. Mixed GC는 young generation 전체와 일부 old region만 골라서 수집합니다.

이미지 2

Evacuation-Failure-2

상단 GC Root가 가리키는 1번 객체를 스캔하고, 바로 옆 free region을 survivor region으로 바꿔 그쪽으로 1번 객체를 evacuation 합니다. 이것이 G1의 정상적인 복사형 수집 흐름입니다.

이미지 3

Evacuation-Failure-3

이어서 2번 객체도 새 survivor region으로 이동합니다. 아직은 evacuation이 정상적으로 진행되는 상태입니다.

이미지 4

Evacuation-Failure-4

다음으로 3번 객체도 새 survivor region으로 이동합니다. 즉 young 쪽 live object는 문제 없이 복사되고 있습니다.

이미지 5

Evacuation-Failure-5

이제 왼쪽 GC Root가 가리키는 5번 객체를 보고, 이를 담기 위해 새 old region 을 확보해 이동시킵니다. Mixed GC에서는 선택된 old region도 evacuation 대상이므로, old 객체도 새 old region으로 복사될 수 있습니다.

이미지 6

Evacuation-Failure-6

이어서 6번 객체도 새 old region으로 이동합니다. 여기까지는 selected old region 정리가 정상적으로 이루어지고 있는 모습입니다.

이미지 7

Evacuation-Failure-7

이제 4번 객체를 다른 region으로 이동시키려는데, 더 이상 destination으로 쓸 region이 없습니다. 이 경우가 전형적인 Allocation 기반 Evacuation Failure 입니다.

이미지 8

Evacuation-Failure-8

이번에는 Pinned 객체 를 생각해볼 수 있습니다. 공간이 남아 있어도 pinned object가 들어 있는 region은 evacuate하기 어렵기 때문에, 이 경우도 evacuation failure처럼 처리될 수 있습니다.

이미지 9

Evacuation-Failure-9

GC 이후 free space가 일부 다시 확보된 상태를 생각해볼 수 있습니다. 하지만 4번 객체가 남아 있는 region 이나 Pinned 객체가 있던 region 은 이번에 깨끗하게 비워지지 못했기 때문에 후속 GC에서 다시 부담이 됩니다. OpenJDK 이슈 설명처럼 failed region은 old이면서 거의 꽉 찬 것처럼 남기 쉬워, 뒤따르는 GC가 이를 다시 정리해야 하는 상황이 생깁니다.


실패한 young region은 왜 old처럼 보일까?

Pinned young region에 대해서는 OpenJDK JEP 이슈가 비교적 직접적으로 설명합니다.

Minor collection 중 pinned young region은 failed evacuation처럼 취급되어 old generation으로 승격됩니다. 즉 evacuation에 실패한 young 쪽 공간은 더 이상 “잘 정리된 young”로 남지 못하고, old 쪽 부담으로 넘어갈 수 있습니다.

일반 evacuation failure에 대해서도 OpenJDK 측 설명은 failed region을 old이면서 거의 꽉 찬 상태처럼 다루는 경향을 보여줍니다. 그래서 evacuation failure가 누적되면 old pressure가 빠르게 높아질 수 있습니다.


Free space가 전혀 생기지 않으면?

가장 나쁜 경우는 GC를 했는데도 공간을 거의 못 비우는 경우입니다. 최악의 경우 GC가 전혀 공간을 확보하지 못하면 G1이 Full GC를 스케줄링 됩니다. 이 Full GC는 entire heap in-place compaction 을 수행하므로 매우 느릴 수 있습니다.

또한 Full GC가 발생하기 전에 evacuation failure, 특히 to-space exhausted 로그가 먼저 보이는 경우가 많다고 설명합니다. 즉 실무에서는 긴 Full GC가 갑자기 튀어나오는 것이 아니라, 그 전에 evacuation failure 징후가 선행되는 경우가 흔합니다.


정리

Evacuation Failure는 G1GC가 살아 있는 객체를 다른 region으로 복사하지 못한 상황입니다.

대표 원인은 두 가지입니다.

  • Allocation 실패: destination region 공간 부족
  • Pinned 객체: 객체가 현재 위치에 계속 있어야 해서 이동 불가

이 상황이 발생하면 G1은 이미 옮긴 객체는 그대로 두고, 옮기지 못한 객체는 제자리에 남긴 채 GC를 마무리하려고 합니다. 하지만 failed region은 old pressure를 키우고 후속 GC 부담으로 이어질 수 있습니다. 그리고 이 문제가 심해져 GC가 free space를 거의 만들지 못하면 결국 Full GC 로 이어질 수 있습니다.



Copyright 201- syh8088. 무단 전재 및 재배포 금지. 출처 표기 시 인용 가능.

버튼

×

喜欢就点赞,疼爱就打赏