1. JVM, GC 그리고 객체
JVM이 관리하는 메모리 영역 중, 애플리케이션 성능에 가장 지대한 영향을 미치는 곳은 단연 힙(Heap) 영역입니다.
우리가 new 키워드로 생성하는 대부분의 객체가 이곳에 저장되기 때문입니다.
이 거대한 힙 영역을 효율적으로 관리하기 위해 JVM은 가비지 컬렉터(GC) 를 고용했습니다.
GC는 더 이상 사용되지 않는 객체를 찾아내고 지우는 역할을 수행합니다.
그렇다면 GC는 어떤 기준으로 메모리를 관리할까요? 여기서 등장하는 것이 바로 **'세대 단위 컬렉션 이론'**입니다.
2. 세대 단위 컬렉션 이론
GC는 모든 객체를 동일하게 취급하지 않습니다.
효율적인 관리를 위해 객체의 생존 주기에 관한 몇 가지 가설을 전제로 설계되었습니다.
- 약한 세대 가설 (Weak Generational Hypothesis): 대다수의 객체는 생성되자마자 금방 쓰레기(Garbage)가 되어 사라집니다.
- 강한 세대 가설 (Strong Generational Hypothesis): GC 과정에서 살아남은 횟수가 늘어날수록, 해당 객체는 앞으로도 계속 살아남을 가능성이 높습니다.
- 세대 간 참조 가설: 서로 다른 세대(Old ↔ Young)에 속한 객체 간의 참조는 같은 세대 간의 참조보다 훨씬 적습니다.
이러한 가설은 실제 통계로도 증명되었습니다. IBM의 연구에 따르면, 일반적으로 첫 번째 GC 수행 시 약 98%의 객체가 소멸한다고 합니다. HotspotVM은 이 점을 착안하여 Eden 영역과 Survivor 영역의 비율을 8:1:1로 설계했습니다.
3. Heap 영역의 상세 구조
힙 영역은 객체의 생존 기간에 따라 크게 Young Generation과 Old Generation으로 나뉩니다.
3-1. Young Generation (젊은 세대)
생명 주기가 짧은 객체들이 머무는 곳입니다.
- Eden: 객체가 생성되자마자 처음 저장되는 곳입니다. 이곳이 꽉 차면 Minor GC가 발생합니다.
- Survivor Space (S0, S1): Eden에서 살아남은 객체들이 이동하는 곳입니다.
- Copy & Scavenge 알고리즘: Minor GC가 발생하면 Eden과 사용 중인 Survivor 영역(S0)에서 살아남은 객체들을 비어있는 Survivor 영역(S1)으로 모두 이동시킵니다.
- 파편화 방지
- 메모리 파편화란?
- 메모리 공간은 남아있지만 연속적이지 않아 큰 객체를 할당하지 못하는 상태
- Survivor 영역을 두 개로 나누어 한쪽으로 몰아넣는 방식을 사용하는 이유는 바로 이 파편화를 방지하고 메모리를 차곡차곡 정렬하기 위함
- 메모리 파편화란?
- Age bit: 객체가 Survivor 영역을 오갈 때마다 나이(Age)를 먹습니다. 특정 임계값(MaxTenuringThreshold)을 넘기면 Old Generation으로 승진(Promotion)합니다.
3-2. Old Generation (오래된 세대)
Young Generation에서 오랫동안 살아남은 객체들이 이동해 오는 영역입니다.
- 객체의 크기가 크거나 생명 주기가 긴 객체들이 보관됩니다.
- 이곳이 가득 차면 Major GC (혹은 Full GC) 가 발생합니다.
- 주로 Mark & Compact 알고리즘을 사용하여 메모리를 정리합니다.
3-3. Permanent & Metaspace (메타 정보 영역)
- *Permanent (Java 7 이전):
- 클래스와 메소드의 메타 정보를 저장하던 곳으로, 힙 영역의 일부였습니다.
- 크기가 고정되어 있어
OOM(Out Of Memory)의 주원인이 되기도 했습니다.
- *Metaspace (Java 8 이후):
- Permanent 영역이 사라지고 대체된 영역입니다.
- Native Memory 사용
- JVM 힙이 아닌 OS가 관리하는 네이티브 메모리를 사용하므로, 시스템 메모리가 허용하는 한 자동으로 크기가 확장됩니다.
- 이로 인해 힙 사이즈 설정과 별개로 메모리 사용량이 늘어날 수 있음을 인지해야 합니다.
- Spring 프레임워크처럼 리플렉션(Reflection)을 많이 사용하는 경우, 런타임에 클래스 정보가 많이 로드되어 이 영역을 많이 사용하게 됩니다.
4. JVM 메모리 옵션 가이드
서버를 운영할 때 자주 보게 되는 JVM 옵션들을 정리했습니다. 각 옵션이 서로 겹치거나 충돌할 때 우선순위가 적용될 수 있으니 주의가 필요합니다.
-Xms: 힙 영역의 초기 사이즈를 설정합니다.-Xmx: 힙 영역의 최대 사이즈를 설정합니다.- 보통 운영 환경에서는 힙 크기 변경에 따른 오버헤드를 줄이기 위해
-Xms와-Xmx를 동일하게 설정하는 것을 권장합니다.
- 보통 운영 환경에서는 힙 크기 변경에 따른 오버헤드를 줄이기 위해
-Xmn/-XX:NewSize: Young Generation의 크기를 설정합니다.-Xmn은 전체 크기를 고정하는 것이고,-XX:NewSize는 초기 크기를 지정합니다.
-XX:MaxPermSize- Java 8부터는 무시되거나 경고가 발생하는 옵션입니다.
- 대신
-XX:MaxMetaspaceSize를 사용하여 메타스페이스의 상한선을 두는 것이 안전합니다.
- 대신
- Java 8부터는 무시되거나 경고가 발생하는 옵션입니다.
운영 팁: 헬스 체크와 STW 서버의 상태를 확인하는 L4/L7 스위치의 헬스 체크(Health Check)가 실패하는 경우가 있습니다.
만약 힙 메모리 사용량을 기준으로 헬스 체크를 하거나, Full GC로 인해 Stop The World(STW, 모든 작업 중단) 시간이 길어지면, 서버는 멀쩡히 살아있음에도 불구하고 '응답 없음'으로 간주되어 서비스에서 제외될 수 있습니다.
GC 튜닝 시 이 점을 꼭 고려해야 합니다.
5. 대표적인 GC 알고리즘의 진화
GC는 역사가 깊은 만큼 다양한 알고리즘으로 발전해 왔습니다.
5-1. Mark and Sweep (표시하고 쓸어담기)
1960년대에 제안된 가장 기초적인 방식입니다.
- 동작: 사용 중인 객체를 식별(Mark)하고, 나머지 객체를 제거(Sweep)합니다.
- 단점: 객체를 지운 자리가 듬성듬성 비게 되어 메모리 파편화(Fragmentation) 가 발생합니다.
5-2. Mark and Copy (표시하고 복사하기)
Mark and Sweep의 파편화 문제를 해결하기 위해 1969년 로버트 페니첼이 제안했습니다.
- 동작: 메모리를 두 영역으로 나눈 뒤, 한쪽만 사용합니다. 꽉 차면 살아남은 객체만 다른 쪽으로 복사하고, 기존 영역을 싹 비웁니다.
- 단점: 전체 메모리의 절반밖에 사용하지 못한다는 비효율이 있었습니다.
- 개선: 1989년 앤드류 아펠이 이를 개선하여 Eden, S0, S1의 비율을 8:1:1로 제안했습니다. (이 방식이 현재 Young Gen의 표준 모델이 되었습니다. 전체의 90%를 사용하면서도 Copy 방식의 이점을 취할 수 있습니다.)
5-3. Mark and Compact (표시하고 압축하기)
Old Generation 처럼 객체가 많이 살아남는 영역을 위해 1974년 에드워드 루더스가 제안했습니다.
- 동작: Mark 후, 살아남은 객체들을 한쪽 구석으로 몰아넣어(Compact) 빈 공간을 연속적으로 만듭니다.
- 단점: 객체를 이동시키는 과정은 비용이 많이 듭니다. 특히 이 과정에서 애플리케이션이 멈추는 Stop The World가 발생하며, 살아남은 객체가 많을수록 중단 시간은 길어집니다.