凌晨告警:服务起不来了,日志只有一行
一个深夜,我们的风控服务在发布后反复重启失败。Pod 日志最后一行永远是这句话:
java.lang.OutOfMemoryError: unable to create new native thread
at java.base/java.lang.Thread.start0(Native Method)
at java.base/java.lang.Thread.start(Thread.java:802)
注意,这不是堆 OOM,堆还很空。这是操作系统层面没法再给 JVM 开新线程了。我第一反应是"加内存",但加完内存毫无变化——方向错了,得先搞清楚线程数到底卡在哪。
根因一:进程能开的线程数有上限
JVM 里每创建一个线程,操作系统就要给它分配一个轻量级进程(LWP)和一块栈内存。整台机器上对单用户/单进程能开的线程总数有硬限制,常见三道关:
# 1. 当前 shell 对进程的线程数限制(ulimit -u)
$ ulimit -u
4096
# 2. 系统全局最大线程数
$ cat /proc/sys/kernel/threads-max
150000
# 3. 单个进程能映射的内存区域数(线程栈也占一项)
$ cat /proc/sys/vm/max_map_count
65530
我们这台容器 ulimit -u 被设成了 4096,而风控服务在当时竟然已经开了约 3900 个线程,逼近上限,再来一个就抛 OOM。为什么有这么多线程?继续查。
根因二:线程池被无界地建
翻代码发现,风控规则引擎里有个"每个租户一个线程池"的设计,而租户是动态注册的:
// 每个租户来就 new 一个线程池,核心线程 20,无上限
private final Map<String, ExecutorService> pools = new ConcurrentHashMap<>();
public ExecutorService poolFor(String tenant) {
return pools.computeIfAbsent(tenant, t ->
Executors.newCachedThreadPool( // 危险:最大 Integer.MAX_VALUE
new ThreadFactoryBuilder().setNameFormat(t + "-%d").build()));
}
newCachedThreadPool 的坑在于最大线程数 Integer.MAX_VALUE,来一个任务开一个线程,空闲 60 秒才回收。租户数有 180 个,每个池都悄悄养着十几二十个空闲线程,再加上业务线程、GC 线程、Netty 的 eventLoop,总数轻松破 3900。
根因三:栈大小放大了消耗
线程数受限的另一个维度是内存:每个线程默认栈大小在 64 位 Linux 上通常是 1MB(-Xss 控制)。3900 个线程仅栈就吃掉约 3.9 GB 虚拟内存。我们把 -Xss 从默认降到 256k:
# 调小栈,单线程省内存,但递归深的方法可能 StackOverflow
JAVA_OPTS="$JAVA_OPTS -Xss256k"
这只治标。线程数下来了,但"无界建池"这个雷还在。真正的解法是收敛线程池:
// 改为全局有限线程池,按租户分队列而非分池
private final ExecutorService shared = new ThreadPoolExecutor(
32, 128,
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(1024),
new ThreadFactoryBuilder().setNameFormat("risk-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy());
改成共享池后,线程总数稳定在 150 个左右,离 4096 上限很远,OOM 消失。
三个层面一起治
| 层面 | 动作 | 效果 |
|---|---|---|
| 代码 | 无界缓存线程池 → 共享有限线程池 | 线程数 3900 → 150 |
| JVM | -Xss 256k | 单线程省内存 |
| 容器 | ulimit -u 调到 8192 | 留安全余量 |
小结
unable to create new native thread是系统级线程耗尽,和堆内存无关,加堆没用。- 查
ulimit -u、threads-max、max_map_count三道关,定位卡在哪。 newCachedThreadPool是无界线程池,动态建池是大忌,必须收敛到有限共享池。-Xss能省单线程内存,但只是缓冲,别拿它当正解。
这次事故让我在团队立了条规矩:任何 Executors.newXXXThreadPool 的调用都要在 Code Review 里说明线程上限来源。无界线程池就像无界队列,平时安静,崩的时候只给你一行 OOM 和一夜无眠。