본문 바로가기
[ Error ]/JAVA

[ Java ] OutOfMemoryError(With 힙덤프) 에러 분석해보기

by 환이s 2024. 5. 29.


 

안녕하세요🤚

오늘은 실무에서 프로젝트 진행 중 Heapdumponoutofmemoryerror 가 발생해서

해당 에러의 개념과 실전 예제 코드를 통해서 알아보겠습니다 😄

 

 

Heapdumponoutofmemoryerror 옵션은

Java 애플리케이션이 메모리 부족(OutOfMemoryError)

오류가 발생했을 때 발생합니다.

 

Java는 개체를 Heap(힙) 공간에 생성하고

이 생성 위치에 대한 주소를 가지고 Object Reference(개체 참조)하는

 방식으로 사용합니다.

 

개체를 생성하는 과정에서 Heap 공간에 개체를 할당하기 위한 공간이 부족한 경우 발생하는데,

이 경우 가비지 컬렉터는 새로운 개체를 생성할 수 있는 공간을 확보할 수 없습니다.

 

드물게 가비지 컬렉션을 수행하는데 과도한 시간이

소비되며 메모리를 사용하지 못하는 상황에서도 발생할 수 있는데,

 

제가 OutOfMemoryError를 접하게 된 상황이

바로 이 부분이었습니다😇

 

실무에서 프로젝트 기능 개발과 단위 테스트를 끝내고

통합 테스트를 진행하던 중... SM 업무 요청 사항으로

운영 DB에서 사용자 데이터 조회를 시도했는데...

 

쿼리가 동작될 때까지 기다리다가

10분이 경과돼도 데이터가 나오지 않아

조회를 멈추고 다른 작업을 하고 있었는데

과장님이 사이트가 동작이 멈췄다고 다급하게 외치셨습니다.. 🥹

 

모니터링 화면을 확인해 보니

쿼리가 99+ 요청이 나오면서 처리가 안되고

그대로 운영 사이트가 멈춰버린 것이었습니다.

 

서버에서 실시간 로그를 확인해 보니

OutOfMemoryError가 발생했다는 걸 확인하고

DB 측에 현재 요청 들어온 쿼리 전부 삭제 요청 드렸는데도

해결이 되지 않아, 결국 서버를 재가동시킨 아찔한 상황이었습니다..^^

 

즉, 제 상황을 참고해서 알아보자면 

Java Heap 공간에 새로운 개체를 생성할 수 없는 경우에 에러가 발생합니다.

 

 

그렇다고 OutOfMemoryError가 반드시 메모리 누수를 의미하는 것은 아닙니다.

 

정확히는 지정한 Heap 크기(혹은 기본 크기)가

애플리케이션에 충분하지 않은 경우에 발생하는 겁니다.

 

그러면 OutOfMemoryError 예외의 종류와 원인이 무엇인지 하나씩 알아가 봅시다👌

 


 

java.lang.OutOfMemoryError: Java heap space

 

Java Heap이 일시적인 과도한 요구 또는 지속적인 누수로 인해

더 이상 요청한 메모리를 할당할 수 없을 때 발생합니다.

 

특정 프로그램에서 한 번에 많은 메모리를 할당하는 경우라면

-Xmx 옵션으로 Heap 크기를 늘려서 해결할 수 있으나,

 

지속적 누수로 인한 경우라면 Heap dump를 떠서 누수 포인트를 찾아야 합니다.

 

간단한 예제 코드를 통해서 해당 에러를 발생시켜보겠습니다.

import java.util.ArrayList;
import java.util.List;

public class HeapSpaceOutOfMemoryExample {
    public static void main(String[] args) {
        List<int[]> list = new ArrayList<>();
        try {
            while (true) {
                list.add(new int[1_000_000]); // 큰 배열을 계속 추가
            }
        } catch (OutOfMemoryError e) {
            System.out.println("Caught OutOfMemoryError: Java heap space");
            e.printStackTrace();
        }
    }
}

 

위 코드를 보시면 큰 배열을 계속해서 추가하여 Heap 메모리를 소진시키고

OutOfMemoryError: Java heap space 오류를 발생시킵니다.

// 콘솔
Caught OutOfMemoryError: Java heap space
java.lang.OutOfMemoryError: Java heap space
    at HeapSpaceOutOfMemoryExample.main(HeapSpaceOutOfMemoryExample.java:8)

java.lang.OutOfMemoryError: Metaspace

MetaSpace는 Java 8 이후부터 사용하는데,

Class Meta Data를 저장하는 데 사용하는 Native 영역의 메모리입니다.

 

Meta Data는 클래스 정의에 대한 정보(클래스의 이름/생성자 정보/ 필드 정보/메서드 정보)를 제공하고

Java의 ClassLoader가 현재까지 로드한 Class들의 Meta Data가 저장되는 공간입니다.

 

해당 에러는 MetaSpace가 가득 찰 때 발생합니다.

일반적으로 Class를 무한정 생성하는 경우는 많이 없기 때문에

MetaSpace 에러가 발생하는 경우에는 대부분 메모리 할당량을 늘려주는 것으로 해결되지만

간혹 Libarary들이 Class들을 양산하고 있을 수 있습니다.

 

import javassist.ClassPool;

public class MetaspaceOutOfMemoryExample {
    public static void main(String[] args) {
        ClassPool classPool = ClassPool.getDefault();
        try {
            for (int i = 0; i < Integer.MAX_VALUE; i++) {
                Class<?> clazz = classPool.makeClass("Class" + i).toClass();
            }
        } catch (OutOfMemoryError e) {
            System.out.println("Caught OutOfMemoryError: Metaspace");
            e.printStackTrace();
        }
    }
}

 

위 코드는 javassist 라이브러리를 사용하여

대량의 클래스를 동적으로 생성하여 MetaSpace를 소진시키고 에러를 발생시킵니다.

//콘솔
Caught OutOfMemoryError: Metaspace
java.lang.OutOfMemoryError: Metaspace
    at javassist.ClassPool.toClass(ClassPool.java:1105)
    at javassist.ClassPool.toClass(ClassPool.java:1064)
    at MetaspaceOutOfMemoryExample.main(MetaspaceOutOfMemoryExample.java:10)

java.lang.OutOfMemoryError: GC overhead limit exceeded

 

GC overhead limit exceeded는 가비지 컬렉터 실행과정에서

Java 프로그램이 느려지는 경우에 발생합니다.

 

가비지 수집 후, Java 프로세스가 Java 컬렉션을 수행하는데

걸리는 시간의 약 98% 이상을 소비하고 Heap의 2% 미만이 복구된 상태에서

지금까지 수행하는 과정에서 가비지 컬렉션 중

 

java.lang.OutOfMemoryError가 5번 이상 생성되는 경우에 발생합니다.

 

이 예외는 일반적으로 데이터를 할당하는 데

필요한 공간이 Heap에 없는 경우에 발생합니다.

 

import java.util.ArrayList;
import java.util.List;

public class GCOverheadLimitExceededExample {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();
        try {
            while (true) {
                list.add(String.valueOf(System.nanoTime()).intern());
            }
        } catch (OutOfMemoryError e) {
            System.out.println("Caught OutOfMemoryError: GC overhead limit exceeded");
            e.printStackTrace();
        }
    }
}

 

위 코드는 많은 양의 작은 문자열을 생성하여 Heap 메모리를 소진시키고,

JVM이 가비지 컬렉션을 빈번하게 수행하게 만들어 예외를 발생시킵니다.

 

위 예외가 발생했을 때는

Heap 크기를 늘리거나,
-xx:-UseGCOverheadLimit 선택사항을 추가하여

java.lang.OutOfMemoryError가 발생하는 초과 오버헤드 GC 제한 명령을 해제할 수 있습니다.

//콘솔
Caught OutOfMemoryError: GC overhead limit exceeded
java.lang.OutOfMemoryError: GC overhead limit exceeded
    at java.base/java.lang.String.intern(Native Method)
    at GCOverheadLimitExceededExample.main(GCOverheadLimitExceededExample.java:8)

 


java.lang.OutOfMemoryError: Direct buffer memory

 

Direct buffer memory  예외는 JVM이 직접 버퍼 메모리를 더 이상 할당할 수 없을 때 발생합니다.

버퍼는 주로 네이티브 메모리를 사용하여 JVM Heap 외부에 할당되며, 주로 I/O 작업에 사용됩니다.

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class DirectBufferMemoryExample {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();
        try {
            while (true) {
                buffers.add(ByteBuffer.allocateDirect(10 * 1024 * 1024)); // 10MB 크기의 직접 버퍼를 계속 추가
            }
        } catch (OutOfMemoryError e) {
            System.out.println("Caught OutOfMemoryError: Direct buffer memory");
            e.printStackTrace();
        }
    }
}

 

위 예제는 직접 버퍼를 계속해서 할당하여 네이티브 메모리를 소진시키고

예외를 발생시킵니다.

Caught OutOfMemoryError: Direct buffer memory
java.lang.OutOfMemoryError: Direct buffer memory
    at java.base/java.nio.Bits.reserveMemory(Bits.java:175)
    at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:112)
    at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:327)
    at DirectBufferMemoryExample.main(DirectBufferMemoryExample.java:8)

 

위 예외를 해결하기 위해서는

JVM의 최대 직접 메모리 크기를 증가시키거나,

애플리케이션 내에서 직접 버퍼의 사용량을 관리하고,

메모리 릭을 방지해야 합니다.

 

또한, 가비지 컬렉션 설정을 조정하고 네이티브 메모리 모니터링 도구를 사용하여

메모리 사용 패턴을 분석할 수 있습니다.

 

모니터링 도구로는 VisualVM가 있습니다.

 

VisualVM: Home

News: April 24, 2024: VisualVM for VS Code Integration Extension Released The VisualVM for VS Code extension has been released. It integrates the VisualVM tool with Visual Studio Code. More information is available at http://visualvm.github.io/idesupport.h

visualvm.github.io

 

VisualVM은 JDK에 포함된 버전과 깃헙에서 제공하는 버전,

2가지 버전이 존재합니다.

 

Oracle JDK 9부터 VisualVM은 GraalVM에 대한 분석도 가능해졌습니다.

GraalVM에서 실행되는 애플리케이션을 분석하는 용도로 유용한 듯싶습니다.

 

 


java.lang.OutOfMemoryError: Unable to create new native thread

 

Unable to create new native thread 예외는

OS에서 어떤 이유로 스레드를 생성하는데 실패했을 때 발생합니다.

 

메모리가 부족해서일 수도 있고,

OS에서 쓰레드 개수를 제한해서 일 수도 있습니다.

 

Top으로 확인했을 때 free 메모리가 많이 남아 있다면 OS에서 건 제한이 원인일 확률이 높습니다.

 

import java.util.ArrayList;
import java.util.List;

public class UnableToCreateNewNativeThreadExample {
    public static void main(String[] args) {
        List<Thread> threads = new ArrayList<>();
        try {
            while (true) {
                Thread thread = new Thread(() -> {
                    try {
                        Thread.sleep(Long.MAX_VALUE);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
                thread.start();
                threads.add(thread);
            }
        } catch (OutOfMemoryError e) {
            System.out.println("Caught OutOfMemoryError: Unable to create new native thread");
            e.printStackTrace();
        }
    }
}

 

위 예제는 새로운 스레드를 계속해서 생성하여 시스템 스레드 자원을 소진시키고

해당 예외를 발생시킵니다.

 

//콘솔
Caught OutOfMemoryError: Unable to create new native thread
java.lang.OutOfMemoryError: Unable to create new native thread
    at java.base/java.lang.Thread.start0(Native Method)
    at java.base/java.lang.Thread.start(Thread.java:813)
    at UnableToCreateNewNativeThreadExample.main(UnableToCreateNewNativeThreadExample.java:13)

 

 

해결 방법으로는 다양한데, 먼저

운영 체제의 스레드 수 제한을 확인하고 필요에 따라 조정하는 방법입니다.

 

리눅스의 경우, 'ulimit' 명령을 사용하여 사용자당 최대 스레드 수를 확인하고 설정할 수 있습니다.

 

ulimit -u

 

현재 사용자당 스레드 수를 확인하고 제한을 늘려줍니다.

ulimit -u 5000  # 예를 들어, 최대 스레드 수를 5000으로 설정

 

 

두 번째로는 시스템의 메모리와 CPU 사용량을 모니터링하고 최적화합니다.

너무 많은 스레드를 생성하지 않도록 애플리케이션 로직을 조정합니다.

 

또한, 각 스레드의 스레드의 스택 크기를 줄이면 더 많은 스레드를 생성할 수 있습니다.

JVM 옵션의 '-Xss'를 사용하여 스레드 스택 크기를 설정할 수 있습니다.

 

예를 들어, 기본 스택 크기를 612KB로 설정한다고 가정한다면 아래처럼 설정할 수 있습니다,

java -Xss612k -cp . YourMainClass

 

 

마지막으로 직접 스레드를 생성하는 대신,

스레드 풀을 사용하여 스레드 관리를 효율적으로 해주는 방식인데,

Java의 'ExecutorService'를 사용하면 스레드 풀을 쉽게 구현할 수 있습니다.

 

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 고정된 크기의 스레드 풀 생성
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        for (int i = 0; i < 100; i++) {
            executorService.execute(() -> {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
                System.out.println(Thread.currentThread().getName() + " is working");
            });
        }

        // 스레드 풀 종료
        executorService.shutdown();
    }
}

 


마치며

 

오늘은 OutOfMemoryError 예외에 대해 알아봤습니다.

다음 포스팅에서 뵙겠습니다.

728x90