자바의 가비지 컬렉터(Garbage Collector)를 이해하려면 메모리에 대한 이해가 먼저다.
자바 실행 프로그램인 JVM & 메모리
메모리는 OS가 관리하는데 모든 프로그램들은 OS 위에서 돌아간다.
프로그램이 실행하는데 필요한 메모리는 OS에게 "나 메모리좀 줘" 하고 요청을 하여 제공 받는다.
자바의 실행 프로그램인 JVM도 예외는 아니다. JVM 또한 메모리가 필요하면 OS에게 요청해야한다.
그런데 OS가 처음부터 자기가 가진 메모리 전부를 JVM에 할당해주면 다른 프로그램들에게 줄 메모리가 없게 된다. 따라서, 각 프로그램에게 메모리의 일정부분만 빌려주는 방식으로 관리가 된다.
JVM이 메모리를 쓰다가, OS가 준 메모리 용량이 턱없이 부족하다면?
JVM은 프로그램을 실행하다가 메모리가 부족하면 OS에게 메모리를 더 달라고 요청하고 OS가 JVM에게 메모리를 더 빌려준다.
JVM은 변수,함수 등 값이 OS로부터 받은 메모리들 중 어느 곳에 저장할지 그 주소를 할당해야 한다.
JVM은 자기가 받은 메모리 안에서 절대주소가 아니라 거기에 대한 '상대주소'를 할당한다.
이걸 offset 주소라고 한다.
JVM이 받은 메모리의 절대 주소는 101 ~ 200, 301 ~ 400이지만, 이들의 상대적 주소는 0 ~ 200이라는 뜻이다.
즉, 프로그램이 OS로부터 부여받은 메모리는 물리적으로는 분리되어 있을 수 있지만 논리적으로는 하나의 메모리처럼 동작한다.
가비지(Garbage)란?
가비지는 정리되지 않은 메모리, 유효하지 않은 메모리 주소를 말한다.
프로그램을 실행하다보면 가비지가 발생하게 된다.
String[] array = new String[2];
array[0] = '0';
array[1] = '1';
array = new String[] {'G', 'C' };
위 코드에서 String 배열이 할당되기 전에 할당한 0과 1은 어디로 갔을까?
이렇게 주소를 잃어버려서 사용할 수 없는 메모리가 '정리되지 않은 메모리'이다.
프로그래밍 언어에서는 Danling Object, 자바에서는 Garbage라고 부른다.
추가로 앞으로 사용하지 않고 메모리를 가지고 있는 객체 역시 Garbage에 포함된다.
가비지 컬렉터(Garbage Collector)란?
가비지 컬렉터는 메모리가 부족할 때 쓰레기(가비지)를 정리해주는 프로그램을 말한다.
프로그램을 실행하다보면 가비지가 발생하게 되는데 유효한 메모리가 아니다.
즉, 정리되지 않은 채로 남겨져있는 메모리로 사용되지도 않으면서 자리를 차지하고 있게된다.
JVM의 가비지 컬렉터는 가비지를 다른 용도로 사용할 수 있게 메모리 해제를 시키는 프로그램이다.
C++와 같은 다른 언어에서는 사용하지 않을 객체의 메모리를 직접 해제해주어야 하지만 자바는 GC가 처리하니 개발자 입장에서는 편리하다. 다만 모든 메모리 누수를 잡아주는 것은 아님으로 메모리 누수에 대한 경계를 늦추어서는 안된다.
가비지 컬렉터는 언제 실행되는가?
JVM은 메모리를 부여받고 열심히 프로그램들을 실행하다가 메모리가 부족해지는 순간이 오면 OS에게 추가로 메모리를 더 요청하게 된다.
바로 이 메모리를 더 달라고 요청하는 때에 가비지 컬렉터(Garbage Collector)가 실행된다.
또, 서버 프로그램인 경우에는 24시간 내내 돌아가는데 JVM이 한가할 때(idle time) 가비지 컬렉터가 실행된다.
(JVM이 종료되면, 당연히 사용하던 모든 메모리는 OS에게 반납된다.)
Stop The World
Stop-the-world는 GC 실행을 위해 JVM이 애플리케이션 실행을 멈주는 것이다.
GC가 실행 될 때는 GC를 실행하는 쓰레드를 제외한 모든 스레드들이 작업을 멈춘다.
GC 작업이 완료한 이후에야 중단했던 작업을 다시 시작한다.
대개의 경우 GC 튜닝이란 이 stop-the-world 시간을 줄이는 것을 말한다.
먼저 GC 의 유형들을 이해하기 위해서는 JVM 의 메모리 관리에 대해 알아야 한다.
JVM의 메모리 영역
JVM에는 일반적으로 Young Generation / Old Generation 이라는 두가지의 물리적 공간이 존재한다.
Eden 영역부터 Survivor 영역까지를 Young 영역이라고 부른다.
크게 Young, Old, Perm 영역으로 나누어 지는데 JDK 8버전 부터는 Young 영역 > Eden/ Survivor로 구분되어진다.
GC는 2가지 전제를 갖고 있다.
대부분의 객체가 금방 Unreachable 한 상태가 된다는 것과 Old 객체에서 Young 객체로의 참조가 적다는 점이다.
Young Generation 영역
: 새롭게 생성한 객체가 위치한다.
많은 객체가 이 영역에 생성되었다 사라지며 이를 Minor GC라고 한다.
Old Generation 영역
: 접근불가능한 상태가 되지않아 Young 영역에서 살아남은 객체가 이 영역으로 복사된다.
Young 영역보다 크게 할당되며 GC는 적게 발생한다. 이 영역에서 객체가 사라질 때 Major GC 또는 Full GC 가 발생한다.
Old 영역에서는 Card table이라는 512 byte의 Chunk가 존재하며 Old영역의 객체 중 Young 영역의 객체를 참조하는 객체의 정보들을 저장한다.
Young 영역은 Eden 영역과 2개의 Survivor 영역으로나뉜다.
New를 이용해서 객체를 생성하면 이는 Eden 영역에 위치하게 된다.
Eden 영역에서 GC가 한번 발생 후 살아남은 객체는 Survivor 영역 중 하나로 이동한다.
이 때 객체가 Eden 영역에서 Survivor1, Survivor2 영역으로 이동할 때 Minor GC 가 수행된다.
지속적인 Eden 영역에서의 GC 이후 Survivor 영역으로 객체가 계속 쌓이고 Survivor 영역 내 빈곳으로 살아남은 객체들이 이동한다. 이러한 과정을 계속 반복 후 Survivor 영역들이 가득차게 되면, 남은 객체가 Old 영역으로 이동한다. 2개의 Survivor 영역에 모두 데이터가 존재하거나 모두 사용량이 0이면 시스템은 비정상이다.
객체 GC과정
객체가 생성되어 메모리에 올라간 후 사용하지 않을 때 GC되기까지의 과정이다.
객체가 생성되어 Eden 영역에 올라간다.
처음 생성된 객체는 Eden 영역에 할당된다. 이후 Eden영역이 꽉 찬다면 할당이 해제되지 않은 객체를 Survivor 영역으로 이동시킨다.
Eden 영역이 꽉 차면 Survivor 영역으로 넘어간다. 단 Survivor 영역중 하나는 반드시 비어있어야 한다.
Survivor 영역에 있는 객체는 올라가있는 Survivor 영역이 꽉 찰 때 다시 GC 심사를 받는다. 할당이 되어있고 아직 사용중이라는 판단이 들면 다른 Survivor 영역으로 이동한다.
여기서 Survivor 영역을 거치지 않고 바로 Old 영역으로 이동하는 경우가 있다.
바로 객체의 크기가 Survivor 영역의 크기보다 큰 경우이다.
Old 영역에 들어간 객체는 풀 GC, 메이저GC가 발생하지 않는한 GC되지 않는다.
GC의 종류
GC는 크게 메이저GC와 마이너GC로 나눌 수 있습니다.
GC의 유형
1. Serial GC
하나의 CPU로 Young/Old 영역을 연속적으로 처리하며, 컬렉션이 수행될 때 애플리케이션이 정지된다.
Serial GC는 다음 흐름으로 진행된다.
살아있는 객체는 Eden 영역에 올라간다 -> Eden 영역이 꽉차면 To Survivor 영역으로 '살아있는 객체'를 이동시킨다
-> To Survivor 영역이 꽉 찰경우 Eden, FromSurvivor 영역에 남은 객체를 Old 영역으로 이동시킨다
이후 Old 영역에서는 쓰지 않는 객체를 표시해서 한 곳으로 모으고 삭제하는 'Mark-sweep -compact' 알고리즘을 사용한다.
해당 알고리즘은 다음 흐름으로 진행된다.
살아있는 객체를 찾아 표시한다 -> Old 영역을 스캔하여 쓰레기 객체를 표시한다 -> 쓰레기 객체를 지우고 살아있는
객체를 모은다
즉, Old 영역에 살아있는 객체를 Mark 하고 Heap의 앞부분부터 살아있는 객체를 Sweep한 뒤, 힙의 앞부분부터 객체를 쌓는다.(Compact)
메모리와 CPU 코어수가 적을 때 좋다.
2. Parallel GC
Serial GC와 기본적인 알고리즘은 같지만, GC를 처리하는 Thread의 개수가 여러 개다.
Parallel GC의 목표는 다른 CPU가 GC의 진행시간 동안 대기 상태로 남아 있는 것을 최소화 하는 것이다.
즉, Serial GC 의 Young 영역에서 진행하는 컬렉션을 병력 방식으로 처리하여 GC의 부하를 줄이고 성능을 향상시킬 수 있다.
메모리와 CPU 코어 수가 많을수록 좋다.
3. Parallel Old GC
Parallel GC와 같지만 Old 영역의 GC 알고리즘만 다르다.
Mark-Summary-Compact 알고리즘이며 조금 더 복잡하다.
4. CMS GC
Stop-the-world 이후 Initial marking 시 살아있는 객체만 찾는다.
이후 concurrent mark 단계에서 참조를 따라가며 새로 추가되거나 참조가 끊긴 객체들을 remark 한다.
모든 작업이 멀티스레드 환경에서 동시진행되기 때문에 stop-the-world 시간이 매우 짧은 대신 memory와 CPU를 많이 사용하고 compaction 단계가 제공되지 않는다.
즉, 힙 메모리의 크기가 클 때 적합하며 Young 영역에 대한 처리 방법은 Parallel GC의 Young 영역의 GC 알고리즘과 동일하며 Old 영역의 GC는 다음 흐름을 따른다.
짧은 대기 시간으로 살아있는 객체를 찾는다 -> 서버 수행시 살아있는 객체에 표시를 한다 -> 표시 도중에 변경된 객체에
대해 다시 표시한다 -> 표시된 쓰레기를 정리한다
CMS 방식에서는 컴팩션을 하지 않는다.
따라서 메모리를 몰아놓지 않으므로 다른 옵션을 사용해서 메모리를 모아주는 작업이 필요하다.
5. G1 GC
Young/Old 영역으로 나누는 방식을 사용하지 않는 특이한 컬렉터로 G1컬렉터는 바둑판 모양으로 구성되어있으며 약 2000개의 구역을 사용한다.
이 바둑판 모양의 구역에서 일부를 선정하여 Young 영역으로 지정한 후 해당 구역에 데이터가 꽉 차면 GC를 진행하고 GC 후 살아있는 객체만 Survivor 영역으로 이동한다.
GC를 변경하는 이유가 뭘까?
바로 GC 튜닝을 통해 더 나은 성능을 구현하려고 하기 때문이다.
"모든 Java 기반의 서비스에서 GC 튜닝을 해야 할까?"
결론부터 이야기하면 모든 Java 기반의 서비스에서 GC 튜닝을 진행할 필요는 없다.
GC 튜닝이 필요 없다는 이야기는 운영 중인 Java 기반 시스템의 옵션과 동작이 다음과 같다는 의미이다.
메모리 크기도 지정하지 않고 Timeout 로그가 수도 없이 출력된다면 여러분의 시스템에서 GC 튜닝을 하는 것이 좋다.
GC 튜닝을 위해 지켜야할 기본적인 원칙은 GC 튜닝이 로직에 영향을 미치지 않도록 가능한 늦게 수행하고m객체 생성을 최소화하는 것이다.
즉, GC 튜닝은 Java 코드 최적화와 맞물려 있는 영역이라고도 할 수 있다.
가령 String 의 append 시, + 연산으로 2개 이상의 String 을 더하는 대신, StringBuilder 등을 쓰는 것도 일종의 메모리 튜닝이라고 할 수 있다.
그 외에는 설정적인 부분으로 JVM 옵션으로 메모리 크기를 조절하고 GC 방식을 옵션으로 지정해주는 등이 있다.
GC 튜닝의 절차
GC를 튜닝하는 절차도 대부분의 성능 개선 작업과 크게 다르지 않다.
GC 상황을 모니터링하며 현재 운영되는 시스템의 GC 상황을 확인해야 한다.
GC 상황을 확인한 후에는, 결과를 분석하고 GC 튜닝 여부를 결정해야 한다.
분석한 결과를 확인했는데 GC 수행에 소요된 시간이 0.1~0.3초 밖에 안 된다면 굳이 GC 튜닝에 시간을 낭비할 필요는 없다.
하지만 GC 수행 시간이 1~3초, 심지어 10초가 넘는 상황이라면 GC 튜닝을 진행해야 한다.
GC 튜닝을 진행하기로 결정했다면 GC 방식을 선정하고 메모리의 크기를 지정한다.
이때 서버가 여러 대이면 여러 대의 서버에 GC 옵션을 서로 다르게 지정해서 GC 옵션에 따른 차이를 확인하는 것이 중요하다.
GC 옵션을 지정하고 적어도 24시간 이상 데이터를 수집한 후에 분석을 실시한다.
운이 좋으면 해당 시스템에 가장 적합한 GC 옵션을 찾을 수 있다.
그렇지 않다면 로그를 분석해 메모리가 어떻게 할당되는지 확인해야 한다.
그 다음에 GC 방식/메모리 크기를 변경해 가면서 최적의 옵션을 찾아 나간다.
GC 튜닝 결과가 만족스러우면 전체 서버의 GC 옵션을 적용하고 마무리 한다.
GC 변경 사례
1. GC 옵션을 활용하여 FULL GC 수행되지 않도록 튜닝
신규로 개발된 S 서비스는 Full GC를 수행하는데 시간이 오래 소요되고 있다.
먼저 jstat –gcutil의 결과를 보자.
S0 S1 E O P YGC YGCT FGC FGCT GCT
12.16 0.00 5.18 63.78 20.32 54 2.047 5 6.946 8.993
왼쪽에 있는 Perm 영역까지의 정보는 처음 GC 튜닝을 할 때에는 중요하지 않다.
오른쪽에 있는 YGC부터의 값이 중요하다.
Timestamp : JVM의 시작 시간 이후의 시간
S0 : Survivor0의 사용률
S1 : Survivor0의 사용률
E : Eden 영역의 사용률
O : Old 영역의 사용률
P : Permanent 영역의 사용률
YGC : Young generation의 GC 이벤트 수
YGCT : Young generation의 가비지 컬렉션 시간
FGC : Full GC 이벤트 수
FGCT : Full의 가비지 컬렉션 시간
GCT : 가비지 콜렉션 시간
Minor GC와 Full GC가 한 번 수행될 때 평균 얼마나 소요되었는지 계산하면 다음과 같다.
Minor GC를 수행하는데 37ms면 양호한 상황이다.
하지만, Full GC가 평균 1.389초 걸렸다는 것은 DB Timeout을 1초로 한 시스템에서는 GC가 발생할 때 많은 Timeout이 발생할 수 있다는 말이 된다. 이런 상황의 시스템은 GC 튜닝을 해야 한다.
이 상태에서 무작정 GC 튜닝을 시작하면 안 되고 메모리를 어떻게 사용하고 있는지 살펴봐야 한다.
메모리 사용량은 jstat –gccapacity 옵션으로 확인한다. 이 서버에서 확인한 결과는 다음과 같다.
NGCMN NGCMX NGC S0C S1C EC OGCMN OGCMX OGC OC PGCMN PGCMX PGC PC YGC FGC
212992.0 212992.0 212992.0 21248.0 21248.0 170496.0 1884160.0 1884160.0 1884160.0 1884160.0 262144.0 262144.0 262144.0 262144.0 54 5
주요한 값만 살펴 보면 다음과 같다.
New 영역사용크기: 212,992 KB
Old 영역사용크기: 1,884,160 KB
즉, 전체 할당된 메모리의 크기는 Perm 영역을 제외하고 2GB이며, New 영역:Old 영역이 1:9이다. jstat보다 더 상세하게 상황을 확인하기 위해서 -verbosegc 로그를 추가하고, 3개의 인스턴스에 다음과 같이 3가지 옵션을 지정했다. 다른 옵션은 별도로 추가하지 않았다.
NewRatio=2
NewRatio=3
NewRatio=4
하루 정도 지난 이후 이 시스템의 GC 로그를 확인했다.
운이 좋게도 이 시스템은 NewRatio를 지정한 이후에 Full GC가 한번도 발생하지 않았다.
왜 그랬을까? 그 이유는 해당 시스템에서 생성되는 객체는 대부분 금방 소멸되는 객체이며, 객체가 생성된 이후에 Old 영역으로 넘어가지 않고, New 영역에서 모두 사라져 버리기 때문이다.
이 상황에서는 다른 옵션은 변경할 필요도 없다. 따라서, NewRatio 값 중에서 가장 좋은 값을 선택하면 된다. 가장 좋은 값은 어떻게 판단할까? 각 NewRatio의 Minor GC 평균 응답 시간을 분석하면 된다.
각 옵션별 평균 응답시간은 다음과 같다.
NewRatio=2 : 45 ms
NewRatio=3 : 34 ms
NewRatio=4 : 30 ms
New 영역의 크기는 가장 작지만 GC시간이 짧은 NewRatio=4이 가장 좋은 옵션이라는 결론을 내렸으며, GC 옵션을 적용한 이후에 이 서버에서는 Full GC가 발생하지 않았다.
참고로 해당 서비스의 JVM이 시작하고 며칠이 지난 후에 수행한 jstat –gcutil 결과는 다음과 같다.
S0 S1 E O P YGC YGCT FGC FGCT GCT
8.61 0.00 30.67 24.62 22.38 2424 30.219 0 0.000 30.219
서버에 요청이 많지 않아서 GC가 자주 발생하지 않았다고 생각할 수도 있다.
하지만, Minor GC가 2,424 번 수행될 동안 Full GC는 한 번도 수행되지 않았다.
2. GC 옵션과 메모리 할당을 조절하여 활용하여 GC 속도개선 튜닝
사내에 운영 중인 APM(Application Performance Manager)에서 주기적으로 JVM이 오랫동안(8초 이상) 동작하지 않는다는 것을 발견했다. 해당 원인을 찾던 중 Full GC 시간이 오래 소요되는 것을 보고 GC 튜닝을 진행하기로 했다.
튜닝의 첫 단계로 -verbosegc 옵션을 추가했으며, 결과는 다음과 같았다.
위의 그래프는 HPJMeter가 분석 후 자동으로 제공하는 그래프 중 Duration 그래프이다.
JVM이 시작되었을 때부터의 시간이 X축이며, 각 GC의 응답 시간이 Y축이다.
CMS로 표시된 것은 Full GC이며, Parallel Scavenge로 표시된 것은 Minor GC 결과다.
CMS GC가 제일 빠르다고 했는데, 결과를 보면 15초까지 소요된 것도 있다.
왜 이러한 결과가 나왔을까?
CMS가 Compaction 단계를 거치면 오히려 더 느려진다. 게다가 해당 서비스는 메모리를 –Xms1g –Xmx4g로 지정해 놓았고, 4GB까지 메모리를 할당해서 사용하고 있었다.
일단 CMS GC대신 Parallel GC로 변경했다. 메모리 크기는 2GB로 변경하고, NewRatio를 3으로 지정했다. 이렇게 지정하고 몇 시간 후의 jstat –gcutil 결과는 다음과 같다.
S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 30.48 3.31 26.54 37.01 226 11.131 4 11.758 22.890
4GB일 때 15초에 비하면 Full GC 시간이 회당 3초 정도로 빨라지긴 했지만 3초도 빠른 것은 아니다. 그래서 다음과 같이 6개의 케이스를 만들어 변경해 보았다.
Case1 : -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=2
Case2 : -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=3
Case3 : -XX:+UseParallelGC -Xms1g -Xmx1g -XX:NewRatio=3
Case4 : -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=2
Case5 : -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=3
Case6 : -XX:+UseParallelOldGC -Xms1g -Xmx1g -XX:NewRatio=3
이 중 어떤 것이 가장 빠르게 나왔을까? 당연히 할당된 메모리의 크기가 작을 수록 유리한 결과가 나왔다. 가장 GC개선율이 높은 Case6의 Duration 그래프는 다음과 같다. 가장 느린 응답 속도가 1.7초 정도이며, 평균 1초 이내의 양호한 상황으로 바뀌었다.
이 결과를 보고 해당 서비스의 GC 옵션을 모두 Case 6으로 변경했다.
하지만, 이것이 원인이 되어 그날 저녁에 OutOfMemoryError가 발생하는 문제가 있었다.
GC 튜닝을 진행할 때에는 단순히 짧은 시간 동안 쌓인 GC 로그만 분석하고 전체 서버에 적용하는 것은 매우 위험하다. 서비스가 어떻게 운영되고 있는지에 대한 분석도 같이 진행되어야 장애 없이 GC 튜닝이 가능하다.