Administrator
发布于 2022-07-20 / 5775 阅读
124

一次诡异的技术栈 OOM: unable to create new native thread

凌晨告警:服务起不来了,日志只有一行

一个深夜,我们的风控服务在发布后反复重启失败。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 -uthreads-maxmax_map_count 三道关,定位卡在哪。
  • newCachedThreadPool 是无界线程池,动态建池是大忌,必须收敛到有限共享池。
  • -Xss 能省单线程内存,但只是缓冲,别拿它当正解。

这次事故让我在团队立了条规矩:任何 Executors.newXXXThreadPool 的调用都要在 Code Review 里说明线程上限来源。无界线程池就像无界队列,平时安静,崩的时候只给你一行 OOM 和一夜无眠。

参考