Serial GC 소개
약한 세대 가설 (Weak Generational Hypothesis)
자바 메모리 관리의 핵심인 약한 세대 가설(Weak Generational Hypothesis) 을 시각적으로 가장 잘 보여주는 자료 입니다. 그래프를 보면 대부분의 객체는 금방 죽는다 입니다. 하지만 오래 살아 남으면 그만큼 오래 살아 남는다 입니다.
자바의 메모리 구조를 보면 힙(Heap) 영역 이 Young Generation 과 Old Generation 으로 나뉘어 있는 것을 볼 수 있습니다. “그냥 통째로 관리하면 편할 텐데 왜 굳이 복잡하게 나눠놓았을까?” 라는 의문이 들 수 있죠. 그 해답은 바로 객체의 수명 그래프에 있습니다.

대부분의 객체는 ‘금방 죽는다’ (Weak Generational Hypothesis)
위 그래프를 보면 왼쪽 끝(객체가 막 생성된 시점)에 데이터가 폭발적으로 몰려 있는 것을 볼 수 있습니다.
- 금방 죽는 객체: 루프 안에서 잠깐 쓰이는 변수, HTTP 요청 한 번에 응답하고 사라지는 객체 등이 여기 해당합니다. 90% 이상의 객체는 생성되자마자 곧바로
쓰레기(Garbage)가 됩니다. - 오래 살아남는 객체: 반면, 이 고비를 넘기고 살아남은 소수의 객체는 애플리케이션이 종료될 때까지 유지될 가능성이 매우 높습니다. 캐시 데이터나 세션, 싱글톤 객체들이 대표적 입니다.
이 극단적인 수명 차이 때문에 JVM은 세대별 관리 라는 전략을 선택했습니다.

Young Generation (금방 죽는 애들 모임)
객체가 태어나자마자 할당되는 곳입니다. 대부분 금방 죽기 때문에, 아주 자주 그리고 빠르게 청소합니다. 이를 Minor GC 라고 부릅니다. 살아있는 놈들만 쏙쏙 골라내고 나머지는 한꺼번에 날려버리는 방식이라 처리 속도가 매우 빠릅니다.
Old Generation (질긴 애들 모임)
Young 영역에서 여러 번의 GC를 견디고 살아남은 ‘정예 객체’들이 이동해오는 곳입니다. 이곳은 덩치가 크고 잘 죽지 않는 객체들이 모여 있어, Young 영역처럼 자주 청소하면 오히려 시스템에 큰 부담이 됩니다. 그래서 이곳은 가끔씩만 청소하며, 이를 Major GC(또는 Full GC) 라고 부릅니다.
결국 이유는 ‘성능’ 때문입니다. 만약 힙을 나누지 않고 통째로 관리한다면, 새로운 객체 하나를 위해 메모리를 비울 때마다 힙 전체를 뒤져야 합니다. 하지만 세대를 나누면? 금방 죽을 놈들이 모인 좁은 구역(Young)만 집중적으로 공략해서 메모리를 확보합니다. 이 덕분에 전체 애플리케이션이 멈추는 Stop-the-world 시간을 획기적으로 줄일 수 있습니다.
Minor GC 소개



GC Root 로부터 연결된 객체들을 추적하는 화살표가 있습니다. GC Root 와 연결된 객체 경우 아직 사용 중인 ‘살아있는 객체’ 입니다. 반대로 연결되지 않은 객체는 더 이상 필요 없는 쓰레기(Garbage) 입니다. JVM은 전체를 뒤지는 대신, 살아있는 객체만 골라내어 마킹합니다.
추가적으로 검은색 영역 으로 표시된 Remembered Set 은 튜닝의 고급 정보입니다. 가끔 Old 영역 의 객체가 Young 영역의 객체를 참조 할 때가 있습니다. Young 영역만 청소(Minor GC)하고 싶은데, Old 영역 전체를 뒤져서 참조를 확인할 수는 없기 때문에 이때 Remembered Set 이라는 별도의 장부를 써서 “Old 영역의 누군가 얘를 쓰고 있다”는 것을 기록해 둡니다. 덕분에 Minor GC 속도가 훨씬 빨라집니다.
Survivor 영역으로 데이터가 이동하는 장면입니다. 이것이 바로 Mark-Copy 방식입니다. 만약 쓰레기만 골라서 지우면 메모리 곳곳에 구멍이 뚫리는 단편화(Fragmentation) 가 발생합니다. 이러한 문제를 해결 하기 위해 Young 영역(Eden) 에서 살아남은 객체들을 Survivor 영역 으로 통째로 복사(Copy) 합니다.
복사가 끝나면 기존 Eden 영역은 통째로 비워버립니다.

우측의 Old Generation 영역 에서 파란색 화살표를 주목해주세요. Survivor 영역 에서 GC가 반복되어도 끝까지 살아남은 장수 객체 들은 결국 Old 영역 으로 옮겨집니다. 이를 Promotion(승급) 이라고 부릅니다. 이제 이 객체들은 웬만해서는 죽지 않는 ‘정예 요원’이 된 셈입니다.
Mark Sweep Compact
Mark-Sweep-Compact 는 크게 다음 순서로 이해할 수 있습니다.
- Mark
GC Root에서부터 도달 가능한 객체를 찾아서 “이 객체는 아직 살아 있다”라고 표시합니다.
- Sweep
- 표시되지 않은 객체, 즉 더 이상 참조되지 않는 객체를 정리 대상이라고 판단합니다.
- Compact
- 살아 있는 객체들을 한쪽으로 밀어서 재배치하고, 중간중간 흩어져 있던 빈 공간을 하나의 큰 연속 공간으로 합칩니다.
즉,
- Mark: 살아 있는 객체 찾기
- Sweep: 죽은 객체 구분
- Compact: 살아 있는 객체를 모아서 파편화 제거
라고 보면 됩니다.
왜 Compact가 필요한가?
Mark-Sweep 까지만 수행하면 죽은 객체는 정리되지만, 살아 있는 객체들 사이사이에 빈 공간이 남습니다.
예를 들어,
- 객체 A 살아 있음
- 객체 B 죽음
- 객체 C 살아 있음
- 객체 D 죽음
이렇게 되면 메모리에는 [A][빈칸][C][빈칸] 형태가 남게 됩니다.
겉으로 보면 빈 공간이 많아 보이지만, 실제로는 연속된 큰 공간이 없어서 큰 객체를 배치하지 못할 수도 있습니다.
이런 문제를 해결하기 위해 살아 있는 객체를 한쪽으로 붙여서 [A][C][연속된 빈 공간] 형태로 만드는 것이 Compact 의 핵심입니다.

파편화된 상태에서 Compact 준비 시작
이 장면은 GC 이후 살아 있는 객체들만 남아 있지만, 메모리 곳곳에 빈 공간이 흩어져 있는 파편화 상태를 보여줍니다.
아직 객체를 옮기지는 않았고, 이제부터 왼쪽부터 선형 탐색(linear scan) 하면서 살아 있는 객체가 compact 이후 어디로 이동할지 계산할 준비를 합니다. 즉, Compact의 출발점입니다.

첫 번째 살아 있는 객체에 새 주소 부여
첫 번째 살아 있는 객체를 발견하면, compact 이후 이동해야 할 새 주소(forwarding address) 를 계산합니다. 그림의 fa 는 바로 그 의미이며, 이 값은 현재 객체 헤더 등에 임시 저장된 것으로 이해하면 됩니다.
아직 객체를 옮긴 것은 아니고, “너는 나중에 이 위치로 이동할 예정이야” 라고 표시만 한 상태입니다.

두 번째 살아 있는 객체도 새 주소 계산
다음 살아 있는 객체를 찾고, 앞에서 배정된 객체 바로 뒤 위치를 기준으로 두 번째 객체의 새 주소를 계산합니다.
이 과정의 핵심은 살아 있는 객체들을 중간 빈 공간 없이 연속적으로 붙여 배치할 최종 주소를 계산한다는 점입니다.

세 번째 살아 있는 객체의 forwarding address 기록
이제 세 번째 살아 있는 객체에도 fa 가 기록됩니다. 이 시점부터 보이는 흐름은 분명합니다.
- 죽은 객체가 차지하던 공간은 건너뛰고
- 살아 있는 객체만 순서대로 모아서
- compact 이후의 연속 메모리 배치를 미리 계산
하는 단계입니다.

Young 영역의 살아 있는 객체들에 대한 새 주소 계산 완료
Young Generation 안에서 살아 있는 객체들에 대해 새 주소를 차례대로 계산해 두는 작업이 거의 끝난 상태입니다.
이 단계가 중요한 이유는, 뒤에서 참조를 새 주소로 바꿀 수 있으려면 먼저 모든 이동 대상 객체의 최종 위치가 정해져 있어야 하기 때문입니다.
즉, 지금은 “복사 전 주소 설계도”를 만드는 단계라고 볼 수 있습니다.

이제 참조를 새 주소로 바꾸는 단계 시작
여기서부터는 실제 복사가 아니라 참조 주소를 새 주소로 업데이트하는 단계입니다.
그림 하단 설명처럼,
- GC Root
- Young 내부 객체 참조
- Old Generation 객체 참조
- Remembered Set 기반의 Old → Young 참조
등을 모두 새 주소로 바꾸기 시작합니다.
객체는 아직 원래 자리에 있지만, 참조부터 안전하게 새 위치를 바라보도록 변경하는 것입니다.

GC Root가 첫 번째 새 주소를 바라보도록 갱신
GC Root가 가리키던 기존 주소를 이제 fa 가 가리키는 새 주소로 바꾸는 과정입니다.
이 작업이 중요한 이유는 실제 객체가 이동한 뒤에도 루트 참조가 옛 주소를 보면 안 되기 때문입니다.
즉, 루트 참조부터 새 위치를 정확히 인지하게 만드는 단계입니다.

객체 간 참조도 순차적으로 새 주소로 변경
이제 GC Root뿐 아니라 객체 내부 필드가 가리키는 참조들도 하나씩 새 주소로 바뀝니다. 만약 객체 A가 객체 B를 참조하고 있었다면, 이제는 B의 원래 주소가 아니라 compact 후 B가 이동할 새 주소를 참조해야 합니다.
이 단계는 객체 간 연결 관계를 compact 이후에도 그대로 유지하기 위한 필수 과정입니다.

Old → Young 참조까지 함께 갱신
Young Generation 안의 참조만 바꾸면 끝이 아닙니다. Old Generation 객체가 Young Generation 객체를 참조하는 경우도 있기 때문입니다.
이때 사용하는 것이 Remembered Set 입니다. 즉, Old 영역 전체를 무식하게 전부 뒤지지 않고, Young를 참조하는 Old 객체들만 추적해서 필요한 참조를 새 주소로 갱신하는 방식으로 이해할 수 있습니다.

모든 참조 주소 갱신 완료
이제 살아 있는 객체를 가리키는 참조들은 모두 compact 이후의 새 주소를 바라보게 되었습니다. 이 상태가 되면 비로소 실제 객체를 옮겨도 참조 무결성이 깨지지 않습니다.
즉,
- 새 주소 계산 완료
- 참조 갱신 완료
이 되었으므로, 다음 단계에서 실제 객체 복사를 진행할 수 있습니다.

첫 번째 살아 있는 객체 실제 복사
여기서부터는 실제로 객체를 새 주소 위치로 이동시키는 단계입니다. 첫 번째 살아 있는 객체가 미리 계산해 둔 compact 위치로 복사됩니다.
이미 참조는 새 주소를 바라보게 수정되어 있으므로, 객체가 이동해도 참조 일관성이 유지됩니다.

두 번째 객체도 연속된 공간으로 이동
두 번째 살아 있는 객체도 첫 번째 객체 바로 뒤의 연속 공간으로 복사됩니다.
이 단계가 진행될수록 기존에 흩어져 있던 빈 공간은 점점 사라지고, 객체들이 한쪽으로 모이기 시작합니다.
즉, 파편화가 눈에 띄게 해소되는 구간입니다.

나머지 살아 있는 객체들도 차례대로 압축 이동
세 번째, 네 번째 객체도 같은 방식으로 계산된 새 주소로 순서대로 복사됩니다.
여기서 중요한 점은 복사 순서가 제멋대로가 아니라 선형적으로 진행되면서 메모리를 연속 구간으로 재구성한다는 점입니다.
이 과정을 통해 빈 공간이 여러 조각으로 나뉘어 있던 상태가 하나의 큰 빈 공간으로 정리됩니다.

Compact 완료, 연속된 빈 공간 확보
마지막 장면은 Compact가 끝난 상태입니다. 살아 있는 객체들은 한쪽으로 붙어 배치되었고, 그 뒤에는 큰 연속 공간이 만들어졌습니다.
이제 JVM은 새로운 객체를 할당할 때 중간중간 빈 칸을 찾아다닐 필요 없이 연속된 여유 공간을 효율적으로 사용할 수 있습니다.
즉, 이 단계에서 파편화 문제가 실질적으로 해결됩니다.
그림이 전달하는 핵심 정리 하자면
- 첫째, 살아 있는 객체를 바로 옮기지 않는다 먼저 새 주소를 계산하고 (fa 기록)
- 둘째, 참조를 먼저 새 주소로 바꾼다 GC Root, 객체 간 참조, Old → Young 참조를 모두 수정한 뒤
- 셋째, 마지막에 실제 객체를 이동한다 그래야 참조가 꼬이지 않고 안전하게 compact를 수행할 수 있습니다.
즉, 이 그림은 단순히 압축한다 가 아니라 압축을 위해 필요한 내부 절차를 단계적으로 보여주는 좋은 예시입니다.
Mark-Sweep-Compact 의 장단점
다음은 Mark-Sweep-Compact 장단점을 말씀 드리겠습니다.
장점
- 메모리 파편화를 해소할 수 있음
- 큰 연속 메모리 공간 확보 가능
- 이후 객체 할당이 더 단순하고 빨라질 수 있음
단점
- 객체를 실제로 이동해야 하므로 비용이 큼
- 참조 업데이트가 필요함
Stop-The-World시간이 길어질 수 있음
즉, 메모리를 깔끔하게 정리할 수 있는 대신 비용이 상대적으로 큰 GC 방식이라고 볼 수 있습니다.
정리해보자
Mark-Sweep-Compact 는 단순히 “가비지를 치운다”를 넘어, GC 이후 남는 메모리 파편화까지 해결하는 방식입니다. 특히 이번 그림처럼 단계별로 보면 Compact는 다음 순서로 이해할 수 있습니다.
- 살아 있는 객체의 새 주소 계산
- 모든 참조를 새 주소로 갱신
- 실제 객체를 새 위치로 복사
- 연속된 빈 공간 확보
결국 이 과정의 목적은 하나입니다. “메모리를 다시 연속적이고 효율적으로 사용할 수 있게 만드는 것” 입니다.
자바 GC에서 TLAB 알아보기
TLAB 는 여러 스레드가 동시에 객체를 만들 때 생기는 병목과 해결 방식 입니다.
자바 애플리케이션은 보통 여러 스레드가 동시에 동작합니다. 웹 서버라면 요청마다 다른 스레드가 일하고, 백그라운드 작업도 별도 스레드에서 수행됩니다. 이때 공통적으로 자주 일어나는 일이 하나 있습니다. 바로 객체 생성입니다.
1 | new User(); |
이런 객체 생성은 매우 흔하고, JVM 은 이런 객체를 보통 Young Generation 의 Eden 영역에 먼저 할당합니다.
문제는 여러 스레드가 동시에 Eden 에 객체를 할당하려고 하면 같은 메모리 공간을 서로 차지하려고 경쟁하게 된다는 점입니다. 이 경쟁을 줄이기 위해 등장한 개념이 바로 TLAB(Thread Local Allocation Buffer) 입니다.
TLAB가 없다면 무슨 문제가 생길까?

객체를 메모리에 할당하는 가장 단순한 방식은 이런 식입니다.
Eden의 현재 할당 시작 위치를 가리키는 포인터가 있음- 객체를 하나 만들 때마다 그 포인터를 앞으로 이동시킴
- 그 사이 공간을 새 객체가 사용함
이 방식 자체는 매우 빠릅니다. 보통 이를 bump-the-pointer allocation 이라고 이해하면 됩니다.
예를 들면
- 현재
Eden의free pointer가 100번지 - 32바이트짜리 객체 생성
- 100~131 사용
free pointer를 132로 이동
이 자체는 간단합니다. 하지만 문제는 멀티스레드 환경입니다.
예를 들어
- Thread 1도 Eden에 객체를 만들고 싶고
- Thread 2도 Eden에 객체를 만들고 싶고
둘 다 “현재 free pointer”를 기준으로 메모리를 잡으려 하면 동시에 같은 위치를 읽고, 같은 위치에 할당하려고 시도할 수 있습니다. 그러면 다음과 같은 문제가 생깁니다.
같은 주소를 중복 할당할 수 있음
free pointer 갱신이 꼬일 수 있음
메모리 무결성이 깨질 수 있음
즉, 공유 메모리 할당 포인터에 대한
경쟁(contention)이 생깁니다.
그럼 동기화로 해결하면 되지 않을까?
예를 들어 Eden 의 할당 포인터를 갱신할 때마다 lock 을 걸거나 원시적 연산(CAS) 방식으로 충분히 정합성 문제 해결 할 수 있습니다. 하지만 객체는 자주 발생하기 때문에, 전체 처리량이 크게 떨어질 수 있습니다. 즉 정확성은 보장할 수 있어도, 성능을 너무 많이 희생하게 됩니다.
이러한 문제를 해결 하기 위한 TLAB

이 문제를 해결하기 위해 JVM은 Eden 전체를 모든 스레드가 직접 공유하도록 두지 않고, 그 안에서 각 스레드가 잠시 독점해서 사용할 수 있는 작은 영역을 따로 떼어 줍니다. 그게 바로 TLAB(Thread Local Allocation Buffer) 입니다.
동작 개념을 설명하겠습니다.
Eden은Young Generation안에 있는 객체 생성의 기본 공간JVM은Eden의 일부를 잘라서 각 스레드에게 나눠 줌- 각 스레드는 자기
TLAB안에서는 다른 스레드와 경쟁하지 않고 객체를 할당함 TLAB가 다 차면 그때만JVM에 새TLAB를 요청하거나, 경우에 따라 직접Eden에 할당함
즉, 매번 전체 Eden 에 대해 경쟁하지 않고 자기 버퍼 안에서는 락 없이 매우 빠르게 할당할 수 있게 됩니다.
TLAB retire
TLAB 에 남은 공간이 너무 적어서 더 이상 효율적으로 쓰기 어렵다고 판단되면, 현재 TLAB는 은퇴(retire) 됩니다. 여기서 retire란 “이 TLAB는 이제 더 이상 계속 쓰지 않고, 남은 자투리 공간은 포기하고 새 TLAB 를 받겠다”
즉 기존 TLAB 의 끝부분에 애매하게 조금 남은 공간이 있더라도 그 공간이 충분히 유의미하지 않다면 JVM은 그 공간을 정리 대상으로 보고 새 TLAB 를 Eden 에서 다시 받아옵니다.
TLAB refill
기존 TLAB 를 retire 하기로 결정하면, JVM 은 Eden 의 공유 영역에서 해당 스레드를 위한 새로운 TLAB 를 할당(refill) 합니다. 이때 중요한 점은 새 TLAB는 내 스레드 전용으로 사용되지만, 그 새 TLAB 자체를 받아오는 과정은 Eden의 공유 영역에서 일어난다는 것입니다.
즉 평소 TLAB 내부에서 객체를 만드는 것은 thread-local fast-path 하지만 TLAB를 새로 받는 순간은 Eden 공유 영역을 건드려야 하므로 더 비쌈 이라는 차이가 있습니다.

첫 번째 그림은 어떤 스레드가 이미 하나의 TLAB를 사용하고 있고, 그 안쪽에 여러 객체들이 할당되어 있는 상태를 보여줍니다. 하지만 이제 TLAB 끝부분에 남은 공간이 거의 없습니다. 이 상태에서 새 객체를 생성하려고 하면 JVM은 먼저 판단합니다.
지금 남은 자투리 공간을 계속 써볼 가치가 있는가? 아니면 이 TLAB를 retire 시키고 새 TLAB 를 받아오는 게 더 나은가? 즉, 이 시점은 단순한 fast-path 가 끝나고 추가 판단이 필요한 경계 지점입니다.

두 번째 그림은 새 객체가 생성되려는 순간을 보여줍니다. 그런데 현재 TLAB 에는 이 객체를 담을 만한 충분한 공간이 없습니다. 이 경우 JVM은 기존 TLAB를 retire 시키고, Eden의 공유 영역에서 새로운 TLAB를 refill 받아야 합니다.
중요한 점은 이 refill 과정은 thread-local 내부에서 끝나는 것이 아니라 Eden의 공유 자원을 대상으로 수행된다는 것입니다. 그래서 이 과정에는 동기화, JVM 로직, 포인터 갱신 등의 비용이 개입할 수 있고, 이 경로를 slow-path라고 부릅니다.
TLAB Resize
HotSpot/OpenJDK 에서는 -XX:+ResizeTLAB 가 기본적으로 켜져 있으며, TLAB 크기를 고정하지 않고 동적으로 재조정할 수 있습니다.
OpenJDK 쪽 설명 기준으로 TLAB sizing policy는 GC cycle 마다 리사이징 판단을 하고, 최근 할당 이력을 TLABAllocationWeight 비율로 반영합니다. 또한 TLABWasteTargetPercent 기본값은 1, TLABAllocationWeight 기본값은 35입니다.
즉, TLAB는 “한 번 정하면 계속 같은 크기”가 아니라, 스레드별 객체 생성 패턴에 맞춰 앞으로 쓸 만한 적정 크기(desired_size) 를 JVM이 계속 조정하는 구조라고 보면 됩니다. TLAB 초기화/리사이징 통계에서도 desired_size 개념이 실제로 사용됩니다.
TLAB 크기 조정에 영향을 주는 요소
TLAB의 desired_size 는 보통 아래 요소들의 영향을 받습니다.
- Eden 크기
- 동작 중인 스레드 수
- -XX:TLABWasteTargetPercent=N
- 스레드별 allocation rate
- -XX:TLABAllocationWeight=M
-XX:+ResizeTLAB
이 옵션이 켜져 있으면 JVM은 스레드별 TLAB 크기를 workload에 맞게 계속 조정합니다. 객체 생성이 활발한 스레드는 더 큰 TLAB를 받을 가능성이 높고, 반대로 활동이 적은 스레드는 상대적으로 작은 TLAB를 받을 수 있습니다.
-XX:TLABWasteTargetPercent=N (default 1, 단위 %)
이 값이 작으면 Eden 낭비를 더 엄격하게 보려는 방향이 되고, 값이 크면 TLAB를 조금 더 넉넉하게 가져갈 여지가 커집니다. 쉽게 말해 값이 클수록 refill은 줄 수 있지만, TLAB 단위 낭비는 커질 수 있다고 이해하면 됩니다.
-XX:TLABAllocationWeight=M (default 35, 단위 %)
이 값은 직전 allocation 패턴을 얼마나 강하게 반영할지를 나타냅니다. 값이 클수록 최근 allocation rate를 더 빠르게 반영하고, 값이 작을수록 과거 이력까지 조금 더 완만하게 반영합니다.
JVM GC 성능 지표 (Throughput과 Latency)
Throughput 과 Latency 는 무엇이 다를까? JVM GC를 공부하다 보면 가장 자주 보게 되는 성능 지표가 있습니다. 바로 Throughput(처리량) 과 Latency(지연 시간) 입니다.
GC 튜닝을 할 때 많은 분들이 “GC 횟수가 적으면 좋은 것 아닌가?” “Stop-The-World만 짧으면 끝 아닌가?”라고 생각하기 쉬운데, 실제로는 그렇게 단순하지 않습니다.
GC는 결국 애플리케이션 성능 전체와 연결된 문제이고, 그 중심에는 항상 Throughput 과 Latency 의 균형이 있습니다. 이번 글에서는 GC 관점에서 이 두 지표를 어떻게 이해해야 하는지 정리해보겠습니다.
Throughput 이란?
GC에서 Throughput 은
전체 실행 시간 중에서 GC에 사용된 시간을 제외하고, 실제로 애플리케이션이 일한 시간의 비율을 의미합니다. 즉, 쉽게 말하면 프로그램이 멈추지 않고 실제 업무를 처리한 시간이 얼마나 되는가? 라고 이해하면 됩니다.
예를 들어 전체 실행 시간이 100초인데, GC가 5초 걸렸고 애플리케이션이 실제로 일한 시간이 95초라면 Throughput 은 높다고 볼 수 있습니다.
여기서 중요한 점은 객체를 생성하고 사용하는 시간은 애플리케이션 실행 시간에 포함된다는 것입니다. 즉, 객체 할당이 많다고 해서 그 자체가 GC 시간이 되는 것은 아닙니다. GC가 실제로 개입해서 정리 작업을 수행하는 시간이 GC 시간입니다.
Latency 란?
GC에서 Latency 는 GC 때문에 애플리케이션이 멈추는 시간, 즉 Stop-The-World 시간을 의미합니다.
쉽게 말하면 사용자 입장에서 얼마나 오래 멈췄는가? 입니다. 예를 들어 어떤 GC가 한 번 발생할 때마다 300ms씩 애플리케이션을 멈춘다면 이 GC는 Throughput이 괜찮더라도 Latency 측면에서는 좋지 않을 수 있습니다. 특히 다음과 같은 서비스에서는 Latency가 매우 중요합니다.
실시간 응답이 중요한 API 서버 사용자 클릭에 빠르게 반응해야 하는 웹 서비스 지연에 민감한 거래 시스템 짧은 응답 시간을 요구하는 온라인 서비스 여기서도 중요한 점이 하나 있습니다. GC가 백그라운드에서 애플리케이션과 병렬로 일부 작업을 수행하더라도, 그 병렬 수행 시간 전체를 Latency로 보지는 않습니다. Latency는 어디까지나 애플리케이션이 실제로 멈춘 시간을 중심으로 봅니다.
Throughput과 Latency는 왜 둘 다 중요할까?
GC를 평가할 때 Throughput과 Latency가 모두 중요한 이유는, 둘이 보는 관점이 다르기 때문입니다.
Throughput이 보는 것 전체적으로 얼마나 많은 일을 했는가 GC 때문에 전체 작업량이 얼마나 깎였는가 CPU 시간을 애플리케이션에 얼마나 많이 돌려줬는가 Latency가 보는 것 한 번 멈출 때 얼마나 오래 멈췄는가 사용자가 체감하는 끊김이 어느 정도인가 응답 시간 관점에서 서비스 품질이 어떤가
즉 Throughput은 전체 효율 Latency는 순간 멈춤의 체감 이라고 이해하면 좋습니다.
Throughput과 Latency는 trade-off 관계
GC에서 가장 자주 나오는 말 중 하나가 바로 이것입니다. Throughput과 Latency는 trade-off 관계다. 이 말은 한쪽을 극단적으로 좋게 만들면, 다른 한쪽은 손해를 볼 가능성이 크다는 뜻입니다.
예를 들어 생각해보겠습니다. Throughput을 높이려는 방향 GC를 너무 자주 하지 않고, 한 번에 좀 더 많이 모아서 처리하면 전체적으로 애플리케이션이 일하는 시간이 늘어날 수 있습니다. 하지만 그 대신 한 번 GC가 발생했을 때 멈추는 시간이 길어질 수 있습니다.
즉
- GC 횟수는 줄 수 있음
- 전체 처리 효율은 좋아질 수 있음
- 하지만 한 번 멈출 때 오래 멈출 수 있음
Latency를 줄이려는 방향 GC가 오래 멈추지 않도록 작게 자주 나눠서 처리하면 한 번 멈추는 시간은 짧아질 수 있습니다.
하지만 그 대신 GC가 더 자주 개입하면서 전체 애플리케이션 처리 시간 일부를 계속 가져가게 될 수 있습니다.
즉
- Stop-The-World는 짧아질 수 있음
- 사용자 체감은 좋아질 수 있음
- 하지만 전체 처리량은 떨어질 수 있음
서버 애플리케이션에서는 무엇이 더 중요할까?
이 질문은 무조건 Throughput이 중요하다 또는 무조건 Latency가 중요하다 로 답하기 어렵습니다. 결국 서비스 특성에 따라 달라집니다.
Throughput이 더 중요한 경우
- 배치 처리
- 로그 집계
- 대량 데이터 처리
- 응답 시간보다 전체 처리량이 더 중요한 작업
이런 경우에는 한 번 멈추는 시간이 조금 길더라도 전체적으로 더 많은 일을 처리하는 것이 중요할 수 있습니다.
Latency가 더 중요한 경우
- 사용자 요청/응답 API
- 웹 서비스
- 실시간 시스템
- 짧은 응답 시간이 중요한 서비스
이런 경우에는 전체 처리량이 조금 손해 보더라도 한 번의 멈춤 시간을 줄이는 것이 더 중요할 수 있습니다. 즉 서버 애플리케이션이라고 해서 항상 Throughput만 보는 것이 아니라, 이 서버가 어떤 서비스를 하는가? 를 먼저 봐야 합니다.
JVM 옵션 -Xmx 와 -Xms
JVM 메모리 옵션을 볼 때 가장 먼저 접하는 것이 -Xmx 와 -Xms 입니다. 둘 다 힙 크기와 관련된 옵션이지만 의미는 같지 않습니다. 핵심만 먼저 말하면 이렇습니다.
-Xmx는 힙이 최대로 커질 수 있는 한도-Xms는 JVM이 시작할 때 잡아 둘 초기/최소 힙 크기
즉, -Xmx 는 “최대 어디까지 쓸 수 있나”를 정하고, -Xms 는 “시작할 때 최소 얼마를 실제 사용 가능하게 둘까”를 정하는 옵션입니다. Oracle Java 문서에서도 -Xms 는 minimum and initial size, -Xmx 는 maximum size 로 설명합니다. 또한 -XX:InitialHeapSize 는 초기 힙 크기, -XX:MinHeapSize 는 최소 힙 크기, -XX:MaxHeapSize 는 최대 힙 크기를 뜻합니다. -XX:MaxHeapSize 는 -Xmx 와 동등하다고 문서에 명시되어 있습니다.
-Xmx 는 최대 힙 주소공간 예약 으로 이해하면 쉽습니다. JVM은 시작 시 힙을 위해 최대 주소공간을 가상 주소 공간에 reserve 해둘 수 있습니다. Oracle 문서에서는 초기화 시점에 최대 address space 가 virtual 하게 reserve 되지만, 필요해지기 전까지는 physical memory 로 할당되지 않는다 고 설명합니다.
예를 들어 다음과 같이 실행했다고 가정해보겠습니다.
1 | java -Xms1500m -Xmx3g ... |
이 경우 개념적으로는 JVM이 힙에 대해 최대 3GB 까지 사용할 수 있는 범위를 확보해 둡니다.
하지만 이 3GB가 시작하자마자 전부 물리 메모리로 사용되는 것은 아닙니다. Oracle의 진단 문서 예시도 -Xms100m -Xmx1000m 일 때 1000MB를 reserve 하고, 시작 시점에는 100MB만 committed 된다고 설명합니다.
즉, -Xmx3g 를 줬다고 해서 “처음부터 3GB를 다 먹는다”라고 이해하면 틀리고, 좀 더 정확히는 3GB 까지 커질 수 있는 힙 상한을 잡아 둔다고 보는 것이 맞습니다

첫 번째 그림은 -Xmx3g 를 기준으로 최대 가상 주소공간을 먼저 예약해 둔 뒤 그 안에서 -Xms 값만큼만 초기/최소 힙이 실제 commit 된 상태를 표현한 것으로 보면 됩니다.

객체 사용량과 무관하게, JVM이 힙에 대해 최대 범위(-Xmx) 와 초기 확보량(-Xms) 을 어떻게 바라보는지 보여주는 개념도로 설명하면 좋습니다.
즉 힙 안에 객체가 얼마나 차 있느냐와 별개로 JVM은 어디까지 커질 수 있는지 지금 당장 얼마를 committed 상태로 둘지 를 따로 관리합니다. 실제로 Oracle 문서도 reserve 와 committed 를 구분해서 설명하고 있습니다.
-Xmx 와 -Xms 를 같게 주는 경우
실무에서는 -Xms 와 -Xmx 를 같은 값으로 두는 경우가 많습니다.
1 | java -Xms3g -Xmx3g ... |
이렇게 주면 JVM은 시작할 때부터 힙을 3GB로 맞춰 두고, 실행 중 힙을 늘리거나 줄이는 비용을 줄일 수 있습니다.
-XX:NewRatio 알아보기
이 옵션은 Young 세대와 Old 세대의 비율을 정합니다. Java 문서 기준으로 기본값은 2 이며 이는 Old 세대가 Young 세대보다 2배 크다는 뜻으로 이해하면 됩니다.
Copyright 201- syh8088. 무단 전재 및 재배포 금지. 출처 표기 시 인용 가능.