본문 바로가기

java/java

JAVA memory 관리

 

Java 를 사용해서 애플리케이션을 운영하다보면 메모리 관리가 중요한 부분이라는건 모두가 알게된다.  지금까지 내가 알고 있던 지식들 그리고 잘 몰랐던 부분들을 한번에 정리를 하면서 java에서 메모리가 어떻게 할당되고 사용되고 관리되어야하는지 정리하는 글을 써보았다. 

 

JVM의 메모리 설정 

 

java에서 메모리 설정을 주기 위한 옵션 자체는 여러가지가 있지만 가장 가장 기본적인 설정은 

-Xms(최소 메모리)와 -Xmx(최대 메모리)이다 이전에는 -Xms(초기 할당 메모리)를 작게 설정하고, 애플리케이션이 필요할 때 -Xmx(최대 메모리)까지 점진적으로 늘리는 방식이 일반적이었다. 하지만 최근에는 -Xms -Xmx를 동일하게 설정하는 것이 더 권장되는 추세이다. 

(참고로 이 설정은 힙 메모리에 대한 설정이다 비힙 메모리는 포함되지 않는다) 

 

과거에 낮게 시작했던 이유는 물리적 메모리 (RAM)자체가 지금보다 훨씬 적었기 때문, 초기부터 크게 메모리를 차지하면 다른 프로레스들이 부족한 메모리로 OOM 이 발생할 위험이 있었다고 한다) 

 

1. 메모리 리사이징(Resizing) 오버헤드 감소

 

JVM은 기본적으로 애플리케이션의 메모리 사용량이 증가하면 운영체제(OS)로부터 추가 메모리를 할당받아서 Committed 크기를 증가시킨다.  이 과정에서 메모리 할당 요청과 GC 튜닝이 추가로 발생할 수 있는데, 이런 동적인 메모리 확장이 오버헤드를 유발할 수 있다.

 

👉 -Xms -Xmx를 동일하게 설정하면, 초기 실행 시 OS에서 바로 최대 메모리를 확보하기 때문에 추가적인 확장 과정이 필요 없어지고, 성능이 더 안정적

ex) 

-Xms=512m -Xmx=2g 512MB  |  사용량 증가하면 2GB까지 점진적으로 늘어남 (메모리 확장 오버헤드 발생)

-Xms=2g -Xmx=2g                    |  시작부터 2GB를 확보하여 불필요한 메모리 확장 과정이 생략됨

 

 

2. GC(Garbage Collection) 성능 최적화

 

JVM이 메모리를 동적으로 확장할 때, GC는 힙 크기가 변경된 것을 인식하고 조정해야 한다. 이 과정에서 GC 튜닝이 필요할 수도 있고, GC가 힙 크기를 다시 최적화하는 오버헤드가 발생할 수 있는데 이러한 것을 사전에 방지 할 수 있다. 

 

 

3. 클라우드 환경 & 컨테이너(Kubernetes, Docker) 최적화

 

컨테이너 환경에서는 JVM이 동적으로 메모리를 늘리면, 컨테이너의 메모리 리밋을 초과할 가능성이 있다.

예를 들어, -Xmx를 2GB로 설정했지만 -Xms를 512MB로 설정하면, 컨테이너가 처음에는 512MB만 사용하다가 점진적으로 늘어나면서 컨테이너의 제한을 초과할 수도 있어.

 

👉 컨테이너에서 -Xms -Xmx를 동일하게 설정하면, JVM이 시작부터 안정적으로 일정한 메모리를 사용하도록 보장할 수 있음.

👉 예를 들어, Kubernetes에서 리소스 요청/제한(requests/limits)을 맞출 때도 더 예측 가능해짐.

 

 

 

 OS에서 JVM 메모리가 할당되는 과정

  • JVM이 실행될 때, -Xms (초기 Heap 크기)만큼의 메모리를 최소한 확보함. (Committed가 Xms만큼 할당됨)
  • 애플리케이션이 실행되면서 객체를 생성하고, 점점 더 많은 메모리를 사용하면 JVM은 필요할 때마다 OS로부터 추가 메모리를 요청함.
  • OS에서 메모리를 허용하면 Committed 크기가 증가함.
  • 하지만 Max에 도달하면 더 이상 요청할 수 없음 → 메모리 부족(OutOfMemoryError) 발생 가능.
  • GC가 발생하면 사용하지 않는 메모리가 해제되지만, Committed 메모리가 줄어들지는 않을 수도 있음 (OS에 반환하지 않는 경우도 있음).

 

 

할당된 힙 메모리

 

1. Max(jvm_memory_max_bytes)

  • JVM이 사용할 수 있는 최대 메모리 한도
  • Xmx 옵션으로 설정됨.
  • 하지만 이 값이 바로 OS에서 할당된 것은 아님!
  • - JVM이 필요하면 점진적으로 더 많은 메모리를 확보할 수 있음.

 

2. Committed(jvm_memory_committed_bytes)

  • JVM이 현재 운영체제로부터 실제로 확보(예약)한 메모리 크기
  • Committed ≤ Max (최대 한도보다 클 수 없음)
  • 힙 영역에서 GC가 발생하면, 이 값이 줄어들 수도 있음.
  • JVM이 필요에 따라 OS로부터 추가로 메모리를 요청할 수도 있음.

 

3. Used(jvm_memory_used_bytes)

  • JVM이 실제로 사용 중인 메모리 크기
  • Used ≤ Committed (사용한 메모리는 확보한 메모리를 초과할 수 없음)
힙 메모리

jvm에서 힙 메모리는 애플리케이션이 실행 중에 동적으로 생성하는 데이터등이 저장되는 공간이다.
저장되는 데이터로는 new 키워드로 생성된 객체, 배열 데이터, 객체가 갖는멤버 변수, 런타임 생성된 데이터(익명 클래스 람다 등) 
등이 있다. 

 

스택 메모리

jvm은 크게 힙과 스택 메모리로 나뉜다는 소리를 많이 하는데 

스택에는 보통 메서드 호출 정보, 원시 타입 변수(int, double)들이 저장되는데 이때 스택은 운영체제의 네이티브 메모리에 할당된다 
즉 힙은 jvm이 관리하고 스택 메모리는 os가 관리한다 

 

 

 

JVM 메모리 관련 지표

 

spring boot 환경에서 보통 지표의 수집을 위해서 prometheus 를 많이 사용하는데 단순하게 메모리 뿐만 아니라 주로 사용될 수 있는 다양한 지표들 그리고 그런 지표들이 어떤 정보를 전달해주는지 간단하게 정리해보았다. 

Prometheus Metrix 설명
jvm_memory_used_bytes{area="heap"} 힙(Heap) 메모리 사용량
jvm_memory_used_bytes{area="nonheap"} 비힙(Non-Heap) 메모리 사용량 (메타데이터, 클래스 정보, 네이티브 메모리 등)
jvm_memory_committed_bytes{area="heap"} Heap에서 OS가 할당한 메모리
jvm_memory_committed_bytes{area="nonheap"} Non-Heap에서 OS가 할당한 메모리
jvm_memory_max_bytes{area="heap"} Heap 메모리의 최대 크기 (-Xmx)
jvm_memory_max_bytes{area="nonheap"} Non-Heap 메모리의 최대 크기 (-XX:MaxMetaspaceSize)
jvm_gc_pause_seconds_count GC 발생 횟수
{action="end of minor GC"} Minor GC 발생 시간
action="end of major GC"}  Major GC발생 시간
jvm_gc_pause_seconds_sum 총 GC 시간 (초 단위)
{action="end of minor GC"} Minor GC 발생 시간
{action="end of major GC"}  Major GC발생 시간
jvm_memory_used_bytes{area="nonheap", id="Metaspace"} Metaspace 사용량

jvm_memory_committed_bytes{area="nonheap", id="Metaspace"} OS에서 할당받은 Metaspace 메모리

jvm_memory_max_bytes{area="nonheap", id="Metaspace"} Metaspace 최대 크기 (-XX:MaxMetaspaceSize)

jvm_memory_used_bytes{area="nonheap", id="Compressed Class Space"} Compressed Class Space 사용량

jvm_memory_used_bytes{area="nonheap", id="Code Cache"} JIT 컴파일 코드 캐시 사용량
jvm_memory_used_bytes{area="nonheap", id="Direct"} Direct ByteBuffer(NIO) 메모리 사용량

jvm_threads_live_threads{} 현재 실행중인 스레드
jvm_threads_daemon_threads{} 현재 실행중인 데몬 스레드 
(데몬 스레드는 gc스레드, 타이버 스레드, 등등 기본 스레드)
jvm_threads_daemon_threads{} 각 스레드의 상태에 있는 스레드 수 
jvm_threads_peak_threads{} jvm이 시작된 이후 최대 총 스레드 수 

 

 

 

지표 확인

- Heap 메모리에 너무 가까워지면 OOM이 위험이 있다 

- Non-Heap 메모리가 너무 크면 Metaspace (클래스 로딩 관련 메모리) 가 과다 사용될 가능성이있다

- jvm_gc_pause_seconds_count 가 갑자기 급증하면 GC가 자주 발생하는 상태일 가능성이 높다

- jvm_gc_pause_seconds{action="end of major GC"} 값이 너무 크면 Full GC가 길어지고 있고, 메모리 문제가 생길 가능성이 있다

- Metaspace 사용량이 급격히 증가하면클래스 로딩이 과도하게 발생하는 것일 수 있다

- Direct 영역 사용량이 너무 크면 네이티브 메모리 부족으로 인한 OOM 위험이 있다

- Code Cache 사용량이 점진적으로 증가하면 JIT 컴파일이 최적화되지 않거나, 리소스를 과다하게 사용 중일 가능성이 있다 

- virtual thread 의 경우는 os가 아니라 jvm 내부에서 관리하는 스레드이다. 

 

비힙 메모리
비힙 메모리 영역은 힙 메모리의 바깥에서 JVM이 내부적으로 사용하는 메모리의 영역이다. 
대표적인 비 힘 메모리는 아래와 같다 

- Metaspace 
클래스의 메타데이터 저장  (class metadata) . 참고로 class 파일 그 자체가 아니라 jvm이 실행중에 클래스를 로딩하면서 관리하는 정보들을 의미한다. 클래스가 어떻게 동작해야하는지 (구조, 상속, 메서드 정보 등) 에 대한 모든 정보를 jvm이 메모리에 저장하는것 

- Compressed Class Space( 압축된 클래스 공간) 
metaspace 내부에서 compressed class space라는 별도의 영역이 존재. metaspace 내부에서 클래스 참조 정보를 압축해서 저장하는 별도의 공간.  (메모리 절약 목적) 

- JIT 컴파일 코드 캐시 (code cache)
jvm의 jit 컴파일러가 최적화된 바디트를 저장하는 공간이다.
java는 .class   실행되지만 jvm은 이를 실시간으로 네이티브 머신 코드로 변환해서 실행되는데 이 과정이 JIT 컴파일이다. jit 컴파일된 코드는 더 빠르게 실행되기 때문에 jvm은 변환된 코드를 캐싱해서 다음에 더 빠르게 실행되게한다 

- Direct ByteBuffer (네이티브 메모리) 
jvm 힙이 아닌 네이티브 메모리에 직접 버퍼를 할당 하는 케이스에서 할당되는 네이티브 메모리 공간이다 
네트워크 프로그래밍(NIO)나 대용량 데이터를 처리할 때 gc의 영향을 받지 않도록 direct memory를 활용하는 경우가 많다 
(Direct Memory는 GC의 관리 대상이 아니기 때문에, 과도하게 사용하면 OOM(OutOfMemoryError) 발생 위험이 있다)

 

Virtual Thread의 실행 
virtual thread는 jvm내부의 carrier thread(운반 스레드) 위에서 동작한다. 
jvm이 소수의 플랫폼 스레드(carrier thread)를 유지하면서 그 위에 수많은 virtual thread를 실행하는 방식이다. 
carrier thread는 일반적으로 cpu 코어 수 만큼 유지된다. (보ㅍ녀적으로 1이면 1개 2면 2개, Runtime.getRuntime().availableProcessors() 명령어로 확인해볼 수 있다)

 

 

주요 지표 promql

#promql


#Heap 메모리 사용률
jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100

# 비 heap 메모리 사용률 
jvm_memory_used_bytes{area="nonheap"} / jvm_memory_max_bytes{area="nonheap"} * 100

# Metaspace 사용률(%)
jvm_memory_used_bytes{area="nonheap", id="Metaspace"} / jvm_memory_max_bytes{area="nonheap", id="Metaspace"} * 100

# GC 발생 총 시간 
rate(jvm_gc_pause_seconds_sum[1m]) / rate(jvm_gc_pause_seconds_count[1m])

# GC 발생 횟수 
rate(jvm_gc_pause_seconds_count[1m])

# Direct ByteBuffer 사용량 
jvm_memory_used_bytes{area="nonheap", id="Direct"} / jvm_memory_max_bytes{area="nonheap", id="Direct"} * 100

#virtual thread가 사용하는 힙 메모리 모니터링
jvm_memory_used_bytes{area="heap"} / jvm_memory_max_bytes{area="heap"} * 100

 

참고로 promql에서 rate() 는 변화량을 나타낸다 
rate( [1m]) 이런 명령어는 일분사이 해당 지표의 변화량

 

컨테이너 환경에서 사용하는 메모리 

이렇게 메모리 관련해서 정리를 해봤는데 그러면 내가 쿠버네티스 환경에서 컨테이너에 애플리케이션을 올릴때 정확히 어떻게 메모리가 구성되는걸까를 좀 더 정리를 해보면 아래와 같다. 

 

컨테이너의 전체 메모리 

  • 힙 메모리
  • 비힙 메모리 (metaspace, code cache , direcy memory 등) 
  • 네이티브 메모리 (OS Thread Stack, File Buffer, Network Buffer 등) 
  • 기타 메모리 (JVM 자체 프로세스 관리, JIT 컴파일러 등)

위와 같이 다양한 메모리 사용처가 있기 때문에 힙 메모리를 2g만 설정해도 그것 보다 실제 사용 메모리는 더 높다. 

그래서 보통 kubernetes 환경에서 reqeusts.memory와 limits.memory(이 값을 초과하면 컨테이너 OOM발생 후 강제 종료) 를 설정하게 되는데 그거는 컨테이너가 사용할 수 있는 전체 메모리를 의미하는거고 내가 설정하는 jvm의 xmx는 이 메모리중 일부라고 보면된다. 

 

 

그래서 보편적으로 컨테이너 메모리가 2gb이상이라고 했을때 xmx 설정값은 그 메모리값의 60~ 65% 정도로 설정한다