GC(Garbage Collector)

📌 Runtime Data Area부터 알고 있자.

GC를 알기전에 자바에서 데이터를 처리하기 위한 영역에 대해서 알아야 한다. 자바는 Runtime Data Area에서 데이터를 처리한다. 세부적인 영역은 아래와 같다.
PC 레지스터
JVM 스택
힙 (Heap)
메서드 영역
런타임 상수(constant) 풀
네이티브 메서드 스택
GC가 발생하는 부분은 힙 영역이다. 크게 구분하면 Heap 영역과 그렇지 않은 영역으로 나눌 수 있다. 따라서 Heap 영역이 아닌 나머지 영역은 GC의 대상이 되지 않는다. Heap 영역은 '공유 메모리'라고도 불리며 여러 스레드에서 공유하는 데이터들이 저장되는 메모리이다.

📌 GC(Garbage Collection)

GC는 어떤 역할을 수행할까?

하나의 객체는 메모리를 점유하고, 필요하지 않으면 메모리에서 해제되어야 한다.
비유하자면 GC 쓰레기를 정리하는 작업인데 자바에서 쓰레기는 객체를 의미한다.
메모리 할당
사용 중인 메모리 인식
사용하지 않는 메모리 인식

GC를 해도 계속 메모리를 할당한다면 어떻게 될까?

할당한 메모리 영역이 꽉 차면 JVM에 행(Hang)이 걸릴 수 있다. 그러나 여기서 더 메모리를 할당한다면 OutOfMemoryError가 발생해서 JVM이 다운될 수도 있다.
💡
행(Hang)? 서버가 요청을 처리 못하고 있는 상태를 의미한다.

힙 영역은 어떻게 관리하고 있을까?

크게 Young, Old, Perm 세 영역으로 나뉜다. Perm 영역은 자바 언어 레벨에서 사용하는 영역이 아니고, JDK 8부터는 사라졌으므로, Young, Old 영역만 다루려고 한다.
💡
Perm 영역 String 클래스는 intern()이라는 메서드가 존재한다. 해당 메서드를 호출하면 문자열의 값을 바탕으로 한 비교가 가능하게 된다. 즉, equals() 메서드로 비교하지 않고, intern() 메서드가 호출된 문자열들은 == 비교가 가능하다.

Young 영역은 어떻게 구성 되어있고, 어떻게 동작할까?

세부적으로 Eden 영역과 두 개의 Survivor 영역으로 나뉜다.
새로운 객체는 Eden 영역에 위치한다.
Eden 영역이 가득차게 되면, 2개의 Survivor 영역 중에 하나로 이동한다. 2개의 Survivor 중에 우선 순위는 없고, 반드시 하나는 비어있어야 한다. 즉, 2개의 Survivor 영역 중에 비어있는 영역으로 객체가 이동한다.

Old 영역은 어떻게 동작할까?

2개의 Survivor를 왔다 갔다 하던 객체들은 Old 영역으로 이동하게 된다. 하지만 객체의 크기가 아주 크다면 Eden 영역에서 바로 Old 영역으로 이동하는 객체가 있을 수 있다.

마이너 GC, 메이저GC는 뭐고, 어떤 차이가 있을까?

[Minor GC] Young 영역에서 발생하는 GC
[Major GC or Full GC] Old 영역이나 Perm 영역에서 발생하는 GC

GC가 일어나는 과정 요약

1.
처음 생성된 객체는 Young Generation 영역의 일부 → Eden 영역에 위치
2.
Eden 영역이 가득차면 Minor GC 발생 → 참조되지 않는 객체는 메모리에서 제거
3.
살아남은 객체는 비어있는 Survivor 영역으로 이동
4.
Survivor 영역에서 살아남은 객체 → Old Generation(=테뉴어드, Tenured) 영역으로 옮겨진다.
5.
Old Generation 영역에서 미사용된다고 식별되는 객체 → Full GC를 통해 메모리에서 제거
오래되었다는 기준은 Minor GC에서 살아남은 횟수를 기록하는 age bit를 가지고 있다. (Minor GC가 발생할 때마다 age bit가 증가)

📌 5가지의 GC의 방식

JDK 7이상에서 5가지의 GC방식이 존재한다.

Serial Collector

하나의 CPU를 사용한다.
단일 스레드 환경을 위한 가비지 수집기
Young 영역과 Old 영역이 시리얼(연속적으로)하게 처리되는데 이를 수행할 때, Stop-the-world라고 표현한다. (이때 애플리케이션 수행이 정지된다.)
Mark-sweep-compact 콜렉션 알고리즘을 사용한다.
1.
표시 단계 :Old 영역으로 이동된 객체들 중 살아 있는 객체 식별
2.
스윕 단계 : Old 영역의 객체들을 훑는 작업를 수행하여 쓰레기 객체를 식별한다.
3.
컴팩션 단계 : 필요 없는 객체들을 지우고, 살아 있는 객체들을 한 곳으로 모은다.

Parallel Collector

멀티 CPU 환경에서 애플리케이션 처리 속도를 향상시키기 위해 사용되는 GC
Java 8 사용 시, 기본 GC
Mark-sweep-compact 콜렉션 알고리즘을 사용한다.
시리얼 콜렉터와 달리 Young 영역에서의 콜렉션을 병렬로 처리한다. 따라서 많은 CPU를 사용하여 GC 부하를 줄이고 애플리케이션 처리량을 증가시킬 수 있다.

Parallel Compacting Collector

멀티 CPU 환경에서 적합하다.
병렬 콜렉터와 Young 영역에 대한 GC는 동일하고, Old 영역에서 GC에서 새로운 알고리즘을 사용한다.
Old 영역의 GC
1.
표시 단계 : 살아 있는 객체를 식별하여 표시해 놓는 단계
2.
종합 단계 : 이전에 GC를 수행하여 컴팩션된 영역에 살아 있는 객체의 위치를 조사 하는 단계
여러 스레드가 Old 영역을 분리하여 훑는다.
3.
컴팩션 단계 : 컴팩션을 수행하는 단계. 수행 이후에는 컴팩션된 영역과 비어 있는 영역으로 나뉜다.

Concurrent Mark-Sweep(CMS) Collector

중단 시간을 아주 짧게 하기 위해 설계된 GC [Tenured(Old 공간 전용 수집기]
응용 프로그램 스레드와 동시에 수행하여 일시 중지를 최소화
힙 메모리 영역의 크기가 클 때 적합하다.
Parallel GC보다 Full GC 처리 시간은 빠르지만, Concurrent mode failure가 발생하면 다른 Parallel GC보다 느려진다.
Parallel GC는 압축 작업을 진행하고, CMS GC는 수행하지 않는다. 그래서 CMS GC는 Full GC보다 처리 시간이 빠르다.
Concurrent mode failure 발생하는 이유는 CMS GC는 압축 작업을 하지 않기 때문이다. 따라서 해당 경고가 발생한다면 CMS GC가 Parallel GC보다 압축 시간이 더 오래 소요되는 문제가 있다.
Young 영역에 대한 GC는 병렬 콜렉터와 동일하고, Old 영역은 아래와 같다.
1.
초기 표시 단계 : 매우 짧은 대기 시간으로 살아 있는 객체를 찾는 단계
2.
컨커런트 표시 단계 : 서버 수행과 동시에 살아 있는 객체에 표시 해놓는 단계.
3.
재표시 단계 : 컨커런트 표시 단계에서 표시하는 동안 변경된 객체에 대해 다시 표시하는 단계
4.
컨커런트 스윕 단계 : 표시되어 있는 쓰레기를 정리하는 단계
💡
압축 작업이란? 메모리 할당 공간 사이에 사용하지 않는 빈 공간이 없도록 옮겨 메모리 단편화를 제거하는 작업
💡
Concurrent mode failure? 빈 공간이 없을 때 발생하는 경고다. 예를 들어 Old 영역에 남아 있는 크기는 300MB인데 10MB짜리 객체를 넣는다고 했을 때, 연속적인 공간이 없다면 들어갈 수 없는 상태가 된다. 이때 발생하는 것이 Concurrent mode failure이다.

Garbage First Collector (G1)

대용량 메모리 공간(4GB 이상)이 있는 멀티 프로세서 시스템에서 실행되는 응용 프로그램을 위해 설계
중단 시간이 짧은 새로운 수집기로 설계, CMS보다 훨씬 튜닝하기 쉽다.
JDK 7에서 처음 등장한 뒤, JDK 9부터 기본 GC로 사용

어떻게 구성되어 있을까?

기존과 달리 다른 영역으로 구성되어 있다. (바둑판)
바둑판의 사각형 또는 구역을 region이라고 한다. 구역의 개수는 약 2,000개가 있다.
각 구역에서 Eden, Survivor, Old 영역의 역할을 변경해 가면서 하고, Humongous라는 영역도 포함된다.

G1의 Young 영역에서 GC는 어떻게 동작할까?

1.
몇 개의 구역을 선정해서 Young 영역으로 지정한다.
2.
Linear하지 않은 구역에 객체가 생성되면서 데이터 쌓인다.
3.
Young 영역으로 할당된 구역에 데이터가 꽉차면 GC가 발생한다.
4.
GC를 수행하면서 살아있는 객체들만 Survivor 구역으로 이동시킨다.

G1의 Old 영역에서 GC는 어떻게 동작할까?

1.
초기 표시 단계 : Stop-The-World 발생, Old 영역에 있는 객체에서 Survivor 영역의 객체를 참조하고 있는 객체들을 표시한다.
2.
기본 구역 스캔 단계 : Old 영역 참조를 위해 Survivor 영역을 스캔한다. Young GC가 발생하기 전에 수행
3.
컨커런트 표시 단계 : 전체 힙 영역에 살아있는 객체를 찾는다. 이때 Young GC가 발생하면 잠시 멈춘다.
4.
재 표시 단계 : Stop-The-World 발생, 힙에 살아있는 객체들의 표시 작업을 완료한다. SATB라는 알고리즘을 사용하는데 CMS GC에서 사용하는 방식보다 빠르다.
5.
청소 단계 : Stop-The-World 발생, 살아있는 객체와 비어있는 구역을 식별한다. 필요 없는 객체들은 지운다. 비어있는 구역은 초기화한다.
6.
복사 단계 : Stop-The-World 발생, 살아있는 객체들을 비어있는 구역으로 모은다.

JDK 버전별 기본 GC

JDK 7 : Parallel GC
JDK 8 : Parallel GC
JDK 9 : G1 GC
JDK 10 : G1 GC

📌 GC 튜닝 꼭 해야 할까?

기본적인 메모리 크기 정도만 지정하면 웬만큼 사용량이 많지 않은 시스템에서는 튜닝 할 필요가 없다. 그러나 JVM의 메모리 크기도 지정하지 않았고, Timeout이 지속적으로 발생하고 있다면 GC 튜닝을 하는 것이 좋다. 타임아웃 로그가 존재하고 있다는 것은 정상적인 응답을 받지 못했다는 의미가 될 수 있다. GC 튜닝은 가장 마지막에 하는 작업이라는 것을 명심하자.
Old 영역으로 넘어가는 객체의 수를 최소화하자.
Young 영역에 비해 상대적으로 시간이 오래 소요되기 때문이다.
이를 최소화하면 Full GC가 발생하는 빈도를 많이 줄일 수 있다.
Full GC의 실행 시간을 줄이자.
Old 영역의 크기를 적절하게 설정하자. (아래와 같은 트레이드 오프 상황이 생기므로, '잘' 설정하자)
크기를 줄이면 OutOfMemoryError가 발생하거나 Full GC 횟수가 늘어난다.
크기를 늘리면 Full GC 횟수는 줄어들지만, 실행 시간이 늘어난다.

완벽한 옵션은 없다!

성공 사례를 듣고, 나의 시스템에 옵션을 설정한다고 해서 효과가 있는 것이 아니다. 서비스마다 생성되는 객체의 크기나 살아있는 시간도 다르기 때문이다. 따라서 두 대 이상의 서버에 GC 옵션을 다르게 적용해서 비교하고, 옵션을 추가한 서버의 성능이나 GC 시간이 개선 되었을 때 옵션을 추가하도록 하자.

어떤 옵션이 성능에 영향을 줄까?

-Xms, -Xmx, -XX:NewRatio 옵션을 가장 많이 사용한다.

힙 영역 크기

-Xms : JVM 시작 시 힙 영역 크기
-Xmx : 최대 힙 영역 크기

New 영역의 크기

-XX:NewRatio : New 영역과 Old 영역의 비율
-XX:NewSize : New 영역의 크기
-XX:SurvivorRatio : Eden 영역과 Survivor 영역의 비율

Perm 영역은 크기가 크다면 조절하자.

크기가 크다면OutOfMemoryError가 발생할 수 있기 때문에 이런 상황일 때, -XX:PermSize 옵션과 -XX:MaxPermSize 옵션으로 지정하자.

GC 방식 변경하기

JDK 6.0 기준
Serial GC : -XX:+UseSerialGC
Serial GC는 운영 환경에서 사용하지 못한다.
Parallel GC : -XX:+UserParallelGC
그 외 -XX:ParallelGCThreads=value
Parallel Compacting GC : -XX:+UseParallelOldGC
CMS GC : -XX+UseConcMarkSweepGC
그 외 -XX:+UseParNewGC, -XX:+CMSParallelRemarkEnabled, -XX:CMSInitiatingOccupancyFraction=value, -XX:+UseCMSInitiatingOccupancyOnly
G1 : -XX:+UnlockExperimentalVMOptions, -XX:+UseG1GC
JDK 6에서 두 옵션은 같이 사용해야 한다.

어떤 과정으로 GC 튜닝을 해야 할까?

1. GC 상황 모니터링 결과 분석 후 GC 튜닝 여부 결정

GC 수행 시간이 1~3초, 10초가 넘는다면 GC 튜닝을 진행하자
jstat 명령어를 사용하여 운영중인 WAS의 GC 상황을 보자.
Minor GC와 Full GC의 시간만 보지 않고, GC가 수행되는 횟수도 확인하자.
Young 영역의 크기가 너무 작으면 Minor GC가 발생하는 빈도도 높아지고, Old 영역으로 넘어가는 객체의 수도 증가할 수 있다. 이로 인해 Full GC 횟수도 증가한다.
아래 조건을 부합한다면 GC 튜닝이 필요없다. 그러나 절대값은 아니니 참고만 하자.
1.
Minor GC 처리 시간이 빠르다.(50ms 내외)
2.
Minor GC 주기가 빈번하지 않다.(10초 내외)
3.
Full GC 처리 시간이 빠르다.(1초 이내)
4.
Full GC 주기가 빈번하지 않다.(10분에 1회)

2. GC 방식 및 메모리 크기 지정

여러 서버에 서로 다르게 GC 방식을 결정하고, 메모리 크기를 지정해서 차이를 확인하자.
메모리 크기가 크면 → GC 발생 횟수 감소, GC 수행 시간 증가
메모리 크기가 작으면 → GC 횟수 감소, GC 수행 시간 증가

3. 결과 분석

적어도 24시간 이상 데이터를 수집한 후에 최적의 옵션을 찾아 나가자.
-verboasegc 옵션을 지정한 다음, tail 명령어로 로그가 제대로 쌓이는지 확인하자.
다음과 같은 상황을 중점으로 보자.
Full GC 수행 시간 : 가장 많은 비중을 차지한다.
Minor GC 수행 시간
Full GC 수행 간격
Minor GC 수행 간격
전체 Full GC 수행 시간
전체 Minor GC 수행 간격
전체 GC 수행 시간
Full GC 수행 횟수
Minor GC 수행 횟수

4. 결과가 만족스러울 경우 전체 서버에 반영 및 종료

잘못하면 장애로 이어질 수 있기 때문에 조심하자.
현재 시스템에 대한 분석을 꼭 하고, JVM 옵션을 지정하자.

📌 참고 자료

TOP