본문 바로가기

CS/JVM

[JVM 밑바닥까지 파헤치기] JVM 가비지 컬렉터의 역사 (1) - 초기 컬렉터: Serial & Serial Old

들어가며

JVM의 가비지 컬렉터는 20년 넘게 발전해왔습니다. 이 시리즈에서는 클래식 가비지 컬렉터들의 발전 흐름을 따라가며, 각 컬렉터가 왜 등장했고, 어떤 문제를 해결하려 했는지 살펴보겠습니다.

첫 번째 편에서는 가장 기본이 되는 Serial GCSerial Old GC를 다룹니다.

 


GC의 기본 개념 복습

본격적인 설명에 앞서, GC의 핵심 개념을 짚고 넘어가겠습니다.

 

세대별 가설 (Generational Hypothesis)

대부분의 JVM GC는 Weak Generational Hypothesis를 기반으로 합니다:

"대부분의 객체는 젊어서 죽는다"

이 가설에 따라 힙을 Young 영역Old 영역으로 나누고, Young 영역을 더 자주 수집합니다.

┌─────────────────────────────────────────────────────┐
│                      JVM Heap                       │
├─────────────────────┬───────────────────────────────┤
│    Young 영역        │          Old 영역             │
│ ┌─────┬─────┬─────┐ │                               │
│ │Eden │ S0  │ S1  │ │    (오래 살아남은 객체)        │
│ └─────┴─────┴─────┘ │                               │
├─────────────────────┴───────────────────────────────┤
│ 자주 GC (Minor GC)   │ 가끔 GC (Major/Full GC)       │
└─────────────────────────────────────────────────────┘

 

Stop-The-World (STW)

GC가 실행될 때 애플리케이션 스레드가 멈추는 현상입니다. 모든 GC는 어느 정도의 STW를 발생시키며, GC 발전의 역사는 곧 STW를 줄이기 위한 노력의 역사이기도 합니다.

 


Serial GC

개요

Serial GC는 JVM의 가장 오래된 Young 영역 컬렉터입니다. 이름 그대로 단일 스레드로 GC를 수행합니다.

-XX:+UseSerialGC

 

동작 방식: Mark-Copy

Serial GC는 Mark-Copy 알고리즘을 사용합니다.

[GC 전]
Eden:     [A][B][C][D][ ][ ][ ][ ]
Survivor0: [E][F][ ][ ]
Survivor1: [ ][ ][ ][ ]

[Mark - 살아있는 객체 식별]
Eden:     [A][x][C][x][ ][ ][ ][ ]   (B, D는 죽음)
Survivor0: [E][x][ ][ ]              (F는 죽음)

[Copy - Survivor1로 복사]
Eden:     [ ][ ][ ][ ][ ][ ][ ][ ]   (비워짐)
Survivor0: [ ][ ][ ][ ]              (비워짐)
Survivor1: [A][C][E][ ]              (살아남은 객체)

왜 Copy인가?

  1. 살아있는 객체만 복사하면 됨 (죽은 객체는 무시)
  2. 복사하면서 자연스럽게 메모리 압축(Compaction)
  3. 할당이 단순함 (bump-the-pointer)

 

장단점

장점:

  • 구현이 단순함
  • 단일 스레드라 컨텍스트 스위칭 오버헤드 없음
  • 메모리 footprint가 작음

단점:

  • 싱글 스레드이므로 멀티코어 활용 불가
  • 힙이 커지면 STW 시간 증가

적합한 환경

  • 싱글 코어 환경
  • 힙 크기가 작은 애플리케이션 (수백 MB 이하)
  • 클라이언트 애플리케이션

 


Serial Old GC

개요

Serial Old는 Old 영역을 담당하는 Serial 컬렉터입니다. Serial GC와 짝을 이루어 사용됩니다.

 

동작 방식: Mark--Compact

Young 영역의 Mark-Copy와 달리, Old 영역은 Mark-Compact 알고리즘을 사용합니다.

[GC 전]
Old: [A][ ][B][ ][ ][C][ ][D][ ]

[Phase 1: Mark - 살아있는 객체 표시]
Old: [A*][ ][B*][ ][ ][x][ ][D*][ ]   (C는 죽음)

[Phase 2: Sweep - 죽은 객체 정리 (논리적)]
Old: [A*][ ][B*][ ][ ][ ][ ][D*][ ]

[Phase 3: Compact - 한쪽으로 밀어서 압축]
Old: [A][B][D][ ][ ][ ][ ][ ][ ]

왜 Copy가 아닌 Compact인가?

Old 영역의 객체는 대부분 살아있습니다. Copy 방식은 살아있는 객체를 모두 복사해야 하므로, 살아있는 비율이 높은 Old 영역에서는 비효율적입니다. Compact는 이동만 하면 되므로 더 효율적입니다.

 

Mark-Compact의 세 단계

단계 동작 비용
Mark GC Root부터 시작해 살아있는 객체 탐색 살아있는 객체 수에 비례
Sweep 죽은 객체의 공간을 Free List에 등록 힙 크기에 비례
Compact 살아있는 객체를 한쪽으로 이동 살아있는 객체 크기에 비례

 

왜 Compact가 필요한가?

Sweep만 하면 메모리 단편화(Fragmentation)가 발생합니다:

[Sweep만 한 경우]
Old: [A][ ][ ][B][ ][C][ ][ ][ ][D]

→ 크기 3짜리 객체를 할당하려면?
→ 연속된 빈 공간이 없음!
→ OutOfMemoryError 발생 가능

Compact를 통해 빈 공간을 한쪽으로 모아 연속된 할당 공간을 확보합니다.

 


Serial + Serial Old 조합

┌─────────────────────────────────────────────────────┐
│                    Minor GC (Serial)                │
│                                                     │
│  애플리케이션 ──────────────────────────────────    │
│                    │STW│                            │
│  GC 스레드    ─────┼───┼────────────────────────    │
│                    │   │                            │
│              Eden + Survivor를 단일 스레드로 수집    │
└─────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────┐
│                  Major GC (Serial Old)              │
│                                                     │
│  애플리케이션 ──────────────────────────────────    │
│                    │    STW (긴 시간)    │          │
│  GC 스레드    ─────┼────────────────────┼────────   │
│                    │                    │          │
│              Old 전체를 단일 스레드로 수집           │
└─────────────────────────────────────────────────────┘

이 조합은 가장 단순하고 기본적인 GC 구성입니다.

 


실제 GC 로그 살펴보기

[GC (Allocation Failure) 
    [DefNew: 8128K->1024K(9216K), 0.0052873 secs] 
    8128K->6218K(29696K), 0.0053350 secs]
  • DefNew: Serial GC의 Young 영역 (Default New Generation)
  • 8128K->1024K: Young 영역이 8MB에서 1MB로 줄어듦
  • 0.0052873 secs: 약 5ms의 STW
[Full GC (Allocation Failure) 
    [Tenured: 20480K->15360K(20480K), 0.0892456 secs] 
    29696K->15360K(29696K), 0.0893120 secs]
  • Tenured: Serial Old의 Old 영역
  • 0.0892456 secs: 약 89ms의 STW (Major GC는 훨씬 오래 걸림)

 


1990년대 컴퓨팅 환경

Serial GC가 처음 설계된 1990년대 후반의 환경을 이해하면, 왜 이런 설계가 합리적이었는지 알 수 있습니다:

  • 싱글 코어가 일반적: 멀티코어 CPU는 2000년대 중반에나 대중화
  • 힙 크기가 작음: 대부분 수십~수백 MB
  • 메모리가 비쌈: GC 오버헤드를 최소화하는 게 중요

이런 환경에서 Serial GC는 최적의 선택이었습니다.

 


한계와 다음 단계

하지만 시대가 변했습니다:

  1. 멀티코어 CPU 대중화: 싱글 스레드 GC는 CPU 자원 낭비
  2. 힙 크기 증가: 수 GB 힙에서 Serial GC의 STW는 수 초에 달함
  3. 서버 환경의 요구: 더 짧은 pause time, 더 높은 throughput 필요

이러한 한계를 극복하기 위해 병렬 컬렉터들이 등장합니다.

다음 편에서는 ParNew, Parallel Scavenge, Parallel Old를 다루겠습니다.

 


정리

항목 Serial Serial Old
대상 영역 Young Old
알고리즘 Mark-Copy Mark-Compact
스레드 단일 단일
적합한 환경 싱글 코어, 작은 힙 싱글 코어, 작은 힙

Serial GC는 단순하지만, GC의 기본 원리를 이해하는 데 가장 좋은 출발점입니다. 이후의 모든 컬렉터들은 이 기본 위에서 발전했습니다.