线程池使用注意事项详解
线程池使用注意事项详解
线程池是并发编程的利器,但"用上"不等于"用好"。配置不当、生命周期管理缺失、异常处理疏忽,都可能让线程池从性能优化工具变成线上事故的源头。本文系统梳理线程池使用过程中的各类注意事项,帮助避坑。
一、创建阶段的注意事项
1.1 禁止使用 Executors 快捷工厂方法
Executors 提供的 newFixedThreadPool、newSingleThreadExecutor、newCachedThreadPool、newScheduledThreadPool 看似方便,实则暗藏杀机:
| 方法 | 隐患 |
|---|---|
newFixedThreadPool |
使用无界 LinkedBlockingQueue,任务堆积导致 OOM |
newSingleThreadExecutor |
同样无界队列,OOM |
newCachedThreadPool |
最大线程数为 Integer.MAX_VALUE,线程数失控 OOM |
newScheduledThreadPool |
同样最大线程数无上限 |
正确做法:使用 ThreadPoolExecutor 七参构造方法,显式指定有界队列与合理的最大线程数。
// 错误
ExecutorService pool = Executors.newFixedThreadPool(8);
// 正确
ExecutorService pool = new ThreadPoolExecutor(
4, 8, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("biz"),
new ThreadPoolExecutor.CallerRunsPolicy());
1.2 必须使用有界队列
无界队列(LinkedBlockingQueue 无参构造,容量为 Integer.MAX_VALUE)会让 maximumPoolSize 形同虚设,任务无限堆积直至 OOM。
// 危险:无界队列
new LinkedBlockingQueue<Runnable>()
// 安全:有界队列
new ArrayBlockingQueue<Runnable>(500)
注意:队列容量并非越大越好。过大的队列会掩盖处理能力不足的问题,让任务长时间排队,响应延迟飙升却无明显错误。应根据 SLA 设定合理上限,让拒绝策略在过载时及时暴露问题。
1.3 合理设置核心与最大线程数
线程数并非越多越好。过多线程会导致频繁上下文切换,反而降低吞吐。
| 任务类型 | 推荐核心线程数 | 说明 |
|---|---|---|
| CPU 密集型 | N + 1 |
N 为 CPU 核数;多 1 用于偶发阻塞时保持 CPU 利用率 |
| IO 密集型 | 2N ~ 10N |
线程多在等待,可适当多开,需压测确定 |
| 混合型 | 拆分为两个池子 | 分别配置,避免相互拖累 |
int cpu = Runtime.getRuntime().availableProcessors();
int core = cpu + 1; // CPU 密集型
int ioCore = cpu * 2; // IO 密集型
注意:不要硬编码线程数。Android 设备 CPU 核数差异大(4~8 核常见),服务器更是从 2 核到上百核不等,必须动态获取。
1.4 必须自定义 ThreadFactory
默认工厂创建的线程名为 pool-N-thread-M,无法区分业务,出问题时线程堆栈难以定位。
public class NamedThreadFactory implements ThreadFactory {
private static final AtomicInteger POOL_SEQ = new AtomicInteger(1);
private final AtomicInteger threadSeq = new AtomicInteger(1);
private final String prefix;
private final ThreadGroup group;
public NamedThreadFactory(String name) {
this.prefix = name + "-" + POOL_SEQ.getAndIncrement() + "-T-";
this.group = Thread.currentThread().getThreadGroup();
}
@Override
public Thread newThread(Runnable r) {
Thread t = new Thread(group, r, prefix + threadSeq.getAndIncrement(), 0);
t.setDaemon(false);
t.setPriority(Thread.NORM_PRIORITY);
return t;
}
}
命名后,在线程 dump、日志、崩溃栈中能一眼看出是哪个业务池子的哪个线程,排查效率大幅提升。
1.5 谨慎选择拒绝策略
四种内置策略各有适用场景,不可盲目使用默认的 AbortPolicy:
| 策略 | 适用场景 | 注意 |
|---|---|---|
AbortPolicy |
默认;需感知过载并处理 | 抛异常需捕获,否则线程终止 |
CallerRunsPolicy |
不丢任务,降级降速(背压) | 提交线程被占用,可能是主线程! |
DiscardPolicy |
允许丢失、不关心 | 静默丢弃,无日志,易忽略 |
DiscardOldestPolicy |
保留最新、丢弃过期 | 需确认旧任务确实可弃 |
特别注意:CallerRunsPolicy 会让提交任务的线程自己执行任务。如果在主线程提交,会阻塞主线程!Android 中切勿在 UI 线程用此策略提交耗时任务。
自定义拒绝策略时,不要在其中再次调用 executor.execute,否则池子仍满会再次触发拒绝,形成无限递归直至栈溢出。
// 错误:递归
new RejectedExecutionHandler() {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
e.execute(r); // 池子仍满 → 再次拒绝 → 递归 → StackOverflowError
}
};
// 正确:记录或持久化
new RejectedExecutionHandler() {
public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
Log.w("Pool", "rejected, queueSize=" + e.getQueue().size());
// 或写入消息队列稍后重试
}
};
二、提交与执行阶段的注意事项
2.1 区分 execute 与 submit 的异常行为
这是最容易被忽视的差异:
execute(Runnable):任务抛出未捕获异常时,异常会交给线程的UncaughtExceptionHandler,默认打印到 stderr,线程会终止并由池子新建替代线程。submit(Callable/Runnable):返回Future,异常被封装在 Future 内,不会立即抛出。若不调用future.get(),异常会被静默吞掉,任务"成功"完成但实际失败。
// execute:异常会打印
pool.execute(() -> { throw new RuntimeException("boom"); });
// submit:异常被吞!
pool.submit(() -> { throw new RuntimeException("boom"); });
// submit 正确用法
Future<?> f = pool.submit(() -> { throw new RuntimeException("boom"); });
try {
f.get();
} catch (ExecutionException e) {
Log.e("TAG", "任务失败", e.getCause());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
建议:统一在任务内部 try-catch,无论 execute 还是 submit 都能捕获异常,行为一致。
pool.execute(() -> {
try { doWork(); }
catch (Throwable t) { Log.e("TAG", "任务异常", t); }
});
2.2 不要吞掉 InterruptedException
当调用 shutdownNow 或 future.cancel(true) 时,线程会收到中断信号。正确处理中断:
// 错误:吞掉中断
try { Thread.sleep(1000); }
catch (InterruptedException e) { /* 啥也不做 */ }
// 正确:恢复中断状态
try { Thread.sleep(1000); }
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标志
return; // 优雅退出
}
吞掉中断会导致 shutdownNow 无法真正停止任务,线程池"关不干净"。
2.3 任务应尽量轻量、无阻塞
线程池的设计假设任务是非阻塞或轻度阻塞的。若任务长时间阻塞(如无限等待网络、阻塞式 IO 无超时),会迅速耗尽线程,让池子失去并发能力。
// 危险:无超时的阻塞调用
pool.execute(() -> {
String r = socket.read(); // 可能永远不返回
});
// 正确:设置超时
pool.execute(() -> {
String r = socket.read(5, TimeUnit.SECONDS); // 超时即返回
});
原则:所有 IO、锁等待、sleep 都应设置超时,避免线程被永久挂住。
2.4 避免任务间依赖导致死锁
若任务 A 在执行中等待任务 B 的结果,而两者在同一个线程池中,当池子满时 B 无法获得线程,A 永远等待 B,形成死锁。
// 危险:同池子内嵌套提交
pool.execute(() -> {
Future<Integer> f = pool.submit(() -> compute()); // B 任务
int result = f.get(); // A 等待 B,但 B 排队无法执行 → 死锁
});
解决:使用不同的线程池隔离,或避免嵌套提交。
2.5 不要用无界返回值的 Future 集合
invokeAll 会等待所有任务完成,若任务无超时且某个任务卡住,调用将永久阻塞。务必使用带超时的重载:
List<Future<T>> futures = executor.invokeAll(tasks, 10, TimeUnit.SECONDS);
三、生命周期管理的注意事项
3.1 必须在组件销毁时关闭线程池
线程池的工作线程默认是非守护线程。即使 Activity 销毁、甚至整个应用逻辑上"退出",只要池子未关闭,线程依然存活,进程不会终止,造成内存泄漏与电量消耗。
public class MyActivity extends AppCompatActivity {
private ExecutorService pool;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
pool = Executors.newSingleThreadExecutor(); // 仅为示例,生产请用 ThreadPoolExecutor
}
@Override
protected void onDestroy() {
super.onDestroy();
pool.shutdownNow(); // 必须!
}
}
3.2 shutdown 与 shutdownNow 的选择
| 方法 | 行为 | 适用 |
|---|---|---|
shutdown() |
不再接受新任务,已提交任务继续执行完 | 优雅停机,保证任务完成 |
shutdownNow() |
不再接受新任务,尝试中断正在执行的任务,返回未执行任务 | 立即停机,可能丢任务 |
注意:shutdownNow 调用的是 Thread.interrupt(),只能中断响应中断的操作(sleep、wait、IO 等)。若任务不响应中断(如纯 CPU 死循环),线程不会停止。
优雅停机模板:
pool.shutdown();
try {
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
List<Runnable> unfinished = pool.shutdownNow();
Log.w("TAG", "强制关闭,剩余 " + unfinished.size() + " 个任务");
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
Log.e("TAG", "线程池未能完全终止");
}
}
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
}
3.3 不要重复关闭线程池
多次调用 shutdown 是安全的(幂等),但 shutdownNow 返回的未执行任务列表只在第一次调用时有意义。重复关闭虽不报错,但属于无意义操作,应避免。
3.4 全局共享线程池需谨慎
单例共享线程池可减少创建开销,但风险是:
- 一个业务的慢任务拖垮所有业务
- 一个业务的拒绝策略影响其他业务
- 关闭时机难以确定
建议:按业务隔离,每个业务独立池子;若必须共享,务必做好监控与限流。
四、定时任务的注意事项
4.1 周期任务抛异常会停止
ScheduledThreadPoolExecutor 的周期任务若抛出未捕获异常,后续不再执行,且不会有明显错误日志,问题极难发现。
// 危险:异常导致周期任务停止
scheduler.scheduleAtFixedRate(() -> {
doWork(); // 一旦抛异常,周期任务永久停止
}, 0, 5, TimeUnit.SECONDS);
// 正确:内部 try-catch
scheduler.scheduleAtFixedRate(() -> {
try { doWork(); }
catch (Throwable t) { Log.e("TAG", "周期任务异常", t); }
}, 0, 5, TimeUnit.SECONDS);
4.2 scheduleAtFixedRate 与 scheduleWithFixedDelay 的区别
scheduleAtFixedRate:以任务开始时间为基准。若任务执行时间超过周期,会"追赶"或重叠执行。scheduleWithFixedDelay:以任务结束时间为基准。永远保证两次执行间有指定间隔。
周期 = 5s,任务执行耗时 = 8s
scheduleAtFixedRate: 0s----8s----13s----21s----26s... (追赶,间隔变短)
scheduleWithFixedDelay: 0s----8s----13s----21s----26s... (每次结束后再等5s,间隔=13s)
注意:若任务执行时间大于周期,scheduleAtFixedRate 实际退化为串行执行(不会真正并发),需理解其语义避免误用。
4.3 定时任务的取消
scheduleAtFixedRate 返回 ScheduledFuture,可调用 cancel(false) 取消。注意 cancel(true) 中断正在执行的任务,cancel(false) 仅取消后续未执行的。
ScheduledFuture<?> sf = scheduler.scheduleAtFixedRate(task, 0, 5, TimeUnit.SECONDS);
// 取消后续执行
sf.cancel(false);
// 取消并中断当前执行
sf.cancel(true);
五、Android 特有的注意事项
5.1 子线程不能更新 UI
线程池任务执行完后,若要更新 UI,必须切回主线程。直接操作 UI 会抛 CalledFromWrongThreadException。
pool.execute(() -> {
String data = loadData();
// 错误:textView.setText(data);
// 正确:
runOnUiThread(() -> textView.setText(data));
});
5.2 任务持有 Context/View 引用导致泄漏
匿名内部类或 Lambda 会隐式持有外部类引用。若任务在 Activity 销毁后仍执行,且持有 Activity/View,会造成内存泄漏。
// 危险:Lambda 持有 Activity 引用
pool.execute(() -> {
Bitmap bmp = decode(resId); // resId 来自 Activity 资源
imageView.setImageBitmap(bmp); // imageView 持有 Activity
});
// 改进:弱引用 + 生命周期检查
pool.execute(() -> {
Bitmap bmp = decode(resId);
mainHandler.post(() -> {
if (!isFinishing() && !isDestroyed()) {
imageView.setImageBitmap(bmp);
}
});
});
5.3 主线程提交任务配合 CallerRunsPolicy 的陷阱
在主线程用配置了 CallerRunsPolicy 的池子提交任务,当池子满时,任务会在主线程执行,直接卡 UI!
// 危险
ExecutorService pool = new ThreadPoolExecutor(
2, 4, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
new ThreadPoolExecutor.CallerRunsPolicy());
// 主线程提交,池子满时 → 主线程执行耗时任务 → ANR
pool.execute(() -> heavyWork());
解决:Android 中提交任务的拒绝策略不要用 CallerRunsPolicy,或确保提交方不在主线程。
5.4 注意 Android 后台执行限制
Android 8.0+ 对后台应用限制严格,若应用进入后台,线程池任务仍可能被系统限制甚至杀死。长任务应使用 WorkManager 或前台服务。
5.5 不要在 BroadcastReceiver onReceive 中起线程池任务
onReceive 默认在主线程,且系统可能在 onReceive 返回后立即销毁接收器(静态接收器),后台任务会被杀死。需要后台处理应使用 goAsync() 或 WorkManager。
六、监控与排查的注意事项
6.1 关键监控指标
ThreadPoolExecutor pool = ...;
pool.getActiveCount(); // 活跃线程数
pool.getPoolSize(); // 当前线程数
pool.getQueue().size(); // 队列堆积
pool.getTaskCount(); // 计划任务总数
pool.getCompletedTaskCount(); // 已完成任务数
pool.getLargestPoolSize(); // 历史峰值线程数
pool.getRejectedExecutionHandler(); // 拒绝策略
需关注的异常信号:
- 队列 size 持续增长 → 处理能力不足
- activeCount 长期等于 maximumPoolSize → 可能需扩容
- completedTaskCount 停止增长 → 可能死锁或任务卡住
- 频繁触发拒绝策略 → 容量不足或上游过载
6.2 beforeExecute / afterExecute 钩子
可继承 ThreadPoolExecutor 重写钩子方法,实现任务级监控:
public class MonitoredPool extends ThreadPoolExecutor {
private final ThreadLocal<Long> startTime = new ThreadLocal<>();
public MonitoredPool(int core, int max, long keepAlive, TimeUnit unit,
BlockingQueue<Runnable> queue) {
super(core, max, keepAlive, unit, queue);
}
@Override
protected void beforeExecute(Thread t, Runnable r) {
startTime.set(System.currentTimeMillis());
}
@Override
protected void afterExecute(Runnable r, Throwable t) {
long cost = System.currentTimeMillis() - startTime.get();
startTime.remove();
if (cost > 1000) {
Log.w("Pool", "慢任务耗时=" + cost + "ms");
}
if (t != null) {
Log.e("Pool", "任务异常", t);
}
}
}
注意:afterExecute 的 Throwable 参数对 submit 提交的任务为 null(异常封装在 Future 中),需额外处理。
6.3 线程堆栈排查
出现卡顿或死锁时,通过线程 dump 查看池子线程状态:
- 全部
RUNNABLE且 CPU 高 → CPU 密集,可能需扩容或优化算法 - 大量
WAITING在锁上 → 死锁或锁竞争 - 大量
WAITING在队列上 → 任务不足,池子过大 - 线程名能定位到具体业务池子(前提是用了自定义 ThreadFactory)
七、其他易忽视的细节
7.1 allowCoreThreadTimeOut
默认核心线程即使空闲也不回收。调用 pool.allowCoreThreadTimeOut(true) 后,核心线程也会在空闲超时后回收,适合间歇性高负载场景以节省资源。
pool.allowCoreThreadTimeOut(true);
7.2 prestartAllCoreThreads
线程池默认在任务到达时才创建线程。若希望池子启动即创建所有核心线程以备瞬时高并发:
pool.prestartAllCoreThreads();
7.3 不要用线程池执行一次性短任务后立即关闭
频繁创建/关闭线程池丧失了复用意义。应保持池子长期存活,复用执行多次任务。
7.4 动态调参的边界
setCorePoolSize 不能超过 getMaximumPoolSize,否则抛 IllegalArgumentException。调整顺序应为先调大 max,再调大 core;或先调小 core,再调小 max。
// 正确顺序:扩大
pool.setMaximumPoolSize(16);
pool.setCorePoolSize(8);
// 正确顺序:缩小
pool.setCorePoolSize(4);
pool.setMaximumPoolSize(8);
7.5 ForkJoinPool 与普通线程池的选择
ForkJoinPool 适合可分治的任务(如递归拆分的大计算),其工作窃取(work-stealing)机制能提升负载均衡。普通 ThreadPoolExecutor 适合独立任务。不要混用语义。
7.6 不要在任务中修改线程池配置
在任务执行中调用 setCorePoolSize 等修改方法,可能引发非预期行为(如触发线程回收导致当前任务所在线程被中断)。配置应在池子外部调整。
八、自查清单
使用线程池前,对照以下清单逐项确认:
- [ ] 是否使用
ThreadPoolExecutor显式构造,而非Executors工厂方法? - [ ] 是否使用了有界队列?
- [ ] 核心与最大线程数是否根据 CPU 核数与任务类型动态计算?
- [ ] 是否自定义了
ThreadFactory并为线程命名? - [ ] 拒绝策略是否符合业务语义?是否避免了在主线程触发
CallerRunsPolicy? - [ ] 任务内部是否 try-catch,避免异常吞没或周期任务停止?
- [ ] 是否正确处理
InterruptedException,不吞中断? - [ ] 所有阻塞操作是否设置了超时?
- [ ] 是否避免了同池子内任务间依赖(防死锁)?
- [ ] 是否在组件销毁时关闭线程池或取消任务?
- [ ] 是否避免了任务持有 Activity/View 强引用?
- [ ] 切回主线程更新 UI 前是否检查了组件是否已销毁?
- [ ] 是否按业务隔离了线程池?
- [ ] 是否对关键池子做了监控(队列堆积、拒绝次数、慢任务)?
- [ ] 定时任务是否在内部 try-catch 防止停止?
九、总结
线程池的"注意事项"远比"使用方法"重要。一个配置错误的线程池,性能可能还不如单线程串行;一个生命周期管理缺失的线程池,会带来内存泄漏与稳定性问题;一个异常处理疏忽的线程池,会让故障悄无声息地发生。
核心原则可以归纳为三点:
- 可控:显式构造、有界队列、合理上限、自定义命名,让一切尽在掌握。
- 可观测:异常不吞、监控到位、命名清晰,让问题无处遁形。
- 可关闭:与生命周期绑定、优雅停机、不泄漏资源,让系统干净利落。
牢记这三点,线程池才能从"用上"真正走向"用好"。
- 点赞
- 收藏
- 关注作者
评论(0)