JVM

OOM: unable to create new native thread 추적하기

페페로니피자 2024. 9. 23. 02:51
반응형

1) 서론

개인적으로 개발하던 애플리케이션에서 OOM이 발생했습니다.
 
현상과 문제 되는 코드는 명확하게 발견했는데요.
 
원인을 찾아나가는 과정 중에 배운 것을 공유합니다.


2) OutOfMemoryError 발생

JVM 기반 애플리케이션에서 발생했고, 더 이상 요청을 받을 수 없는 상태가 되었습니다.

Caused by: java.lang.OutOfMemoryError: unable to create new native thread
	at java.lang.Thread.start0(Native Method)
	at java.lang.Thread.start(Thread.java:717)

 
이유는 명확했습니다.
 
스레드풀 생성에 singleton pattern이 적용되지 않았습니다. 한번만 초기화되고, 이후에는 초기화된 인스턴스를 재사용했어야 합니다. 하지만 요청마다 계속해서 생성된 것이 원인입니다.
 
문제가 되는 스레드풀을 여러 번 생성해 보겠습니다.
 
Kotlin function으로 작성했기 때문에 호출할 때마다 ThreadPoolExecutor 클래스를 초기화하게 됩니다. 즉 생성한 인스턴스를 어딘가에 할당하고, 재사용할 수 없는 구조입니다. 아래는 Java로 decompile 한 예시입니다.

Kotlin -> Kotlin ByteCode -> Java Decompile

 
Local PC 사양 상 아래와 같이 제한하여 재현해 봅니다

  1. 4천 회 반복하여 스레드 풀 생성
  2. 생성된 스레드풀의 스레드 활성화

 

ExecutorService 인터페이스를 구현한 스레드 풀은 기본적으로 lazy initialization 입니다. 생성 시점에는 단순히 core, max thread pool size를 설정할 뿐 스레드를 생성하지 않습니다. 그렇기 때문에 print문을 이용해서 task를 전달하고, thread active 시킵니다.

 
수행한 테스트를 Profiling 합니다.

 
몇 가지 특징을 볼 수 있는데요.

  • pool-XX-thread 이름의 스레드들이 지속적으로 생성된 것을 확인할 수 있습니다
  • 스레드 풀 생성하는 getThreadPool() 함수에서만 2.61MB 메모리 사용했습니다

 
Active thread는 어떻게 되었을까요?

  • 테스트 수행 전 JVM에서 관리하는 actvie thread는 11개입니다
  • 스레드 풀 생성 후 task를 할당했을 때 active thread는 4012개입니다

 
정리해 보겠습니다.
 
TPS 4천 일 때 초당 메모리를 2.61MB 사용, 스레드를 4012개 사용하게 됩니다. 만약 60초가 지속된다면 약 160MB 메모리 할당, 12만 개의 스레드를 생성하게 됩니다.
 
당연히 OOM이 발생할 수밖에 없습니다.
 
 


3) 그런데 왜 native thread 생성하는 걸까요?

발생한 에러를 볼 때 메시지가 조금 특이합니다. Natvie thread를 생성할 수 없다고 합니다.

Caused by: java.lang.OutOfMemoryError: unable to create new native thread
	at java.lang.Thread.start0(Native Method)
	at java.lang.Thread.start(Thread.java:717)

 
여기서 Native thread는 Kernal thread 입니다. JVM은 스레드를 사용할 때 OS의 kernal thread를 사용합니다. 즉 JVM 애플리케이션에서 스레드를 무한정 만들고, OS에서 active 할 수 있는 thread 수를 넘어간다면 더 이상 JVM에서는 더 이상 할당받을 스레드가 없어지기 때문에 에러가 발생합니다.
 
OpenJDK8 소스를 직접 확인해 봅니다.
 
아래의 사진에서 쓰레드 풀에서 task 할당받고, Thread.start() 호출하면 내부적으로 C++로 구현된 start0() native method를 호출하는 것을 확인할 수 있습니다.

 
아래와 같이 JVM_StartThread 호출합니다.

 
아래 두 개의 코드를 확인했을 때 natvie thread는 OS에서 생성된 Kernel thread임을 알 수 있습니다.
 
그리고 주석을 참고했을 때 너무 많은 스레드가 active 상태이기 때문에 메모리가 부족하면 OS thread가 null 일 수 있음을 경고하고 있습니다. 그리고 이때 OOM을 발생시키도록 권장하고 있습니다.

 
아래에서 다시 최초로 호출한 caller JVM_StartThread 메서드로 돌아옵니다.
위에서 언급한 과정들에서 native thread를 생성하지 못했다면 unable to create new native thread 에러 메시지를 발생시키는 것을 확인할 수 있습니다.

 

 

위 코드의 전체적인 흐름은 아래와 같습니다.


 
조금 복잡하니 정리해 봅니다.

  • JVM에서 사용하는 user level thread는 OS kerneal thread를 사용합니다.
  • 지나치게 많은 kernel thread의 active 상태로 인해서 메모리가 부족하다면 JVM은 Kernel thread를 할당받을 수 없습니다.
  • JVM은 스레드 생성에 실패하면 OOM을 발생시킨다.

4) 그래서 왜 쓰레드를 생성하지 못 하나요?

특이한점은 CPU, Memory 시스템 자원의 임계치가 도달하지 않았고 정상인데, 쓰레드를 생성하지 못 하고 있었습니다.

CPU, Memory 가용치가 남았는데 무엇이 문제일까요?
 
ulimit 명령어를 활용해서 프로세스별 자원을 확인합니다.

$ ulimit -su -S && ulimit -su -H

# 최소 (S)
-s: stack size (kbytes)             8176 // 프로세스별 stack 사이즈
-u: max user processes              2666 // 한명의 user가 사용할 수 있는 쓰레드 수

# 최대 (H)
-s: stack size (kbytes)             65520
-u: max user processes              2666

 
Maximum user processes은 각 OS user가 생성할 수 있는 최대 쓰레드 개수인데요. 만약 A프로세스 10개를 spring 계정이 모두 생성하게 된다면, A프로세스 10개는 최대 쓰레드를 2666개까지만 사용할 수 있습니다.

초과하여 생성 시도하는 경우에는 쓰레드를 더 이상 생성할 수 없습니다. 그리고 앞서 본 것처럼 JVM 입장에서는 OS thread가 NULL이고 에러를 생성하게 됩니다.
 
즉 CPU, Memory 자원의 할당량이 정상적일 때는 active thread 개수를 확인하는것이 중요합니다.


5) 결론

개발을 할 때 시스템 자원의 낭비가 되지 않도록 하는 것은 매우 중요합니다. 당연하게 여기던 것을 실수로 놓칠 수 있고, 큰 장애가 발생할 수도 있습니다.
 
이러한 실수를 방지하기 위해서는 꼼꼼히 개발하는 것도 중요하지만, 모니터링을 통해서 미리 감지하고 예방하는 것도 중요하다고 생각합니다.
 
불필요한 장애가 발생했지만 이로 인해서 많이 배울 수 있었습니다.


참고 문헌

반응형