线程池我用了一年都没出过问题,直到队列堆了10万个任务OOM打挂服务,查了三天才发现是这个参数惹的祸

线程池这玩意,我用了一年多,一直觉得它是最稳的东西。

corePoolSize 设个 10,maximumPoolSize 设个 20,队列用 LinkedBlockingQueue,拒绝策略用 CallerRunsPolicy。每次面试被问到线程池参数,我都能倒背如流。同事问我线程池怎么配,我也能张嘴就来。

直到有一天凌晨两点,我被电话叫醒。

「服务挂了,OOM了,你赶紧看看。」

我打开电脑一看,堆内存直接打满,GC 日志刷了几十个 G。再一看线程池监控,队列堆积了 10 万个任务。

10 万个啊。。。

我当时的第一反应是,不对啊,我用的是 LinkedBlockingQueue,它默认容量是 Integer.MAX_VALUE,理论上确实可以无限堆。但问题是,我从来没想过它真的会堆到这个量级。

查了三天,把 GC 日志翻烂了,把堆 dump 文件拉下来用 MAT 分析了两遍,终于找到了根因。

问题不在线程池本身,而在一个我从来没注意过的参数。

说起来你可能不信,这个参数叫「队列容量」。就是那个我从来没显式设置过的值。

LinkedBlockingQueue 的无参构造函数,默认容量是 Integer.MAX_VALUE,也就是 21 亿多。你没看错,21 亿。这意味着什么?意味着你的线程池在高峰期如果处理速度跟不上提交速度,任务就会无限堆积,直到把内存吃光。

而我那段时间刚好在做一个批量导入的功能,用户上传一个 Excel,解析之后每一行数据都要丢进线程池去做校验和入库。一个 Excel 几万行,几个用户同时上传,队列就这么被撑爆了。

更要命的是,每个任务对象里面还持有一个数据库连接和一份解析后的数据模型,每个对象大概占几 KB。10 万个任务堆在队列里,光是对象本身就吃掉了几百兆内存。再加上每个任务等待期间持有的数据库连接,连接池也被耗尽了。

整个服务就像一个人被堵住了喉咙,吞不下去也吐不出来。

后来怎么修的呢?两个改动。

第一个,把 LinkedBlockingQueue 换成了 ArrayBlockingQueue,容量设成 1000。ArrayBlockingQueue 是基于数组实现的,它在创建的时候就分配好了内存,不会像 LinkedBlockingQueue 那样每个节点都要 new 一个 Node 对象。而且设了上限之后,超过 1000 的任务会直接走拒绝策略,而不是无限堆积。

第二个,把拒绝策略从 CallerRunsPolicy 换成了自定义的。CallerRunsPolicy 的意思是,如果线程池满了,就让提交任务的线程自己去执行。听着挺合理的对吧?但问题是,如果你的提交线程是 Tomcat 的工作线程,它被拿去执行任务了,谁来处理 HTTP 请求?

所以我的自定义拒绝策略是这样的,先尝试把任务存到一个本地的有界队列里,如果本地队列也满了,就返回一个友好的错误提示给用户「系统繁忙,请稍后再试」。最起码不会把整个服务拖死。

这块其实还有一个细节,很多人不知道。ThreadPoolExecutor 的 execute 方法,它的执行逻辑是这样的,先看核心线程数够不够,不够就创建新线程;核心线程满了就丢队列;队列满了就看能不能创建非核心线程;非核心线程也满了才走拒绝策略。

注意这个顺序,任务是先入队,后创建非核心线程。这意味着什么?意味着如果你的队列容量是 21 亿,那非核心线程永远不会被创建出来。maximumPoolSize 设得再大也没用,它就是个摆设。

我之前一直以为 maximumPoolSize 设成 20 就是最多 20 个线程在干活,实际上根本不是那回事。只有在队列满了之后,才会去创建超出核心线程数的线程。你用无界的 LinkedBlockingQueue,maximumPoolSize 就是个数字,没有实际意义。

这个坑踩过一次就记住了。

后来我又去翻了一下 JDK 的源码,在 ThreadPoolExecutor 的 execute 方法里看到了这段逻辑。英文注释写得很清楚,但我之前从来没认真读过。就像你天天用一把刀,但从来没看过刀刃是怎么开的一样。

说到源码,我突然想起一件事。ThreadPoolExecutor 有一个 allowCoreThreadTimeOut 参数,默认是 false。如果你设成 true,核心线程在空闲一段时间后也会被回收。很多人不知道这个参数的存在,觉得核心线程就是常驻的,永远不销毁。其实不是,你可以让它跟非核心线程一样,空闲就死。

但这个参数要慎用。我有一次在项目里把它设成了 true,结果低峰期的时候核心线程全部被回收了,高峰期来了请求又要重新创建线程,白白多了好几毫秒的延迟。对于延迟敏感的接口来说,这几毫秒可能就是压死骆驼的最后一根稻草。

线程池这东西,看着简单,其实里面的水很深。四个参数,corePoolSize、maximumPoolSize、keepAliveTime、workQueue,再加上拒绝策略,排列组合起来能玩出花来。但大多数人(包括之前的我)都是从网上抄一个配置,从来没认真想过为什么要这么配。

我自己的经验是,队列一定要有界,一定要有界,一定要有界。无界队列就是一颗定时炸弹,你不知道它什么时候会爆。宁可让任务被拒绝,也不能让它无限堆积。被拒绝了顶多是用户体验差一点,但 OOM 了整个服务都完蛋。

还有一个建议,线程池一定要加监控。我后来在项目里加了 ScheduledExecutorService,每隔 30 秒打印一次线程池的核心指标,活跃线程数、队列大小、已完成任务数。出了问题第一时间能看到,不用像我那样翻三天日志。

赫拉克利特说过,人不能两次踏进同一条河流。但程序员可以,只要你没有把坑记下来。我后来养成了一个习惯,每次踩完坑都写一个文档,记录问题现象、根因分析、解决方案。不是为了教别人,是为了防止未来的自己再犯同样的错。

凌晨两点的电话,我再也不想接了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值