콘텐츠로 이동

Java OOM 예외 관측 가능성 모범 사례


자주 발생하는 OOM 예외 시나리오

  1. 힙 오버플로우 - java.lang.OutOfMemoryError: Java heap space.
  2. 스택 오버플로우 - java.lang.OutOfMemorryError.
  3. 스택 오버플로우 - java.lang.StackOverFlowError.
  4. 메타스페이스 오버플로우 - java.lang.OutOfMemoryError: Metaspace.
  5. 직접 메모리 오버플로우 - java.lang.OutOfMemoryError: Direct buffer memory.
  6. GC 초과 - java.lang.OutOfMemoryError: GC overhead limit exceeded.

가비지 컬렉터

가비지 컬렉터는 메모리 회수를 실행하는 주체입니다. 제조사와 가상 머신 버전에 따라 포함된 가비지 컬렉터가 크게 다를 수 있으며, 각 가상 머신은 일반적으로 사용자의 애플리케이션 특성과 요구 사항에 따라 각 메모리 세대에 사용할 컬렉터를 조합할 수 있도록 다양한 매개변수를 제공합니다. — 《JAVA 가상 머신 깊이 이해하기》

가비지 컬렉터(가비지 수집기라고도 함)에 관해 《JAVA 가상 머신 깊이 이해하기》 제3판 목차에는 대부분의 가비지 컬렉터가 이미 나열되어 있습니다. 아래 그림을 참조하세요. java_oom_1.png

로컬 JVM 가비지 컬렉터 확인

명령어 java -XX:+PrintFlagsFinal -version |FINDSTR /i ":"를 실행하여 로컬 가비지 컬렉터가 Parallel인지 확인합니다.

C:\Users\lenovo>java -XX:+PrintFlagsFinal -version |FINDSTR /i ":"
     intx CICompilerCount                          := 4                                   {product}
    uintx InitialHeapSize                          := 266338304                           {product}
    uintx MaxHeapSize                              := 4257218560                          {product}
    uintx MaxNewSize                               := 1418723328                          {product}
    uintx MinHeapDeltaBytes                        := 524288                              {product}
    uintx NewSize                                  := 88604672                            {product}
    uintx OldSize                                  := 177733632                           {product}
     bool PrintFlagsFinal                          := true                                {product}
     bool UseCompressedClassPointers               := true                                {lp64_product}
     bool UseCompressedOops                        := true                                {lp64_product}
     bool UseLargePagesIndividualAllocation        := false                               {pd product}
     bool UseParallelGC                            := true                                {product}
java version "1.8.0_101"
Java(TM) SE Runtime Environment (build 1.8.0_101-b13)
Java HotSpot(TM) 64-Bit Server VM (build 25.101-b13, mixed mode)

K8s 환경의 JVM 가비지 컬렉터 확인

K8s 환경에서는 일반적으로 openjdk:8-jdk-alpine 또는 openjdk:8u292를 기본 이미지로 사용하며, 서비스를 시작한 후 가비지 컬렉터가 활성화되어 있지 않음을 확인할 수 있습니다.

root@ruoyi-system-c9c54dbd5-ltcvf:/data/app# 
root@ruoyi-system-c9c54dbd5-ltcvf:/data/app# java -XX:+PrintCommandLineFlags -version
-XX:InitialHeapSize=8388608 -XX:MaxHeapSize=134217728 -XX:+PrintCommandLineFlags -XX:+UseCompressedClassPointers -XX:+UseCompressedOops 
openjdk version "1.8.0_292"
OpenJDK Runtime Environment (build 1.8.0_292-b10)
OpenJDK 64-Bit Server VM (build 25.292-b10, mixed mode)
root@ruoyi-system-c9c54dbd5-ltcvf:/data/app# 

전제 조건

1. JDK 버전 1.8 (JDK8이라고도 함)

각 JDK의 가비지 컬렉션 메커니즘은 서로 다르며 메모리 구조도 크게 변경되었습니다. 특히 1.6, 1.7, 1.8 버전에서 차이가 두드러집니다. 현재 대부분의 기업은 JDK 1.8 버전을 사용하므로 이 모범 사례도 JDK 1.8 버전을 기준으로 합니다. 다른 버전의 JDK를 사용하는 경우에도 이 접근 방식을 참고할 수 있습니다.

2. JVM 관측 가능성 연동

먼저 JVM 관측 가능성을 연동하세요. Guance 보기에서 초기 힙 메모리가 80M임을 확인할 수 있으며, 이는 시작 시 지정한 매개변수와 일치합니다.

image.png

3. 로그 관측 가능성 연동

참조: **Kubernetes 클러스터에서 로그 수집의 다양한 방법. 이번에는 주로 Socket 방식을 사용하지만 다른 방식을 사용해도 됩니다.**

힙 오버플로우 - java.lang.OutOfMemoryError: Java heap space

힙 오버플로우 예외는 매우 흔합니다. 힙 내 객체를 더 이상 회수할 수 없고 힙 메모리가 지속적으로 증가하여 힙 메모리의 최대값에 도달하면 데이터가 가득 차서 발생합니다. 오버플로우를 발생시키는 코드 샘플을 바로 보여드리겠습니다. 최대 힙 메모리를 -Xmx80m으로 설정하여 실행하면 곧바로 오류가 발생합니다.

1. 시작 매개변수

-Xmx80m -javaagent:C:/"Program Files"/datakit/data/dd-java-agent.jar -Ddd.service.name=system -Ddd.agent.port=9529

2. 요청

브라우저에서 http://localhost:9201/exec/heapOOM을 요청하면 일정 시간 후에 예외 출력을 확인할 수 있습니다. 예외 출력이 확인되면 Guance으로 이동하여 해당 로그를 확인하세요.

3. Guance 로그 확인

image.png

스택 오버플로우 - java.lang.OutOfMemorryError

다음과 같은 예외가 발생합니다. 실제로 스레드를 생성해야 하는 경우 프레임 스택 크기 -Xss512k를 조정해야 합니다. 기본 프레임 스택 크기는 1M이며, 크기를 줄이면 더 많은 스레드를 생성할 수 있습니다. 프레임 스택이 부족한 경우 어디에서 많은 스레드가 생성되는지 파악해야 합니다. 운영 환경에서는 jstack 명령어를 사용하여 현재 스레드 상태를 파일로 내보낸 후 fastthread.io 웹사이트에 업로드하여 분석합니다. 코드에 실제로 많은 스레드가 필요한 경우, 【JVM 총 메모리 - 힙 = n * Java 가상 머신 스택】 공식을 통해 힙 메모리 또는 Xss를 줄여 할당 가능한 스레드 수를 늘릴 수 있습니다.

1. 시작 매개변수

-Xmx80m -javaagent:C:/"Program Files"/datakit/data/dd-java-agent.jar -Ddd.service.name=system -Ddd.agent.port=9529

2. 요청

브라우저 주소: http://localhost:9201/exec/stackOOM

3. Guance 로그 확인

순간적으로 스레드가 생성되면 JVM 자체 도구가 스레드 관련 모니터링 지표를 더 이상 보고하지 않습니다. Guance는 여전히 최신 JVM 모니터링 지표를 보고합니다.

image.png

일정 시간 후 JVM 자체 도구에 예외가 발생합니다.

image.png

이후 시스템이 응답하지 않는 현상이 나타납니다.

image.png

스택 오버플로우 - java.lang.StackOverFlowError

주로 재귀 호출, 무한 루프에서 발생합니다. 스택 프레임이 너무 크거나 가상 머신 스택 용량이 너무 작아 새로운 스택 프레임 메모리를 할당할 수 없을 때 HotSpot 가상 머신은 StackOverFlowError 예외를 던집니다. 프로그램이 재귀를 수행할 때마다 데이터 결과(포인터 등)를 스택에 푸시하며, 더 많은 재귀 호출을 견디기 위해 프레임 스택이 더 커야 합니다.

1. 시작 매개변수

-Xmx80m -javaagent:C:/"Program Files"/datakit/data/dd-java-agent.jar -Ddd.service.name=system -Ddd.agent.port=9529

2. 요청

브라우저에서 http://localhost:9201/exec/stackOFE 요청

3. Guance 로그 확인

image.png

메타스페이스 오버플로우 - java.lang.OutOfMemoryError: Metaspace

JDK 8 이후로 PermGen 영역은 완전히 사라졌고, Metaspace가 그 자리를 대체하며 메타데이터 영역이 메서드 영역이 되었습니다. 기본 설정에서는 가상 머신이 메서드 영역(메타데이터 영역)의 오버플로우 예외를 발생시키기 어렵습니다. 클래스 관련 정보, 상수 풀, 메서드 설명자, 필드 설명자 등을 저장하며, 런타임에 많은 수의 클래스가 생성되면 이 영역이 오버플로우됩니다. 시작 시 XX:MetaspaceSizeXX:MaxMetaspaceSize를 너무 작게 설정하면 바로 시작 오류가 발생합니다.

1. 시작 매개변수

-Xmx80m -XX:MetaspaceSize=30M -XX:MaxMetaspaceSize=90M -javaagent:C:/"Program Files"/datakit/data/dd-java-agent.jar -Ddd.service.name=system -Ddd.agent.port=9529

2. 요청

브라우저에서 http://localhost:9201/exec/metaspaceOOM 입력. 3. 로그 확인 메타데이터 오버플로우 발생 후에는 로그 등에 더 이상 기록되지 않습니다.

직접 메모리 오버플로우 - java.lang.OutOfMemoryError: Direct buffer memory

직접 메모리 오버플로우는 힙 메모리 외에도 직접 메모리(힙 외부 메모리)를 사용할 때 발생합니다. NIO는 성능 향상을 위해 Java Heap과 Native Heap 간 전환을 피하기 위해 직접 메모리를 사용합니다. 기본적으로 직접 메모리 크기는 힙 메모리 크기와 동일합니다. 힙 외부 메모리는 JVM의 제한을 받지 않지만, 전체 시스템 메모리의 제한을 받습니다. 아래 코드는 힙 최대 메모리를 80m, 직접 메모리를 70m로 설정하고 매번 1M씩 할당하여 리스트에 추가합니다. 70회(Spring Boot 애플리케이션의 경우 70회 미만) 출력 후 다음 할당 시 nested exception is java.lang.OutOfMemoryError: Direct buffer memory 오류가 발생합니다.

1. 시작 매개변수

-Xmx80m -javaagent:C:/"Program Files"/datakit/data/dd-java-agent.jar -Ddd.service.name=system -Ddd.agent.port=9529

2. 요청

브라우저에서 http://localhost:9201/exec/directBufferOOM 입력.

3. Guance 로그 확인

image.png

GC 초과 - java.lang.OutOfMemoryError: GC overhead limit exceeded

앞의 세 가지 경우 모두 GC 초과를 유발할 수 있습니다. JDK 1.6 이후에 추가된 오류 유형으로, 힙 메모리가 너무 작을 때 발생합니다. GC의 98% 시간 동안 2% 미만의 메모리를 회수할 수 없을 때 이 오류가 발생하며, 즉 최소/최대 메모리에 문제가 있을 때 발생합니다.

Guance

어떤 예외든 Guance의 JVM 모니터링 보기에서 단서를 찾을 수 있으며, 로그 상황과 함께 JVM 매개변수를 튜닝할 수 있습니다. GC 횟수 과다/과소, GC 시간 과다, 스레드 급증, 힙 메모리 급증 등은 모두 주의해야 할 신호입니다.

image.png

Guance OOM 로그 알림

위의 여러 OOM 예외 시나리오는 예외가 발생하는 방식과 Guance에 어떻게 표시되는지 보여주기 위한 것입니다. 실제 운영 환경에서는 OOM 예외가 비즈니스 로직에 영향을 미치고 심각할 경우 시스템 중단을 유발할 수 있습니다. Guance의 알림 기능을 활용하여 관련 담당자에게 신속히 통지하고 조치를 취할 수 있습니다.

StackOverflowError 예외 탐지 구성

image.png

OutOfMemoryError 예외 탐지 구성

image.png

알림 통지 구성

모니터 목록 - 그룹, 알림 통지 버튼 클릭

image.png

통지 대상 구성, Guance는 여러 통지 방식을 지원하며 현재는 이메일 통지를 사용하고 있습니다.

image.png

예외가 발생하면 이메일 통지를 수신할 수 있으며 내용은 다음과 같습니다.

image.png

데모 코드

본 프로그램 코드는 RuoYi 마이크로서비스 프레임워크에서 데모되었습니다.

package com.ruoyi.system.controller;

import com.ruoyi.common.core.domain.system.SysDept;
import com.ruoyi.common.core.web.domain.AjaxResult;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.MethodInterceptor;
import org.springframework.cglib.proxy.MethodProxy;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.lang.reflect.Method;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * @author liurui
 * @date 2022/4/11 9:28
 */
@RequestMapping("/exec")
@RestController
public class ExceptionController {

    @GetMapping("/heapOOM")
    public AjaxResult heapOOM() {
        List<SysDept> list = new ArrayList<>();
        while (true) {
            try {
                TimeUnit.MILLISECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            list.add(new SysDept());
        }
    }

    @GetMapping("/stackOOM")
    public AjaxResult stackOOM() {
        while (true) {
            Thread thread = new Thread(() -> {
                while (true) {
                    try {
                        TimeUnit.HOURS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }

            });
            thread.start();
        }
    }

    @GetMapping("/directBufferOOM")
    public AjaxResult directBufferOOM() {
        final int _1M = 1024 * 1024 * 1;
        List<ByteBuffer> buffers = new ArrayList<>();
        int count = 1;
        while (true) {
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1M);
            buffers.add(byteBuffer);
            System.out.println(count++);
        }
    }

    @GetMapping("/stackOFE")
    public AjaxResult StackOFE() {
        stackOverFlowErrorMethod();
        return AjaxResult.success();
    }

    public static void stackOverFlowErrorMethod() {
        stackOverFlowErrorMethod();
    }

    @GetMapping("/metaspaceOOM")
    public AjaxResult metaspaceOOM() {
        while (true) {
            Enhancer enhancer = new Enhancer();
            enhancer.setSuperclass(SysDept.class);
            enhancer.setUseCache(false);
            enhancer.setCallback(new MethodInterceptor() {
                @Override
                public Object intercept(Object obj, Method method,
                                        Object[] args, MethodProxy proxy) throws Throwable {
                    return proxy.invokeSuper(obj, args);
                }
            });
            enhancer.create();
        }
    }
}

문서 평가

이 페이지가 도움이 되었나요?