본문으로 건너뛰기
개발 머꼬
개발 노트Java
hohyeon.dev6

OutOfMemoryError: Java heap space 해결 방법, 메모리가 남는데도 힙이 터지는 이유

  • #Common Pitfall
  • #Engineering Note
  • #Java

문제 발생

한 달 치 주문 CSV를 읽어 정산 합계를 내는 배치였습니다. 파일은 112MB 정도였고, 노트북에서는 몇 초 만에 끝났습니다. 그런데 메모리 512MB로 잡아 둔 컨테이너에 올리자 첫 실행에서 바로 죽었습니다.

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.base/java.lang.StringUTF16.compress(StringUTF16.java:215)
	at java.base/java.lang.String.<init>(String.java:4760)
	at java.base/java.lang.String.<init>(String.java:303)
	at java.base/java.io.BufferedReader.implReadLine(BufferedReader.java:403)
	at java.base/java.io.BufferedReader.readLine(BufferedReader.java:347)
	at java.base/java.io.BufferedReader.readLine(BufferedReader.java:436)
	at java.base/java.nio.file.Files.readAllLines(Files.java:3408)

이상한 건 모니터링이었습니다. 죽기 직전 컨테이너 메모리 사용량은 한도의 절반에도 못 미쳤습니다. 메모리가 남아 있는데 메모리가 부족하다고 죽은 겁니다. 검색하면 "-Xmx를 늘리세요"가 제일 먼저 나오는데, 얼마로 늘려야 하는지, 늘려도 되는 건지부터 알 수 없었습니다.

이 글은 Java 21에서 재현했습니다. 컨테이너를 직접 띄울 수 없는 환경이라, JVM이 보는 메모리 크기를 -XX:MaxRAM으로 지정해 같은 조건을 만들었습니다.

원인 분석

Java heap space는 "힙에 객체를 더 둘 자리가 없다"는 뜻입니다. Oracle의 메모리 누수 문제 해결 가이드는 이 메시지를 Java 힙에 객체를 할당하지 못했다는 뜻으로 설명합니다. 그리고 이게 반드시 메모리 누수를 의미하지는 않는다고 짚습니다. 지정한 힙 크기나, 지정하지 않았다면 기본 크기가 애플리케이션에 부족한 단순한 설정 문제일 수 있다고요. 반대로 오래 도는 애플리케이션이라면 의도치 않게 객체 참조를 계속 쥐고 있어서 GC가 회수하지 못하는 누수일 수도 있습니다. 같은 메시지 뒤에 원인이 두 갈래인 겁니다.

JVM은 메모리의 25%만 힙으로 씁니다. java 명령 문서에 따르면 -Xmx의 기본값은 실행 시점에 시스템 구성을 보고 정해지고, -XX:MaxRAMPercentage의 기본값은 25%입니다. 여기서 기준이 되는 메모리(-XX:MaxRAM)는 JVM 프로세스가 쓸 수 있는 메모리입니다. 컨테이너 지원(UseContainerSupport)은 기본으로 켜져 있어서, 컨테이너 안에서는 호스트 메모리가 아니라 컨테이너 한도가 기준이 됩니다. JVM이 보는 메모리만 바꿔 가며 최대 힙 크기를 찍어 보니 정확히 4분의 1이었습니다.

MaxRAM=1g  -> MaxHeapSize 256 MB
MaxRAM=2g  -> MaxHeapSize 512 MB
MaxRAM=4g  -> MaxHeapSize 1024 MB
MaxRAM=16g -> MaxHeapSize 4096 MB

512MB 컨테이너라면 힙은 128MB입니다. 메타스페이스나 스레드 스택처럼 힙 바깥에서 쓰는 메모리도 있긴 하지만, 이 배치처럼 데이터를 힙에 쌓는 프로그램에게 나머지 384MB는 대부분 쓰지 못하는 메모리입니다. 모니터링에서 메모리가 남아 보였던 이유가 이겁니다. 같은 프로그램을 JVM이 보는 메모리 크기만 바꿔 돌려 보니 512MB에서는 Java heap space로 죽고, 1GB와 노트북 크기인 16GB에서는 끝까지 돌았습니다.

그리고 코드가 파일 전체를 힙에 올리고 있었습니다. 스택 트레이스 맨 아래가 Files.readAllLines입니다. 112MB짜리 파일의 모든 줄을 String으로 만들어 리스트에 담고 있었습니다. 줄마다 객체 오버헤드가 붙고, 한글이 섞인 문자열은 글자당 2바이트로 저장되니 힙에서는 파일 크기보다 더 많은 자리를 차지합니다. 그래서 같은 -Xmx64m로 줄 단위로 흘려 처리하는 버전을 돌리자 150만 줄을 문제없이 다 읽었습니다. 설정도 빠듯했지만, 데이터가 커질수록 언젠가는 터질 구조였던 겁니다.

메시지 뒤 설명이 다르면 다른 문제입니다. 같은 Oracle 가이드에 따르면 GC overhead limit exceeded는 GC가 대부분의 시간을 쓰면서도 힙을 거의 회수하지 못하는 상태이고, Metaspace는 클래스 메타데이터를 담는 네이티브 메모리가 바닥난 경우입니다. 이 글은 Java heap space만 다룹니다.

해결 방안

  1. 먼저 지금 힙이 얼마인지 확인합니다. 추측으로 숫자를 바꾸기 전에, 실제로 돌 환경에서 최대 힙 크기를 봅니다. 컨테이너 안에서 java -XshowSettings:vm -version을 실행하면 바로 나옵니다. 512MB 조건에서 돌려 보니 Max. Heap Size (Estimated): 128.00M이었습니다. 실행 중인 프로세스라면 jcmd <pid> VM.flags로 볼 수 있고, 같은 조건에서 -XX:MaxHeapSize=134217728(128MB)이 찍혔습니다. 노트북에서 된다고 서버에서도 되는 게 아닌 이유가 이 숫자 하나로 설명됩니다.

  2. 컨테이너에서는 -Xmx보다 -XX:MaxRAMPercentage로 지정합니다. -Xmx에 고정값을 적으면 컨테이너 한도를 바꿀 때마다 같이 고쳐야 하고, 깜빡하고 한도보다 크게 남겨 두면 힙이 자라다 컨테이너 자체가 강제 종료될 수 있습니다. 이건 예전 글 "컨테이너가 랜덤하게 재시작됐는데 알고 보니 OOMKilled였던 경우"에서 다룬 상황입니다. 비율로 지정하면 한도를 따라갑니다.

JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"

java 문서의 예시도 75입니다. 2GB 조건에서 확인해 보니 최대 힙이 512MB에서 1536MB로 늘었고, 512MB 조건에서 죽던 배치도 75%로 주자 끝까지 돌았습니다. 100%로 주지 않는 건 힙 바깥에서 쓰는 메모리 자리를 남겨 두기 위해서입니다. 스레드가 많거나 네이티브 메모리를 쓰는 라이브러리가 있다면 더 낮춰야 합니다.

  1. 큰 데이터는 한 번에 올리지 말고 흘려 처리합니다. 힙을 늘리는 건 데이터가 그만큼 커질 때까지 시간을 버는 것뿐입니다. 이 배치는 합계만 필요했으니 줄을 모아 둘 이유가 없었습니다.
try (Stream<String> lines = Files.lines(Path.of("orders.csv"))) {
    long sum = lines.mapToLong(l -> Long.parseLong(l.split(",")[2])).sum();
}

이렇게 바꾸자 -Xmx64m에서도 같은 결과가 나왔습니다. DB에서 읽는 코드도 원리는 같습니다. 전체 목록을 한 번에 받아 리스트로 만드는 대신, 나눠서 읽고 처리한 묶음은 바로 놓아줍니다.

  1. 힙을 늘려도 다시 터지면 누수를 의심하고 증거부터 남깁니다. 며칠씩 도는 서버에서 시간이 지날수록 터진다면 설정보다 누수일 가능성이 큽니다. java 문서에 따르면 -XX:+HeapDumpOnOutOfMemoryError를 켜면 OutOfMemoryError가 날 때 힙 덤프를 남기고, 기본은 꺼져 있습니다. 경로는 -XX:HeapDumpPath로 정합니다. 덤프 파일은 힙 크기만큼 커서, 96MB 힙에서 102MB 파일이 나왔습니다. 컨테이너라면 재시작해도 남는 볼륨을 경로로 줍니다. 덤프를 기다리지 않고 추세를 보려면 Oracle 가이드대로 jcmd <pid> GC.class_histogram을 간격을 두고 여러 번 찍어 비교합니다. 요청마다 정적 HashMap에 세션을 넣고 지우지 않는 코드로 재현해 보니 4초 사이에 이렇게 늘었습니다.
   4:         12201         292824  Leak$Session
   4:         28601         686424  Leak$Session

GC를 거쳐도 줄지 않고 계속 느는 클래스가 있으면, 그 객체를 누가 붙잡고 있는지 덤프에서 따라가면 됩니다.

  1. 한 스레드만 죽고 서버는 멀쩡해 보이는 상태를 막습니다. OutOfMemoryError는 메모리를 할당하려던 스레드에서 던져집니다. 작업 스레드 하나에서 힙을 다 쓰게 만들어 보니, 그 스레드는 Exception in thread "export-worker" java.lang.OutOfMemoryError: Java heap space를 남기고 죽었지만 메인 스레드는 계속 살아서 출력을 이어 갔고 프로세스는 종료 코드 0으로 끝났습니다. 서버라면 일부 기능만 망가진 채로 계속 요청을 받는 상태가 됩니다. JDK 8u92 릴리스 노트에 추가된 -XX:+ExitOnOutOfMemoryError는 첫 OutOfMemoryError에서 JVM을 종료합니다. 노트에도 메모리 부족 에러를 처리하기보다 JVM을 재시작하는 쪽을 선호할 때 쓰라고 적혀 있습니다. 같은 코드에 이 옵션과 힙 덤프 옵션을 함께 주니 덤프를 먼저 남긴 뒤 Terminating due to java.lang.OutOfMemoryError: Java heap space를 출력하고 종료 코드 3으로 끝났습니다. 컨테이너 환경이라면 재시작은 오케스트레이터에 맡기고, 원인은 덤프로 찾습니다.

공식 문서

마지막 수정

좋아요북마크

댓글0

아직 댓글이 없어요. 첫 의견을 편하게 남겨 보세요.