老炮踩坑录 · 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——看起来不多。
但你别忘了:
- 每个
PartUploader持有File对象的引用。虽然File对象本身不大,但底层关联的文件描述符是操作系统资源。200 个任务排队,意味着 200 个读操作等待。 - 如果多个用户同时上传呢?每次调用
uploadFile都new一个新的线程池。10 个用户同时上传 100MB 以上的文件,就是 10 个线程池、2000 个排队任务。 - 更关键的是——这不是"会不会 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老炮踩坑录」,不错过每一篇真实案例,少踩坑。