Appearance
ExecutorService / 线程池
在总览中的位置:Java 并发模型总览 §2
相关主题:Thread / Runnable / Callable / Future · 死锁 / 活锁 / 竞态定位
一句话定位
线程池不是“少写几个 new Thread()”,而是把任务调度、队列、扩容、拒绝策略、生命周期管理统一到一个可治理的执行层。
代码索引
| 主题 | Lab 说明 | 源码 |
|---|---|---|
ThreadPoolExecutor 核心参数、扩容顺序、拒绝策略 | thread-pool-basics | ThreadPoolBasics.java |
Android 常用线程池预设、Executors 工厂方法陷阱 | thread-pool-basics | AndroidThreadPoolPresets.java |
ArrayBlockingQueue / LinkedBlockingQueue / SynchronousQueue 差异 | queue-strategy-compare | QueueStrategyCompare.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. 提交任务时的真实决策顺序
线程池不是“先扩容再排队”,而是:
- 线程数
< corePoolSize:优先创建核心线程执行 - 达到核心线程数:优先入队
- 队列满了:再尝试扩到
maximumPoolSize - 线程已到
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 线程池 | Android | Flutter | Web | Backend |
|---|---|---|---|---|---|
| 执行模型 | ExecutorService / ThreadPoolExecutor | 主线程 + 业务线程池 + HandlerThread | isolate / compute() / 任务调度 | event loop + Worker | request pool / 业务 pool / scheduler |
| 关键风险 | 线程膨胀、队列堆积、拒绝策略误用 | ANR、掉帧、页面销毁后旧任务继续跑 | isolate 启动成本、消息序列化 | 长任务堵塞主线程 | 队列爆炸、线程饥饿、尾延迟上升 |
| 推荐心智 | 先定义“允许多少任务在系统里” | 优先小而有界、按业务拆池 | 尽量减少重任务频繁起 isolate | 保持主线程短任务 | 按 SLO / 依赖类型拆池 |
| 调优入口 | 活跃线程、队列长度、拒绝次数、耗时分布 | Logcat、ANR traces、Systrace / profiler | DevTools timeline | Performance panel | metrics + profiler + thread dump |
常见场景
1. Android 图片压缩 / 缩略图 / 本地文件处理池
这类任务特点:
- 不能跑在主线程
- 用户可以通过连点把任务量迅速放大
- 旧任务可能很快失去价值(页面已退出 / 当前 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-basics | thread-pool-basics | ThreadPoolBasics.java · AndroidThreadPoolPresets.java |
| queue-strategy-compare | queue-strategy-compare | QueueStrategyCompare.java |
当前仍缺:线程池隔离 / 池内互相
Future.get()饥饿 /shutdownNow()中断响应 /ScheduledExecutorService漂移与堆积的专属实验。
复习检查题
线程池提交任务时,为什么不是“先扩到最大线程数,再考虑排队”?
答:
ThreadPoolExecutor的默认决策顺序是:先补核心线程,达到核心线程数后优先入队,只有队列满了才继续扩到最大线程数,最后才触发拒绝策略。这个顺序决定了系统是“先排队”还是“先扩线程”。为什么 Android 上要格外警惕
CallerRunsPolicy?答:因为线程池满时,被拒绝的任务会在“提交任务的线程”里同步执行。如果提交路径可能在主线程,那么耗时任务就会直接在 UI 线程跑,造成卡顿甚至 ANR。
为什么说
newFixedThreadPool()并不一定“更安全”?答:因为它底层默认用的是近无界
LinkedBlockingQueue。线程数虽然固定,但高峰任务会一直堆进队列,最终可能造成内存上涨、任务延迟失控甚至 OOM。ArrayBlockingQueue和SynchronousQueue的选型差异是什么?答:
ArrayBlockingQueue有明确容量,适合用有限排队换稳定;SynchronousQueue零容量,不存任务,倾向于直接交给线程执行或扩线程,更适合受控环境下追求低排队延迟,不适合移动端激进使用。线程池参数调优时,为什么“线程越多越快”通常是错觉?
答:因为线程增加会带来上下文切换、锁竞争和缓存抖动。CPU 密集型任务尤其如此;IO 密集型虽然能适当增多线程,但仍受下游依赖和系统资源上限约束。
速记
- 线程池的本质是治理任务,不是复用线程这么简单。
- 先 core,再 queue,再 max,最后 reject。
- 移动端优先“小而有界”;服务端优先“按业务拆池”。
Executors工厂方法省代码,但也最容易把风险藏起来。