들어가며
이전 편에서 병렬 컬렉터의 한계를 살펴봤습니다. 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에 계승되었습니다.