본문 바로가기

CS/JVM

[JVM 밑바닥까지 파헤치기] JVM 가비지 컬렉터의 역사 (3) - 동시성 컬렉터: CMS

들어가며

이전 편에서 병렬 컬렉터의 한계를 살펴봤습니다. throughput은 좋아졌지만, STW 자체는 없앨 수 없었습니다. 특히 대용량 힙에서 Major GC가 발생하면 수 초간 멈추는 문제가 있었죠.

이번 편에서는 애플리케이션과 동시에 GC를 수행하는 CMS(Concurrent Mark Sweep)를 다룹니다.

 


CMS의 목표

CMS의 핵심 목표는 짧은 pause time입니다.

Parallel GC:
  "멈추는 건 어쩔 수 없어. 대신 빨리 끝내자."

CMS:
  "멈추는 시간 자체를 최소화하자. 대부분의 작업은 앱이 돌아가면서 하자."
-XX:+UseConcMarkSweepGC

 


CMS의 구조

CMS는 Old 영역 전용 컬렉터입니다. Young 영역은 ParNew가 담당합니다.

┌─────────────────────────────────────────────────────┐
│                      JVM Heap                       │
├─────────────────────┬───────────────────────────────┤
│    Young (ParNew)   │        Old (CMS)              │
│ ┌─────┬─────┬─────┐ │                               │
│ │Eden │ S0  │ S1  │ │   Concurrent Mark Sweep       │
│ └─────┴─────┴─────┘ │                               │
└─────────────────────┴───────────────────────────────┘

 


CMS의 동작 단계

CMS는 4단계로 동작합니다:

┌──────────────────────────────────────────────────────────────┐
│  1. Initial Mark (STW)                                       │
│     ↓                                                        │
│  2. Concurrent Mark (동시 실행)                               │
│     ↓                                                        │
│  3. Remark (STW)                                             │
│     ↓                                                        │
│  4. Concurrent Sweep (동시 실행)                              │
└──────────────────────────────────────────────────────────────┘
시간 흐름 →

App:  ────────│░│─────────────────│░░│────────────────────
               ↑                   ↑
          Initial Mark          Remark
             (짧음)              (짧음)

GC:        │░│████████████████│░░│████████████████████
            ↑        ↑          ↑          ↑
      Initial   Concurrent    Remark   Concurrent
        Mark      Mark                   Sweep

░ = STW (짧음)
█ = Concurrent (앱과 동시 실행)

 


1단계: Initial Mark (STW)

GC Root에서 직접 참조하는 Old 객체만 표시합니다.

GC Roots (스택, 스태틱 변수 등)
    │
    ├──→ [Young 객체]
    │
    └──→ [Old 객체 A] ← 이것만 표시!
              │
              └──→ [Old 객체 B] ← 아직 안 봄

왜 빠른가?

  • Root에서 직접 연결된 것만 확인
  • 그래프 탐색 없음
  • 보통 수 ms 이내

 


2단계: Concurrent Mark (동시 실행)

Initial Mark에서 표시한 객체부터 시작해 전체 객체 그래프를 탐색합니다.

[애플리케이션 실행 중]
User Request → 처리 중... → Response

[동시에 GC 스레드]
[Old 객체 A] → [Old 객체 B] → [Old 객체 C] → ...
     ↓              ↓              ↓
   Live!         Live!          Live!

핵심: 애플리케이션이 멈추지 않음!

 

하지만 문제가 있습니다. Marking 중에 애플리케이션이 참조를 바꿀 수 있습니다:

[Marking 시작]
A → B → C

[앱이 참조 변경]
A → B    C    (B→C 참조 제거)
    ↓
    D         (B→D 참조 추가)

[문제]
- C가 이미 Live로 표시됨 (floating garbage)
- D를 놓칠 수 있음 (위험!)

 


Write Barrier(쓰기 참조)와 Incremental Update(증분 업데이트)

CMS는 Write Barrier로 참조 변경을 추적합니다:

// 참조 변경 시 (개념적)
void writeBarrier(Object holder, Object* field, Object newValue) {
    if (concurrentMarkingInProgress) {
        // 새로 추가된 참조를 Dirty Card로 표시
        markCardDirty(holder);
    }
    *field = newValue;
}

Incremental Update 방식:

  • 새로 생긴 참조를 추적
  • Remark 단계에서 다시 확인

 

핵심은 CMS가 newValue(새 참조)만 추적한다는 점입니다. oldValue(끊어진 참조)는 추적하지 않아요.

[Concurrent Mark 시작]
A → B → C (B, C 모두 Live로 표시)

[Mark 중에 앱이 참조 변경]
A → B    C (B→C 참조 제거)
    ↓
    D      (B→D 참조 추가)

[Incremental Update]
- D가 새로 참조됨 → Dirty Card 표시 → Remark에서 D를 Live로 ✓
- C는? 이미 Live로 표시됨, 끊어진 건 추적 안 함 → 그대로 Live 유지

 

Remark가 커버하는 것:

상황 Remark가  처리? 결과
새 참조 추가 (B→D) D를 Live로 표시
기존 참조 제거 (B→C 끊김) C는 이미 Live면 그대로 유지

Remark는 "놓친 live 객체"를 찾는 거지, "잘못 표시된 live"를 취소하진 않습니다. 그래서 Floating Garbage가 생기는 거예요.

 

참고로 G1의 SATB는 반대로 끊어지는 참조(oldValue)를 추적합니다. 접근 방식이 다르지만 둘 다 Floating Garbage는 발생해요. 이건 4편에서 자세히 다루겠습니다.

 


3단계: Remark (STW)

Concurrent Mark 중에 변경된 참조를 최종 확인합니다.

[Dirty Card 확인]
"Concurrent Mark 중에 변경된 곳들"
    ↓
다시 스캔해서 놓친 객체 찾기
    ↓
최종 Live 객체 확정

왜 STW가 필요한가?

  • 애플리케이션이 계속 바꾸면 끝이 안 남
  • 잠깐 멈추고 "이 시점 기준으로 확정"

Remark은 Initial Mark보다는 길지만, Full GC보다는 훨씬 짧습니다 (보통 수십 ms).

 


4단계: Concurrent Sweep (동시 실행)

Live로 표시되지 않은 객체들을 Free List에 반환합니다.

[Marking 결과]
┌─────┬─────┬─────┬─────┬─────┬─────┐
│Live │Dead │Live │Dead │Dead │Live │
└─────┴─────┴─────┴─────┴─────┴─────┘

[Sweep 후]
┌─────┬─────┬─────┬─────┬─────┬─────┐
│Live │Free │Live │Free │Free │Live │
└─────┴─────┴─────┴─────┴─────┴─────┘

Free List: [1] → [3] → [4] → ...

핵심: Compaction을 하지 않음!

이게 CMS의 가장 큰 특징이자 약점입니다.

 


CMS의 특징: No Compaction

왜 Compaction을 안 하나?

Compaction은 객체 이동을 의미합니다:

[Compaction]
┌────┬────┬────┬────┬────┬────┐
│ A  │    │ B  │    │    │ C  │
└────┴────┴────┴────┴────┴────┘
          ↓
┌────┬────┬────┬────┬────┬────┐
│ A  │ B  │ C  │    │    │    │
└────┴────┴────┴────┴────┴────┘

객체가 이동하면 모든 참조를 업데이트해야 합니다. 이건 애플리케이션이 동시에 실행되는 상황에서 매우 어렵습니다:

[앱 스레드]
obj.field  →  주소 0x1000의 객체 접근

[GC 스레드]
주소 0x1000의 객체를 0x2000으로 이동 중...

[문제]
앱이 잘못된 주소에 접근!

그래서 CMS는 Sweep만 하고 Compaction은 안 합니다.

 

메모리 단편화 (Fragmentation)

Compaction을 안 하면 단편화가 발생합니다:

[시간이 지나면...]
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ A  │    │ B  │    │ C  │    │    │ D  │
└────┴────┴────┴────┴────┴────┴────┴────┘

총 빈 공간: 4칸
하지만 연속된 공간: 최대 2칸

→ 크기 3짜리 객체 할당 실패!

 


CMS의 문제점

1. Fragmentation으로 인한 Full GC

[상황]
Old 영역 여유 공간: 100MB (단편화됨)
Young에서 승격하려는 객체: 50MB (연속 필요)

[결과]
CMS 실패 → Serial Old로 Fallback
→ Full GC 발생 (긴 STW!)

이게 Concurrent Mode Failure입니다.

2. Floating Garbage

위에서 설명했듯이, Concurrent Mark 중에 참조가 끊어져도 이미 Live로 표시된 객체는 취소되지 않습니다:

결과: 실제로는 죽었지만 이번 사이클에서 수집 안 됨
→ 다음 사이클까지 메모리 점유 (Floating Garbage)

Floating Garbage가 많으면 Old 영역이 더 빨리 차고, CMS 사이클이 더 자주 돌아야 합니다.

3. CPU 경쟁

Concurrent 단계에서 GC 스레드가 CPU를 사용합니다:

[4코어 시스템]
App 스레드: 4개 실행 중

[CMS Concurrent Mark 시작]
GC 스레드: 1개 추가

[결과]
5개 스레드가 4코어에서 경쟁
→ 애플리케이션 throughput 감소

기본적으로 (CPU 수 + 3) / 4개의 GC 스레드를 사용합니다.

4. 언제 시작할지 타이밍 문제

CMS는 Old가 가득 차기 전에 시작해야 합니다:

[너무 늦게 시작]
Old: 90% 차있음 → CMS 시작
Concurrent Mark 중에 Old 100% 도달
→ Concurrent Mode Failure!

[너무 일찍 시작]
Old: 30% 차있음 → CMS 시작
→ 불필요한 GC, CPU 낭비
-XX:CMSInitiatingOccupancyFraction=70  (Old 70%에서 시작)
-XX:+UseCMSInitiatingOccupancyOnly     (항상 이 값 사용)

 


GC 로그 살펴보기

CMS 정상 동작

[GC (CMS Initial Mark) [1 CMS-initial-mark: 68456K(98304K)] 
    76892K(130944K), 0.0012345 secs]
    
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.025/0.026 secs]

[CMS-concurrent-preclean-start]
[CMS-concurrent-preclean: 0.003/0.003 secs]

[GC (CMS Final Remark) [YG occupancy: 8436K (32640K)]
    [Rescan (parallel), 0.0034567 secs]
    [weak refs processing, 0.0001234 secs]
    76892K(130944K), 0.0045678 secs]
    
[CMS-concurrent-sweep-start]
[CMS-concurrent-sweep: 0.015/0.016 secs]

[CMS-concurrent-reset-start]
[CMS-concurrent-reset: 0.001/0.001 secs]
  • CMS Initial Mark: 1.2ms STW
  • CMS-concurrent-mark: 25ms (동시)
  • CMS Final Remark: 4.5ms STW
  • CMS-concurrent-sweep: 15ms (동시)

총 STW: 약 6ms (Full GC의 수백 ms와 비교!)

Concurrent Mode Failure

[GC (CMS Initial Mark) ...]
[CMS-concurrent-mark-start]
[CMS-concurrent-mark: 0.123/0.125 secs]
...
(concurrent mode failure): 98304K->65432K(98304K), 0.8765432 secs]
98304K->65432K(130944K), [Metaspace: ...], 0.8901234 secs]
  • concurrent mode failure 발생
  • 0.89초 STW — CMS가 실패하고 Serial Old로 Fallback

CMS 튜닝 포인트

주요 옵션

# CMS 시작 시점
-XX:CMSInitiatingOccupancyFraction=70

# Concurrent GC 스레드 수
-XX:ConcGCThreads=2

# Remark 전 Young GC 강제
-XX:+CMSScavengeBeforeRemark

# Incremental Mode (deprecated)
-XX:+CMSIncrementalMode

Fragmentation 완화

# Full GC 시 Compaction
-XX:+UseCMSCompactAtFullCollection

# N번 Full GC마다 Compaction
-XX:CMSFullGCsBeforeCompaction=1

 


CMS의 역사적 위치

등장 (Java 1.4.1, 2002년)

  • 최초의 동시성 Old 컬렉터
  • 저지연 요구사항에 대응

전성기 (Java 5 ~ 7)

  • 웹 애플리케이션 서버의 표준
  • ParNew + CMS 조합이 일반적

쇠퇴 (Java 8 ~ 9)

  • G1이 성숙해지면서 대체됨
  • Java 9에서 Deprecated

제거 (Java 14)

  • 완전히 제거됨
  • G1 또는 ZGC/Shenandoah로 전환 권장

 


CMS의 한계 정리

문제  원인  결과
Fragmentation No Compaction Concurrent Mode Failure
Floating Garbage Concurrent Marking 메모리 효율 저하
CPU 경쟁 Concurrent 스레드 Throughput 감소
타이밍 문제 예측 어려움 CMF 또는 불필요한 GC

 


다음 단계: G1 GC

CMS의 문제들을 해결하기 위해 G1(Garbage-First) GC가 등장합니다:

  • Region 기반: 힙을 작은 Region으로 분할
  • 선택적 수집: 전체가 아닌 일부 Region만 수집
  • Compaction 포함: Fragmentation 해결
  • 예측 가능한 pause: 목표 시간 설정 가능

다음 편에서 G1 GC를 자세히 다루겠습니다.

 


정리

항목  CMS
목표 짧은 pause time
Young 컬렉터 ParNew와 조합
알고리즘 Concurrent Mark-Sweep
STW 단계 Initial Mark, Remark (짧음)
Compaction 없음
주요 문제 Fragmentation, CMF
상태 Java 14에서 제거됨

CMS는 최초로 동시성을 실현한 Old 컬렉터로, GC 발전사에서 중요한 이정표입니다. 비록 지금은 제거되었지만, CMS의 아이디어는 G1, ZGC, Shenandoah에 계승되었습니다.