news 2026/10/18 4:53:47

Executors 线程池无界队列隐患审计,附 ThreadPoolExecutor 正确配置方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Executors 线程池无界队列隐患审计,附 ThreadPoolExecutor 正确配置方案

老炮踩坑录 · F08 · 翻车现场系列

· 基于「企业融合平台」真实源码

· 关键词:Executors · 无界队列 · OOM · ThreadPoolExecutor · 阿里规约

👋 欢迎阅读

🏠个人主页:知守观
📘我的专栏:老炮踩坑录
💻当前内容:Executors 线程池

文章目录

    • 案发现场
    • 算一笔账
    • 阿里规约背后的道理
    • 怎么改
    • 但这个代码还有一个更深的问题
    • 阿里规约第一页,到底在说什么
    • 自查清单
    • 写在最后

阿里 Java 开发手册,[并发处理] 这一节的第4条规约,几乎所有面试都会考:

线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式。

原因写得很清楚:Executors返回的几种线程池,要么队列无界(FixedThreadPool、SingleThreadExecutor),要么线程无界(CachedThreadPool),都有 OOM 风险。

这条规约我背得滚瓜烂熟,面试的时候还曾经拿它当杀手锏——毕竟,能说出LinkedBlockingQueue无界队列导致 OOM 的候选人,一看就是"有深度"的。

然后我翻开了自己 2022 年写的项目代码。

3 处Executors.newFixedThreadPool(5),一字不差,整整齐齐。

规约第一页,我就没做到。

本文整个思路大纲图:

案发现场

这个项目的文件上传走的是华为云 OBS(对象存储),大文件用分片上传——把一个文件切成 5MB 一块,5 个线程并行上传,最后合并。

创建线程池的代码,3 个文件里各有一份,几乎一模一样:

// AsyncElecServiceImpl.java L414// HyunFileServiceImpl.java L114// HOBSFileServiceImpl.java L51ExecutorServiceexecutorService=Executors.newFixedThreadPool(5);

然后往线程池里塞任务:

longpartSize=5*1024*1024L;// 5MB 一块longpartCount=file.length()%partSize==0?file.length()/partSize:file.length()/partSize+1;for(inti=0;i<partCount;i++){longoffset=i*partSize;longcurrPartSize=(i+1==partCount)?file.length()-offset:partSize;executorService.execute(newPartUploader(file,file.length(),offset,currPartSize,i+1,uploadId,partETags,obsClient,objectKey,bucketName));}executorService.shutdown();while(!executorService.isTerminated()){executorService.awaitTermination(10,TimeUnit.SECONDS);}

逻辑很清楚:算出分片数,每个分片一个PartUploader(实现Runnable),提交给线程池,等全部完成,合并分片。

看起来没什么问题,对吧?

5 个线程,固定大小,上传完就 shutdown。干净利落。

直到我把application.yml打开:

spring:servlet:multipart:max-file-size:1024MBmax-request-size:1024MB

允许上传 1024MB 的文件。

算一笔账

1024MB 的文件,按 5MB 一块切:

partCount = 1024 / 5 = 205 块

205 个PartUploader任务提交给newFixedThreadPool(5)。

5 个线程同时跑,同一时刻只有 5 个任务在执行。剩下的 200 个任务呢?

打开Executors.newFixedThreadPool的源码:

publicstaticExecutorServicenewFixedThreadPool(intnThreads){returnnewThreadPoolExecutor(nThreads,nThreads,0L,TimeUnit.MILLISECONDS,newLinkedBlockingQueue<Runnable>());// 看这里}

LinkedBlockingQueue——无界队列。没有指定容量,默认是Integer.MAX_VALUE(约 21 亿)。

200 个PartUploader对象安静地排在队列里,每个持有:

  • 一个File引用(文件句柄)
  • offset、partSize、partNumber等 long/int 字段
  • 一个ObsClient引用
  • 一个List<PartEtag>的共享引用

单个PartUploader对象不大,大约几百字节。200 个也就几十 KB——看起来不多。

但你别忘了:

  1. 每个PartUploader持有File对象的引用。虽然File对象本身不大,但底层关联的文件描述符是操作系统资源。200 个任务排队,意味着 200 个读操作等待。
  2. 如果多个用户同时上传呢?每次调用uploadFile都new一个新的线程池。10 个用户同时上传 100MB 以上的文件,就是 10 个线程池、2000 个排队任务。
  3. 更关键的是——这不是"会不会 OOM"的问题,而是"该不该这么写"的问题。

阿里规约说的不是"一定会 OOM",而是"你不应该把命运交给一个无界队列"。

阿里规约背后的道理

Executors的几种快捷创建方式,问题各不相同:

方法风险原因
newFixedThreadPool队列无界LinkedBlockingQueue无容量上限,如果任务堆积 → 报OOM
newSingleThreadExecutor队列无界同上,只是只有一个线程
newCachedThreadPool线程无界maximumPoolSize = Integer.MAX_VALUE,疯狂创建线程 → OOM
newScheduledThreadPool队列无界同newFixedThreadPool

四种方法,四种都可能导致 OOM。区别只是死法不同:前三种是"任务堆死",CachedThreadPool是"线程撑死"。

核心思想只有一个:资源必须有上限。

线程数要有上限,队列长度也要有上限。你无法预测未来会有多少任务进来,但你可以规定"最多排队多少,超了就拒绝"。系统的稳定性,永远排在第一位。

ThreadPoolExecutor的 7 个参数,本质上就是在回答这 7 个问题:

参数回答的问题
corePoolSize常驻几个线程?
maximumPoolSize最多几个线程?
keepAliveTime空闲线程多久回收?
unit时间单位是什么?
workQueue排队用什么队列?最多排多少?
threadFactory线程怎么创建?(名字、优先级)
handler队列满了怎么办?(拒绝策略)

Executors帮你回答了前 4 个,但把最关键的"队列上限"和"拒绝策略"藏起来了。你用了它,就等于默认了"队列无限长"和"永远不拒绝"——在生产环境里,这两个默认值就是定时炸弹。

怎么改

把Executors.newFixedThreadPool(5)换成ThreadPoolExecutor:

// 改之前ExecutorServiceexecutorService=Executors.newFixedThreadPool(5);// 改之后ExecutorServiceexecutorService=newThreadPoolExecutor(5,// 核心线程数5,// 最大线程数(和核心一样 = 固定大小)0L,TimeUnit.MILLISECONDS,newLinkedBlockingQueue<>(200),// 关键:队列有上限了newThreadFactoryBuilder()// 给线程起名字,排查问题时方便.setNameFormat("obs-upload-%d").build(),newThreadPoolExecutor.CallerRunsPolicy()// 队列满了,调用者自己跑);

三个关键变化:

1. 队列从"无界"变成了"有界"(200)。

1024MB / 5MB = 205 块,留 200 的队列可能还不够。但这就是重点——你需要根据业务算出一个合理的上限,而不是默认 21 亿。如果文件最大 100MB(100/5=20 块),队列设 50 就够了。

2. 拒绝策略选了CallerRunsPolicy。

队列满了怎么办?4 种策略:

策略行为适用场景
AbortPolicy(默认)抛RejectedExecutionException不能丢任务,让调用方处理
CallerRunsPolicy提交任务的线程自己执行允许降速,但不能丢
DiscardPolicy静默丢弃任务可丢
DiscardOldestPolicy丢弃最老的任务新任务优先

文件上传不能丢分片,所以选CallerRunsPolicy——队列满了,上传主线程自己干,相当于自动降速。

3. 线程有了名字。

newThreadFactoryBuilder().setNameFormat("obs-upload-%d").build()

出问题时 dump 线程栈,看到的是obs-upload-0、obs-upload-1,而不是pool-3-thread-2。排查效率提升 10 倍。

但这个代码还有一个更深的问题

改完 API,我回头又看了一遍这段代码,发现了一个比"用错 API"更严重的问题:

每次上传都创建一个线程池。

publicvoiduploadFile(Filefile,...){ExecutorServiceexecutorService=Executors.newFixedThreadPool(5);// ... 上传逻辑 ...executorService.shutdown();}

uploadFile每被调用一次,就new一个线程池。5 个线程创建 → 用完 → 销毁。

如果 10 个用户同时上传文件,就是 10 个线程池、50 个线程同时存在。线程是操作系统资源,创建和销毁都有开销。5 个线程的线程池,创建 + 销毁大概需要几毫秒——听起来不多,但这是完全可以避免的浪费。

正确的做法是把线程池提升为类级别的单例:

@ServicepublicclassObsFileServiceImpl{privatefinalExecutorServiceuploadExecutor=newThreadPoolExecutor(5,5,60L,TimeUnit.SECONDS,newLinkedBlockingQueue<>(200),newThreadFactoryBuilder().setNameFormat("obs-upload-%d").build(),newThreadPoolExecutor.CallerRunsPolicy());publicvoiduploadFile(Filefile,...){// 直接用 uploadExecutor,不创建新的// ...}@PreDestroypublicvoiddestroy(){uploadExecutor.shutdown();}}

线程池跟着 Bean 的生命周期走,应用启动时创建,应用关闭时销毁。中间不管上传多少次,都是同一池线程在干活。

这才是"资源池"的本意——"池"就是用来复用的。每次 new 一个用完就丢,那不叫线程池,叫 “线程是一次性商品”。

有个细节我没完全确认:把线程池提升为类级别单例后,Spring Bean 销毁时的 shutdown() 调用,在不同 Spring Boot 版本上的执行时机是不是一致的?我验证的是 2.1.0,新版本没试过。有踩过坑的兄弟评论区指点一下。

阿里规约第一页,到底在说什么

回头看这条规约,它表面上是在说"不要用Executors",实际上是在说三件事:

第一,资源必须有上限。

无界队列 = 没有上限。你不能假设"任务不会太多"——万一来了一个大文件呢?万一并发高了呢?代码里的"不会",在生产环境里就是"会"。

第二,默认值不等于安全值。

LinkedBlockingQueue的默认容量是Integer.MAX_VALUE。这个默认值是为了"通用性"——它假设你不知道该设多少。但在生产代码里,"不知道"不是借口——你必须根据业务算出来。

第三,便捷 API 是陷阱。

Executors.newFixedThreadPool(5)一行代码搞定线程池,多方便。但方便的东西往往把关键决策藏起来了。ThreadPoolExecutor的 7 个参数看着烦,但每一个都在逼你想清楚一个关键问题。

烦,是因为它重要。

自查清单

如果你项目里也有Executors,按这个清单排查:

  • 全局搜Executors.new,看有几处
  • 每处确认:任务量有没有上限?队列该设多大?
  • 线程池是"每次 new"还是"单例复用"?每次 new 的一定要改成单例
  • 拒绝策略选了什么?任务能丢吗?不能丢就选CallerRunsPolicy
  • 线程有没有名字?dump 线程栈时能不能一眼认出来?
  • 线程池有没有正确关闭?shutdown()在 finally 里吗?

自查打卡:搜一下你的项目,grep -r “Executors.new” src/ | wc -l,数字打在评论区。我看看谁的线程池最“原生态”。

写在最后

阿里规约这条规则,我面试时背过无数次。但在自己的项目里,我用了 3 次Executors,用了 4 年,从来没觉得有问题。

直到今天写这篇文章,我才认真去看了newFixedThreadPool的源码——原来它用的是无界队列。

以前只知道"不能用",不知道为什么不能用。知道"不能用"和知道"为什么不能用",差距就在这几行源码里。

面试背规约,能让你拿到 offer。理解规约背后的道理,能让你少踩一次 OOM。

规约第一页,不是写给面试官看的,是写给凌晨三点被 OOM 报警叫醒的你看的。


如果这篇文章帮你少踩一个坑,就是它最大的价值体现。

最后问一句:你们项目里的线程池是怎么创建的?是Executors快捷方式,还是ThreadPoolExecutor手动配?评论区聊聊,我看看有多少人踩过阿里规约这条。


下期预告:《加了 @Transactional 就万事大吉?吞掉异常后事务根本没回滚》
我是老炮,18 年 Java 老兵,仍在一线。关注「Java老炮踩坑录」,不错过每一篇真实案例,少踩坑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/18 4:52:57

Claude Code入门指南:终端AI编程助手从安装到实战

最近总有朋友问我&#xff0c;Claude Code到底是个什么东西&#xff0c;怎么装、怎么用、值不值得折腾。我研究了一下&#xff0c;发现抛开那些高大上的宣传&#xff0c;它本质就是一个跑在终端里的AI编程助手&#xff0c;通过命令行对话的方式帮你读写代码、执行命令、排查问题…

作者头像 李华
网站建设 2026/10/18 4:47:56

手写webpack自定义loader与plugin:从零掌握构建扩展原理

不要从“我”开始建议不要使用“本文”、“我”等第一人称视角作为开篇方式&#xff0c;切换到更客观、中立的叙述口吻会更好。下面我调整一下&#xff0c;重写开头&#xff0c;给你一个更干净、更自然的版本。webpack作为前端打包工具里的老大哥&#xff0c;几乎每个项目都绕不…

作者头像 李华
网站建设 2026/10/18 4:47:35

Compose 副作用(Side Effects)等API 详解

官网&#xff1a;Compose 中的附带效应 | Jetpack Compose | Android Developers 前置概念&#xff1a;什么是副作用&#xff1f; Compose 的核心理念是声明式 UI 重组&#xff08;Recomposition&#xff09;。重组可能随时发生、可能部分执行、可能被取消。因此&#xf…

作者头像 李华
网站建设 2026/10/18 4:44:40

IDA花指令对抗实战:从反汇编原理到脚本化清理

开头做逆向这块的朋友&#xff0c;不管是分析恶意样本还是自己调试商业软件&#xff0c;迟早都会撞上花指令这堵墙。我第一次在 IDA 里碰到花指令时&#xff0c;反汇编窗口里一片飘红&#xff0c;F5 出来的伪代码完全没法看&#xff0c;函数边界被切得支离破碎&#xff0c;光是…

作者头像 李华
网站建设 2026/10/18 4:44:07

Codex 入门实践笔记:从安装到安全避坑

一、Codex 是什么OpenAI Codex 是 OpenAI 推出的 AI 编程 Agent&#xff0c;2026 年已全面升级&#xff0c;提供本地 CLI、VS Code 扩展、Cursor/Windsurf 集成和桌面应用四种使用形态。核心能力是在终端中自主完成代码编写、修改、执行 Shell 命令、搜索文件和抓取网页等完整工…

作者头像 李华