이 글을 작성하기 위해 지난 한 달 동안 Garbage Collection(이하 GC)에 대해 자세히 공부해보았다.
이번 글에서는 Java에서 사용하는 Generational GC에 대해 알아보며,
Naver D2 블로그의 Java Garbage Collection 글과 Java HotSpot VM G1GC 글을 토대로 작성했다.
계속해서 글을 읽기 전에 아래 네 글을 읽고 오는 것이 좋다.
- [Garbage Collection] (1) 개념, 유래, 한계, GC에 대해 알아야 하는 이유
- [Garbage Collection] (2) GC 알고리즘 - Reference Counting Algorithm(참조 횟수 카운팅)
- [Garbage Collection] (3) GC 알고리즘 - Tracing Garbage Collection(추적 기반) : Mark-Sweep, Mark-Sweep-Compact, Tri-color Marking
- [Garbage Collection] (4) GC 알고리즘 - Tracing Garbage Collection(추적 기반) : Copying Algorithm, Generational Algorithm
복습
위의 네 개의 글을 읽기 귀찮을 수도 있으니 간단하게 GC에 대해 알아보자.
프로그램이 실행되다 보면, 더 이상 사용되지 않는 쓰레기 객체가 발생한다.
Java는 그러한 쓰레기 객체를 C 언어처럼 개발자가 직접 찾아 해제하지 않고 가비지 컬렉터(GC)가 찾아 지운다.
참고) 해당 객체를 NULL로 지정하거나, System.gc() 메서드를 호출하면 명시적으로 지울 수 있으나,
후자의 방법은 시스템의 성능에 매우 큰 악영향을 끼치므로 절대 사용하면 안 된다고 한다.
하지만 어떤 GC 알고리즘을 사용하더라도,
GC를 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 'stop-the-world'는 항상 발생하게 된다.
그렇기 때문에 'GC 튜닝을 한다' 라고 하면, 대부분 이 'stop-the-world' 시간을 줄이는 것이다.
Weak Generational Hypothesis
Java의 GC는 'Weak Generational Hypothesis'라는 다음 두 가지 가설(가정 혹은 전제 조건) 하에 만들어졌다.
- 대부분의 객체는 금방 접근 불가능 상태(unreachable)가 된다.
- 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.
이 가설의 장점을 최대한 살리기 위해,
Oracle에 의해 유지 보수및 배포가 이루어지고 있으며 가장 일반적으로 사용하는 JVM인 'HotSpot JVM'에서는
JVM의 Runtime Data Area에 있는 Heap Area를 Young 영역과 Old 영역, 크게 2개로 나누었다.
(JVM 구성요소에 관해서는 [Java] JVM이란? - (3) 구성 요소 글 참고)
(번외로, Weak과 반대 개념인 'Strong Generational Hypothesis'도 있을지 궁금하여 찾아보니
이곳의 15 페이지에서 해답을 찾을 수 있었다)
Young 영역 & Old 영역 (+ Permanent 영역)
Young (Generation) 영역에는 새롭게 생성된 객체의 대부분이 위치하게 된다(Allocation).
위에서도 말했듯이 대부분의 객체가 금방 접근 불가능(쓰레기) 상태가 된다고 가정했기 때문에,
매우 많은 객체가 이 영역에 생성되었다가 사라진다.
Young 영역에서 객체가 사라질 때, 'Minor GC'가 발생했다고 한다.
Old (Generation) 영역에는 Young 영역에서 쓰레기 상태가 되지 않고 살아남은 객체가 복사된다(Promotion).
보통 Young 영역보가 크게 할당하며,
크기가 큰 만큼 Young 영역에서 발생하는 GC의 횟수보다 적게 발생한다.
Old 영역에서 객체가 사라질 때, 'Major GC(혹은 Full GC)'가 발생했다고 한다.
Permanent (Generation) 영역은 객체나 억류(intern)된 문자열 정보를 저장하는 곳이며,
Method Area 라고도 한다.
뭔가 Permanent라는 단어의 뜻을 생각해보면 Old 영역에서 살아남은 객체가 영원히 남아있는 곳 같은데,
절대 그렇지 않다.
Permanent 영역 역시 GC가 발생 가능하며 GC 발생 시, Major GC 횟수에 포함된다.
If.. 오래된 객체 -> 젊은 객체 참조가 존재한다면?
Weak Generational Hypothesis의 두 번째 가설에서
'오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다' 라고 하였는데,
만약 참조하는 경우가 존재하는 경우엔 어떻게 처리될까?
이러한 경우를 위해 Old 영역에는 위와 같은 512 Byte의 카드 테이블(card table)이 존재하는데,
Old 영역의 객체가 Young 영역의 객체를 참조할 때마다 이 카드 테이블에 표시가 된다.
따라서 Minor GC(Young 영역에서의 GC)를 실행할 때,
Old 영역에 있는 모든 객체의 참조를 확인하지 않고,
이 카드 테이블만 뒤져서 GC 대상인지 식별한다.
이러한 카드 테이블은 write barrier를 사용하여 관리한다.
(write barrier란 애플리케이션에서 참조를 대입하거나 rewrite 할 때 해당 참조를 별도로 기록하는 처리이며,
Minor GC를 빠르게 할 수 있도록 하는 장치)
그래서 write barrier 때문에 약간의 오버헤드는 발생하지만, 전반적인 GC 시간은 줄어들게 된다.
Minor GC와 Young 영역의 구성
객체가 가장 먼저 생성되고 대부분 소멸되는 Young 영역에 대해 자세히 알아보자.
Young 영역은 아래 3개의 영역으로 나누어진다.
(1) Eden 영역
(2) Survivor 영역
(3) Survivor 영역
'(2)와 (3)이 같은데 오타인가?' 라고 생각했다면, 아니다.
Survivor 영역은 2개로 이루어져 있다.
아래 그림을 보며 Young 영역에서 발생하는 Minor GC를 통해 각 영역의 처리 절차를 순서에 따라 알아보자.
(a) 새로 생성된 대부분의 객체는 Eden 영역에 위치한다.
(b) Eden 영역에서 GC가 한 번 발생하고 살아남은 객체는 Survivor 영역 중 하나로 이동된다.
(c) 이후에 Eden 영역에서 또다시 GC가 발생할 때마다 이전의 GC에서 살아남은 객체가 이동된 Survivor 영역으로 객체가 계속 쌓인다.
(d) Eden 영역의 GC에서 살아남은 객체들이 계속 쌓이는 Survivor 영역이 가득 차게 되면 Minor GC를 실행하고,
그 중에서 살아남은 객체를 다른 Survivor 영역으로 이동시킨 후, 가득 찼던 Survivor 영역은 몽땅 비운다.
(e) (a)~(d) 과정을 반복하다가 계속해서 살아남아 있는 객체는 Old 영역으로 이동하게 된다.
위와 같은 절차를 통해 알 수 있는 것은,
Survivor 영역 중 하나는 '반드시 비어 있는 상태'로 남아 있어야 한다는 것이다.
만약 두 Survivor 영역에 모두 데이터가 존재하거나,
두 영역의 사용량이 모두 0이라면 시스템이 정상적인 상황이 아니라는 뜻이 된다.
이러한 내용은 이전에 알아보았던
의 Copying Algorithm의 내용과 굉장히 유사하며,
위 글에서도 언급한 것처럼,
Generational GC의 알고리즘이 Copying Algorithm을 기반으로 했다는 것을 알 수 있다.
참고로, HotSpot VM에서는 보다 빠른 메모리 할당을 위해
'bump-the-pointer'라는 기술과 'TLABs(Thread-Local Allocation Buffers)라는 기술을 사용하는데,
이것까지 기술하면 글이 너무 길어질 것 같아 글 가장 상단부에 링크 걸어놓은 Naver D2 블로그의 글을 참고하면 된다.
Major GC의 방식
Old 영역은 데이터가 가득 차면 GC를 실행하는 단순한 방식이다.
GC 방식에 따라 처리 절차가 달라지는데 JDK 7을 기준으로 아래 5가지 방식이 존재하며,
각 GC 방식 이름 옆 괄호 안의 내용은 해당 GC 방식을 수동으로 활성화하는 옵션이다.
1. Serial GC (-XX:+UserSerialGC)
Minor GC는 앞서 'Minor GC와 Young 영역의 구성' 파트에서 설명했던 방식을 사용하고,
Major GC는 우리가 이전의 글에서 알아보았던 Mark-Sweep-Compact Algorithm을 사용한다.
위 링크 글을 참고하면 되지만 Mark-Sweep-Compact Algorithm에 대해 간단하게 설명하자면,
먼저 Old 영역에서 살아있는 객체를 식별(Mark)하고
Heap(힙) 영역의 앞 부분부터 확인하여 살아 있는 객체만 남긴다(Sweep).
그 후, 각 객체들이 연속되게 쌓이도록 힙의 가장 앞 부분부터 채워서
객체가 존재하는 부분과 객체가 없는 부분으로 나눈다(Compact).
이러한 Serial GC는 적은 메모리와 CPU 코어 개수가 적을 때 적합한 방식이라 애플리케이션의 성능이 많이 떨어지기 때문에,
최근의 운영 서버에서는 절대 사용하면 안 되는 방식이다.
2. Parallel GC (-XX:+UseParallelGC)
Throughput GC라고도 불리는 Parallel GC는 Serial GC와 기본적인 알고리즘은 같지만,
GC를 처리하는 쓰레드가 하나였던 Serial GC와는 다르게 Parallel GC는 GC를 처리하는 쓰레드가 여러 개이다.
그렇기 때문에 Serial GC보다 빠르게 객체 처리가 가능하고,
메모리가 충분하고 코어의 개수가 많을 때 유리하다.
아래 그림을 통해 Serial GC와 Parallel GC의 쓰레드를 한 눈에 비교 가능하다.
3. Parallel Old GC (-XX:+UseParallelOldGC)
방금 전 알아본 Parallel GC와 비교하여 Old 영역의 GC 알고리즘만 다른데,
Mark-Sweep-Compact Algorithm이 아닌
Mark-Summary-Compact Algorithm을 사용한다.
Summary 단계는 Sweep 단계와 다르게 앞서 GC를 수행한 영역에 대해
별도로 살아 있는 객체를 식별하는 등의 약간 더 복잡한 단계를 거친다.
4. CMS(Concurrent Mark-Sweep) GC (-XX:+UseConcMarkSweepGC)
Low Latency GC라고도 불리는 CMS GC는 지금까지 알아본 GC 방식 중 가장 복잡한 방식인데,
아래 그림을 보며 Serial GC와 비교해보자.
Serial GC는 'stop-the-world' 때 Mark, Sweep, Compact 모든 단계를 수행하기 때문에
애플리케이션이 멈추는 시간이 굉장히 길다.
하지만 CMS GC는 최초 Initial Mark 단계에서
Class Loader에서 가장 가까운 객체 중 살아 있는 객체만 찾고 끝내기 때문에 'stop-the-world' 시간이 매우 짧다.
그 후, Concurrent Mark 단계에서는 Initial Mark 단계에서 살아있다고 확인된 객체들이 참조하는 객체들을 따라가며 확인하는데,
다른 쓰레드가 실행 중인 상태에서 동시에 진행된다는 특징이 있다.
그 다음, Remark 단계에서는 Concurrent Mark 단계에서 새로 추가되거나 참조가 끊긴 객체를 확인하며
'stop-the-world'가 짧게 발생하고,
마지막으로 Concurrent Sweep 단계에서는 쓰레기 객체를 정리하는 작업을 실행하는데,
Concurrent Mark 단계와 마찬가지로 다른 쓰레드가 실행되고 있는 상황에서 진행된다는 특징이 있다.
위와 같은 방식을 통해 'stop-the-world' 시간이 매우 짧음을 알 수 있고,
이러한 특성 때문에 Low Latency GC라고도 불리며,
모든 애플리케이션의 응답 속도가 매우 중요할 때 CMS GC를 사용한다.
하지만 'stop-the-world' 시간이 짧다는 장점과 함께 아래 두 가지의 단점도 존재한다.
(1) 다른 GC 방식보다 메모리와 CPU를 더 많이 사용한다.
(2) Compact 단계가 기본적으로 제공되지 않는다.
따라서 CMS GC를 사용할 때에는 신중하게 검토 후 사용해야 하며,
(2)의 단점 때문에 조각난 메모리가 많아 Compact 작업을 실행하면,
다른 GC 방식의 'stop-the-world' 시간보다 더 길어지기 때문에 Compact 작업이 얼마나 자주, 오랫동안 수행되는지 확인해야 한다.
5. G1(Garbage First) GC (-XX:+UserG1GC)
G1 GC는 Garbage First(1)라는 이름답게 쓰레기로 가득찬 Heap 영역을 집중적으로 수집하며,
Java 9부터 Default GC이다.
통계를 계산해가며 GC 작업량을 조절하고,
큰 메모리를 가진 Multi-Processor 시스템에서 사용하기 위해 개발된 GC이다.
'stop-the-world' 시간을 최소화하면서(Real-time, 즉 실시간 GC는 아니다),
따로 설정을 하지 않아도 Throughput(처리량)도 확보하는 것이 G1 GC의 목표이다.
'Java Heap의 50% 이상이 살아있는 객체인 경우',
'시간이 흐르며 Allocation 비율과 Promotion 비율이 크게 달라지는 경우',
'GC가 너무 오래 걸리는 경우(0.5~1초)' 등의 상황에서 G1 GC를 쓰면 도움이 된다고 한다.
이 방식을 이해하려면 지금까지 알아본 Young / Old 영역에 대해서는 잊어야 하며,
기본적으로 아래 네 가지 개념을 숙지해야 한다.
- 비어 있는 영역에만 새로운 객체가 할당된다.
- 쓰레기가 쌓여 꽉 찬 영역을 우선적으로 비운다.
- 꽉 찬 영역에서 살아있는 객체를 다른 영역으로 옮기고, 꽉 찬 영역은 비운다.
- 위와 같은 과정이 Compact(조각 모음)의 역할도 한다.
아래 그림을 통해 G1 GC의 작동 방식에 대해 자세히 알아보자.
위 그림은 아래 다섯 가지를 뜻한다.
- 빨간색 영역은 Young 영역의 Eden 영역처럼 쓰이고 있는 영역이다.
- 빨간색 S는 Young 영역의 Survivor 영역처럼 쓰이고 있는 영역이다.
- 빨간색 영역이 꽉 차면 살아있는 객체를 빨간색 S로 옮기고 빨간색 영역을 비운다.
- 파란색 영역은 Old 영역처럼 쓰이고 있는 영역이다.
- 파란색 H는 하나의 영역보다 크기가 커서 여러 영역을 차지하고 있는 커다란 객체(Humongous Object)이다.
위처럼 전체 Heap 영역을 바둑판 모양처럼 여러 영역(region)으로 나누어 관리하기 때문에,
그러한 영역들의 참조를 관리하기 위해 전체 Heap 영역의 5% 미만의 크기를
'Remember Set'이라는 것으로 만들어 사용한다.
일단 작동 방식을 러프하게 설명하자면,
한 영역에 객체를 할당하고 GC를 실행하다가,
해당 영역이 꽉 차면 다른 영역에 객체를 할당하고
GC를 실행한다.
즉, 지금까지 우리가 알아보았던
Young의 세 가지 영역(Eden 1개, Survivor 2개)에서 Old 영역으로 데이터가 이동하는 단계가 사라진 것이다.
좀 더 자세하게 작동 방식에 대해 알아보자.
아래 두 Phase를 번갈아 가며 수행하는데, 그 아래의 그림과 함께 살펴 보자.
- Young-only Phase : Old 객체를 새로운 공간으로 옮긴다.
- Space Reclamation Phase : 공간 회수
(1) Young-only Phase : Old 객체의 점유율이 threshold(임계) 값을 넘어서면 전환된다.
(2) Concurrent Start : 살아있는 객체들에 Mark(마킹) 작업을 한다.
(3) Remark : Mark(마킹)를 끝내고, 쓰레기 객체들을 해지한다.
(4) Cleanup : Space Reclamation Phase로 들어갈지 말지 판단한다.
(5) Space Reclamation Phase : Young, Old 객체 가리지 않고 살아있는 객체는 적절한 곳으로 대피시킨다(Evacuation).
작업 효율이 떨어지게 되면 이 단계는 끝나고, 다시 Young-only Phase로 전환된다.
(*) 만약 애플리케이션 메모리가 부족한 경우, 다른 GC들처럼 Full GC를 수행한다.
이러한 방식은 말도 많고 탈도 많은 CMS GC를 대체하기 위해 만들어졌으며,
그런 의도에 맞게 성능이 가장 큰 장점이기 때문에 지금까지 알아본 어떤 GC 방식보다도 빠르다.
이 글에서는 GC에 대한 개념적인 부분만 다루려고 했기 때문에,
G1 GC에 좀 더 깊게 알아보려면 글 가장 상단부에서 언급한 블로그의 글을 들어가보자.
추가적으로, GC 튜닝을 할 때에는 서비스마다 WAS에서 생성하는 객체의 크기와 생존 주기가 모두 다르고
장비의 종류도 다양하기 때문에,
WAS의 쓰레드 개수, 장비당 WAS 인스턴스 개수, GC 옵션 등은
지속적인 튜닝과 모니터링을 통해 본인의 서비스에 가장 적합한 값을 찾아야 한다고 한다.
누가 그런 소리를 하냐고?
2010년 개최된 JavaOne 컨퍼런스에 참여한 Oracle JVM을 만드는 천재 엔지니어들이..
끝!
'Study > Java' 카테고리의 다른 글
[Java][Jackson][Spring] @JsonIgnoreProperties(ignoreUnknown=true)? 꼭 필요한가? (1) | 2022.06.26 |
---|---|
[Java] removeIf 사용법 (0) | 2022.04.01 |
[Java] HashMap, HashSet 이란? - (5) HashMap과 HashSet의 차이 (4) | 2021.04.01 |
[Java] HashMap, HashSet 이란? - (4) HashSet이란? (0) | 2021.03.31 |
[Java] HashMap, HashSet 이란? - (3) HashMap이란? (0) | 2021.03.27 |