brunch

You can make anything
by writing

C.S.Lewis

by 에디의 기술블로그 Mar 01. 2020

Java Collections Heap Dump 분석

Java Heap Dump 분석 및 HashSet 메모리 효율 검토

추가 의견 - 2020.4...

글을 발행한 후, 다시 읽어보니, 결론이 없는 허접한 글입니다. 급하게 작성한 글이라서, 내용이 허접합니다. 

필요한 부분만 가볍게 참고하시길 바랍니다.





이 글에서는, 자바 애플리케이션의 "Out Of Memory" 상황을 재현하고, OOM의 원인을 찾는 과정에 대한 내용을 정리해서 공유한다. Heap Dump 분석으로 메모리 상황을 검토하였고, 핵심 원인이 되는 Java Collection 의 HashSet에 대해서 좀 더 자세히 알아보겠다. 또한, 클라우드 모던 런타임 "쿠버네티스"에 애플리케이션을 배포하기 위해서 Spring Cloud DataFlow(SCDF)를 함께 구축하였다.


항상 반복되는 필자의 잘못된 습관이지만, 글을 다 쓰고 다시 읽어보니 잡다한 내용으로 가득한 글이 되었다. 이 글에서 등장하는 "쿠버네티스"와 "Spring Cloud DataFlow(SCDF)"는 이 글의 핵심 주제와는 거리가 멀다. Out Of Memory, Heap Dump 분석 및 Java HashSet의 메모리 효율에 대한 내용이 핵심 주제이며, 쿠버네티스와 SCDF 에 대해서 잘 모른다해도 상관없다.

편하고 가볍게 읽어주길 바라며 잘못된 내용이 있거나 궁금한 질문이 있다면 댓글로 남겨주길 바란다.


Overview

회사에서 운영 중인 시스템 중에서 메모리를 많이 사용하는 배치 애플리케이션이 있다. 해당 배치는 Spring Cloud DataFlow 에서 배포하여 쿠버네티스 클러스터 노드에서 주기적으로 실행하는 스프링 기반의 애플리케이션이다. 해당 배치는 메모리를 많이 사용하는데, 가끔씩 OutOfMemory(OOM) 장애가 발생한다. 몇일 분석해보니 애플리케이션의 코드 중 HashSet 을 사용하는 메서드에서 메모리 오버헤드가 엄청나게 커진다는 사실을 알게되었다. 해당 애플리케이션은 가용 메모리를 많이 할당해서 장애가 없도록 운영 중이지만, 메모리를 많이 사용하는 이슈는 아직 해결하지는 못하였다. 외부에 공개되는 이 글에서는, 회사 비즈니스 및 업무 내용에 대해서는 전혀 다루지 않고, 일반적인 기술 얘기로 풀어나가겠다. (궁금하다면 개인적으로...)


요약하면 아래와 같다. 


자바 Collections 중 HashSet 에 대해서 간략하게 살펴본다.

힙메모리에 대해서 알아본다.

Heap Dump 생성 방법을 검토한다.

Heap Dump 분석 사례를 알아보기 위해서, 쿠버네티스에 스프링 애플리케이션을 배포한다. 

Heap Dump 분석 결과를 바탕으로 메모리를 많이 사용하는 로직을 찾아낸다.

메모리 가용을 높여서 임시로 해결한다.

문제가 되는 Java Collection(HashSet) 에 대해서 자세히 알아본다. 

글을 정리하고 마무리한다.


Java Collections

자바에서 제공하는 컬렉션 API에 대해서 간단하게 살펴보자. 


Java Collections 

자바에서 제공하는 컬렉션은 아래와 같다.  

주요 인터페이스는 List, Set, Map 인데, List 와 Set 은 공통점이 많기 때문에 Collection 인터페이스로 상단에 정의된다. Map 은 키,밸류 형식으로 구성된다. 간단하게 정리하면 아래와 같다.

 

List : 순서 O, 데이터중복 O

Set : 순서 X, 데이터중복 X

Map : Key&Value 저장, Key중복X, Value중복 O


HashSet

HashSet 은 Set 인터페이스의 구현체이며, HashMap의 래퍼 클래스이다. 

HashSet 클래스를 보면 HashMap 의 래퍼클래스임을 확인할 수 있다.

HashSet 에 대한 설명은 자바 기초 서적을 참고하였다.



이것이 자바다[신용권, 한빛출판사] - 736page 참고

HashSet 은 객체들을 순서 없이 저장하고 동일한 객체는 중복 저장하지 않는다. HashSet이 판단하는 동일한 객체란 꼭 같은 인스턴스를 뜻하지는 않는다. HashSet은 객체를 저장하기 전에 먼저 객체의 hashCode()메서드를 호출해서 해시코드를 얻어낸다. 그리고 이미 저장되어 있는 객체들의 해시코드와 비교한다. 만약 동일한 해시코드가 있다면 다시 equals() 메서드로 두 객체를 비교해서 true 가 나오면 동일한 객체로 판단하고 중복 저장을 하지 않는다. 



자바 컬레션에 대해서 상세하게 설명하지는 않겠다. 자바 관련 기초 서적 중에서 "이것이 자바다" 라는 책을 추천한다. 


힙메모리

중요한 내용이지만 이 글에서는 생략한다. 각자 공부를 해보길 바란다.  


Heap Dump 생성 및 확인

Heap Dump 는 특정 시점에서의 Heap 메모리 사용을 파일로 저장한 것이다. Heap Dump 파일을 분석하면 메모리를 어디서 많이 사용하는지 파악할 수 있다. 


Heap Dump 생성 (jmap)

jmap 명령어로 현재 실행중인 애플리케이션의 Heap 덤프 파일을 생성할 수 있다. pid는 실행 중인 자바 애플리케이션의 프로세스 id 이다. 


Heap Dump 분석 (jhat)

jhat 를 사용해서 간단하게 Heap Dump 를 분석할 수 있다.  

jhat 실행해보니 필자의 개인 노트북이 매우 힘들어 한다. 메모리를 3기가 이상 사용한다.  

잠시 기다리면 아래와 같이 localhost:7000 포트가 오픈되며 "Server is ready" 라는 메시지가 표시된다.

서버가 준비되면 아래와 같이 브라우저에서 7000 포트로 접속해서 확인할 수 있다. 

jhat에 대해서 간단하게 소개하였지만 사용하기 편하지는 않다. 

필자는 이번 글에서는 Eclipse Memory Analyzer 을 사용해서 Heap Dump 를 분석할 예정이다.


Eclipse Memory Analyzer(MAT)

아래 링크에서 다운로드 받아서 설치할 수 있다. 

https://www.eclipse.org/mat/

 

Heap Dump 분석 사례 분석

간단한 애플리케이션을 만들어서, Heap Dump 를 분석해보자.  

필자의 소스코드는 엉망이니, 코딩을 따라하지 말고 눈으로 보고 이해하길 바란다.  


애플리케이션 구성

스프링부트 2.2.5.RELEASE 버전으로 시작한다.

Spring Cloud Task 프로젝트를 만들기 위해서 아래와 같이 spring-cloud-starter-task 디펜던시를 추가한다.

스프링 클라우드 버전은 아래와 같이 Hoxton.SR1 이다. 

데이터 매핑을 위해서, Coffee 라는 이름의 허접한(?) 클래스를 아래와 같이 만들었다.

HashSet 자료구조에 300만의 Coffee 데이터를 저장하는 코드이다.  

해당 애플리케이션은 쿠버네티스에 배포할 것이다. 쿠버네티스에서 Pod이 실행되면 300만개의 Coffee 인스턴스가 HashSet 에 저장된다.


쿠버네티스에서 실행

쿠버네티스에 배포하기 위해서 Spring Cloud Dataflow(SCDF)를 사용한다. (참고로 클라우드 런타임 환경인 쿠버네티스에 실행시켰을 뿐이며, 로컬 환경에서 실행해도 똑같은 결과가 나온다.) 도커 이미지 빌드, 도커 허브 푸쉬, SCDF의 Apps 에 Task 를 등록 한 후 최종적으로 Tasks 에 신규 테스크를 등록하였다. 

배포 시 쿠버네티스에서 실행하는 Pod 의 메모리를 1기가로 제한해보자.

Launch the task 를 실행하면, 쿠버네티스에서 애플리케이션이 실행한다. 

이때 중요한 사실은, Pod 의 메모리 제한이 1기가로 제한이다. Pod 의 Yaml 을 확인해보자 .

1기가를 넘게 되면 Out Of Memory : Java heap space 장애가 발생한다. 애플리케이션 실행 로그를 확인해보자. 


메모리를 많이 사용하는 범인(?) 찾기(Heap Dump 분석)

HeapDump를 분석해보자. Eclipse Memory Analyzer(MAT)를 실행해서 Heap Dump 파일을 오픈하면 아래와 같이 바로 분석해준다.  


하단에 Leak Suspects 를 클릭하면 아래와 같다. 

친절하게도 문제가 되는 부분을 알려준다.

Details 를 확인해보면 아래와 같다. 

HashSet 에서 Retained Heap 메모리를 엄청나게 사용하는 것으로 보인다.


HashSet 에 300만 데이터를 저장했을 뿐인데, 오버헤드가 이렇게 높게 발생하는건가?? 의문이다...


참고로, Eclipse Memory Analyzer 에서 1기가 이상의 Heap Dump 를 분석하기 위해서는 별도의 설정을 해야한다. 필자는 힙덤프 파일의 용량을 줄여서 테스트를 했고, Eclipse Memory Analyzer 에서 로드하는 힙덤프 사이즈는 1기가를 넘지 않는다. 실제로 필자의 샘플 애플리케이션에서 300만개의 데이터를 HashSet 에 저장하면, 2기가가 넘는 HeapDump 파일이 생성될 것이다.


가장 빠른 해결책

가장 빠른 해결책은 무엇일까? 가용 메모리를 늘려주면 된다. 

SCDF 에서 2기가로 설정해서 쿠버네티스에 배포해보자. 


쿠버네티스에서 신규로 실행 된 Pod 의 Yaml 을 확인해보자. 아래와 같이 메모리 제한이 2기가로 셋팅이 잘 되었다. 

아래와 같이 두개의 Pods를 보면, 아래 빨간색 물음표가 나온 Pod 은 메모리 문제로 실패하였고, 

메모리를 2기가로 설정한 후 실행하였을 땐 정상적으로 성공하였다.


SCDF 에서도 Complete 로 완료되었다. 

어쨋든, Heap Dump 분석을 통해서 메모리를 많이 쓰는 로직을 찾았고, 근본적인 해결책은 아니지만 임시방편으로 가용 메모리를 늘려서 해결하였다. 


자...이제부터는, 근본적인 원인이 되는 HashSet 에 대해서 공부해보자. 

(참고로 미리 말하자면, 공부만 할뿐... 해결책에 대해서는 이 글에서 제시하지 않는다.)


HashSet 을 사용하는 것이 왜 문제가 되나?

필자는 그동안 자바 개발자로 살아오면서 HashSet 사용이 문제가 될거라고 생각해 본적이 없었다. 하지만, 이번 사례를 통해서 HashSet 또는 HashMap 등을 사용할 때 메모리 효율에 대해서 생각해야 한다는 사실을 깨달았다. 


전회사에서의 비슷한 사례를 기억속에서 다시 꺼내기...

전회사에서의 경험은 꺼내고 싶지 않지만... 전회사에서 검색포털서비스를 운영하면서 겪은 경험을 소개한다. 검색 컨텐츠 데이터를 제공하는 API 서버를 운영하였다. 해당 API 서버는 자바 스프링 기반이며, 핵심 로직에서 컨텐츠 데이터를 HashMap에 저장한다. HashMap 에 저장된 캐시 메모리는 사용자의 요청에 매우 빠르게 응답할 수 있었지만, 안타깝게도 60기가의 메모리를 사용하는 심각한 문제가 생기게 되었다. 필자는, 해당 애플리케이션의 캐시 메모리를 공용 캐시로 분리하였다. 아래 글을 참고하길 바란다. 

https://brunch.co.kr/@springboot/173

공용 캐시인 레디스에 데이터를 전부 저장하였는데, 최종 저장 데이터는 5기가를 넘지 않았다. 


두번의 사례를 통해서 필자의 깨달음을 정리하면,

HashMap 또는 HashSet 에 대용량의 데이터를 저장하면, 자바 애플리케이션에 심각한 메모리 오버헤드를 발생할 수 있기 때문에, 주의가 필요하다는 사실을 깨닫게 되었다.


Retain Heap

글 초반에 설명했던, Heap Dump 분석 결과를 다시 살펴보자. 

엄청나게 많은 Retained Heap 를 사용하는 것으로 보인다. 



Retained Heap 관련해서

[JVM Performance Optimizing 및 성능분석 사례 - 류길현,오명훈,한승민 저, 엑셈출판사] 의 212page ~ 212page 내용을 참고하였다. 

Shallow Heap 과 Retained Heap 은 아래와 같다. 

Shallow Heap : 해당 객체의 메모리

Retained Heap : 객체의 메모리와 해당 객체에 의해서만 직접, 간접적으로 참조하는다른 객체들을 포함한 메모리

Retained Heap 메모리는 자신의 메모리 공간의 크기와 자신이 GC 될 때 함께 GC 될 수 있는 참조하고 있는 객체의 메모리 공간의 크기를 모두 합산한 크기를 의미한다. Heap 분석 시 Retained Heap 크기가 큰 객체를 찾아내는 것이 메모리 누수를 찾는데 매우 중요하다. 



HashSet 객체가 처음 생성되고 할당되는 메모리는 높지 않지만, Retained Heap 메모리가 엄청나게 발생하는 것을 알 수 있다. 


Java Collections HashSet OverHead

간단한 두줄의 코드를 보자.


private Set<Pojo> hashSet = new HashSet<>();

hashSet.add(pojo);


필자에게 위 코드는 너무 자연스럽고 당연한 코드로 받아들여진다. 하지만, 이렇게 간단한 2줄의 코드가 심각한 메모리 오버헤드를 발생할 수 있다니...


참고로, HashSet 은 Set의 구현체이며, HashMap 의 래퍼클래스이다. HaspMap 과 유사하지만, 중복되는 요소를 제한하는 기능이 추가되었다. 그래서, HashMap 에 비해서 약간의 메모리 오버헤드가 더 발생한다. 

HashSet에 비교해서 HashMap의 메모리 오버헤드가 낮다고 생각하면 안된다. 메모리를 많이 잡아먹는건 둘다 마찬가지로 높다. 도찐개찐이다. HashSet 이 중복제거 기능으로 인해서 조금 더 높을 뿐,,,



그렇다면, 도대체 왜? 

HashSet 은 메모리 오버헤드가 많이 발생하는가? 

필자가 더 많은 경험을 하고, 백엔드 개발에 노하우가 생기는 시점에서 다시 글을 작성하겠다. 

자바 컬렉션의 메모리 효율에 대해서 기술적으로 설명하기에는 필자는 아직 많이 부족하다.



Put your fat Collections on a diet

아래 아티클을 읽어보길 바란다. 글의 제목은 "Put your fat Collections on a diet!" 이다. 

https://dzone.com/articles/put-your-fat-collections-diet


우리가 편하게 사용했던 HashMap, HashSet 등이 메모리를 이렇게 많이 잡아먹는다는 사실을 우리는 명심해야 하지만, 그렇다고 해서 Java Collectoin 를 사용하지 말자는 얘기는 절대 아니다. 거의 매일 사용하고 있으며, 앞으로도 사용하게 될 것이다. 


오해가 없기를 바란다. HashSet 를 사용하지 말자는 얘기가 절대 아니다.


성능 vs 메모리 반비례 관계(?)

비록 Hash 계열의 Collection 클래스의 메모리 효율은 낮지만, 반대로 성능은 매우 좋을 것이다. 만약, 성능이 크게 중요하지 않고, 메모리 효율이 중요한 상황이라면 HashSet 또는 HashMap의 사용에 대해서 고민이 필요하다.


힙메모리 베스트 프래틱스

"자바 성능 튜닝(자바 성능 향상을 위한 완벽 가이드) - 스캇 오크스, 최가인역 비제이퍼블릭" 249page ~ 310page 에 읽을만한 내용이 있다. 


마무리

이번 글에서는, Heap Dump 분석을 통해서 HashSet 의 메모리 효율에 대해서 살펴보았다. 다시 한번 강조하지만 이 글은 HashSet 를 사용하지 말자고 주장하는 글이 아니다. 소프트웨어를 만들 때는, 성능과 메모리 사이에 균형을 잡아야 하며 완벽한 정답은 없다. 


그때그때 상황에 맞게 가장 효율적인 소프트웨어를 만들기 위해서 항상 연구를 해야한다.


레퍼런스

https://brunch.co.kr/@springboot/173

https://www.ibm.com/developerworks/library/j-codetoheap/index.html

https://dzone.com/articles/put-your-fat-collections-diet

브런치는 최신 브라우저에 최적화 되어있습니다. IE chrome safari