Java heap space가 부족해요

HiroPark·2023년 1월 12일
1

문제해결

목록 보기
7/11

ec2 인스턴스에서 gradle build가 지속적으로 실패하는 문제가 발생했습니다.

처음 만난 문제는 이거였습니다.

What went wrong:
Gradle build daemon disappeared unexpectedly (it may have been killed or may have crashed)

안일하게도 해당 에러를 그대로 복사 붙여넣기 후 구글링 하여
처음 만난 블로그 를 보고 그대로 적용해 주었으나, 될리가 없죠...

Task :compileTestJava FAILED
FAILURE: Build failed with an exception.

  • What went wrong:
    Execution failed for task ':compileTestJava'.
    -> Java heap space

그니깐, 근본적으로 JVM의 힙이 모자랐기 때문에 생긴 문제였던 것인데, 데몬 없이 빌드를 하는 것이 근원적인 해결책이 될 수는 없었던 것입니다.

여전히 "Java heap Space" 가 부족해요! 라는 에러가 발생했습니다.
JVM의 heap space가 부족한 것입니다.

이렇게 된김에 JVM에 대해 구조에 대해 정리하고 가겠습니다.

JVM

  • JVM의 메모리 구조입니다.

  • 밑은 JVM 자체의 구조입니다

JVM은 컴퓨터에서 자바 코드를 시행하는 Virtual Machine 입니다.

  • 자바 컴파일러가 컴파일을 거쳐 .class 형태로 바이트코드(기계어) 화 된 컴파일 파일을 만들면,
  • 이를 JVM의 클래스 로더가 JVM 메모리 영역으로 가져옵니다.
  • 이렇게 JVM에 로딩된 바이트 코드들을 Execution Engine이 명령어 단위로 읽어서 실행합니다
  • 이러한 실행 과정 중에서 JVM은 필요에 따라서 스레드 동기화, 가비지 컬렉션 등으로 메모리와 자원을 관리합니다.
  • Runtime Data Area(JVM memory) 는 실질적인 수행을 책임집니다.

Runtime Data Area(JVM memory)

JVM의 메모리 구성은 운영체제나 , JVM의 구현에 따라 달라질 수 있다만, 제가 알기로는 대체적으로 이렇습니다.

Higher Memory Addresses
    | Stack (function call frames, local variables)
    | 
    |
    | JVM Heap (dynamic memory allocation)
    | 
    |
    | Method Area
    |
    |
Lower Memory Addresses
  • 스택 : 지역 변수, 매개 변수등의 임시 데이터를 위한 공간.
    함수가 호출될 때마다 함수 호출 프레임이 스택에 쌓여서 스택을 구성합니다. 메모리의 아래(낮은 주소)를 향해 영역을 확장하지만, 일반적으로 고정된 크기를 갖습니다.

  • 힙 : 런타임에 dynamic memory allocation(동적 할당 - 런타임에 메모리를 할당)으로 할당되는 데이터가 저장됩니다. new를 통해 객체나 배열 등의 인스턴스가 생성되면 이곳에 할당됩니다. 메모리의 위(높은 주소)를 향해 영역을 확장합니다.

  • 메서드 영역 : JVM의 시작과 함께 생성됩니다.
    JVM이 읽은 클래스에 대한 정보와 클래스 변수(static 변수)가 저장됩니다.
    더불어 상수(constants)의 pool도 저장됩니다.

PC register와 Native Method Stack도 메모리 상에 존재하나, 다소 다른 역할을 갖고 있기 때문에 힙과 스택과의 높낮이 차를 비교할 바는 아닙니다.

  • PC레지스터 : 메모리의 "특정 구획" 이라 볼 수 없습니다. 프로그램의 현재 실행중인 명령어의 주소를 가집니다.

  • 네이티브 메서드 스택: 자바가 아닌 다른 언어 로 쓰인 코드를 위한 스택입니다. Java Native Interface(JNI)가 해당 영역을 사용합니다.

이들 중, 제게 지금 부족한 힙 영역 에 대해 자세히 알아보겠습니다.

  • Young Generation
    : 말 그대로 새로운 객체를 할당하기 위해 확보한 공간입니다.
    사진에서는 Eden과 From 과 To로 나눠져있는데 Eden과 From+To를 합쳐서 "Survivor" 로 생각하면 될 듯합니다.
    처음 객체가 생성되면 에덴에 할당됩니다. 성경에서 최초의 인간이 탄생(?)한 곳이 에덴인데, 이름에서 장소의 특성을 바로 알아볼 수 있습니다.
    JVM은 이 에덴에서 주기적으로 GC를 돌립니다. 만약 여기에서 살아남는다면 살아남은 객체들은 Survivor로 넘어갑니다. 여러번의 GC에서 살아남은 생존 객체들은 Old generation으로 보냅니다.

  • Old Generation(Tenured)
    : 말했듯이 Young에서 살아남은 객체들이 존재합니다. Old가 일반적으로 Young 보다 크기 때문에 가바지컬렉션이 young보다 적게 발생합니다.

  • Permanent Generation
    : 클래스나 메소드 객체 같은 클래스 레벨의 정보를 저장하는데 사용됩니다.
    또한 JVM 자신이 사용하는 클래스 로더나, 쓰레드 객체등의 내부 객체도 저장합니다.
    => Java 8 부터는 힙에 해당하지 않습니다. 용어도 "Meta Space"로 바뀌었습니다

아무튼! 이 Heap 공간이 부족하답니다. 그냥 늘려줄까 하는데, 부작용이 있을 수도 있을까 하여 검색을 해보았습니다.

Max Heap Size(Xmx)를 늘리는 것은

  • GC 시간을 증가시킨다.
    따라서 애플리케이션이 얼마만큼의 메모리를 필요로 하는지 , 다른 애플리케이션이나 서버의 메모리 필요는 어느정도인지를 고려해야 한다고 합니다.

고로, 애플리케이션에 적당한 크기로 힙 사이즈를 키워줘야 하지만, 너무 커서는 안된다네요.
최대 크기에서 두배씩 줄여가며 문제가 되지 않는 지점을 찾는 것을 추천하네요.
저는 현재 크기에서 두배씩 늘려가며 적정선을 찾아보겠습니다.
t2.micro 인스턴스를 사용하기 때문에 사실 키울 수 있는 선택의 폭이 그렇게 크지는 않습니다..

java -XX:+PrintFlagsFinal -version 2>&1 | grep -i -E 'heapsize|permsize|version'

로 현재 힙 사이즈를 확인해봅니다.

바이트 단위이기 때문에, 250mb 정도의 최대 크기를 갖고 있는 것을 알 수 있네요.

어느 정도까지 늘릴 수 있을까요?
free 명령어를 사용하여 현재 메모리가 얼마나 가용한지 확인해봅니다.

바이트 단위인가 싶어서 엥? 했는데, free -h 로 human readable 하게 알려달라고 하면 대략 500mb 정도가 남았다고 친절하게 알려줍니다.

아무튼, Xmx를 두배로 늘려봐도 괜찮을 듯 합니다.

와중에, 자잘한 문제로 sudo nohup으로 jar파일을 실행해서 그런가, sudo nohup으로 실행한 프로세스와 그냥 nohup으로 실행한 프로세스 두개가 돌고 있는 문제를 찾았습니다.

이 문제에 대해서는 자세한 원인은 모르겠다만, sudo로 돌린 아이가 슈퍼유저 권한을 가져서, 배포 스크립트 내의 kill명령어에도 죽지 않은것이라 추측해봅니다. 해당 프로세스를 sudo kill -9 pid로 죽여주고

sudo 대신 일반 nohup으로 jar을 실행시켜줍니다만... 왜 sudo를 넣었는지 과거의 나의 의도를 바로 파악할 수 있었습니다. 현재 80 포트에서 애플리케이션이 실행되는데, 리눅스는 1024 이하 포트는 root에게만 권한을 열어주죠. 8080으로 포트를 옮기고, 로드밸런서의 타겟을 80에서 8080으로 조정해주고, sudo를 빼주었습니다.

원래의 논의로 돌아와서, 이제 진짜 heap size를 늘려보겠습니다.

heap size 늘리기

구글링해서 나오는 여러가지 방법을 시도해봤는데, 작동한 것은 단 하나였습니다.

실패한 것들
1. added org.gradle.jvmargs=-Xmx512m at application.properties
2. run "export CATALINA_OPTS=-Xmx512m;" in terminal
3. run "export JAVA_OPTS="-Xmx512m";" in terminal
4. added -Xmx512m in running shell script file like "java -Xmx512m -jar myapp.jar"

다 실패했습니다. "java -XshowSettings:vm" 을 사용하면 친절하게 힙 사이즈를 알려주는데, 해당 명령어들을 돌려봐도 여전히, 250mb였습니다.

마지막으로 해당 링크를 따라해봤는데, 드디어 성공했습니다.
https://medium.com/@hwimalasiri/how-to-increase-maximum-heap-size-of-jvm-in-ubuntu-e836b15284eb

왜 1~4는 먹히지 않았을까요?? 나름의 추측과 Chat gpt의 도움을 받아보았습니다.

  1. org.gradle.jvmargs=-Xmx512m 를application.properties에 추가

    : 애플리케이션 실행에만 영향을 주는 옵션, 이 애플리케이션을 구동하는 JVM자체에 영향을 주지 않는다.(JVM의 최대 크기가 이것보다 작기 때문에 의미가 없다)

  2. "export JAVA_OPTS="-Xmx512m" 나 "export CATALINA_OPTS=-Xmx512m;" 실행
    : JAVA_OPTS나 CATALINA_OPTS는 톰캣 서버를 구동시 JVM옵션을 설정하기 위해 사용하는 환경변수는 맞다만, 모든 JVM이 이를 인지하는 것은 아니기에, 저의 JVM에는 적용이 안됐을 수도 있습니다. 솔직히 이 친구들이 먹히지 않은 이유는 잘 모르겠습니다.

  3. "java -Xmx512m -jar myapp.jar"를 실행 shell script 파일에 추가
    : 이것도 1번과 마찬가지로 jar 파일 수행시에만 영향을 주지, JVM자체에는 영향을 미치지는 못합니다. 그리고, 저의 경우는 빌드 과정에서 실패했던 것인데, jar을 실행할때 힙을 늘려주려 해봐야 의미가 없죠, 시간상 후순위니까요..

반대로, export JAVAOPTIONS= =Xmx512m을 /etc/profile 에 더해주는 것이 먹혔던 이유는 해당 파일에 있는 환경변수는 jvm을 사용하는 모든 유저와 프로세스에 적용되기 때문입니다.

이 방식은 JVM이 시작시 해당 옵션들을 적용할 것을 보장 하기에 스프링 부트 애플리케이션에 JVM옵션을 설정하는 가장 확실한 방식입니다.

이전의 방법들과 달리, 해당 방식은 jvm자체의 설정을 바꿈으로써 실제 힙 메모리를 늘리는데 성공한 것입니다

힙 사이즈를 늘려주니 드디어 빌드가 잘 됩니다. 맨 처음의 데몬 문제만 다시금 짚고 끝내겠습니다.

솔직히 "데몬"에 대해서 자세한 개념을 몰라서, 이참에 공부하는 계기로 삼았습니다.

daemon

  • Gradle 빌드시, Gradle daemon은 백그라운드에서 돌면서 메모리에 빌드에 대한 정보를 유지하는 역할.
  • 이를 통해 그레이들은 더 빨리 시작할 수 있고, 다시 빌드를 실행할때 속도를 단축 가능. - 겨울에 차의 시동을 미리 걸어두는 것과 같은 것이라 생각하면 됩니다.
  • —no-daemon으로 빌드한다면, 데몬 프로세스 없이 빌드하는 것이기 때문에 그레이들은 각 빌드마다 JVM 프로세스를 새롭게 시작할 것입니다. 따라서 수행 시간에 있어서 속도가 느려질것입니다. 데몬이 있을때와 달리, 추운데 갑자기 차의 시동을 걸려는 것이라 생각하면 될 듯합니다. 특히, 의존성이 많은 큰 규모의 프로젝트 일 수록 더 많은 시간이 소요될 것입니다.
  • 그럼에도 불구하고 데몬이 없이 Gradle build를 하고 싶은 순간들이 있는데,
    • 메모리 : 데몬이 메모리의 많은 양을 차지하기 때문에, 메모리 사용량을 줄이기 위해 데몬 없이 빌드하는 경우가 있습니다
    • 일관성 : 데몬 없이 빌드하는 것은 더 일관되게 빌드할 수 있는 방법입니다. 특히, 일관되지 않은 환경(다양한 머신 등)에서. 왜냐하면 데몬은 이전 빌드에 대한 정보를 가지고 있고, 현재 빌드에 그것들을 사용하기 때문입니다.
      데몬이 이전 빌드에서 정보를 가져오기 때문에, 데몬과 함께한다면 성공하지만, 데몬이 없이는 실패하는 빌드가 있을 수 있습니다.마치 오랫동안 엑셀을 가지고 일하던 사람이 일을 더 빠르고 효율적으로 할수는 있지만, 엑셀 없이는 간단한 계산을 수행할 수 없는 상황과 같습니다.
    • 디버깅 : Gradle daemon 때문에 문제가 발생하는지 알기 위해 데몬을 빼고 빌드해서 트러블 슈팅을 진행합니다
    • 매 빌드가 이전 객체의 영향을 받지 않고, 매번 새롭게 시작하길 원할때

결론적으로, 처음에 난 데몬 문제도 "메모리 공간 부족" 이슈이기 때문에 힙 사이즈를 늘려준 이후 데몬과 함께 빌드하여도 해당 에러가 재발하지는 않았습니다.

profile
https://de-vlog.tistory.com/ 이사중입니다

0개의 댓글