OutOfMemoryError; Java Heap Space
OOM 에러 발생 문제상황
api 중 하나가, 로그성 테이블의 데이터를 읽어와서 엑셀파일(byte array)을 만들어서 반환해주는 기능을 갖고 있었다. 처음에는 요구사항이 생겨서 큰 생각없이 빨리 만들어주었던 api 인데, 이 api 로 인해서 OutOfMemoryError : Java Heap Space
를 맞이하게 되었다. 해당 api의 코드를 디버깅 해 본 결과, 당연히 메모리가 부족할 수 밖에 없는 로직이었다.
DB 테이블의 로우 갯수가 4만개, 32만개나 되는데, (1)
그 데이터를 모두 메모리에 올린 다음, 그 데이터로 (2)
엑셀 sheet를 만들고, 그 엑셀 sheet를 Client에서 받아 볼 수 있도록, (3)
byte array로 변경하면서 3중으로 메모리를 먹고 있었던 것이다. 그래서 한 번 api 호출 시, Heap Size 가 600Mb 나 증가하는 것을 확인하였다..
문제상황 분석 방법
이러한 OOM 에러를 맞이했을 때 꼭 봐야할 것들을 나열하면 아래와 같다. 내 경우에는, 분석해보니 MemoryLeak의 문제는 아니였다.
- GC log
- Heap Usage
- 메모리 덤프를 이용한 Memory leak 확인
Java의 메모리 영역, Heap 메모리 구조, GC에 대한 정리
Java 메모리 영역(Runtime Data Area)
PC Register
: 스레드별로 보유하고 있는 스레드가 어떤 부분을 어떤 명령으로 실행해야 할 지에 대한 기록을 하는 부분. 현재 수행 중인 JVM 명령의 주소를 가짐JVM 스택
: 스레드별 보유. 이 영역에는 지역 변수와 부분 결과를 저장하며, 메서드 호출 및 리턴에 관련된 정보가 보관Heap
: 모든 클래스의 인스턴스와 배열이 할당. JVM이 시작될 때 생성되며 가비지 컬렉터에 의해 관리됨메서드 영역
(Method Area = Class Area = Static Area) : 모든 JVM의 스레드가 공유하며, 각 클래스의 구조 정보를 저장하는 영역.
런타임 상수 풀, 필드, 메서드 데이터, 메서드와 생성자의 코드, 클래스와 인터페이스 인스턴스의 초기화를 위한 특수 메서드(special method)들에 대한 정보들이 들어 있음Native Method Stack
: 자바 언어 이외의 네이티브 언어를 호출할 경우 타 언어의 스택 정보를 여기에 저장함.
Heap 메모리 구조
일반적으로 우리가 개발하는 객체들은 힙 영역에 저장되며, 이 힙 영역은
Eden
, Survivor
, Tenured
, Permanent
영역으로 나뉜다.(JDK 8 부터는 permanent 영역 사라지고 일부가 meta space 영역으로 변경됨)
New/Young Generation
Eden
: 객체들이 최초로 생성되는 공간Survivor 0/1
: Eden에서 참조되는 객체들이 저장되는 공간
Old Generation
: New/Young Generation에서 일정시간 참조되고 있는 살아남은 객체들이 저장되는 공간Permenant Generation
- 생성된 객체의 주소값이 저장된 공간
- Reflection을 사용하여 동적으로 클래스가 로딩되는 경우에 사용됨. 내부적으로 Reflection 기능을 자주 사용하면 이 영역에 대한 고려가 필요
Garbage Collection
Minor GC
:New/Young Generation
에서 일어나는 GC- 최초에 Eden에 객체가 생성됨
- Eden 영역에 객체가 가득 차게 되면 첫 번째 GC(Minor GC)가 일어나고, Survivor0 영역에 Eden 영역의 메모리를 복사, 그 후 Survivor0 제외한 다른 영역 객체 제거
- Eden과 Survivor0 도 가득차면, 그 중에 참조되고 있는 객체를 Survivor1으로 복사후 Survivor1를 제외한 다른 영역의 객체를 제거
- 위 과정 중 일정 횟수 이상 참조되고 있는 객체들을 Survivor1에서 Old로 이동
- 위 과정을 계속 반복하며, Survivor1 까지 꽉 차기 전에 계속해서 Old로 비움
Major GC
(Full GC): Old 영역에서 일어나는 GC- Old 영역에 있는 모든 객체들을 검사하며 참조되고 있는지 확인
- 참조되지 않은 객체들을 모아 한번에 제거
- Minor GC 보다 시간이 훨씬 많이 걸리고, 실행중 GC를 제외한 모든 스레드가 중지함
- Major GC가 일어날 때 Old 영역의 객체를 제거하며 Heap 메모리 영역 중간중간에 구멍이 생기게 되는데, 이를 없애기 위해 재구성을 함(메모리 정리 중에 다른 스레드가 메모리 사용할 수 없기에 모든 스레드가 중지)
GC 확인 방법
- JDK 에 포함된 명령어인 jstat 활용.
jstat -gcutil <pid> 1s
Jstat output 참고- CCS : CodeCache usage 비율
- YGC : Young Generation Garbage Collection 횟수
- YGCT: Young Generation Garbage Collection 소요시간(sec)
- FGC : Full GC 횟수
- FGCT: Full GC 소요시간(sec)
- CGC : Concurrent Garbage Collection 횟수
- CGCT : Concurrent Garbage Collection 소요시간(sec)
- GCT : Total GC 소요 시간(sec)
- JVM flag에
-Xlog:gc*
(JDK17) 붙이기 JDK17 Throughput and Footprint Measurement1
-Xlog:gc:/usr/src/gc.log
위와 같이 JVM Flag에 추가해주면, /usr/src/gc.log 파일에 gc log를 남기게 됨
Heap Usage 확인 방법
JDK에 포함된 명령어인 jcmd 활용. jcmd <pid> GC.heap_info
메모리 덤프
- 수동으로 실행중인 자바 어플리케이션 메모리 덤프 하는 방법
jmap -dump:format=b,file=<dump file 이름>.hprof <PID>
- JVM Flag를 붙여서, OOM 에러 발생 시, Java Heap dump 하도록 설정
1 2
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/src
- HeapDumpPath는 dump 파일의 경로가 아니라 해당 파일이 생길 디렉토리의 경로를 입력
위 방법으로 도출된 메모리 덤프를 Eclipse Memory Analyzer 를 이용해서 분석 이번 글에서는 해당 툴 사용방법까지 다루지는 않으려고 한다.
해결 방안
위의 방법들을 확인해서 어플리케이션의 GC Count, Heap Usage를 확인해보면서 아래와 같이 생각 정리를 했다.
- 일단 DB의 그 데이터들을 다 어플리케이션에 올리는 것 자체가 메모리를 많이 사용할 수 밖에 없음(그러나 지금은 임시적으로 해당 API가 필요함)
- 해당 API가 꼭 필요하다면 어플리케이션에서 사용하는 메모리를 줄이기 위해, 엑셀 생성을 클라이언트(프론트엔드) 측에서 진행하자!
엑셀 생성을 프론트 측으로 옮김으로써
서버에서 사용하는 메모리를 줄였고, API 호출할 때 GC 가 일어나는 횟수도 상당히 개선함으로써 문제를 어느정도 해결하였다.
그리고 근본적인 해결을 위해서는 추후에 API 자체에 기간 조건을 두어 한번에 조회하는 데이터 자체를 줄이기
를 진행하면 어플리케이션에서 한번에 큰 메모리를 사용하는 상황은 개선될 것으로 보인다.
느낀점
이번 경험을 통해 GC와 Java의 메모리 구조 등에 대해서 더 잘 이해할수 있는 계기가 되었고 APM의 중요성을 체감하였다.
갑자기 메모리를 많이 사용하여 OOM 에러가 발생하면 어플리케이션이 더이상 사용할 수 없는 먹통이 되어버리기에 그러한 상황을 미연에 방지하기 위해서는 평소에 모니터링 툴과 알람 시스템을 구축이 필요하겠다는 것을 크게 배우는 기회가 되었다.