Skip to main content

Command Palette

Search for a command to run...

G1 GC 내부 구조 — Region, Remembered Set, SATB, 그리고 일시 정지를 예측하는 GC

Updated
13 min read

Java 9부터 기본 GC 자리에 앉은 G1은 "정해진 시간 안에 가장 쓰레기가 많은 곳부터 치운다"는 한 줄로 요약됩니다. 이 글은 그 한 줄을 가능하게 한 내부 구조 — 리전 기반 힙, Remembered Set과 Card Table, SATB(Snapshot-At-The-Beginning) 마킹, 동시 마킹 사이클의 다섯 단계, 그리고 일시 정지 시간을 맞추기 위한 예측 모델 — 를 OpenJDK 문서와 G1 메인테이너의 글을 근거로 정리합니다. 마지막에는 Full GC를 병렬화한 JEP 307과 쓰기 배리어를 다시 설계한 JEP 522까지 다룹니다.


1. G1은 무엇을 다르게 했나

등장 배경

Java 9 이전, 서버 환경의 기본 GC는 Parallel GC였습니다. 처리량(Throughput) 위주로 설계된 collector라 GC 시간 자체는 짧지만, 힙이 수 GB로 커지면 Stop-The-World(STW) 일시 정지가 수백 밀리초에서 수 초까지 길어지는 문제가 있었습니다. 그 사이 등장한 **CMS(Concurrent Mark Sweep)**는 마킹과 스윕을 애플리케이션과 동시에 수행해 일시 정지는 줄였지만, 압축(compaction)을 하지 않아 단편화가 누적되면 결국 Serial Old로 떨어지는 Full GC를 피할 수 없었습니다.

G1(Garbage-First)은 이 두 문제를 동시에 해결하려는 시도였습니다.

  • 압축까지 수행하면서도 일시 정지 시간을 사용자가 설정한 목표 안에 묶고
  • **동시 마킹(concurrent marking)**으로 STW를 마킹과 분리해 일시 정지를 짧게 유지하고
  • "정해진 시간 안에 가장 쓸모 있는 일"을 하기 위해 힙을 리전(region) 단위로 잘라 단위 작업의 크기를 조절할 수 있게 만들었습니다.

JEP 248이 G1을 JDK 9 서버 기본 GC로 승격시킨 이유는 명료합니다. "처리량 위주 collector보다 대부분 사용자에게 더 나은 종합 경험을 제공한다." 이름의 "Garbage-First"는 가장 쓰레기가 많은 리전부터 회수한다는 정책을 그대로 옮긴 것입니다.

한 문장 요약

힙을 동일 크기 리전으로 자르고,
리전 간 참조를 Remembered Set으로 추적해,
동시 마킹으로 살아있는 객체를 식별한 뒤,
목표 일시 정지 시간에 맞춰 가장 가치 있는 리전부터 evacuate한다.

이 한 문장을 하나씩 풀어내는 것이 이 글의 목적입니다.


2. Region 기반 힙 구조

동일 크기 리전으로 자른다

G1은 힙을 **고정 크기 리전(region)**으로 분할합니다. 리전 크기는 시작 시점에 JVM이 결정하며, 1 MB ~ 32 MB 사이의 2의 거듭제곱값을 가집니다. 기본 정책은 "전체 리전 수가 약 2048개를 넘지 않도록" 하는 것입니다. 16 GB 힙이라면 8 MB 리전, 64 GB 힙이라면 32 MB 리전을 고르는 식입니다.

명시적으로 지정할 때는 -XX:G1HeapRegionSize=16m처럼 옵션을 사용합니다.

flowchart LR
    subgraph Heap["G1 Heap"]
      direction LR
      R1[E]:::eden
      R2[E]:::eden
      R3[S]:::survivor
      R4[O]:::old
      R5[O]:::old
      R6[H]:::humongous
      R7[H]:::humongous
      R8[F]:::free
      R9[E]:::eden
      R10[O]:::old
      R11[F]:::free
      R12[O]:::old
    end
    classDef eden fill:#bde,stroke:#06c
    classDef survivor fill:#fda,stroke:#c60
    classDef old fill:#ccc,stroke:#444
    classDef humongous fill:#fdd,stroke:#c00
    classDef free fill:#fff,stroke:#aaa

각 리전은 다음 중 한 가지 역할(role) 을 가집니다.

  • Eden(E) — 새 객체가 처음 할당되는 영역
  • Survivor(S) — Young 수집 후 살아남은 객체가 임시로 머무는 영역
  • Old(O) — 여러 번 살아남아 승격(promote)된 객체가 거주하는 영역
  • Humongous(H) — 리전 크기의 절반을 초과하는 객체를 위한 특수 영역
  • Free(F) — 아직 어느 역할도 부여되지 않은 비어 있는 리전

전통적인 GC와 결정적으로 다른 점은 Young/Old가 연속된 메모리 블록이 아니라는 것입니다. Eden, Survivor, Old는 "이 시점에 이 역할을 맡은 리전들의 집합(논리적 집합)"일 뿐이며, 물리적으로는 힙 어디에나 흩어져 있을 수 있습니다. 또한 한 번 Old였던 리전이 다음 사이클에서 Eden으로 재활용될 수도 있습니다.

이 유연성이 "가장 쓰레기가 많은 리전부터 회수한다"는 정책을 가능하게 합니다.

Humongous 리전 — 큰 객체의 특수 처리

리전 크기의 절반을 초과하는 객체는 일반 할당 경로를 타지 않고 Humongous 객체로 분류되어 Old 세대의 연속된 Humongous 리전들에 통째로 할당됩니다. 객체의 시작 주소는 항상 첫 리전의 시작에 정렬되며, 마지막 리전의 남는 공간은 그 객체가 회수되기 전까지 사용 불가한 상태로 남습니다.

이 설계가 의미하는 바는 두 가지입니다.

  • Humongous 객체는 승격을 거치지 않고 곧장 Old에 들어갑니다. Young 수집의 대상이 아닙니다.
  • 리전 크기를 작게 잡으면 단순히 큰 배열·문자열 정도가 Humongous로 분류되어 단편화와 잦은 동시 사이클 트리거의 원인이 됩니다. Oracle 문서가 이 경우 -XX:G1HeapRegionSize를 키워 "이전에는 Humongous였던 객체가 일반 할당 경로를 타게" 만들 것을 권하는 이유입니다.

3. 리전 간 참조 추적 — Remembered Set과 Card Table

왜 Remembered Set이 필요한가

G1이 일부 리전만 수집하려면, 수집 대상 리전 바깥에서 안으로 들어오는 참조를 빠르게 찾아낼 수 있어야 합니다. 매번 힙 전체를 스캔하면 일시 정지 목표를 맞출 수 없으므로, G1은 각 리전마다 자신을 가리키는 포인터의 위치를 미리 모아 두는 자료구조를 유지합니다. 이것이 **Remembered Set(RSet)**입니다.

RSet은 각 리전이 자기 자신에 대해 하나씩 가지며, 다른 리전의 어느 카드(card)가 이 리전의 객체를 가리키는 참조를 담고 있는지를 기록합니다. 즉 "incoming references" 인덱스입니다.

Card Table — 메모리 페이지의 소프트웨어판

전체 힙은 작은 고정 크기 단위인 카드(card) 로 분할되고(HotSpot에서는 512 byte), 각 카드에 1바이트가 대응하는 거대한 배열이 Card Table입니다. 카드 안의 어느 워드에서든 참조 쓰기가 일어나면 그 카드를 dirty로 표시합니다. 마치 OS의 페이지 보호 비트처럼, "이 영역에서 최근에 쓰기가 있었다"를 비트맵 한 칸으로 추적하는 것입니다.

flowchart LR
    App[App Thread] -->|"object.f = ref"| Barrier[Post-Write Barrier]
    Barrier -->|"mark card dirty"| CT[(Card Table)]
    Barrier -->|"enqueue card"| DCQ[(Dirty Card Queue)]
    Refine[Refinement Thread] -->|"drain"| DCQ
    Refine -->|"update"| RS[(Remembered Set)]

동시 정제(Refinement)

참조 대입이 일어날 때마다 RSet을 직접 갱신하면 다중 스레드 환경에서 락 경합이 너무 큽니다. G1은 이를 분리합니다.

  1. Post-write barrier: 애플리케이션 스레드는 단지 카드를 dirty로 표시하고, 해당 카드 주소를 자신의 per-thread Dirty Card Queue에 넣습니다. 이게 전부입니다.
  2. Refinement thread: 별도 스레드(-XX:G1ConcRefinementThreads)가 이 큐를 비우면서 dirty 카드를 실제로 읽어, 안에 담긴 포인터를 분석해 대상 리전의 RSet에 등록합니다.

이렇게 분리하면 애플리케이션 핫패스의 비용은 "카드 한 바이트 쓰기 + 큐 enqueue" 수준으로 짧아집니다. 단, Schatzl이 지적했듯 "애플리케이션 스레드와 정제 스레드가 같은 카드에 동시에 쓸 수 있으므로, 잃어버린 갱신을 막기 위해 배리어에 비교적 비싼 메모리 동기화가 필요"했습니다. 이 비용이 JEP 522가 다시 손대게 된 핵심 이유입니다.


4. SATB — 마킹의 일관성을 지키는 발상

문제: 마킹 중에 그래프가 바뀐다

동시 마킹은 애플리케이션과 동시에 돌기 때문에, 마킹이 진행되는 동안에도 객체 그래프가 끊임없이 변합니다. 새 객체가 만들어지고, 참조가 끊기고, 어떤 참조는 가까운 곳에서 먼 곳으로 점프합니다.

만약 마킹 스레드가 "객체 A는 처리 완료, 다음 B 처리 중"인 상태에서 애플리케이션이 A.f = newObj; B.f = null을 실행해 버리면, 마킹 스레드는 newObj를 영영 만나지 못한 채 마킹을 끝낼 수 있습니다. 이렇게 살아 있는 객체를 죽은 것으로 판정하는 것은 GC가 절대 해서는 안 되는 실수입니다.

해법: 시작 시점 스냅샷

G1이 채택한 해법은 Snapshot-At-The-Beginning(SATB) 입니다. Schatzl이 정리한 정의는 이렇습니다.

마킹 시작 시점에 살아 있던 객체는 그 마킹 사이클 동안 살아 있는 것으로 간주합니다. 그 이후에 새로 할당된 객체는 묵시적으로(implicitly) 살아 있는 것으로 처리하고 추적하지 않습니다.

핵심은 두 가지입니다.

  • 시작 시점의 가상 스냅샷을 본다. 실제로 복사본을 만드는 것은 아닙니다. 스냅샷의 경계만 기억하면 충분합니다.
  • 사이클 동안 끊긴 참조는 무시한다. 끊어진 참조의 끝에 있는 객체가 실제로는 죽었더라도, 그 사이클에서는 살아 있는 것으로 보존되고 다음 사이클에서야 회수됩니다. 이를 floating garbage라고 합니다.

Top-At-Mark-Start(TAMS) 포인터

"시작 시점에 살아 있던 객체"의 경계를 어떻게 표시할까요. 각 리전은 객체 할당의 끝(top) 위치를 추적하는데, G1은 마킹 시작 순간의 top 값을 TAMS(Top-At-Mark-Start) 포인터로 기록합니다.

TAMS 아래에 있는 객체는 "마킹 사이클이 추적해야 하는 객체"이고, TAMS 위에 새로 할당된 객체는 "묵시적으로 산 것으로 친다." 마크 비트맵(mark bitmap)에도 같은 의미가 반영되어, 마킹 결과를 다음 회수 단계에서 사용할 때 TAMS를 기준으로 살아 있음 여부를 구분합니다.

SATB Pre-Write Barrier

스냅샷을 유지하려면, 참조가 덮어쓰일 때 원래 가리키던 참조를 어딘가에 보관해 두어야 합니다. 그래야 그 객체가 살아 있다는 사실이 보존됩니다.

// 의사 코드
void store_with_satb_barrier(Object obj, Field f, Object newRef) {
    if (concurrent_marking_active) {
        Object old = obj.f;
        if (old != null) {
            satb_local_buffer.push(old);   // 잃어버리기 전에 보관
        }
    }
    obj.f = newRef;
}

각 애플리케이션 스레드는 local SATB 버퍼를 갖고, 버퍼가 차면 마킹 스레드에게 넘깁니다. 마킹 스레드는 버퍼에 들어온 객체들을 마치 마킹 시작 시점에 도달 가능했던 것처럼 다시 마킹 큐에 넣어 추적합니다.

이 배리어는 오직 동시 마킹이 활성화된 동안에만 작동합니다. 평소(young-only 수집)에는 비용이 없습니다.


5. 동시 마킹 사이클 — 다섯 단계

G1의 마킹 사이클은 STW와 동시(concurrent) 단계가 교차하며 진행됩니다.

flowchart LR
    YP["Young Pause<br/>(piggybacked<br/>Initial Mark)"] --> RRS[Root Region Scan]
    RRS --> CM[Concurrent Marking]
    CM --> Remark[Remark Pause]
    Remark --> Cleanup[Cleanup Pause]
    Cleanup --> Mixed[Mixed Evacuation]

5.1 Initial Mark — Young 일시 정지에 얹어 처리

JDK 9 이전에는 Initial Mark가 별도 STW 단계였지만, 현재 G1은 이 작업을 다음에 일어날 young evacuation pause에 얹습니다(piggyback). 즉, 마킹 사이클이 필요하다는 결정이 내려지면 곧 다음 young 수집을 일으키고, 그 일시 정지 동안 함께 VM 루트(스레드 스택, JNI 핸들, 시스템 클래스로더 등)를 스캔해 거기서 가리키는 Old 세대 객체들을 비트맵에 마킹합니다. Young 일시 정지에 얹기 때문에 추가 STW 비용은 무시할 수 있을 만큼 작습니다.

5.2 Root Region Scan — 살아남은 Survivor도 루트

이 단계에서 "root region"이란 방금 끝난 young 수집에서 살아남은 Survivor 리전들입니다. 이들은 마킹 사이클 입장에서는 새로 생긴 루트와 같습니다. G1은 이 리전을 동시에(concurrent) 스캔해 Old 세대로 향하는 참조를 찾아 비트맵에 마킹합니다.

이 단계가 다음 young 수집 전에 끝나야 한다는 제약이 있습니다. 끝나지 않은 상태에서 새 young 수집이 들어오면 또 다른 Survivor 집합이 만들어져 추적이 복잡해지기 때문입니다.

5.3 Concurrent Marking — 본격 그래프 순회

여기서 비로소 본격적인 마킹이 시작됩니다. 여러 동시 마킹 스레드가 마킹 큐를 처리하면서 객체 그래프를 BFS/DFS 식으로 순회하고, 도달한 객체마다 마크 비트맵에 비트를 켭니다. 이 동안에도 애플리케이션은 정상적으로 동작하며, SATB pre-write barrier가 끊어진 참조들을 보존해 줍니다.

이 단계에서 G1이 추가로 하는 일이 하나 더 있는데, 각 Old 리전의 살아 있는 바이트 수(live data)를 카운트하는 것입니다. 이 통계가 다음 단계에서 회수할 가치가 높은 리전을 고르는 데 결정적으로 쓰입니다.

5.4 Remark — 짧은 STW로 마무리

동시 마킹의 마지막은 짧은 STW로 마무리합니다. 이 일시 정지에서 G1은:

  • 모든 애플리케이션 스레드의 잔여 SATB 버퍼를 비우고,
  • 스레드 스택을 다시 한 번 훑어 마지막 변경을 반영하고,
  • 그 결과로 새로 만난 객체들을 마저 추적해 마킹을 종료합니다.

java.lang.ref.Reference(Soft/Weak/Phantom) 처리, 클래스 언로딩, 빈 리전의 회수도 이 단계나 직후 단계에서 일어납니다.

5.5 Cleanup — 회수할 리전 선정

Remark 직후의 짧은 STW와 그 뒤를 잇는 동시 단계로 이루어집니다.

  • 마킹 결과가 비어 있는 리전(살아 있는 객체가 하나도 없는 리전)은 즉시 통째로 회수됩니다. 별도의 evacuation 없이 free 리스트로 돌아갑니다.
  • 살아 있는 객체가 있는 Old 리전들은 live data 비율로 정렬되고, 이후 일어날 mixed 수집의 후보로 분류됩니다.

여기서 garbage-first 정책이 비로소 행사됩니다. 살아 있는 데이터가 적은 리전 = 회수하면 큰 공간을 돌려받을 수 있는 리전이 우선 순위 큐의 위쪽에 오게 되고, 다음에 일어날 mixed 수집들이 위에서부터 차례로 그 리전들을 함께 비웁니다.


6. Evacuation — Young과 Mixed 수집

Young 수집

Eden과 Survivor 리전들이 가득 차면 young 일시 정지가 일어납니다. 이 STW 동안 G1은:

  1. 수집 대상 리전 집합(Collection Set, CSet)에 Eden 전부와 현재 Survivor를 넣고,
  2. 각 리전의 RSet과 dirty card queue를 처리해 CSet 바깥에서 들어오는 참조를 모두 찾아내고,
  3. 살아 있는 객체를 **새 Survivor 또는 Old 리전으로 복사(evacuate)**하고,
  4. 비워진 옛 Eden/Survivor 리전을 free 리스트로 돌립니다.

복사 자체가 곧 압축이므로, G1은 매 수집마다 부분 압축을 수행한다고 볼 수 있습니다.

Mixed 수집

마킹 사이클이 끝나면 G1은 "회수 가치가 있는 Old 리전 후보 목록"을 갖고 있습니다. 이때부터 일어나는 일련의 young 수집은 Old 리전 일부를 같이 묶어 진행되며, 이를 Mixed GC라고 합니다.

Mixed GC가 Old 리전을 CSet에 포함시키는 기준은 liveness 임계치입니다. -XX:G1MixedGCLiveThresholdPercent(기본값 65)는 "live 데이터 비율이 이 값 미만인 Old 리전만 CSet에 추가" 한다는 의미입니다. 임계치가 높을수록 더 많은 Old 리전이 CSet에 들어가고, 일시 정지는 길어집니다.

flowchart LR
    subgraph CSet["Collection Set"]
      Eden1[Eden]:::eden
      Eden2[Eden]:::eden
      Surv1[Survivor]:::survivor
      Old1[Old<br/>30% live]:::old
      Old2[Old<br/>45% live]:::old
    end
    subgraph Dst["Destination Regions"]
      NewSurv[New Survivor]:::survivor
      NewOld[New Old]:::old
    end
    CSet -->|"copy live objects"| Dst
    classDef eden fill:#bde,stroke:#06c
    classDef survivor fill:#fda,stroke:#c60
    classDef old fill:#ccc,stroke:#444

Mixed 사이클은 한 번에 모든 후보 Old 리전을 비우지 않고, 여러 번에 걸쳐(-XX:G1MixedGCCountTarget 기본 8회) 나눠 처리합니다. 이렇게 나눠야 한 번의 일시 정지가 목표 시간을 넘기지 않습니다.

전체 회수 가능 공간이 -XX:G1HeapWastePercent(기본 5) 아래로 떨어지면 mixed 단계는 종료되고, G1은 다음 마킹 사이클을 기다립니다.


7. 일시 정지를 예측하는 모델

MaxGCPauseMillis — soft goal

G1의 가장 도드라진 특성은 사용자가 일시 정지 목표를 설정할 수 있다는 점입니다.

-XX:MaxGCPauseMillis=200   # 기본값

다만 이 값은 soft goal입니다. 메모리 압력이 높거나 객체 생존율이 갑자기 치솟으면 목표를 초과할 수 있다고 Oracle 문서가 명시하고 있고, 너무 작게 설정하면 오히려 GC가 더 자주 일어나 전체 처리량을 떨어뜨릴 수 있습니다.

예측 모델이 보는 것

G1은 매 수집마다 자기 자신의 동작 시간을 측정하고, 그 통계로 다음 수집에서 처리 가능한 작업량을 예측합니다. Oracle 문서가 정리한 예측 모델의 입력은 다음과 같습니다.

  • 비슷한 크기의 young 세대를 evacuate하는 데 걸린 시간
  • 한 번의 수집에서 복사된 객체의 수
  • 그 객체들이 얼마나 서로 얽혀 있었는지(참조 구조의 연결도)

이 통계를 바탕으로 G1은 "이번 일시 정지에서 CSet에 몇 개의 리전을 더 넣어도 안전한가"를 계산합니다. Young 리전은 거의 무조건 포함되지만, Old 리전(mixed 수집에서)은 예산이 허락하는 만큼만 들어갑니다.

일시 정지를 짧게 하려면

Oracle 튜닝 가이드의 조언은 직관적입니다.

  • 일시 정지를 줄이려면 힙을 키우고, MaxGCPauseMillis는 그대로 두거나 약간만 낮춥니다. 힙이 크면 mixed 수집을 더 여유 있게 분할할 수 있습니다.
  • MaxGCPauseMillis를 무작정 낮추는 것은 오히려 역효과입니다. G1이 매 수집에서 CSet을 너무 작게 잡아 처리량이 떨어지고, 결국 메모리가 부족해져 Full GC로 떨어질 수 있습니다.

8. Full GC — G1이 실패하는 순간

Full GC가 일어나는 시나리오

G1은 Full GC를 피하기 위해 설계됐지만, 동시 마킹이 할당 속도를 따라가지 못하거나, mixed 수집이 단편화를 충분히 해소하지 못하거나, 큰 Humongous 객체를 위한 연속 리전을 확보하지 못하면 fallback Full GC가 일어납니다.

JEP 307 — Full GC를 병렬화하다

JEP 307은 이 시나리오의 worst-case 지연을 줄이기 위한 변경이었습니다. 이전 G1의 Full GC는 단일 스레드 mark-sweep-compact였고, 멀티코어를 전혀 활용하지 못해 "G1을 쓰는데도 Full GC만 일어나면 다른 collector보다 더 느린" 모순이 있었습니다.

JEP 307은 이 알고리즘을 young/mixed 수집과 동일한 수의 스레드로 병렬화해, Parallel collector의 Full GC와 동등한 성능을 목표로 했습니다. JDK 10에 들어갔습니다.


9. JEP 522 — 쓰기 배리어를 다시 설계하다

문제 — 배리어 자체가 무겁다

앞서 본 G1의 post-write barrier는 단순히 "카드 표시"가 아니라, 정제 스레드와 같은 카드를 동시에 다룰 수 있으므로 메모리 동기화가 필요한 코드였습니다. 연구자들에 따르면 G1의 쓰기 배리어가 처리량을 10~20% 떨어뜨릴 수 있다고 합니다(Parallel collector 대비).

해법 — 카드 테이블을 두 개로

JEP 522는 카드 테이블을 두 벌로 분리하는 발상입니다.

  • 애플리케이션 스레드는 첫 번째 카드 테이블만 동기화 없이 갱신합니다. 배리어 코드가 짧아지고, 컴파일러가 더 잘 최적화할 수 있습니다.
  • 정제 스레드는 처음에는 비어 있는 두 번째 카드 테이블에 작업 결과를 모읍니다.
  • G1 수집이 시작될 때, 그 시점에 첫 번째 카드 테이블을 스캔하면 일시 정지 목표를 넘길 것 같다는 판단이 서면 두 테이블을 원자적으로 swap합니다. 이후 애플리케이션 스레드는 (방금 빈) 두 번째 테이블에 쓰고, 정제 스레드는 (방금 가득 찬) 첫 번째 테이블을 동기화 부담 없이 처리합니다.

ZGC가 이미 사용하던 double-buffered remembered set 발상을 G1에 옮긴 것에 가깝습니다. JDK 25에 들어갔습니다.


10. 정리 — 한 줄로 다시 보기

처음에 적은 한 줄을 다시 봅니다.

힙을 동일 크기 리전으로 자르고,
리전 간 참조를 Remembered Set으로 추적해,
동시 마킹으로 살아있는 객체를 식별한 뒤,
목표 일시 정지 시간에 맞춰 가장 가치 있는 리전부터 evacuate한다.

이제 각 줄이 어떻게 가능한지 보입니다.

  • 리전 단위 분할은 일시 정지 시간 안에 처리할 수 있는 단위 작업의 크기를 조절할 수 있게 해 줍니다.
  • Remembered Set + Card Table + 정제 스레드는 부분 수집을 가능하게 만들면서 애플리케이션 핫패스 비용을 최소화합니다.
  • SATB 동시 마킹은 마킹 중에도 그래프가 변하는 상황에서 일관성을 보장하면서 STW를 최소화합니다.
  • 예측 모델은 매 수집의 통계를 모아 "다음에는 얼마나 안전한가"를 계산하고, 그 안에서 garbage-first 정책으로 회수 가치가 높은 리전을 우선합니다.

G1이 등장한 지 10년이 넘었지만, JEP 307의 병렬 Full GC, JEP 344의 abortable mixed collection, 최근 JEP 522의 쓰기 배리어 재설계처럼 내부 구조는 계속 다듬어져 왔습니다. ZGC가 더 짧은 일시 정지를 제공하지만, 처리량과 일시 정지의 균형, 폭넓은 호환성, 적은 메모리 오버헤드 때문에 G1은 여전히 JDK의 기본 GC로 자리를 지키고 있습니다.

내부 구조를 알고 있으면 GC 로그를 읽을 때 어디를 봐야 할지가 명확해집니다. "Evacuation Pause"는 CSet 복사 시간이고, "Concurrent Mark"는 STW가 아니라 동시 단계이며, "to-space exhausted"는 mixed 수집의 destination 리전이 부족하다는 신호입니다. 다음에 GC 튜닝을 마주칠 때, 이 글이 그 한 줄들 뒤의 그림을 떠올리는 데 도움이 되길 바랍니다.


참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ