반응형

1. 들어가며


최근 JDK 8에서 JDK 17로 업그레이드하는 과정에서 애플리케이션의 메모리 사용량이 급격히 증가하는 현상이 발생했고, 결국 Kubernetes의 Pod가 OOMKilled 상태로 종료되는 문제가 발생했습니다.

처음에는 자바(Java) 애플리케이션에서 흔히 겪는 Heap 메모리 부족이나 GC 문제일 것으로 예상했습니다. 하지만 로그를 분석해 보니, 예상과는 다르게 JVM Heap이 아닌 외부에서 메모리 문제가 발생하고 있었습니다. 저에게는 다소 낯설고 새로운 유형의 문제였죠.

이 문제를 분석하며 알게 된 중요한 환경 변수 중 하나가 바로 MALLOC_ARENA_MAX였습니다. 이 글에서는 제가 해당 옵션을 이해하기 위해 정리했던 배경지식과 개념들을 적어보려합니다.

 

2. Java 애플리케이션은 메모리를 어떻게 사용할까?


Java를 공부하다보면 흔히 접하는 개념 중 하나가 바로 JVM의 Heap 메모리입니다. 하지만 Java 애플리케이션은 Heap 외에도 다양한 메모리 영역을 사용합니다. 그 중 Native Memory에 대해 알면 앞서 이야기했던 MALLOC_ARENA_MAX를 더 잘 이해할 수 있어 이것부터 정리해보겠습니다.

Native Memory란?

Java 애플리케이션은 Heap 외에도 Native Memory라는 영역을 사용합니다. Native Memory는 JVM이 직접 관리하지 않고 OS에서 관리하는 메모리인데요. 즉, JVM 바깥의 메모리 영역을 의미합니다.

이러한 Native Memory는 다음과 같은 상황에서 사용됩니다.

  1. DirectByteBuffer
  2. JVM의 Thread Stack
  3. JIT 컴파일러
  4. JNI (Java Native Interface)

그럼 각각에 대해서 간단히 알아보겠습니다.

[1] DirectByteBuffer

Java의 ByteBuffer라는 클래스는 두 가지 방식으로 메모리를 할당할 수 있습니다.

  1. Heap 메모리를 사용하는 방식(allocate)
  2. Native 메모리를 사용하는 방식(allocateDirect)
// Heap에 할당
ByteBuffer heapBuffer = ByteBuffer.allocate(1024);

// Native 메모리에 할당
ByteBuffer nativeBuffer = ByteBuffer.allocateDirect(1024);

이렇게 Native 메모리를 사용하는 이유는 주로 파일I/O나 네트워크I/O를 할 때 JVM Heap을 거치지 않고 OS와 직접 데이터를 주고받아 속도를 높이기 위함입니다.

이렇게 할당된 Native 메모리는 JVM이 아닌 OS가 직접 관리하기 때문에 GC의 대상이 아닙니다. 따라서 명시적으로 해제되지 않으면 메모리 누수의 원인이 될 수 있습니다.

[2] JVM Thread Stack

모든 Java Thread는 자신만의 스택을 가지고 있습니다. 스택에는 메소드 호출과 로컬 변수를 저장하는데요. 이 메모리는 Heap이 아닌 Native Memory에 할당됩니다. 또한 기본적으로는 하나의 스레드당 약 1MB의 메모리를 사용합니다.(이건 -Xss 옵션으로 조정 가능)

따라서 너무 많은 스레드를 생성하면 Heap 사용량과는 무관하게 Native 메모리 사용량이 증가할 수 있습니다.

[3] JIT(Just-In-Time) 컴파일러

Java 코드는 바이트코드로 컴파일 된 후, JVM에서 다시 한번 더 네이티브 코드로 변환됩니다. 이 과정을 JIT 컴파일이라 하며, 변환된 코드는 Native Memory에 저장됩니다.

C1, C2 컴파일러가 있으며, 특히 C2 컴파일러는 더 복잡한 최적화를 수행하면서 더 많은 Native Memory를 사용한다고 합니다.

[4] JNI(Java Native Interface)

JNI는 Java가 다른 언어(e.g. C, C++)와 상호작용할 수 있도록 돕는 인터페이스입니다. JNI를 통해 호출한 네이티브 코드는 Java Heap이 아닌 Native 영역에서 직접 메모리를 할당합니다.

예를 들어, JNI 코드가 malloc을 호출하면 이는 Native Memory에 메모리를 할당한 것입니다. 이 영역은 JVM의 GC 대상이 아니기 때문에 개발자가 직접 관리해야 합니다.

Native Memory 중간 정리

Heap 메모리 사용량은 적당한데 애플리케이션이 OOMKilled 되는 경우라면 Native 메모리 문제가 원인일 수 있습니다. 특히 컨테이너 환경에서 RSS(Resident Set Size)가 증가하는 문제를 겪었다면 높은 확률일 수 있죠.

요것을 이해하고 나면 MALLOC_ARENA_MAX와 같은 옵션이 중요한 역할을 할 수 있다는 것을 알 수 있습니다.

 

3. RSS 메모리란 무엇인가?


메모리와 관련된 문제를 분석하다보면 RSS(Resident Set Size)라는 용어를 접하게 될 수 있습니다. 저는 이번에 처음 알게되었는데요. RSS는 Java 애플리케이션의 메모리 문제를 디버깅할 때 중요한 지표가 될 수 있습니다.

RSS는 간단히 말해 OS가 프로세스를 위해 실제로 할당한 물리 메모리의 크기를 말합니다. 즉, 특정 프로세스가 RAM(실제 물리 메모리)에서 차지하고 있는 총 메모리 크기입니다.

예를 들어 실행하고 있는 Java 애플리케이션이 다음과 같은 메모리 사용량을 가진다고 가정해보죠.

  • JVM Heap = 512MB
  • Native Memory = 100MB
  • 기타 (코드, 라이브러리 등) = 50MB

OS가 봤을 때 이 프로세스의 RSS는 이 모든 영역을 포함해서 총 662MB 정도가 됩니다. 즉, RSS는 Heap 메모리 뿐 아니라 프로세스가 사용하는 모든 종류의 메모리를 합산한 값이라고 생각하면 됩니다.

RSS가 높으면 왜 문제가 될까?

RSS 메모리가 높아지면 다음과 같은 시나리오에서 문제가 발생할 수 있습니다.

  1. 컨테이너 환경(e.g. Kubernetes Pod)에서 메모리 사용 제한(limit)을 설정
  2. RSS가 limit을 넘게되면 OOM이 발생
  3. 이 때 Kubernetes는 해당 Pod를 강제로 종료시킴

즉, RSS가 관리되지 않으면 운영 환경에서 갑작스러운 서비스 장애로 이어질 수 있기 때문에 이 지표도 모니터링 대상이 되어야합니다.

RSS 증가 원인은 Heap이 아닐수도 있다.

위에서 언급했던 것처럼 RSS는 Heap으로만 구성되지 않습니다. 따라서 RSS 증가의 원인은 다음과 같은 것들이 될 수 있습니다.

  1. DirectByteBuffer 누수
  2. JNI 코드 메모리 누수
  3. 스레드 수의 급격한 증가 (Thread Stack 증가)
  4. JIT 컴파일러의 메모리 사용 급증
  5. glibc malloc의 Arena로 인한 메모리 사용 증가

이번 글에서 주제로 삼았던 옵션과 가장 밀접한 관련이 바로 5번에 있는데요. 차근차근 알아보도록 하죠.

 

4. malloc과 glibc, arena


지금까지 Native memory, RSS에 대해 알아보았는데요. 이제 RSS가 증가하는 원인 중 하나인 malloc, glibc 그리고 arena에 대해서 알아보려 합니다.

malloc이 무엇인가요?

malloc은 리눅스 시스템에서 메모리를 동적으로 할당할 때 사용하는 가장 기본적인 함수입니다. C를 배워보았다면 한번쯤은 사용해봤을 함수인데요. 간단히 말해, 프로그램이 OS로부터 메모리를 빌려오는 행위입니다.

Java에서는 JVM이 Heap 메로리를 자동으로 관리하기 때문에 이런 함수를 거의 쓸 일이 없지만 JVM도 내부적으로는 native memory 할당을 위해 malloc을 사용하고 있습니다.

예를 들어, Java가 Native 영역(DirectByteBuffer, JNI, JIT 등)에 메모리를 할당할 때 내부적으로는 malloc 함수를 호출합니다.

그럼 glibc는 뭔가요?

glibc는 리눅스 시스템에서 제공하는 대표적인 C 라이브러리입니다. 쉽게 말해 리눅스에서 프로그래밍을 할 때 기본적으로 제공되는 다양한 함수(메모리 관리, 문자열 처리, 파일 I/O)를 모아놓은 도구 모음입니다. 이 glibc 안에 바로 메모리 할당과 관련된 malloc 함수가 포함되어있습니다.

glibc malloc의 특성

멀티 스레드 프로그램에서는 여러 스레드가 동시에 메모리를 요청(malloc)할 수 있는데요. 이 때 하나의 메모리 공간을 여러 스레드가 동시에 접근하면 문제가 발생할 수 있습니다. 이를 해결하기 위해 glibc는 arena 라는 개념을 도입했죠.

 

5. glibc arena와 MALLOC_ARENA_MAX


arena는 glibc malloc에서 사용하는 Memory Pool로 동시에 여러 malloc 요청을 빠르게 처리하기 위해 미리 만들어 둔 공간입니다.

  • 각각의 arena는 독립된 메모리 공간과 관리 구조를 가지고 있습니다.
  • 여러 스레드가 메모리 요청을 할 때, 각각의 스레드는 서로 다른 arena를 사용할 수 있습니다.
  • 결과적으로 여러 개의 arena가 있으면 메모리 할당의 성능이 좋아집니다.

그런데..

각 arena는 독립적으로 관리되기 때문에 각자 일정량의 메모리를 캐시 형태로 보유하고 있습니다. 따라서 arena가 많이 생기면 사용하는 메모리 총합이 늘어날 수 밖에 없죠. 즉, 많은 양의 arena는 성능을 좋게 만들어 줄 순 있지만, 메모리를 trade-off 하는 셈입니다. 즉, RSS 증가를 내어주는 것이죠.

arena 개수를 제한하기 : MALLOC_ARENA_MAX

위에서 언급한 메모리 증가 문제를 해결하기 위해 glibc는 arena의 최대 개수를 제한할 수 있는 환경 변수르 ㄹ제공합니다. 그 환경변수가 바로 MALLOC_ARENA_MAX 입니다.

  • 기본적으로 glibc는 CPU 코어 수의 n 배만큼 arena 상한을 둡니다.(설정마다 다름)
  • 근데 이 때 주의해야할 것이 컨테이너 환경(Pod)에서는 CPU를 1개만 할당했어도, Node의 하드웨어 CPU 개수를 기준으로 arena 개수 상한이 설정되기 때문에 과도한 리소스를 사용할 수 있는 잠재적 위험을 가지고 있습니다.
  • 이 때 MALLOC_ARENA_MAX를 설정하여 강제로 arena 개수를 제한할 수 있습니다.

 

6. 나가며


옵션 하나 이해하려고 이것저것 알아보았는데, 추후 시간이 나면 공부해볼 것 키워드도 남겨둬보겠습니다.

복사했습니다!