GC는 언제 발생할까?


GC란?

자바에서 메모리 관리를 누가 해야 하는가에 대한 생각을 해보자.
자바에서 메모리를 GC라는 알고리즘을 통하여 관리하기 때문에,
개발자가 메모리를 처리하기 위한 로직을 만들 필요가 없고 만들어서는 안된다.

Garbage Collection 은 말그대로 쓰레기를 정리하는 작업이다.
자바 프로그래밍을 할 때 쓰레기란 어떤 것일까?
자바에서 쓰레기는 객체이다. 하나의 객체는 메모리를 점유하고, 필요하지 않으면 메모리에서 해제되어야 한다.

메로리 점유는 다음과같이 쉽게 할 수 있다.
이러한 코드에서는 a라는 객체가 만들어쟈 메모리의 한 부분을 점유하게 된다.

String a = new String();     

그럼 다음의 코드를 보자.

public String makeQuery(String code) {
    String queryPre = "Select * from table_a where a='";
    String queryPost = "' order by c";
    return queryPre + code + queryPost;
}

이 메서드를 호출한 후 수행이 완료되면 queryPre 객체와 queryPost 객체는 더 이상 필요가 없는 객체,
즉 쓰레기가 된다.
이 쓰레기 객체를 효과적으로 처리하는 작업을 GC라고 한다.




자바의 Runtime data area는 이렇게 구성된다.

이 영역 중에서도 GC가 발생하는 부분이 바로 힙 영역이다.
거꾸로 말하면, 나머지는 GC 대상이 아니라는 것이다.

다음은 이 영역들을 그림으로 나타낸 것이다.


스크린샷 2020-06-08 오후 2 59 29

그림은 복잡해 보이지만, 자바의 메모리 영역은 단순하게 ‘Heap 메모리’와 ‘Non-heap 메모리’로 나뉜다.

  1. Heap 메모리
    클래스 인스턴스, 배열이 이 메모리에 쌓인다.
    이 메모리는 ‘공유 메모리’라고도 부르며 여러 스레드애서 공유하는 데이터들이 저장되는 메모리다.

  2. Non-heap 메모리
    이 메모리는 자바의 내부 처리를 위해서 필요한 영역이다.
    주된 영역이 바로 메서드 영역이다.

여기서 Heap 영역과 메서드 영역은 JVM이 시작될 때 생성된다.




GC의 원리

GC 작업을 하는 가비지 콜렉터는 다음의 역할을 한다.

사용하지 않는 메모리를 인식하는 작업을 수행하지 않으면,
할당한 메모리 영역이 꽉 차서 JVM에 행(Hang)이 걸리거나 더 많은 메모리를 할당하려는 현상이 발생할 것이다.

JVM의 메모리는 여러 영역으로 나뉘는데, GC와 관련된 부분은 힙이다.
따라서 가비지 콜렉터가 인식하고 할당하는 자바의 힙 영역에 대해서 알아보자.

힙 영역 구조 참고




GC의 종류

크게 두 가지 타입으로 나뉜다.

이 두 가지 GC가 어떻게 상호작용하느냐에 따라서 GC 방식에 차이가 나며, 성능에도 영향을 준다.

GC가 발생하거나 객체가 각 영역에서 다른 영역으로 이동할 때 애플리케이션의 병목이 발생하면서 성능에 영향을 주게 된다.
그래서 핫 스팟 JVM 에서는 스레드 로컬 할당 버퍼라는 것을 사용한다.
이를 통하여 각 스레드별 메모리 버퍼를 사용하면 다른 스레드에 영향을 주지 않는 메모리 할당 작업이 가능해 진다.




5가지 GC 방식

JDK 7 이상에서 지원하는 GC 방식에는 다섯 가지가 있다.

Java VM(Virtual Machine) 내부에서 garbage collection 작업을 수행하는 엔진을 garbage collector라고 부른다.
Java VM 내부에 garbage collector가 여러 개 구현되어 있고,
Java VM을 실행할 때 command line parameter로 garbage collector를 선택할 수 있다.

[참고]
5가지 방식 자세한 설명 : https://hyerin6.github.io/2020-03-23/GC(2)/
GC 과정에 대한 Naver D2 글 : https://d2.naver.com/helloworld/1329


1. Serial GC 시리얼 콜렉터

mark-sweep-compact 알고리즘을 사용해 Old 영역의 GC를 수행한다.
첫 단계에서 Old 영역에 살아있는 객체를 식별하고 힙의 앞부분부터 확인하여 살아있는 객체는 남긴다.
마지막 단계에서 각 객체가 연속되게 쌓이도록 살아있는 객체들을 한 곳으로 모은다.

적은 메모리와 CPU 코어 개수가 적을 때 적합한 방식이다.
그러나 운영 서버에서 절대 사용하면 안 되는 방식이 Serial GC다.
Serial GC는 데스크톱의 CPU 코어가 하나만 있을 때 사용하기 위해서 만든 방식이다.
Serial GC를 사용하면 애플리케이션의 성능이 많이 떨어진다.


2. Parallel GC 패러랠 콜렉터 (병렬 콜렉터)

스루풋 콜렉터(throughtput collector)라고도 부른다.
시리얼 콜렉터와 기본적인 알고리즘은 같은데 이 방식은 Young 영역을 병렬로 처리한다.

이 방식의 목표는 다른 CPU가 대기 상태로 남아있는 것을 최소화하는 것이다.
시리얼 GC는 GC를 처리하는 스레드가 하나이지만, Parallel GC는 여러개이기 때문에
Parallel GC는 GC의 부하를 줄이고 빠르게 처리량을 증가시킬 수 있다.

메모리가 충분하고 코어 개수가 많을 때 유리하다.


3. Parallel Old GC

Parallel GC와는 Old 영역의 GC 과정만 다르다.
이 방식은 Mark-Summary-Compact 단계를 거친다.
Summary 단계는 앞서 GC를 수행한 영역에 대해서
별도로 살아있는 객체를 식별한다는 점에서 Mark-sweep-Compact 알고리즘의 sweep 단계와 다르게 조금 더 복잡하다.

병렬 콜렉터와 동일하게 이 방식도 여러 CPU를 사용하는 서버에 적합하다.


4. CMS GC

로우 레이턴시 콜렉터(low-latency-collector)로도 알려져 있다.
힙 메모리 영역이 클 때 적합하다.
Young 영역에 대한 GC는 병렬 콜렉터와 동일하다.

old 영역에 대한 GC는 다음과 같다.

CMS GC는 stop-the-world 시간이 짧다는 장점에 반해 다음과 같은 단점이 존재한다.

이 방식은 2개 이상의 프로세서를 사용하는 서버에 적당하다. (예 - 웹 서버)
Young 영역의 GC를 더 잘게 쪼개서 대기 시간을 줄일 수 있다.
모든 애플리케이션의 응답 속도가 매우 중요할 경우에 사용한다.


5. G1 GC

Garbage First 는 지금까지의 GC(Young, Old)와는 다른 영역으로 구성되어 있다.

G1 GC의 Young GC
(1) 몇 개의 구역을 선정하여 Young 영역으로 지정한다.
(2) 이 Linear 하지 않은 구역에 객체가 생성되면서 데이터가 쌓인다.
(3) Young 영역으로 할당된 구역에 데이터가 꽉차면 GC를 수행한다.
(4) 살아남은 객체는 Sirvivor 구역으로 이동한다.

Old 영역 GC는 CMS GC 와 비슷하게 진행되며 여섯 단계로 나뉜다.
여기서 STW라고 표시된 단계는 모두 Stop the world가 발생한다.


G1 GC의 Old GC
(1) 초기 표시 (STW) : Old 영역에 있는 객체에서 Survivor 영역의 객체를 참조하고 있는 객체들을 표시한다.

(2) 기본 구역 스캔 단계 : Old 영역 참조를 위해서 Survivor 영역을 훑는다.
참고로 이 작업은 Young GC가 발생하기 전에 수행 된다.

(3) 컨커런트 표시 단계 : 전체 힙 영역에 살아있는 객체를 찾는다.
만약 이때 Young GC가 발생하면 잠시 멈춘다.

(4) 재표시 단계 (STW) : 힙에 살아있는 객체들의 표시 작업을 완료한다.
이 떄 snapshot-at-the-beginning(SATB)라는 알고리즘을 사용하며, 이는 CMS GC에서 사용하는 방식보다 빠르다.

(5) 청소 단계 (STW) : 살아있는 객체와 비어 있는 구역을 식별하고, 필요 없는 객체들을 지운다.
그리고 비어 있는 구역은 초기화한다.

(6) 복사 단계 (STW) : 살아있는 객체들을 비어 있는 구역으로 모은다.

G1은 CMS GC의 단점을 보완하기 위해 만들어졌으며 성능도 매우 빠르다.




GC가 어떻게 수행되고 있는지 보고 싶다면

시스템을 분석하려면 관련된 툴을 사용해야 한다.
여러 방법이 있는데 jstat 이라는 명령을 사용하여 실시간으로 보거나 verbosegc 옵션을 사용하여 로그를 남길 수도 있다.

자바 인스턴스 확인을 위한 jps

jps는 해당 머신에서 운영 중인 JVM의 목록을 보여준다.
JDK의 bin 디렉터리에 있으며, 사용법이 매우 간단하다.

jps [-q] [-mlvV] [-Joption] [<hostid>]     


GC 상황을 확인하는 jstat

jstat는 GC가 수행되는 정보를 확인하기 위한 명령어이다.

jstat -<option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]       

Garbage Collection 모니터링 방법 Naver D2 글 : https://d2.naver.com/helloworld/6043




GC 튜닝을 항상 할 필요는 없다.

결론부터 이야기하면 모든 Java 기반의 서비스에서 GC 튜닝을 진행할 필요는 없다.

GC 튜닝이 필요 없다는 이야기는 운영 중인 Java 기반 시스템의 옵션과 동작이 다음과 같다는 의미이다.

(여기서 타임아웃이란 DB 작업과 관련된 타임아웃, 다른 서버와의 통신시 타임아웃)

즉, JVM 메모리 크기도 지정하지 않았고 Timeout 로그가 수도 없이 많이 출력되었다면 GC 튜닝을 하는 것이 좋다.
그러나 한 가지 명심해야 할 것은 GC 튜닝은 가장 마지막에 하는 작업이라는 것이다.

GC 튜닝을 하는 이유가 무엇인지 근본적인 원인을 생각해 보자.
Java에서 생성된 객체는 가비지 컬렉터(Garbage Collector)가 처리해서 지운다.
생성된 객체가 많으면 많을수록 가비지 컬렉터가 가 처리해야 하는 대상도 많아지고, GC를 수행하는 횟수도 증가한다.
즉, 여러분이 운영하고 만드는 시스템이 GC를 적게 하도록 하려면 객체 생성을 줄이는 작업을 먼저 해야 한다.

운영하고 만드는 시스템이 GC를 적게 하도록 하려면 객체 생성을 줄이는 작업이 먼저 필요하다.

예를 들어, 이 책의 본문에 나오는 대부분의 내용을 지키면 된다.
String 대신 StringBuilder 나 StringBuffer 사용하거나,
로그를 최대한 적게 쌓도록 하는 등 임시 메모리를 적게 사용하도록 하는 작업은 중요하다.

만약 애플리케이션 메모리 사용도 튜닝을 많이 해서
어느 정도 만족할 만한 상황이 되었다면, 본격적으로 GC 튜닝을 시작하면 된다.

GC 튜닝의 목적을 두 가지로 나눠보자.


(1) Old 영역으로 넘어가는 객체의 수 최소화하기

Old 영역의 GC는 New 영역의 GC에 비하여 상대적으로 시간이 오래 소요되기 때문에
Old 영역으로 이동하는 객체의 수를 줄이면 Full GC가 발생하는 빈도를 많이 줄일 수 있다.

객체를 New 영역에만 남긴다는 것은 아니고,
New 영역의 크기를 잘 조절함으로써 큰 효과를 볼 수 있다는 것이다.


(2) Full GC 시간 줄이기

Full GC 수행 시간은 상대적으로 Young GC에 비하여 길다.
그래서 Full GC 실행에 오랜 시간이 소요되면 연계된 여러 부분에서 타임아웃이 발생할 수 있다.

하지만 Old 영역의 크기를 줄여버리면 OutOfMemoryError가 발생하거나 Full GC 횟수가 늘어난다.
반대로 크기를 늘리면 Full GC 횟수는 줄어들지만 실행 시간이 늘어난다.
Old 영역의 크기를 적절하게 잘 설정해야 한다.




GC의 성능을 결정하는 옵션들

이런 저런 옵션을 많이 설정한다고 시스템의 GC 수행 속도가 월등히 빨라지진 않는다.
오히려 더 느려질 확률이 높다. 두 대 이상의 서버에 GC 옵션을 다르게 적용해서 비교해 보고,
옵션을 추가한 서버의 성능이나 GC 시간이 개선된 때에만 옵션을 추가하는 것이 GC 튜닝의 기본 원칙다.
절대로 잊지 말자!

-Xms 옵션(JVM 시작 시 힙 영역 크기)과 -Xmx 옵션(최대 힙 영역 크기)은 필수로 지정해야 하는 옵션이다.
그리고 NewRatio 옵션(New영역과 Old 영역의 비율)을 어떻게 설정하느냐에 따라서 GC 성능에 많은 차이가 발생한다.

GC 방식 중에서 특별히 신경쓸 필요가 없는 방식은 Serial GC다.
Serial GC는 클라이언트 장비에 최적화되어 있기 때문이다.




GC 튜닝의 절차

(1) GC 상황 모니터링
GC 상황을 모니터링하며 현재 운영되는 시스템의 GC 상황을 확인해야 한다.

(2) 모니터링 결과 분석 후 GC 튜닝 여부 결정
분석한 결과를 확인했는데 GC 수행에 소요된 시간이 0.1~0.3초 밖에 안 된다면 굳이 GC 튜닝에 시간을 낭비할 필요는 없다.

하지만 GC 수행 시간이 1~3초, 심지어 10초가 넘는 상황이라면 GC 튜닝을 진행해야 한다.

그런데 만약 Java의 메모리를 10GB 정도로 할당해서 사용하고 있고 메모리의 크기를 줄일 수 없다면
방법은 없을 것 같다.
GC 튜닝 전에 시스템의 메모리를 왜 높게 잡아야 하는지에 생각해 봐야 한다.

튜닝 여부 결정에 대한 자세한 내용은 책을 한번 더 확인…

(3) GC 방식 / 메모리 크기 지정
GC 튜닝을 진행하기로 결정했다면 GC 방식을 선정하고 메모리의 크기를 지정한다.
이때 서버가 여러 대이면 서버에 GC 옵션을 서로 다르게 지정해서 GC 옵션에 따른 차이를 확인하는 것이 중요하다.

(4) 결과 분석
운이 좋으면 해당 시스템에 가장 적합한 GC 옵션을 찾을 수 있지만 그렇지 않다면
로그를 분석해 메모리가 어떻게 할당되는지 확인해야 한다.
그 다음에 GC 방식 / 메모리 크기를 변경해 가면서 최적의 옵션을 찾아 나간다.

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




1, 2 단계 : GC 상황 모니터링 및 결과 분석하기

다음의 조건에 모두 부합한다면 GC 튜닝이 필요 없다.

주의할 점은 GC 상황을 확인할 때 시간만 보면 안 된다는 점이다.
GC 가 수행되는 횟수도 확인해야 한다.


3.1 단계 : GC 방식 지정

Serial GC는 운영에서 사용하지 못해 제외되고, JDK 7이 아니면 G1 GC도 제외되어
Parallel GC, Parallel Compacting GC, CMS GC 중에서 하나를 선택해야 한다.

가장 좋은 방법은 세 가지 방식을 모두 적용해 보는 것이다.
일반적으로 CMS GC가 다른 Parallel GC 보다 작업 속도가 빠르다.

하지만 항상 빠른 것은 아니다.
Concurrent mode failure 이 발생하면 다른 Parallel GC 보다 느려진다.

Concurrent mode failure 이란?
Parallel GC와 CMS GC의 가장 큰 차이점은 압축(Compaction) 작업 여부이다.
압축 작업은 메모리 할당 공간 사이에 사용하지 않는 빈 공간이 없도록 옮겨서 메모리 단편화를 제거하는 작업이다.

CMS GC는 메모리에 빈 공간이 여기저기 생긴다. 그렇기 때문에 크기가 큰 객체가 들어갈 수 있는 공간이 없을 수도 있다.
예를들어, Old 영역에 남아 있는 크기가 300MB 인데도 10MB짜리 객체가 연속적로 들어갈 공간이 없을 수 있다.

그럴 때 Concurrent mode failure 라는 경고가 발생하면서 압축 작업을 수행한다.
그런데, CMS GC를 사용할 때는 압축 시간이 다른 Parallel GC 보다 더 오래 소요된다.
그래서 오히려 더 문제가 될 수 있다.




3.2 단계 : 메모리 크기

메모리 크기와 GC 발생 횟수, GC 수행 시간의 관계는 다음과 같다.

- 메모리 크기가 크면,

- 메모리 크기가 작으면,

메모리 크기를 크게 설정할 것인지, 작게 설정할 것인지에 대한 정답은 없다.




4단계 : GC 튜닝 결과 분석

분석할 때에는 다음의 사항을 중심으로 살펴보는 것이 좋다.
GC 옵션을 결정하는 데 가장 큰 비중을 차지하는 것은 1번 항목인 Full GC 수행 시간이다.


운이 좋아서 한 번에 가장 적합한 GC 옵션을 찾으면 좋지만, 그렇지 못한 경우가 대부분이다.
한 번에 끝내려다가 잘못하면 서비스에 OutOfMemoryError가 발생할 수 있으니 조심해서 GC 튜닝을 진행하는 것이 좋다.




같이 보면 좋은 GC 관련 블로그 글