一说到Java线程池,很多刚接触并发的同学第一反应是:不就是Executors.newFixedThreadPool(5),然后往里面submit任务吗?确实,两行代码就能跑起来,但真正等到线上出问题——队列堆积、拒绝策略误触发、任务莫名其妙丢失——你才会意识到,线程池不是“一new了之”那么简单的东西。
这篇我打算用一篇到底的方式,把Java线程池从概念、参数、内置实现、队列选型、拒绝策略,到生产级封装、避坑经验、面试高频考点全部梳理一遍。不需要你有多深的基础,只要跟着代码走一遍,配合我踩过的坑来解释,基本能覆盖日常开发和面试的大多数场景。
1. 先搞清楚线程池到底解决了什么问题
1.1 用一个现实场景理解线程池
假设你开了一家快递驿站,每天来取件的人络绎不绝。如果你每来一个客户就临时雇一个员工,高峰期可能雇了几百人,但大多数时间员工都在闲着,工资照发,很快就亏本。更麻烦的是,雇人需要时间,客户等不了那么久。
线程池的思路和驿站雇人一模一样:提前招好几个固定员工(核心线程),让他们一直在岗;生意太好忙不过来时,把客户先引导到等候区排队(阻塞队列);排队也排不下的时候,再临时招一批兼职(非核心线程);兼职也招满了,那就只能拒客或者让店长亲自接待(拒绝策略)。而你作为老板,永远不会为了一个人就临时去大街上拉人——那个代价太高了。
映射到Java里,“临时拉人”就是new Thread().start(),每次创建线程都要操作系统分配资源、创建栈、注册调度,成本不低。线程池的本质就是复用和限流:复用已有线程避免频繁创建销毁,同时用一个有界队列兜住突发流量,避免无限创建线程把系统打垮。
1.2 线程池的三个核心机制
我用三个词来概括线程池对任务流量的处理能力:
- 复用:线程执行完一个任务后不会销毁,而是继续从队列里取下一个任务。这是线程池性能的核心来源。
- 缓冲:所有多余的任务先进阻塞队列,生产者(调用方)不需要等待线程空闲,提交动作本身是异步且极快的。
- 限流与保护:线程数上限(maximumPoolSize)和队列容量其实是一道流量闸门,超过系统承受能力就直接拒绝,防止进程OOM或者假死。
这三个机制合起来,就是你理解后面所有参数的地基。任何关于线程池的讨论,归根到底都是在调这三者之间的平衡。
1.3 什么时候该用线程池,什么时候不该用
我见过不少项目,不管三七二十一,所有异步操作全丢线程池。结果线程池成了下一个战场,任务互相抢占,排查问题时线程日志一坨乱麻。
需要线程池的典型场景:接口里要并行调用多个下游服务、批量处理大量独立任务(如批量发送邮件)、需要定时或延迟执行任务、后台异步写日志或数据落库。不适合线程池的场景:任务本身执行时间极短但数量爆炸(比如每秒百万级的内存计数),这种更适合直接用单线程循环或批量聚合;任务之间有强依赖需要互相等待的,也尽量别往线程池里塞,调度和监控都麻烦。
2. ThreadPoolExecutor七个参数逐个拆解
2.1 从构造函数开始认识参数
Java线程池的真实面目是java.util.concurrent.ThreadPoolExecutor,它最完整的构造函数长这样:
public ThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler)七个参数,每个都有明确的职责。我第一次看这堆参数时觉得乱,后来发现只要抓住一条主线:线程池需要回答“线程从哪来、任务放哪、满了怎么办”三个问题。参数再多,也逃不开这三大类。
2.2 corePoolSize与maximumPoolSize:线程数量的上下限
corePoolSize是常驻线程数量,也就是即使闲着也不会被回收的核心线程数(当然,后面的allowCoreThreadTimeOut可以打破这个约定,后面细说)。maximumPoolSize是线程数量的上限,包括核心线程和临时创建的额外线程。
注意一个很多人搞错的细节:线程池不是一开始就创建corePoolSize个线程的。它是懒加载的——来一个任务才创建一个核心线程,直到数量达到corePoolSize后,后续任务才会进入队列。只有队列满了之后,线程池才会考虑继续创建线程到maximumPoolSize。这个顺序非常重要,我在第2.5节详细走一遍流程。
2.3 keepAliveTime与TimeUnit:空闲线程的回收策略
当线程数超过了corePoolSize(也就是那些非核心线程),它们完成手头的任务后,不会立刻被销毁,而是会空等一段时间继续从队列里取任务。如果这段时间内一直没等到新任务,线程就超时回收了。TimeUnit就是配合keepAliveTime的时间粒度,比如10, TimeUnit.SECONDS表示空闲10秒回收。
从JDK 1.6开始,如果调用了allowCoreThreadTimeOut(true),核心线程也可以被超时回收,这样线程池在长期空闲时可以把线程数降到0,对突发流量不高但长期运行的服务很友好。
2.4 workQueue、threadFactory与handler:任务、线程与兜底的三个配角
workQueue是任务等待队列,这个参数的选择直接决定了线程池的缓冲能力和拒绝时机,单独放在第4节展开。threadFactory是线程工厂,默认实现会创建非守护线程,名字叫“pool-1-thread-1”这种。强烈建议你在生产环境自定义ThreadFactory,给线程起业务相关的名字(比如order-handler-%d),否则线上排查问题时看到一堆“pool-3-thread-2”想死的心都有。handler是拒绝策略,也就是线程池和队列都满了时,新任务该怎么处理,同样放在第4节细说。
2.5 线程池从提交任务到执行的完整流转过程
这是面试必问,也是理解一切异常行为的关键。我按流程拆成六步:
- 提交任务后,如果当前线程数小于corePoolSize,创建新线程执行任务。
- 如果线程数已经达到corePoolSize,将任务放入等待队列,等待空闲线程取走。
- 如果队列也已经满了,且当前线程数小于maximumPoolSize,创建新线程执行任务。
- 如果线程数已经达到maximumPoolSize,且队列也满了,触发拒绝策略。
- 非核心线程空闲超过keepAliveTime后,被回收,线程数向corePoolSize回落。
- 任务执行过程中如果抛了异常,线程本身会销毁重建(除非你用别的方式兜住异常,后面说)。
一句话记住:先核心线程,再队列,再非核心线程,最后拒绝。这个顺序决定了你配置参数时的一个基本判断——队列和最大线程数不可能同时作为“主要缓冲”,因为队列满之前,最大线程数这个参数是不会被触发的。
3. Executors内置线程池:用起来顺手,坑也不少
3.1 五种内置线程池分别长什么样
java.util.concurrent.Executors是官方提供的线程池静态工厂,最常用的有下面五种:
| 工厂方法 | 核心线程 | 最大线程 | 队列 | 特点 |
|---|---|---|---|---|
| newFixedThreadPool(n) | n | n | LinkedBlockingQueue(无界) | 固定线程数,任务排队执行 |
| newCachedThreadPool() | 0 | Integer.MAX_VALUE | SynchronousQueue | 弹性伸缩,空闲60秒回收 |
| newSingleThreadExecutor() | 1 | 1 | LinkedBlockingQueue(无界) | 单线程串行执行 |
| newScheduledThreadPool(n) | n | Integer.MAX_VALUE | DelayedWorkQueue | 支持定时和延迟任务 |
| newWorkStealingPool() | 处理器核心数 | 不设上限 | 内部任务窃取队列 | 基于ForkJoinPool,适合大任务拆分 |
看到没?newCachedThreadPool的最大线程数是Integer.MAX_VALUE,newFixedThreadPool和newSingleThreadExecutor用的是无界队列。这两个细节,就是网上疯传“不推荐Executors”的直接原因。
3.2 为什么阿里规范不让用Executors
个人实际经历:之前有个内部数据同步服务,用了newFixedThreadPool(10),往队列里丢了几十万个同步任务。队列是无界的,主流程确实没报错,但内存里堆积的任务对象和它们引用的数据上下文,直接把堆内存顶到了上限,GC频繁触发,服务响应从几十毫秒飙到几秒。
Executors的“方便”恰恰是它的危险所在:无界队列让你没有止损点,大数值上限让你没有防线。所以《Java开发手册》明确建议:线程池不允许使用Executors创建,要通过ThreadPoolExecutor手动配置。说白了不是不能用,而是你要清楚自己选用了什么队列、什么上限、什么拒绝策略,并且愿意为这些选择负责。
3.3 什么时候用内置、什么时候手动创建
我的底线是:哪怕是本地写个一次性脚本,我也倾向于手动配置ThreadPoolExecutor,因为代码的复用性和可读性更好,参数看得见摸得着。
那内置工厂就一无是处吗?也不尽然。比如newSingleThreadExecutor,它比你自己折腾单线程队列更标准,且内部自带无界队列和拒绝策略,适合特别简单、任务量极小的场景。newScheduledThreadPool也是定时任务最简单的人门方式。但凡是可能承载高流量、任务量大、会被迭代维护的代码,都手动创建吧,花30秒写参数,换来的是半年后排查问题时不用骂自己。
4. 阻塞队列和拒绝策略:线程池的两条生命线
4.1 四种常用阻塞队列的差异
队列选型直接决定线程池在面对流量尖峰时的行为。Java里常用的阻塞队列有这么几个:
- ArrayBlockingQueue:有界、基于数组、FIFO,需要指定容量。最典型的“公平排队”的队列,容量定死,满了就触发扩容线程或拒绝。
- LinkedBlockingQueue:基于链表,默认容量是
Integer.MAX_VALUE,也就是无界;也可以传入容量构造有界队列。吞吐比ArrayBlockingQueue略高,但容量不指定时风险极大。 - SynchronousQueue:不存储任务的队列。每个
put必须等一个take,相当于“直接交接”。这也意味着线程池不会排队,任务来了必须立刻有线程处理,没有线程就新建,这也是newCachedThreadPool用它的原因。 - PriorityBlockingQueue:基于堆的优先队列,任务按优先级顺序执行,但同样默认无界。
- DelayQueue:延迟队列,任务到指定时间才能被取出,
ScheduledThreadPoolExecutor的定时能力就是靠它实现的。
选错了队列的后果,我在第4.2节用实际场景展示。
4.2 队列选型与场景匹配
如果你做的任务是低频但重要的,比如订单状态同步,建议用有界队列,比如ArrayBlockingQueue(1000)。队列满了以后,多余的任务交给拒绝策略处理,而不是让所有任务都堆在内存里。
如果你做的是高频且允许丢弃一部分的数据,比如实时日志收集,那可以考虑LinkedBlockingQueue配合一个较大的容量,但请务必设置上限,而不是无界。
如果你做的是在线接口的并行调用,比如一个请求要并行分发到三个下游服务,这种场景任务量不会爆发,用SynchronousQueue配合足够线程数是最合适的,因为每个任务都需要立刻执行,没有排队的必要。
这里我特别提醒一句:队列容量和maximumPoolSize的取舍是一种零和博弈。队列越大,任务缓冲能力越强,但线程数的扩容能力越难触发;队列越小,线程数更容易升到maximumPoolSize,但拒绝策略也更容易触发。实际生产里,我习惯把队列容量设置得偏小,让线程池更快进入“饱和状态”,然后依靠监控报警和拒绝策略兜底,而不是让无界队列把问题掩盖住。
4.3 四种拒绝策略的本质与实战选择
当线程数达到maximumPoolSize且队列已满,新提交的任务会交给RejectedExecutionHandler处理。JDK内置了四种策略:
| 策略 | 行为 | 风险 |
|---|---|---|
| AbortPolicy(默认) | 直接抛出RejectedExecutionException | 调用方必须显式捕获,否则任务会“断崖式”报错 |
| CallerRunsPolicy | 谁提交的谁执行,即由调用的线程去执行这个任务 | 阻塞调用方,天然降级,但会让提交者线程变慢 |
| DiscardPolicy | 静默丢弃新任务 | 数据无感知丢失,非常隐蔽 |
| DiscardOldestPolicy | 丢弃队列中最老的任务,再重试提交 | 老任务可能被丢弃,有损 |
四选一没有标准答案,看业务容忍度。举几个实际例子:
- 资金交易、订单支付这类任务,不能静默丢,应该用
AbortPolicy并显式捕获异常做补偿处理。 - 内部异步任务,比如生成报表、推送通知,用
CallerRunsPolicy最稳,因为调用方线程能执行,说明系统还有余力,只是慢一点。 - 实时性强的数据采集、日志上报,允许丢弃最老的数据,用
DiscardOldestPolicy,保证新数据优先。 - 我见过有人用
DiscardPolicy接流量峰值,但最后数据对不上账的例子,复盘时整个团队都说不出话,因为日志里根本没有任何报错。所以静默丢弃要谨慎,如果要用,务必在丢弃逻辑里打一条WARN级别的日志。
4.4 自定义拒绝策略与优雅降级
内置策略不够用时,可以自定义。我之前做过的方案是:拒绝时把任务写入一个本地持久化队列,由另一个后台线程慢慢重试,这样就实现了“削峰填谷”。
RejectedExecutionHandler myHandler = (r, executor) -> { if (r instanceof RunnableWrapper) { ((RunnableWrapper) r).saveToLocalForRetry(); } else { log.warn("task rejected, runnable type: {}", r.getClass().getName()); } };自定义拒绝策略的核心理念是**“拒绝不等于丢”**:把被拒任务转成消息、写入数据库、发送到MQ,都比直接抛异常或丢数据好。这也是生产级线程池和教学demo之间最大的分水岭。
5. 从零写一个生产级线程池:完整可跑的代码示例
5.1 第一步:自定义线程工厂,让线程好追踪
先搞一个能命名的ThreadFactory,顺便处理一下线程的daemon属性和异常记录:
import java.util.concurrent.ThreadFactory; import java.util.concurrent.atomic.AtomicInteger; public class NamedThreadFactory implements ThreadFactory { private final String prefix; private final AtomicInteger seq = new AtomicInteger(1); private final boolean daemon; public NamedThreadFactory(String prefix) { this(prefix, false); } public NamedThreadFactory(String prefix, boolean daemon) { this.prefix = prefix; this.daemon = daemon; } @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, prefix + "-" + seq.getAndIncrement()); t.setDaemon(daemon); t.setUncaughtExceptionHandler((thread, throwable) -> System.err.println("[" + thread.getName() + "] uncaught: " + throwable.getMessage())); return t; } }线程命名为什么重要?有一次线上JVM线程dump,只要看到线程名是order-async-pool-7,就能立刻定位到是订单异步模块的线程,省去翻代码猜进程的功夫。这个习惯我从第一行生产代码养成到现在,从没后悔过。
5.2 第二步:完整配置线程池
下面这个配置是我在多个服务里验证过的经典模板,有界队列+CallerRunsPolicy+显式拒绝兜底:
import java.util.concurrent.*; public class PoolConfig { public static ThreadPoolExecutor buildOrderPool() { int core = 4; int max = 8; long keepAlive = 30; BlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(1000); ThreadFactory factory = new NamedThreadFactory("order-async"); RejectedExecutionHandler handler = new ThreadPoolExecutor.CallerRunsPolicy(); ThreadPoolExecutor executor = new ThreadPoolExecutor( core, max, keepAlive, TimeUnit.SECONDS, queue, factory, handler); // 预启动核心线程,避免第一个任务到来时产生创建延迟 executor.prestartAllCoreThreads(); // 允许核心线程空闲超时回收,流量低谷时节省资源 executor.allowCoreThreadTimeOut(true); return executor; } }两个容易被忽略的小技巧都在这里:prestartAllCoreThreads()会在启动阶段就创建好核心线程,对延迟敏感的服务有帮助;allowCoreThreadTimeOut(true)则让线程池在长期空闲时能把线程数降到0,节约系统资源。但不是所有场景都适合预启动,如果你服务本身流量不大,预启动纯粹是浪费。
5.3 第三步:封装工具类和提交任务
直接操作ThreadPoolExecutor对象当然可以,但我更推荐封装一层,把“提交任务”“执行并返回结果”区分开:
import java.util.concurrent.*; public class TaskSubmitter { private final ThreadPoolExecutor pool; public TaskSubmitter(ThreadPoolExecutor pool) { this.pool = pool; } // 提交后不关心结果 public void fireAndForget(Runnable task) { try { pool.execute(task); } catch (RejectedExecutionException e) { log.warn("task rejected: {}", task); } } // 提交并期望拿到返回值 public <T> Future<T> submitWithResult(Callable<T> task) { return pool.submit(task); } // 提交一批独立任务,收集所有结果 public <T> List<T> invokeAllAndWait(Collection<Callable<T>> tasks, long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { List<Future<T>> futures = pool.invokeAll(tasks, timeout, unit); List<T> result = new ArrayList<>(); for (Future<T> f : futures) { result.add(f.get()); } return result; } }注意execute和submit的区别:execute直接执行任务,不关心返回值;submit会把任务包装成FutureTask,即使run()里面抛了异常,异常也会被吞进Future里,只有你调用future.get()时才抛出ExecutionException。这个差异既是便利也是坑,后面第6节专门说。
5.4 第四步:优雅关机
线程池的关闭有讲究,不关的话,JVM不会主动退出,尤其是web容器里的非守护线程池。正确姿势是两段式:
public void shutdownGracefully(ThreadPoolExecutor pool, long waitSec) { pool.shutdown(); // 不再接受新任务,已提交的继续执行 try { if (!pool.awaitTermination(waitSec, TimeUnit.SECONDS)) { pool.shutdownNow(); // 超时了,强制中断剩余任务 if (!pool.awaitTermination(5, TimeUnit.SECONDS)) { System.err.println("pool did not terminate"); } } } catch (InterruptedException e) { pool.shutdownNow(); Thread.currentThread().interrupt(); } }shutdown()和shutdownNow()的区别:前者会等队列里的任务都执行完,给业务一个收尾期;后者直接返回尚未执行的任务列表并尝试中断正在执行的线程。我平时先shutdown再awaitTermination,超时后用shutdownNow兜底。特别提醒:不要指望shutdownNow能中断正在执行的任务,如果任务不响应中断信号,它是不会停的。
5.5 动态调整与监控扩展
ThreadPoolExecutor本身支持动态调整参数:
pool.setCorePoolSize(6); pool.setMaximumPoolSize(12); pool.setKeepAliveTime(45, TimeUnit.SECONDS);生产环境里,如果你配置了监控系统,可以根据队列深度和线程活跃度动态调参,比如流量高峰前提前把corePoolSize调大。光调整还不够,还得看得见数据。我一般这样搞监控:
ThreadPoolExecutor monitorPool = new ThreadPoolExecutor(4, 8, 30, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1000), factory, handler) { @Override protected void beforeExecute(Thread t, Runnable r) { super.beforeExecute(t, r); // 记录任务开始时间,方便统计单任务耗时 } @Override protected void afterExecute(Runnable r, Throwable t) { super.afterExecute(r, t); // 任务结束,可输出耗时、异常等指标 } };扩展点beforeExecute和afterExecute是线程池留给你的监控钩子。需要注意的是:如果任务是被submit提交的,afterExecute里的t参数会是null,异常被封装进了Future。想统一处理,得对Runnable做一层包装,把异常包装进去。
6. 实战中我踩过的那些坑
6.1 任务队列无界导致的OOM
这是我上面提过的真实案例,再说一点细节。当时用Executors.newFixedThreadPool(5)处理一批优惠券发放任务,上游一直灌数据,队列里积压了几十万个任务对象。每个任务对象引用了完整的用户信息和优惠券上下文,内存很快爆了。
这类问题在代码评审时完全看不出破绽,因为无界队列的“表面无害”掩盖了风险。修复方案就是手写有界队列,并配套拒绝策略。我现在的习惯是:任何队列,写容量不写容量两说,但心里必须有一个数字,知道系统什么情况下会饱和。
6.2 拒绝策略误用导致重要任务丢失
另一个案例:某服务把拒绝策略配成了DiscardPolicy,任务被静默丢弃没有任何日志。结果对账时发现大量失败,排查半天才意识到是拒绝策略吞了任务。
用DiscardPolicy或DiscardOldestPolicy时,一定记得做两点:一是打印WARN日志,二是设计好丢数据的补偿机制。业务数据宁可抛异常让调用方感知,也不要无提示地消失。
6.3 任务异常被Future“吞掉”
这是最隐蔽的坑。很多同学用pool.submit(task),然后不关心返回值,以为任务执行出错会像普通线程一样打印堆栈。实际上,submit提交的任务,异常会被封装进Future,如果你不调用get(),异常永远不会暴露。我见过一个批处理程序,日志干干净净,但业务数据就是不对,最后发现是某个任务一直在抛SQL异常,全被Future吞了。
我处理这类问题的通用方案:要么用execute提交,在UncaughtExceptionHandler里记录;要么提交前把Runnable包装一层,在run()里try-catch所有异常打日志。这样线上能第一时间看到异常,而不是事后翻数据。
6.4 核心线程数配置的错误直觉
很多人会凭感觉配线程数,比如配了100个核心线程,以为这样能处理更多请求。实际上,线程数并不等于吞吐量。线程太多,CPU上下文切换成本会反过来拖垮性能;线程太少,任务排队享太长时间。
关于线程数,业界有个基础公式:CPU密集型任务,核心线程数设为N(CPU核数)+1即可,顶多到N+2;IO密集型任务,因为大量时间在等IO,可以用2N甚至N*(1+WT/CT)(WT是等待时间,CT是计算时间)。但公式只是起点,最终以压测为准。我通常先按公式估算,再压测把QPS拉到极限,观察线程活跃度和队列深度,再微调。
6.5 线程池隔离与业务混跑
如果把所有业务的异步任务都塞进同一个线程池,那么一个耗时的报表任务可能在队列里排很久,排到后面时,订单推送这类紧急任务全被堵住了。这就是典型的“业务混跑”问题。
建议做法:按业务拆分独立线程池,紧急任务用短队列+大max线程数,非紧急任务用长队列+小max线程数,互相隔离。隔离的逻辑底层还是那句:把线程池当做资源,用几个池子,每个池子管好自己的场景,出问题时也好定位。
6.6 本地变量与上下文传递问题
线程池里的线程是复用的,意味着ThreadLocal里的数据会串号。比如你用ThreadLocal存了当前用户ID,任务A执行完没清理,任务B复用同一个线程时,读到的还是A的用户ID。要处理这种问题,可以在任务提交时把上下文参数显式传进任务对象,或者用TransmittableThreadLocal这类工具做在线程池间的上下文快照传递,但最稳妥的还是任务对象里显式携带业务上下文,别依赖线程级变量。
7. 面试被问到线程池,这样答能过
7.1 线程池执行流程的口头表达
面试官问你“线程池的执行流程”,不要背参数,直接讲故事:先判断当前线程数是否小于核心线程数,小于就新建线程;否则看队列是否满,不满就入队;队列满了看线程数是否小于最大线程数,小于就新建线程;超过了就执行拒绝策略。中间穿插说明:核心线程是懒创建的、队列的缓冲作用、非核心线程空闲回收机制。把这些能讲清楚,至少比背八股文的候选人高一个段位。
7.2 线程池参数设置思路
这道题没有标准答案,面试官想看的是你的工程判断。我会分情况回答:任务类型决定线程数——CPU密集型用N+1,IO密集型用2N或更高;任务容忍度决定队列——需要低延迟就短队列或SynchronousQueue,能接受排队就长队列;任务重要性决定拒绝策略——不能丢就AbortPolicy+补偿,能降级就CallerRunsPolicy。最后补一句“参数配置完必须结合压测调整”,这句话比任何公式都重要。
7.3 线程池是如何“复用”线程的
从Worker线程的实现机制来讲:线程池内部每个线程被包装成Worker,Worker实际上是一个继承了AbstractQueuedSynchronizer的Runnable对象,内部有个循环,不断从workQueue.take()或poll()获取任务执行。因为没有取到任务时,线程要么阻塞在take上等待唤醒,要么在poll超时后回收退出。这就是“复用”的本质——线程不结束,循环取任务,核心线程靠阻塞等待实现零消耗闲置。
7.4 一个常见陷阱题:为什么队列满了才会创建非核心线程
很多人被这个问题问倒,原因在于他们以为“任务多了就加线程”。其实从设计意图上,队列的存在就是希望控制线程总量,避免线程频繁创建销毁。线程创建销毁是有成本的,而队列缓冲是无损的,所以先队列后线程才是最合理的设计。
7.5 最后的面试题陷阱:shutdownNow是否会中断正在运行的线程
我会说:shutdownNow会对正在执行的任务线程发送interrupt()中断信号,但任务是否响应中断取决于代码是否检查Thread.interrupted()。如果任务是死循环且不响应中断,shutdownNow也没办法。所以靠谱的服务,在任务设计阶段就要考虑线程被中断时如何优雅退出。
聊到这里,线程池从参数到实战、从坑到面试基本都过了一遍。我个人体会最深的一句话是:线程池不难,难的是“把它当一个有边界的资源去用”。别让队列无界,别让线程数无上限,别让异常被静默吞掉——定好边界,配好监控,你的线程池就能安安稳稳地服务业务。如果你正在学习Java并发,不妨从今天开始,试着把你项目里所有用Executors创建的线程池替换成手动配置的ThreadPoolExecutor,跑一遍测试,你会发现原来参数调优真的不是玄学,而是能实实在在影响内存和响应时间的事。