Java 内存溢出异常的排查和处理
大纲
内存溢出之无法创建新线程
错误描述
Java 应用抛出 OutOfMemoryError
异常,如下所示:
1 | java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached |
错误分析
分析步骤一
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 |
|
分析步骤三
使用 ulimit
命令,查看系统对进程资源的控制.。特别注意:必须在当前使用的用户下排查,因为不同用户会有不同的资源限制规则。
1 | # 查看单个进程可打开的最大文件数 |
比如,执行 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) 生成线程堆栈 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 | # 或者(显示当前内核认定的线程数,数据相对来说更准确) |
- (3) 检查线程创建来源,在
thread_dump.txt
中,找到可疑的线程,比如:
1 | # 打开线程堆栈 dump 文件 |
1 | "pool-1-thread-1" #22 prio=5 os_prio=0 tid=0x00007f45c80a8000 nid=0x5a13 runnable [0x00007f45c81af000] |
- 内容分析说明:
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 异常。
- Linux 系统默认限制单个进程最多创建 1024 个线程(
- 堆外内存耗尽:
- 每个线程都会占用一定的堆外内存(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) 使用分布式调度(利用分片广播的路由策略),减少单机压力。
- (1) 提高操作系统的最大线程数限制,比如使用命令: