Java 内存溢出异常的排查和处理

大纲

内存溢出之无法创建新线程

错误描述

Java 应用抛出 OutOfMemoryError 异常,如下所示:

1
2
3
4
5
6
7
8
9
10
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:803)
at java.base/java.util.concurrent.ThreadPoolExecutor.addWorker(ThreadPoolExecutor.java:937)
at java.base/java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1343)
at java.base/java.util.concurrent.AbstractExecutorService.submit(AbstractExecutorService.java:140)
at java.base/java.util.concurrent.Executors$DelegatedExecutorService.submit(Executors.java:719)
at org.springframework.cloud.commons.util.InetUtils.convertAddress(InetUtils.java:163)
at org.springframework.cloud.commons.util.InetUtils.findFirstNonLoopbackHostInfo(InetUtils.java:66)
at org.springframework.cloud.client.HostInfoEnvironmentPostProcessor.getFirstNonLoopbackHostInfo(HostInfoEnvironmentPostProcessor.java:66)

错误分析

分析步骤一

Java 异常 java.lang.OutOfMemoryError 一共有 8 种类型,其中 java.lang.OutOfMemoryError: unable to create new native thread 这类异常通常发生在应用试图创建新线程时。可能原因如下:

  • 系统内存耗尽:操作系统无法为新线程分配内存。
  • 应用创建了过多线程:应用创建的线程数量超过了操作系统的限制。
  • 操作系统限制:操作系统并不允许应用程序创建这么多线程,Linux 系统默认允许单个进程可以创建的线程数是 1024 个,当应用创建超过这个数量的线程,就会抛出 OOM 异常。

当遇到 java.lang.OutOfMemoryError:unable to create new native thread 异常时,可以使用以下方法排除解决:

  • 通过监控在发生 OOM 之前,服务器剩余物理内存、进程创建的线程数以及 ulimit -n 的结果进行综合排查解决。
  • 同时,可以通过 jstack -l 命令将线程栈 dump 下来,进一步分析具体是哪里创建了线程,是否合理。
  • 常见因分析及解决方案:
    • (1) 创建了过多的线程:减少线程数。
    • (2) 操作系统对线程数的限制:调大线程数限制。
    • (3) 服务器内存不足:增加物理内存。
    • (4) 进程的堆内存太大:减小堆内存大小。
    • (5) 服务器的进程数太多:减少进程数。
    • (6) 线程栈的太大:调小线程栈。

特别注意

通过分析,上述 OOM 异常通常与 JVM 的虚拟机栈和本地方法栈的内存溢出有关,出现该问题的情况有两种:一种是创建的线程过多,另一种是线程数量不多但是内存空间不足。

分析步骤二

  • 查看系统当前的线程总数
1
ps -eLf | wc -l
  • 使用 Shell 脚本查看每个 Java 应用创建的线程数量
1
2
3
4
5
6
7
8
9
#!/bin/bash

ps aux | grep java | grep -v grep | awk '{print $2, $NF}' | while read line
do
pid=$(echo $line| awk '{print $1}')
pidname=$(echo $line| awk '{print $2}')
pid_count=$(ps huH p ${pid} | wc -l)
echo "pid: ${pid} 线程数: ${pid_count} 应用: ${pidname}"
done

分析步骤三

使用 ulimit 命令,查看系统对进程资源的控制.。特别注意:必须在当前使用的用户下排查,因为不同用户会有不同的资源限制规则。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 查看单个进程可打开的最大文件数
ulimit -n

# 查看单个用户可创建的最大进程数
ulimit –u

# 查看进程可用的最大虚拟内存
ulimit -v

# 查看进程可用的最大栈大小
ulimit -s

# 查看进程的所有资源限制
ulimit -a

比如,执行 ulimit -a 命令,输出以下信息:

重点留意以下几个参数的值是否设置得当:

  • open files:单个进程能打开的最大文件数(ulimit -n)。
  • max user processes:单个用户的最大并发进程数(ulimit -u)。
  • stack size:进程栈的最大大小(ulimit -s)。
  • cpu time:进程的最大 CPU 时间(ulimit -t)。
  • virtual memory:进程可用的最大虚拟内存(ulimit -v)。
  • resident set size:最大物理内存占用(ulimit -m,可能不适用)。

解决方案

  • 排查应用是否创建了过多的线程

    • 通过 jstack -l 命令确定应用创建了多少线程,超量创建的线程的堆栈信息,以及是谁创建了这些线程。一旦明确这些问题,便很容易解决。
  • 调整操作系统线程数阈值

    • 操作系统会限制进程允许创建的最大线程数,使用 ulimit -u 命令查看限制。
    • 某些服务器上此阈值默认设置得过小,比如 1024。一旦应用创建超过 1024 个线程,就会遇到 java.lang.OutOfMemoryError: unable to create new native thread 异常。如果是这种情况,可以尝试增大操作系统线程数阈值。
  • 增加服务器内存

    • 如果上述两项未能排除问题,可能是正常增长的业务确实需要更多内存来创建更多线程。如果是这种情况,考虑增加服务器内存(物理内存)。
  • 减小堆内存

    • 在 Java 中,当创建一个线程时,会在 JVM 内存中创建一个内存对象,同时还会创建一个操作系统线程,而这个操作系统线程的内存不是使用 JVM 内存的,而是使用操作系统中剩下的物理内存。因此,即使 Java 堆内存是充足的,如果剩余的物理内存太小,无法满足更多操作系统线程创建所需的内存时,也会抛出 OOM 异常。
    • 换而言之,给 JVM 分配的内存越多,能创建的线程数就越少,也就是越容易发生 OutOfMemoryError 异常。这说明不是 JVM 内存不够,而是因为线程消耗的是操作系统内存。线程不在 JVM 的堆内存上创建,而是在堆内存之外的内存上创建。
    • 所以,如果分配了 JVM 堆内存后只剩下很少的可用物理内存,依然可能遇到 java.lang.OutOfMemoryError: unable to create new native thread 异常。
    • 考虑如下场景:系统总内存 6G,堆内存分配了 5G,永久代 512M。在这种情况下,JVM 占用了 5.5G 内存,系统进程、其他用户进程和线程将共用剩下的 0.5G 内存,很有可能没有足够的可用内存创建新的线程。如果是这种情况,可以考虑减小堆内存的大小。
  • 减少进程数

    • 这和减小 JVM 堆内存的原理相似,考虑如下场景:系统总内存 32G,Java 进程数 5 个,每个进程的堆内存 6G。
    • 在这种情况下,Java 进程总共占用 30G 内存,仅剩下 2G 内存用于系统进程、其他用户进程和线程,很有可能没有足够的可用内存创建新的线程。如果是这种情况,可以考虑减少每台机器上的进程数。
  • 减小单个线程栈大小(-Xss 参数设置)

    • 线程会占用内存,如果每个线程都占用更多内存,那么整体上将消耗更多的内存。
    • 每个线程默认占用的内存大小取决于 JVM 实现,可以使用 JVM 的 -Xss 参数来限制单个线程栈大小,以此降低线程的总内存消耗。
    • 早期 JVM 默认每个线程占用 256K 内存,现在一般为 1M 内存。假设要创建 500 个线程,如果每个线程需要 1M 内存,那么所有线程占用的总内存为 500M。
    • 如果实际上 256K 内存足够线程正常运行,配置 -Xss256k,那么 500 个线程将只需要消耗 125M 内存,也就是只需要原本所占内存的四分之一。
    • 特别注意,如果线程栈大小通过 JVM 参数 -Xss 设置得过小,当线程请求分配的栈内存不足时,将会抛出 StackOverflowError 异常。

如何计算 Java 进程可创建的最大线程数量

  • 可创建最大线程数量的计算公式:(MaxProcessMemory – JVMMemory – ResverdOsMemory) / ThreadStackSize = 线程数量
  • MaxProcessMemory:一个进程最多使用的内存大小。
  • JVMMemory:JVM 的堆内存大小,也就是 Heap。因为只能设置堆的大小,这个的值是堆的最小值 + PermGen 的大小。
  • ResverdOsMemory:操作系统保留的内存,也就是操作系统内核使用的,一般为 120M。
  • ThreadStackSize:线程栈的大小,单位为字节(byte),所以上面的公式要统一换成字节来计算。系统有 64G 内存,就算不适用公式计算也应该知道内存是绝对够用的。那到底是什么问题呢?

操作示例

分析线程堆栈信息

使用 jstack 工具将 Java 进程的线程堆栈信息 dump 出来,并分析 java.lang.OutOfMemoryError: unable to create new native thread 问题的步骤如下。

生成线程堆栈 dump 文件
  • (1) 获取 Java 进程的 ID(即 PID)
1
jps -l
1
2
# 或者
ps -aux|grep java
  • (2) 生成线程堆栈 dump 文件
1
jstack -l PID > thread_dump.txt

如果 Java 进程已崩溃,可以使用以下命令强制生成线程堆栈 dump 文件

1
jstack -F PID > thread_dump_force.txt
分析线程堆栈 dump 文件
  • (1) 使用 grep 统计线程数(如果线程数过多,可能是程序创建了过多线程,导致操作系统无法创建新的本地线程)
1
grep 'tid=' thread_dump.txt | wc -l
  • (2) 还可以结合 ps 命令查看特定进程的线程数
1
ps -eLf | grep PID | wc -l
1
2
# 或者(显示当前内核认定的线程数,数据相对来说更准确)
cat /proc/PID/status | grep Threads
  • (3) 检查线程创建来源,在 thread_dump.txt 中,找到可疑的线程,比如:
1
2
# 打开线程堆栈 dump 文件
vi thread_dump.txt
1
2
3
4
"pool-1-thread-1" #22 prio=5 os_prio=0 tid=0x00007f45c80a8000 nid=0x5a13 runnable [0x00007f45c81af000]
java.lang.Thread.State: RUNNABLE
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
  • 内容分析说明:
    • tid=... 是 Java 线程的 ID。
    • nid=... 是本地线程的 ID(可用于与系统级工具关联)。
    • java.lang.Thread.State: RUNNABLE 表示线程的状态,若有大量线程处于 RUNNABLE 状态,可能存在线程泄漏问题。
    • ThreadPoolExecutor$Worker.run 说明线程来自 ThreadPoolExecutor 线程池,可能是线程池未正确回收线程导致 OOM。

设置最大可打开文件数

比如,可以临时设置进程的最大可打开文件数(open files)为 1048576。特别注意:当前是在哪个用户下进行设置。

1
ulimit -n 1048576

若希望永久设置进程的最大可打开文件数(open files)可以参考以下教程:

最佳实践

XXL-JOB 无法创建新线程

错误描述

  • Java 应用(使用了 XXL-JOB 的客户端)使用的 XXL-JOB 阻塞处理策略是 单机串行
  • Java 应用(使用了 XXL-JOB 的客户端)运行一段较长时间后,会抛出 java.lang.OutOfMemoryError: unable to create native thread

错误分析

  • 在 XXL-JOB 中,任务调度通常涉及线程池管理,而单机串行阻塞策略的特点是:

    • 同一时间只执行一个任务,后续任务必须等待前面的任务完成。
    • 如果任务执行时间过长,调度中心不断触发新的任务,导致大量任务堆积在任务队列中。
    • 任务堆积会导致线程池持续创建新线程,最终可能超过操作系统允许的最大线程数,触发 java.lang.OutOfMemoryError: unable to create native thread
  • 导致 XXL-JOB 抛出 OOM 异常的核心因素:

    • 线程池溢出:
      • 由于新任务不断提交,但老任务未处理,线程池可能不断扩容,最终线程总数达到 maxPoolSize,无法再创建新的线程。
    • 系统线程数限制:
      • Linux 系统默认限制单个进程最多创建 1024 个线程(ulimit -u)。
      • 当任务积压时,XXL-JOB 可能会尝试创建更多线程,但超出系统限制后,就会抛出 OOM 异常。
    • 堆外内存耗尽:
      • 每个线程都会占用一定的堆外内存(Native Memory),如果线程数过多,可能会耗尽堆外内存(操作系统内存),导致 OOM 发生,即使 JVM 堆内存是足够的。

解决方案

  • 根据 错误分析步骤,排查一下是否因系统资源限制(比如:单个进程能打开的最大文件数),导致业务应用无法创建新的线程。
  • 可参考的解决方案:
    • (1) 提高操作系统的最大线程数限制,比如使用命令:ulimit -n 1048576
    • (2) 使用 -Xss 参数降低 JVM 线程栈的大小,减少内存占用,比如:java -Xss256k -jar app.jar
    • (3) 如果 XXL-JOB 客户端的线程池使用了 LinkedBlockingQueue(默认是无界队列),更改为使用有界队列(如 ArrayBlockingQueue),避免任务无限堆积导致抛出 OOM 异常。
    • (4) XXL-JOB 的阻塞处理策略主要有三种:单机串行、丢弃后续调度和覆盖之前调度‌,更改阻塞处理策略为 丢弃后续调度(适用于幂等任务),避免任务大量堆积。
    • (5) 优化任务处理逻辑,比如:(a) 拆分长任务,避免单个任务执行时间过长。(b) 使用分布式调度(利用分片广播的路由策略),减少单机压力。

参考资料