我用 Executors 创建线程池,被阿里规约第一页打了脸——老项目并发踩坑实录

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

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

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

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

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

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

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

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

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

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

本文思路大纲见下图:

案发现场

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

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

java 复制代码
// AsyncElecServiceImpl.java L414
// HyunFileServiceImpl.java L114
// HOBSFileServiceImpl.java L51

ExecutorService executorService = Executors.newFixedThreadPool(5);

然后往线程池里塞任务:

java 复制代码
long partSize = 5 * 1024 * 1024L;  // 5MB 一块
long partCount = file.length() % partSize == 0
    ? file.length() / partSize
    : file.length() / partSize + 1;

for (int i = 0; i < partCount; i++) {
    long offset = i * partSize;
    long currPartSize = (i + 1 == partCount) ? file.length() - offset : partSize;
    executorService.execute(new PartUploader(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 打开:

yaml 复制代码
spring:
  servlet:
    multipart:
      max-file-size: 1024MB
      max-request-size: 1024MB

允许上传 1024MB 的文件。


算一笔账

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

ini 复制代码
partCount = 1024 / 5 = 205 块

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

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

打开 Executors.newFixedThreadPool 的源码:

java 复制代码
public static ExecutorService newFixedThreadPool(int nThreads) {
    return new ThreadPoolExecutor(nThreads, nThreads,
        0L, TimeUnit.MILLISECONDS,
        new LinkedBlockingQueue<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:

java 复制代码
// 改之前
ExecutorService executorService = Executors.newFixedThreadPool(5);

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

三个关键变化:

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

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

2. 拒绝策略选了 CallerRunsPolicy。

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

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

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

3. 线程有了名字。

java 复制代码
new ThreadFactoryBuilder()
    .setNameFormat("obs-upload-%d")
    .build()

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


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

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

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

java 复制代码
public void uploadFile(File file, ...) {
    ExecutorService executorService = Executors.newFixedThreadPool(5);
    // ... 上传逻辑 ...
    executorService.shutdown();
}

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

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

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

java 复制代码
@Service
public class ObsFileServiceImpl {

    private final ExecutorService uploadExecutor = new ThreadPoolExecutor(
        5, 5, 60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(200),
        new ThreadFactoryBuilder().setNameFormat("obs-upload-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy()
    );

    public void uploadFile(File file, ...) {
        // 直接用 uploadExecutor,不创建新的
        // ...
    }

    @PreDestroy
    public void destroy() {
        uploadExecutor.shutdown();
    }
}

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

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


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

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

第一,资源必须有上限。

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

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

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

第三,便捷 API 是陷阱。

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

烦,是因为重要。


自查清单

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

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

写在最后

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

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

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

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

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


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

本文同时发于公众号「Java老炮踩坑录」,欢迎品鉴。

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

相关推荐
lizhongxuan44 分钟前
Agent Sandbox 怎么选
后端
用户5446933925644 分钟前
为什么有管理员权限,Token正确,访问却是403
后端
nyaomaru44 分钟前
你的 Type Guard 可能会悄悄地与 TypeScript 类型发生偏移 🔧
后端·typescript
明月_清风1 小时前
数据平台到底是什么?一篇文章搞懂数据平台开发
大数据·后端·数据分析
心之语歌1 小时前
DeerFlow Docker Desktop 部署教程
后端
美好世界1 小时前
Codex 源码导读:第二部分——一次 Turn 的完整生命周期
后端
据说幸运很容易1 小时前
接口测试框架重构:YAML 用例驱动 + 分层设计
后端·架构
海岳云舟1 小时前
SpringCloudGateway 动态转发后端服务
后端
我的div丢了肿么办1 小时前
自定义类型和类型别名以及实例化结构体的5种方式
后端·go
小满zs1 小时前
Go语言第十三章(互斥锁,读写锁)
后端·go