Skip to content

ExecutorService / 线程池

在总览中的位置Java 并发模型总览 §2
相关主题Thread / Runnable / Callable / Future · 死锁 / 活锁 / 竞态定位

一句话定位

线程池不是“少写几个 new Thread()”,而是把任务调度、队列、扩容、拒绝策略、生命周期管理统一到一个可治理的执行层。

代码索引

主题Lab 说明源码
ThreadPoolExecutor 核心参数、扩容顺序、拒绝策略thread-pool-basicsThreadPoolBasics.java
Android 常用线程池预设、Executors 工厂方法陷阱thread-pool-basicsAndroidThreadPoolPresets.java
ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue 差异queue-strategy-compareQueueStrategyCompare.java

为什么需要

真实工程里,线程池解决的从来不只是“线程复用”,而是以下四个更贵的问题:

  • 资源边界:线程不是免费的。每多一个线程,都会带来栈内存、调度竞争和上下文切换。
  • 高峰控制:任务高峰来时,是排队、扩线程、丢弃,还是让调用方自己跑?这必须提前定义。
  • 故障隔离:图片压缩、日志写盘、网络回调后处理,不该共用一个池把彼此拖死。
  • 生命周期治理:模块关闭、页面退出、服务下线时,哪些任务要跑完,哪些任务可以直接丢?

如果没有线程池治理,常见后果是:

  • Android 连点触发几十个压缩/解析任务,页面退出后后台还在埋头跑,前台开始掉帧。
  • Backend 某个下游慢了以后,请求任务不断入队,线程看起来不多,实际上队列已经成为延迟炸弹。
  • 默认 Executors.newFixedThreadPool() 看起来很安全,最后却因为近无界队列积压吃满内存。

底层机制

1. ThreadPoolExecutor 的 7 个核心参数

java
ThreadPoolExecutor pool = new ThreadPoolExecutor(
    2,
    4,
    30L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(16),
    r -> new Thread(r, "img-compress-worker"),
    new ThreadPoolExecutor.AbortPolicy()
);
参数作用选型重点
corePoolSize常驻线程数CPU 型任务通常接近核数;移动端更要保守
maximumPoolSize最大线程数决定高峰时是否继续扩线程
keepAliveTime非核心线程空闲多久回收突刺流量后是否快速收缩
unit时间单位keepAliveTime 配套
workQueue队列类型和容量决定排队、背压、内存风险
threadFactory线程创建方式必须命名,方便 dump / profiler 排障
RejectedExecutionHandler无法接收任务时怎么办本质是业务降级策略

2. 提交任务时的真实决策顺序

线程池不是“先扩容再排队”,而是:

  1. 线程数 < corePoolSize:优先创建核心线程执行
  2. 达到核心线程数:优先入队
  3. 队列满了:再尝试扩到 maximumPoolSize
  4. 线程已到 maximumPoolSize 且队列也满:触发拒绝策略

这正是 ThreadPoolBasics.java 演示的行为。

3. 队列类型决定系统“是慢下来,还是抖起来”

ArrayBlockingQueue

  • 固定容量,有硬边界
  • 适合 Android / App / 小内存环境
  • 风险:容量太小会更早触发拒绝,但这往往比无界堆积更可控

LinkedBlockingQueue

  • 可指定容量;默认构造接近无界
  • 倾向于“先排队,少扩线程”
  • 风险:高峰时任务无限堆积,线程数看起来很稳,实际上内存与延迟已经失控

SynchronousQueue

  • 零容量,不存任务
  • 任务必须立刻交给线程执行,否则就扩线程 / 拒绝
  • 风险:高峰时线程数可能快速膨胀,移动端尤其危险

4. 拒绝策略不是边角料,而是降级决策

策略行为适合场景典型风险
AbortPolicy直接抛 RejectedExecutionException核心任务失败必须可见上层不兜底会直接炸出来
CallerRunsPolicy由提交任务的线程自己执行后台提交路径,想要自然限流如果是主线程提交,直接卡 UI / ANR
DiscardPolicy静默丢弃新任务低价值埋点、日志问题隐蔽,容易很久才发现数据丢失
DiscardOldestPolicy丢最旧任务,再尝试接收新任务旧任务过时、新任务更有价值业务若要求全量成功,会产生难解释缺口

5. shutdown() / shutdownNow():收尾方式不同

java
pool.shutdown();
if (!pool.awaitTermination(30, TimeUnit.SECONDS)) {
    pool.shutdownNow();
}
  • shutdown():不接新任务,但让已提交任务尽量跑完
  • shutdownNow():尝试中断正在执行的任务,并清空队列里尚未开始的任务

Android 注意:

  • 不要在主线程长时间 awaitTermination()
  • shutdownNow() 必须配合任务内部处理中断,否则停不干净

6. 经验边界:参数不是公式,是假设

任务类型常见起步值更重要的边界
CPU 密集(压缩、加密、解析)core ≈ CPU 核数线程过多只会增加切换,不会更快
IO 密集(DB、文件、网络后处理)可高于 CPU 核数必须同时看外部依赖上限,否则只是把阻塞放大
Android 图片/本地任务2~4 个线程常是更稳的起点小而有界的队列通常优于激进扩线程
串行写盘/日志1/1 单线程池顺序性比并行度更重要

更贴近移动端的例子:

java
ThreadPoolExecutor imagePool = new ThreadPoolExecutor(
    2,
    4,
    30L,
    TimeUnit.SECONDS,
    new ArrayBlockingQueue<>(8),
    r -> new Thread(r, "image-compress-worker"),
    new ThreadPoolExecutor.AbortPolicy()
);

为什么这样配:

  • 2~4:对 CPU+少量 IO 混合任务先保守
  • ArrayBlockingQueue(8):明确任务堆积上限
  • AbortPolicy:让高峰失败可见,由上层重试 / 降级,而不是悄悄拖垮进程

Android / Flutter / Web / Backend 对照

维度Java 线程池AndroidFlutterWebBackend
执行模型ExecutorService / ThreadPoolExecutor主线程 + 业务线程池 + HandlerThreadisolate / compute() / 任务调度event loop + Workerrequest pool / 业务 pool / scheduler
关键风险线程膨胀、队列堆积、拒绝策略误用ANR、掉帧、页面销毁后旧任务继续跑isolate 启动成本、消息序列化长任务堵塞主线程队列爆炸、线程饥饿、尾延迟上升
推荐心智先定义“允许多少任务在系统里”优先小而有界、按业务拆池尽量减少重任务频繁起 isolate保持主线程短任务按 SLO / 依赖类型拆池
调优入口活跃线程、队列长度、拒绝次数、耗时分布Logcat、ANR traces、Systrace / profilerDevTools timelinePerformance panelmetrics + profiler + thread dump

常见场景

1. Android 图片压缩 / 缩略图 / 本地文件处理池

对应 Labthread-pool-basics · AndroidThreadPoolPresets.java

这类任务特点:

  • 不能跑在主线程
  • 用户可以通过连点把任务量迅速放大
  • 旧任务可能很快失去价值(页面已退出 / 当前 item 已变化)

建议:

  • 小而有界线程池
  • 有界队列
  • 明确拒绝策略与页面退出后的取消善后

2. 串行日志 / 埋点写盘池

java
ExecutorService logPool = new ThreadPoolExecutor(
    1,
    1,
    0L,
    TimeUnit.MILLISECONDS,
    new ArrayBlockingQueue<>(200),
    r -> new Thread(r, "log-writer"),
    new ThreadPoolExecutor.DiscardPolicy()
);

适合:

  • 单文件顺序写入
  • 丢少量低价值日志可接受

不适合:

  • 审计、支付、强一致业务日志

3. Backend 多下游 fan-out 聚合池

服务端常把“请求主线程池”和“重活后台池”拆开:

  • 请求线程:只做编排与超时控制
  • 业务线程池:处理昂贵计算 / 下游并发

排障要点:

  • 如果请求线程也在做重活,会同时拖垮吞吐和尾延迟
  • 如果多个依赖类型共用一个池,慢依赖会污染快依赖

4. 老 Java 模块 / SDK 的回调后处理池

当网络库、桥接库或老 SDK 还不在协程世界里时,往往要用线程池兜住:

  • 回调转磁盘写入
  • 回调转缓存更新
  • 回调转图片解码 / JSON 解析

此时最重要的不是“能跑”,而是:

  • 线程必须命名
  • 池必须可观测
  • 失败必须可见

常见坑

现象修法
裸用 Executors.newFixedThreadPool()线程不多,但队列不断堆积,最后 OOM / 高延迟高要求场景显式写 ThreadPoolExecutor,并指定队列容量
裸用 Executors.newCachedThreadPool()高峰时线程数暴涨,CPU 抖动、内存上涨限制 maximumPoolSize,谨慎使用 SynchronousQueue
CallerRunsPolicy 配到主线程提交路径池满时耗时任务直接在主线程执行,卡顿或 ANR主线程路径优先 AbortPolicy,上层提示或重试
不同业务共用一个池日志/压缩/上传互相拖累按任务类型和 SLO 拆池
锁、RPC、磁盘 IO 全塞进同一小池表面像死锁,实则线程池饥饿拆池、限时、避免池内互等
忘记命名线程dump 和 profiler 很难看出线程来源自定义 ThreadFactory
忘记关闭线程池进程退不干净、测试挂住、资源泄漏模块退出时 shutdown() / shutdownNow()

与相近概念对比

概念适合什么最大风险
new Thread()单个底层实验或极少量手工线程资源治理缺失
ExecutorService统一提交与关闭任务参数写得太抽象,看不出边界
ThreadPoolExecutor需要显式掌控参数与治理策略不理解队列与拒绝策略会误配
newFixedThreadPool低风险短期 demo默认近无界队列
newCachedThreadPool短平快、受控后台环境默认近无界最大线程数
ScheduledExecutorService定时 / 周期任务周期任务执行时间超过周期时易堆积

队列策略对比

队列倾向行为最适合最怕什么
ArrayBlockingQueue明确背压移动端、本地任务、有限内存容量过小导致过早拒绝
LinkedBlockingQueue先排队任务量可控、短期积压可接受默认无界引发延迟 / OOM
SynchronousQueue优先扩线程极短任务、受控环境线程暴涨

对应实验

Lab说明源码
thread-pool-basicsthread-pool-basicsThreadPoolBasics.java · AndroidThreadPoolPresets.java
queue-strategy-comparequeue-strategy-compareQueueStrategyCompare.java

当前仍缺:线程池隔离 / 池内互相 Future.get() 饥饿 / shutdownNow() 中断响应 / ScheduledExecutorService 漂移与堆积的专属实验。

复习检查题

  1. 线程池提交任务时,为什么不是“先扩到最大线程数,再考虑排队”?

    ThreadPoolExecutor 的默认决策顺序是:先补核心线程,达到核心线程数后优先入队,只有队列满了才继续扩到最大线程数,最后才触发拒绝策略。这个顺序决定了系统是“先排队”还是“先扩线程”。

  2. 为什么 Android 上要格外警惕 CallerRunsPolicy

    :因为线程池满时,被拒绝的任务会在“提交任务的线程”里同步执行。如果提交路径可能在主线程,那么耗时任务就会直接在 UI 线程跑,造成卡顿甚至 ANR。

  3. 为什么说 newFixedThreadPool() 并不一定“更安全”?

    :因为它底层默认用的是近无界 LinkedBlockingQueue。线程数虽然固定,但高峰任务会一直堆进队列,最终可能造成内存上涨、任务延迟失控甚至 OOM。

  4. ArrayBlockingQueueSynchronousQueue 的选型差异是什么?

    ArrayBlockingQueue 有明确容量,适合用有限排队换稳定;SynchronousQueue 零容量,不存任务,倾向于直接交给线程执行或扩线程,更适合受控环境下追求低排队延迟,不适合移动端激进使用。

  5. 线程池参数调优时,为什么“线程越多越快”通常是错觉?

    :因为线程增加会带来上下文切换、锁竞争和缓存抖动。CPU 密集型任务尤其如此;IO 密集型虽然能适当增多线程,但仍受下游依赖和系统资源上限约束。

速记

  • 线程池的本质是治理任务,不是复用线程这么简单。
  • 先 core,再 queue,再 max,最后 reject。
  • 移动端优先“小而有界”;服务端优先“按业务拆池”。
  • Executors 工厂方法省代码,但也最容易把风险藏起来。