GC(Garbage Collectors) 에서 이어지는 글입니다.
🚨 Issue
성능 테스트 중 CPU 가 90~100까지 튀어서 테스트를 진행하기 어려운 상황이 되었습니다 🤦♂️
아직 목표 성능에 도달하지 못했기에... 원인이 무엇인지 분석하기 시작했습니다.
사실 가장 큰 원인은 대량의 데이터를 쿼리할 때 cpu 사용량이 오르는 것이었는데, 예상 가능한 내용이므로 품질 개선과 성능,안정성을 위해 다른 부분에서 작업을 진행하기로 했습니다.
GC 도 작업 대상 중 하나로 선정하여, GC로 인한 CPU 사용 빈도를 최소화하도록 조치했습니다.
GC 빈도를 줄이기 위해 체크한 내용은 다음과 같습니다
Memory Leak 과 CPU
메모리 누수가 발생하면, 사용하지 않는 객체 또는 메모리를 해제하지 않은 객체가 계속 쌓이기 때문에 시스템의 메모리 사용량이 계속 증가하게 됩니다.
메모리 사용량이 증가하면 시스템의 가용 메모리가 부족해지고, 최종적으로 OOM 으로 이어질 수 있습니다.
OS는 메모리 부족 상황을 해결하기 위해 메모리 압축(compression)이나 스왑(swap) 등으로 메모리를 재조정하고, 이 과정에서 CPU 사용량이 증가할 수 있습니다.
또한, Memory leak으로 인해 GC가 더 자주 발생할 수 있습니다. GC 에서 사용하지 않는 객체를 식별하고 메모리에서 제거하는 작업을 수행할 때 CPU 를 사용하기 때문에, 메모리 누수로 인해 GC의 빈도가 늘어나면 CPU 사용량이 증가할 수 있습니다.
GC 튜닝
GC 를 튜닝한다
는 말은 일반적으로 Full GC 빈도와 실행 시간을 줄이는 것을 의미합니다.
힙 크기를 적절하게 설정하여 Full GC 발생 빈도를 줄일 수 있습니다.
너무 작은 힙 크기는 OutOfMemoryError
를 발생시키고, 너무 큰 힙 크기는 GC 작업에 더 많은 시간이 소요되어 Full GC 발생 빈도가 늘어날 수 있습니다.
힙 메모리를 조정해가며 메모리 대비 GC 발생 빈도가 적당한 설정을 찾는 것이 중요합니다.
객체를 생성하는 비용은 상대적으로 높기 때문에, 객체를 최대한 재활용 하거나 객체의 수명을 연장하여 GC 작업을 줄일 수 있습니다. 객체 수명이 짧은 경우 메모리 공간이 빠르게 차게 되므로, 객체 수명을 연장하면 GC 작업의 빈도를 줄일 수 있습니다.
Spring
에서@Bean
으로 등록하는 객체들은 애플리케이션에서 사용될 때까지 메모리에 유지되므로, 일종의 객체 캐싱이나 객체 재사용의 역할을 합니다.Spring
은 내부적으로 객체 풀링(pooling)을 사용하여 객체를 관리하기도 합니다. 이렇게 객체 수명을 연장하여 재사용하면, 객체를 반복적으로 생성하고 소멸시키는 오버헤드를 줄일 수 있어서 메모리 사용을 최적화할 수 있습니다.
SoftReference / WeakReference
사용SoftReference
/ WeakReference
는 GC 작업을 수행할 때 우선적으로 해제되기 때문에, 메모리 누수를 줄일 수 있습니다.
import java.lang.ref.SoftReference;
import java.util.HashMap;
import java.util.Map;
public class ImageCache {
private Map<String, SoftReference<Image>> cache;
public ImageCache() {
cache = new HashMap<>();
}
public Image getImage(String filename) {
SoftReference<Image> ref = cache.get(filename);
if (ref != null) {
Image image = ref.get();
if (image != null) {
return image;
}
}
Image image = loadImage(filename);
cache.put(filename, new SoftReference<>(image));
return image;
}
private Image loadImage(String filename) {
// load image from file system or network
}
}
자바의 Reference 와 GC 의 관한 내용 중 또 하나가 Inner Class 와 nested static class
인데, Inner Class 는 상위 객체를 강참조하고 있기 때문에 GC 의 대상이 되지 않습니다.
이는 이펙티브 자바에도 나오는 내용으로, inner class 를 생성할 때는 static 으로 생성하는 것이 권장됩니다.
public class BaseClass {
private Pointer first;
private class InnerClass {
// BaseClass 와 참조가 이어진 상태로, GC 대상에서 제외됨
}
private static class InnerClass {
// heap memory 에 할당되어 GC 대상이 됨
}
}
GC performance hit for inner class vs. static nested class
Java Reference와 GC
GC
변경GC 알고리즘을 조정하여 Full GC 작업을 최소화할 수 있습니다. GC 알고리즘은 애플리케이션의 메모리 사용 패턴에 따라 다르게 동작하므로, 적절한 알고리즘을 선택하고 튜닝하는 것이 중요합니다.
각 서비스의 WAS에서 생성하는 객체의 크기와 생존 주기가 모두 다르고, 장비의 종류도 다양합니다.
때문에 WAS의 스레드 개수와 장비당 인스턴스 개수, GC 옵션 등은 지속적인 튜닝과 모니터링을 통해서 해당 서비스에 가장 적합한 값을 찾아야 합니다.
Conclude.
1차적으로 Heap 사이즈를 늘려 FullGC 발생 빈도를 줄였습니다.
그리고 nGrinder, VirtualVM, jstat 을 통해 GC 별 성능을 비교했고, 결과는 아래와 같았습니다.
해당 데이터를 기반으로 parallel -> G1GC 로 변경하며 작업을 종료했습니다.
GC | GC 횟수 | 총 GC 수행시간 |
---|---|---|
parallel gc | 912-955 | 34~39 sec |
g1 gc | 435 | 21 sec |
g1 gc (NewRatio 1) | 518 | 23 sec |
g1 gc (NewSize=1536m MaxNewSize=1536m) | 341 | 19 sec |
cms gc | 3843 | 2 min 23 sec(FullGC 발생) |
참고
-XX:MaxGcPauseMillis
설정을 할 경우 G1GC 는 일시 중지 목표 시간을 충족하기 위해 Young 영역을 임의로 수정하기 때문에-Xmn
이나-XX:NewRatio
등 Young Gen 사이즈를 명시적으로 세팅하는 것은 피해야 합니다.
Ref.
Appendix: GC 모니터링
방법 | 종류 |
---|---|
CUI | - jstat -verbosegc 옵션 |
GUI | - jconsole - Visual VM - Visual GC |
Appendix: 간단한 OOM 재현 방법
OOME - Java Heap Space
Java Heap Space 에러는 가장 자주 일어나는 에러로, 기본적인 메모리 부족으로 인한 에러입니다.
이 에러는 단순히 생성하고자 하는 오브젝트가 JVM의 Heap 메모리 가용 영역을 넘어설 경우 발생시킬 수 있습니다.
public class Memory {
public static void main(String[] args) throws Exception {
int[] i = new int [10000*10000];
}
}
위 코드를 –Xmx256m
옵션과 함께 실행하면 Heapspace가 부족하다는 에러가 발생합니다.
OOME 케이스 - GC Overhead Limit Exceeded
GC(Garbage Collector)가 너무 빈번하게 일어나서 오버헤드가 걸렸다는 뜻인데, GC가 동작하는 조건이 가용 메모리가 부족한 것으로부터 시작하기 때문에 근본적으로는 앞서 말했던 Heap 메모리 부족으로부터 시작한다고 볼 수 있습니다.
정확히는 GC 작업을 하느라 전체 동작시간의 98%를 소비했는데도 불구하고, Heap 메모리를 2% 이하로 확보했을 경우 이 에러가 발생합니다.
import java.util.*;
public class Memory {
public static void main(String[] args) {
Map map = System.getProperties();
Random r = new Random();
while (true) {
map.put(r.nextInt(), "value");
}
}
}
해당 작업을 –Xmx100m -XX:+UseParallelGC
옵션과 함께 실행하면 에러를 재현할 수 있다.
에러 메세지는 GC 오류이지만 거의 대부분 Heapspace가 실제로 부족하거나, 큰 메모리를 사용하게 되는 코드가 있거나, 메모리 누수를 유발하는 코드가 어딘가에 있다고 보면 되기 때문에 괜히 GC라는 문구를 보고 튜닝을 시도해서는 안됩니다.
OOME 케이스 - Metaspace
Metaspace는 Java의 Classloader가 현재까지 로드한 Class들의 메타데이터가 저장되는 공간입니다.
Java 계열의 언어에서 이름이 다른 Anonymous Class를 다량 생성하거나, 실제로 Class가 많은데 메모리가 부족할 경우에 해당 에러가 발생합니다.
일반적으로 Class를 무한정 생성하는 경우는 많이 없기 때문에 메모리 할당량을 늘려주는 것으로 해결되지만, 간혹 3rd Party Lib들이 Class들을 양산하고 있을 수 있습니다.
주로 Scala, Kotlin 등이 제공하는 Command Line Compiler, REPL(Read Eval Print Loop)를 내부적으로 활용하거나, 이에 준하는 Janino같은 Runtime Compiler 또는 ScriptEngine을 사용한 어플리케이션, Javassist와 같은 Dynamic Class Generation을 활용한 어플리케이션을 긴 시간 동안 서비스할 때 많이 발생합니다.
public class Memory {
static javassist.ClassPool cp = javassist.ClassPool.getDefault();
public static void main(String[] args) throws Exception {
for (int i=0;; i++) {
Class c = cp.makeClass("Generated" + i).toClass();
}
}
}
외부 라이브러리인 Javassist를 사용하고, -XX:MaxMetaspaceSize=256m
옵션으로 실행하면 Metaspace 에러를 발생시킬 수 있습니다.