본문 바로가기

기술 블로그

Java 스레드의 메모리 관리와 할당에 대한 이해

배경

Spring Boot에서는 Async 어노테이션으로 비동기 메서드를 쉽게 작성할 수 있다. 우리 팀에서 운영 중인 서비스에는 알림 생성 로직을 비동기로 실행하고 있다. 그 알림들 자체는 미래에 발생하기 때문에 동기적으로 작성할 필요가 없고, 유저들에게 응답속도를 높이기 위한 결정으로 보였다.

 

Spring Boot의 코어 기능인 AutoConfiguration 덕분에 ThreadPoolTaskExecutor 빈도 자동으로 등록되지만, 실무에서는 IO-Bound, CPU-Bound 등 목적에 맞는 스레드 풀을 만들어 운영할 필요가 있다. 서로 다른 특성을 가진 작업을 혼합해서 사용하면 비효율이 발생하기 때문이다. CPU 사용이 낮은 IO-Bound 작업은 코어보다 많은 수의 스레드를 풀에 담아 사용하고, CPU 사용률이 높아 컨텍스트 스위칭 비용이 높은 CPU-Bound 작업은 코어 수에 비례해서 제한해야 한다.

 

이러한 최적화 작업을 해보기 앞서 자바 스레드와 메모리 사용에 대한 이해가 필요하다고 생각되어 간단한 실험을 구상하고 진행하였다. 주어진 로컬 환경에서 메모리와 관련된 JVM설정을 파라미터로 하여 몇개의 스레드가 생성되고 OOM과 같은 에러를 발생시키는지 확인하고 이에 대한 결과를 분석해보고자 하였다.

 

방법론

환경

아래와 같은 호스트 및 개발 환경에서 실험을 진행하였다.

 

  • JVM: Corretto-21(21.0.4)
  • OS: macOS 15.4.1(24E263)
  • CPU: Apple M1
  • Memory: 8GB RAM (native memory) 

 

테스트 코드

Spring 어플리케이션과 순수 자바 어플리케이션 둘중에 어느 환경에서 뚜렷한 결과를 볼 수 있을지 고민했다. Spring 어플리케이션은 디폴트로 운영되는 스레드 풀이나 여러 빈들이 등록될것으로 예측되어, 순수 자바 어플리케이션에서 진행을 하였다.

 

주어진 메모리의 한계점까지 스레드를 생성하기 위해 아래와 같은 코드를 작성하였다.

 

public class PlainThreadTest {
    public static void main(String[] args) {
        int count = 0;
        try {
            while (true) {
                new Thread(() -> {
                    try {
                        Thread.sleep(Long.MAX_VALUE);
                    } catch (InterruptedException ignored) {}
                }).start();
                count++;
                if (count % 100 == 0) System.out.println("Created threads: " + count);
            }
        } catch (Throwable t) {
            System.out.println("Crashed at thread count: " + count);
            t.printStackTrace();
        }
    }
}

 

  1. while 문 내에서 Thread 인스턴스 생성
  2. Thread 객체에 내에서 sleep() 메서드 호출
  3. 인터럽트 발생시 무시
  4. 생성된 Thread 인스턴스는 바로 시작하고 카운트 업
  5. 에러 발생시 발생 시점까지 생성된 Thread 개수를 출력

 

실행 명령어

자바 어플리케이션은 아래와 같은 명령어와 옵션으로 실행을 할 수 있으며 힙과 스택 메모리를 결정할 수 있다. 각 명령어와 옵션에 대한 설명도 작성해두었다.

java -Xmx64m -Xss256k PlainThreadTest
java -Xmx64m -Xss1m PlainThreadTest

 

  • java: JVM 실행 명령
  • -Xmx64m: 힙 메모리 최대 크기를 64MB로 설정
  • -Xss256k: 각 스레드 스택 크기를 256KB로 설정
  • PlainThreadTest: 실행할 클래스 이름(컴파일된 .class파일 기준)

Xss 옵션으로 개별 스레드의 스택 사이즈(Stack Size)를 결정할 수 있다. 결과 섹션에서도 서술하겠지만, 주목할 점은 힙 사이즈나 스택 사이즈를 설정해도 처음부터 그 주소공간을 모두 차지하면서 실행되는 방식이 아니다. 스레드가 task를 수행하면서 stack이 깊어지면 그 만큼 할당이 되면서 메모리를 차지하게 되는데, 이렇게 할당된 공간은 스레드가 작업을 마치고 스레드풀로 돌아간다고 해도 빼앗기지 않는다. 할당된 공간은 그대로 이며, 다른 작업을 하면서 새로운 콜 스택이 그 위에 overwrite되는 방식이다. 만약 SS보다 스택이 깊어지게 된다면 그때 스택오버플로우가 발생하게 된다.

 

옵션 설명 예시 단위 기본값 (환경 의존)
-Xms 초기 heap 메모리 크기 -Xms256m KB, MB, GB 보통 1/64 of RAM
-Xmx 최대 heap 메모리 크기 -Xmx512m KB, MB, GB 보통 1/4 of RAM
-Xss 스레드 1개당 stack 크기 -Xss512k B, KB, MB 보통 1MB

 

 

실행 명령어의 경우 256, 512, 1024 처럼 2의 배수로 설정할 필요는 없으며 자유롭게 설정하면 된다. 스레드의 스택사이즈는 보통 1MB로 설정된다고 한다.

 

접미사 의미 예시
k 또는 K kilobytes(1024 bytes) -Xss512k
m 또는 M megabytes(1024 KB) -Xmx512m
g 또는 G gigabytes(1024MB) -Xmx2g
(없음) bytes -Xss65536 = 64KB

 

결과

위에서 작성한 테스트 클래스를 컴파일하고 아래와 같은 명령어로 실행을 하였다. 명령어는 현재 경로를 기준으로 class를 실행시키기 때문에 -cp 옵션으로 클래스 패스(class path) 옵션을 같이 입력해줘야 한다.

 

java -cp src -Xmx64m -Xss256k PlainThreadTest

 

 

 

실행 결과는 아래와 같았다. 2027개의 스레드를 생성하고 OutOfMemory(이하 OOM) 에러를 발생시키며 while문을 빠져나왔다. 하지만, 실행 결과를 제대로 분석해본다면 OOM의 원인은 메모리 부족이 아닌 OS의 리소스 limit에 의한 에러다.

 

위에서 실행한 스택 사이즈 옵션은 256KB이기 때문에 2027개의 스레드의 크기는 많아봐야 500MB이며 최대 힙사이즈를 포함한다면 600에 못미치기 때문에 로컬 호스트의 native memory보다 한참 모자라다는 것을 알 수 있다. 따라서, OS에 의한 리소스 제한임을 의심해볼 수 있다. 에러 로그 중 pthread_create failed에서 pthread_create는 스레드 생성 명령어인데 JVM이 OS에 native thread 요청을 하는 경우 OS자원의 부족으로 거부함을 나타낸다.

```bash
Created threads: 100
Created threads: 200

...

Created threads: 1900
Created threads: 2000
[0.110s][warning][os,thread] Failed to start thread "Unknown thread" - pthread_create failed (EAGAIN) for attributes: stacksize: 256k, guardsize: 16k, detached.
[0.110s][warning][os,thread] Failed to start the native thread for java.lang.Thread "Thread-2027"
Crashed at thread count: 2027
java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
        at java.base/java.lang.Thread.start0(Native Method)
        at java.base/java.lang.Thread.start(Thread.java:1518)
        at PlainThreadTest.main(PlainThreadTest.java:11)

 

 

원인을 파악하기 위해 아래와 같은 명령어로 유저가 생성할 수 있는 스레드의 수를 확인할 수 있다. soft limit은 1333이며, hard limit은 2000인데 대략 유사하지만 27개의 스레드가 더 생성되었음을 확인할 수 있다. hard limit을 넘어서 생성된다는 것이 이상하여 찾아보니 아래 세 가지가 원인이 될 수 있다고 한다.

ulimit -Su  → 1333   # 현재 세션의 soft limit
ulimit -Hu  → 2000   # 시스템 전체에서 허용하는 hard limit

 

  • JVM이 시작할 때 이미 여러 system thread를 선점
  • OS는 pthread_create() 요청을 받는 경우, 시스템 상황과 여유 메모리를 고려하여 조금 더 허용하는 경우가 있음
  • JVM의 internal thread pool 또는 ForkJoinPool이 실험 중 스레드 개수가 겹칠 수 있음

또한 활성 상태 보기를 통해 실행 중인 java어플리케이션의 메모리 사용량은 몇 십 MB 단위임을 확인할 수 있었다. 이를 더 자세히 확인하기 위해 java 어플리케이션의 PID를 이용해서 아래 명령어로 가상 메모리의 사용량과 실제 RAM 상에 올라간 크기를 확인하였다.

 

 

ps -o pid,vsz,rss,comm -p 27689

 

 

확실한 차이를 위해 스레드의 스택 사이즈를 1GB로 하고 아래와 같은 결과를 얻을 수 있었다. 가상 메모리(Virtual size)의 사용량은 스레드의 개수와 어느정도 일치함을 알 수 있지만, 실제 RAM(RSS; Resident set size)상에 올라간 크기는 그에 미치지 못했다. 이로 부터 알 수 있는 점은 스레드 수 만큼 가상 주소공간을 예약하지만 실제로 물리적인 주소로 맵핑되어 할당되지 않는다. 현재 구현된 테스트 코드의 대부분의 스레드는 sleep()으로 Wait 상태에 있기 때문에 스택이 깊어지는 등의 일이 발생하지 않는다. 

 

 

 

 

결론

위 실험을 통해 다음과 같은 결론을 얻을 수 있었다:

  • -xss 옵션과 스레드 수에 따라 일정 크기의 공간을 가상 주소공간으로 예약
  • 대부분의 스레드는 sleep() 중이라 stack frame의 사용이 거의 없음
  • OS는 Lazy allocation, demand paging 등으로 실제 접근한 만큼한 물리 메모리에 맵핑

 

레퍼런스

  1. Exploring the Impact of Stack Size on JVM Thread Creation: A Myth Debunked | Bazlur Rahman (2023)