Java并发(五):线程池与资源隔离
Java并发(五):线程池与资源隔离
导语:线程池是并发知识里最"工程化"的一块,也是线上事故的高发区。本篇覆盖七大参数、执行流程(含最常考的反直觉顺序)、拒绝策略、大小配置、线程复用与回收机制、状态流转,以及监控与动态调参、资源隔离选型。共 13 题。
一、线程池原理
1. 为什么要用线程池?
答: 三个核心价值:
- 降低资源消耗:复用已有线程,避免频繁创建/销毁线程的开销(创建线程需向 OS 申请栈空间与内核资源);
- 提高响应速度:任务到达时可直接用现存线程执行,无需等待线程创建;
- 提高可管理性:统一管控线程数量、排队策略、监控指标,防止无节制创建线程拖垮系统。
反面案例:不使用线程池、每个请求
new Thread(),在突发流量下会瞬间创建大量线程,导致 OOM 或 CPU 全耗在上下文切换上。
2. 线程池的核心参数(ThreadPoolExecutor)有哪些?
答: 七个参数:
| 参数 | 含义 |
|---|---|
corePoolSize | 核心线程数。默认常驻,即使空闲也不回收(除非设 allowCoreThreadTimeOut(true)) |
maximumPoolSize | 最大线程数(核心 + 非核心的上限) |
keepAliveTime | 空闲存活时间,默认只作用于超出核心数的那部分线程 |
unit | keepAliveTime 的时间单位 |
workQueue | 任务阻塞队列(ArrayBlockingQueue、LinkedBlockingQueue、SynchronousQueue 等) |
threadFactory | 线程工厂,用来定制线程名(强烈建议自定义命名,便于排查问题)、优先级、守护属性 |
handler | 拒绝策略,饱和时的兜底处理 |
记忆技巧:"两个数、两个时间、一个队列、一个工厂、一个策略"。其中最容易踩坑的是
workQueue的有界性——用无界队列会让maximumPoolSize彻底失效。
3. 线程池的任务执行流程(工作原理)是什么?
答: 提交任务时按以下顺序判断:
- 若当前线程数 <
corePoolSize→ 创建核心线程执行该任务; - 若线程数 ≥
corePoolSize→ 任务入阻塞队列排队; - 若队列已满且线程数 <
maximumPoolSize→ 创建非核心线程执行; - 若队列已满且线程数 =
maximumPoolSize→ 触发拒绝策略。
关键结论(常考误区):顺序是「先填满核心线程 → 再入队列 → 再扩到最大线程 → 最后拒绝」,而不是"先扩到最大线程再入队"。
推论:如果
workQueue是无界的(如new LinkedBlockingQueue<>()),那么队列永远不会满,第 3、4 步永远走不到——maximumPoolSize形同虚设,线程数会一直停在corePoolSize,任务却在队列里无限堆积。这正是Executors.newFixedThreadPool的隐患。
4. 线程池为什么使用阻塞队列?
答: 阻塞队列在线程池中承担生产者-消费者解耦与流量缓冲两个角色:
- 当核心线程都在忙时,新任务进入队列排队等待,而不是无限创建线程(
put/offer的"生产者侧"); - 空闲线程主动从队列取任务,队列为空时自动阻塞(
take的"消费者侧"),避免空转轮询浪费 CPU。
这天然实现了线程复用与削峰填谷:流量洪峰时任务排队而非压垮系统,洪峰过后由有限线程慢慢消化。
5. 线程池有哪些拒绝策略?
答: RejectedExecutionHandler 提供四种内置策略:
| 策略 | 行为 | 适用 |
|---|---|---|
AbortPolicy(默认) | 直接抛 RejectedExecutionException | 需要明确感知失败、快速失败的场景 |
CallerRunsPolicy | 由提交任务的线程自己执行该任务 | 起负反馈作用——提交者被占用,自然放慢提交速度,形成背压 |
DiscardPolicy | 静默丢弃新任务,不抛异常 | 允许丢任务(如非核心的埋点上报) |
DiscardOldestPolicy | 丢弃队列中最老的任务,再尝试提交当前任务 | 只关心最新数据的场景(如实时行情快照) |
生产建议:绝大多数核心业务不要让任务静默丢失。推荐自定义策略——例如先落盘/写本地消息表再重试、或记录日志并告警,避免"丢了任务却毫无感知"。
6. 为什么《阿里巴巴Java开发手册》不建议用 Executors 创建线程池?
答: Executors 的快捷工厂方法隐藏了危险的默认参数:
| 工厂方法 | 隐患 |
|---|---|
newFixedThreadPool(n) | 使用无界 LinkedBlockingQueue,任务可无限堆积 → OOM |
newSingleThreadExecutor() | 同上,无界队列 → OOM |
newCachedThreadPool() | maximumPoolSize = Integer.MAX_VALUE,且用 SynchronousQueue,高并发下无限创建线程 → OOM / 线程数爆炸 |
newScheduledThreadPool(n) | 同样 maximumPoolSize = Integer.MAX_VALUE |
推荐做法:直接用 new ThreadPoolExecutor(...) 显式指定核心参数、有界队列与合理的拒绝策略,并把线程工厂加上业务语义化的命名。
有界队列 +
CallerRunsPolicy(或自定义策略)是相对稳妥的组合:既能兜住突发流量,又不会无界堆积。
7. 如何合理配置线程池大小?
答: 没有万能公式,基本原则是按任务类型估算,再压测调优:
| 任务类型 | 参考公式 | 说明 |
|---|---|---|
| CPU 密集型 | CPU 核数 + 1 | 线程过多只会加剧线程切换;"+1" 用于补偿偶发的页缺失等停顿 |
| IO 密集型 | CPU 核数 × (1 + 平均等待时间 / 平均计算时间) | 等待占比越高,可开越多线程;实践中常从 2 × 核数 起调 |
| 混合型 | 拆分任务类型,分别用不同线程池 | 避免长任务拖住短任务 |
其他实践要点:
- 业务隔离:核心业务与非核心业务用独立线程池,防止互相影响(一个业务的阻塞拖垮全局);
- 一定要压测:公式只是起点,真实最优值取决于接口 RT、下游限流阈值、GC 表现;
- 配合监控:上线后用
getActiveCount、队列长度做动态观测(见第 12 题); - 虚拟线程时代的新答案:对 IO 密集型任务,JDK 21+ 可直接用
Executors.newVirtualThreadPerTaskExecutor(),不再需要为并发数纠结(见《Java并发(六)》)。
8. execute() 与 submit() 的区别?
答:
| 维度 | execute(Runnable) | submit(Callable/Runnable) |
|---|---|---|
| 所属接口 | Executor(ThreadPoolExecutor 实现) | ExecutorService |
| 返回值 | 无(void) | 返回 Future,可获取结果 |
| 异常处理 | 任务内异常直接抛出,由线程的 UncaughtExceptionHandler 处理,线程可能因此终止并被替换 | 异常被封装进 Future,调用 get() 时以 ExecutionException 抛出,不写 get() 就完全感知不到 |
| 能否取消 | 不能 | 可通过 Future.cancel() 取消 |
生产事故点:用
submit()提交任务却从不调用get(),任务内部的异常会被静默吞掉,日志里什么都看不到。若用submit(),务必在任务内部做好 try-catch 并记录日志。
9. shutdown() 与 shutdownNow() 的区别?
答:
| 维度 | shutdown() | shutdownNow() |
|---|---|---|
| 新任务 | 拒绝接收 | 拒绝接收 |
| 队列中已提交任务 | 继续执行完 | 不再执行,作为返回值的 List<Runnable> 返回 |
| 正在运行的任务 | 等待其自然结束 | 尝试 interrupt() 中断 |
| 阻塞性 | 非阻塞,立即返回 | 非阻塞,立即返回 |
| 是否等待终止 | 需配合 awaitTermination() | 同左 |
关键提醒:
shutdownNow()不保证任务真的停下来——它只是发送中断信号,任务是否响应取决于代码中是否检查中断(或调用了可中断的阻塞方法)。对一个不响应中断的死循环任务,shutdownNow()也无可奈何。
10. 线程池有哪些状态?
答: ThreadPoolExecutor 用一个 AtomicInteger 类型的 ctl 编码状态与线程数(高 3 位存状态,低 29 位存工作线程数,因此最大线程数不能超过 2^29 - 1)。共 5 种状态:
| 状态 | 含义 | 进入方式 |
|---|---|---|
| RUNNING | 接受新任务,并处理队列中的任务 | 初始态 |
| SHUTDOWN | 不接受新任务,但继续处理队列中已提交的任务 | 调用 shutdown() |
| STOP | 不接受新任务、不处理队列任务,并中断正在运行的任务 | 调用 shutdownNow() |
| TIDYING | 所有任务已终止、工作线程数为 0,即将执行 terminated() | 前两态满足条件后自动流转 |
| TERMINATED | terminated() 钩子方法执行完毕 | TIDYING 之后 |
状态流转是单向的:
RUNNING → SHUTDOWN → TIDYING → TERMINATED或RUNNING → STOP → TIDYING → TERMINATED。可以重写terminated()做线程池关闭后的资源清理。
11. ThreadPoolExecutor 内部是如何复用线程的?
答: 核心在于 Worker 内部类 + getTask() 循环取任务。
1)Worker 是什么Worker 继承 AQS 并实现 Runnable,每个 Worker 封装一个工作线程:
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
final Thread thread; // 该 Worker 绑定的线程
Runnable firstTask; // 首个任务(可为 null)
volatile long completedTasks;
}Worker 继承 AQS 是为了用不可重入的独占锁标识"当前线程是否正在执行任务"——这也是为什么 shutdown 时能区分"空闲线程"与"工作中的线程"。
2)线程如何被复用
工作线程启动后执行 runWorker(),逻辑是:
// 简化版
while (task != null || (task = getTask()) != null) { // 关键循环
// 加锁、执行 task.run()、统计、处理异常
}执行完 firstTask 后不退出,而是反复调用 getTask() 从队列里取下一个任务,这就是"复用"的本质——线程一直活着,只是不断换任务。
3)getTask() 的取任务与超时回收
- 允许超时(线程数 > 核心数,或开启了核心线程超时)→ 调用
workQueue.poll(keepAliveTime),超时返回 null; - 不允许超时 → 调用
workQueue.take(),永久阻塞直到有任务; getTask()返回null时,runWorker()退出循环,线程通过processWorkerExit()被移除——这就是非核心线程空闲超时后被回收的机制。
4)核心线程的回收
默认核心线程不会超时回收;调用 allowCoreThreadTimeOut(true) 后,核心线程也会走 poll(keepAliveTime) 超时退出。
一句话总结:线程池的复用 = "死循环 + 阻塞队列取任务",线程数量控制 = "先填核心、后扩非核心,非核心空闲超时被回收"。
12. 如何监控线程池?能否动态调整线程池参数?
答:
1)监控指标(ThreadPoolExecutor 提供 getter):
| 方法 | 含义 |
|---|---|
getActiveCount() | 正在执行任务的线程数(最核心的饱和度指标) |
getQueue().size() | 队列积压任务数(堆积预警) |
getCompletedTaskCount() / getTaskCount() | 已完成 / 累计提交任务数 |
getPoolSize() | 当前线程数 |
getLargestPoolSize() | 历史峰值线程数,用于判断是否曾打满到 maximumPoolSize |
getCorePoolSize() / getMaximumPoolSize() | 核心 / 最大线程数配置 |
- 还可重写
beforeExecute()/afterExecute()埋点,统计每个任务的耗时、异常与上下文传递(如 MDC traceId); - 对外暴露时建议自定义线程池并包装成 Spring Bean,通过 Micrometer/Prometheus 上报,配合告警阈值(如队列使用率 > 80%、活跃线程持续打满)。
2)动态调整参数ThreadPoolExecutor 提供了可在运行时热修改核心参数的方法,无需重建线程池:
setCorePoolSize(int)、setMaximumPoolSize(int);setKeepAliveTime(long, TimeUnit);allowCoreThreadTimeOut(boolean);setRejectedExecutionHandler(RejectedExecutionHandler)。
这对流量波动场景价值极大:大促/活动期间可临时调大核心线程与队列容量,活动结束后调回,避免为峰值长期保留大量线程造成资源浪费。
3)工程方案(注意区分出处,避免记混):
- Spring:
ThreadPoolTaskExecutor,可通过配置中心刷新参数; - 美团技术团队提出并实践了动态线程池方案(见其技术博客对
ThreadPoolExecutor参数热更新的实践),其开源生态中常见的两个实现是:- Hippo4j(
opengoofy/hippo4j):独立开源项目,提供线程池动态变参与监控、告警能力; - DynamicTp(
dromara/dynamic-tp):轻量级动态线程池,基于主流配置中心(Nacos/Apollo 等)推送配置。
- Hippo4j(
二、资源隔离
13. 线程池隔离与信号量隔离的区别?什么场景用哪个?
答: 这是资源隔离(如熔断降级组件 Hystrix)的两种思路,用于防止某个依赖的故障拖垮整个服务:
线程池隔离
- 每个依赖服务分配独立线程池,调用在独立线程中执行;
- 优点:隔离彻底——某依赖超时/故障只耗尽自己的线程池,不影响其他服务;天然支持超时中断与快速失败;
- 缺点:线程上下文切换开销大(Tomcat 线程 → 隔离线程,双层线程模型),高并发下线程数膨胀、内存占用高。
信号量隔离
- 不新建线程,用一个计数器限制同时访问该依赖的线程数(例如允许 10 个 Tomcat 线程进入),相当于一道限流关卡;
- 优点:无额外线程、无上下文切换,性能开销极小;
- 缺点:调用线程会被阻塞在依赖调用上无法释放;不能主动超时中断(只能依赖底层调用的超时设置)。
选型原则:
| 场景 | 选择 |
|---|---|
| 调用外部依赖(HTTP/RPC/数据库)、需要超时控制与故障隔离 | 线程池隔离(Hystrix 默认) |
| 纯内存计算、无网络 IO、QPS 极高、只做并发限流 | 信号量隔离(开销更小) |
一句话:线程池隔离用"资源换隔离",信号量隔离用"隔离换开销"。当被隔离的逻辑本身很快、不需要超时中断时,信号量是更经济的选择。
