线程池:从Executors到自定义线程池的设计权衡
大家好,我是程序员小策。
不服?先来几个灵魂拷问热热身:
- 线程池的 7 个参数你能说出几个?corePoolSize 和 maximumPoolSize 的区别是什么?
- 阿里巴巴 Java 开发手册为什么禁止用
Executors.newFixedThreadPool()?它到底哪里有问题? - 任务队列满了之后,线程池会怎么做?4 种拒绝策略分别适用于什么场景?
- 你的线程池参数是怎么配的?是网上抄的公式还是真的根据业务算过?
- 线程池里的线程挂了怎么办?它会自动补吗?
大部分人能回答前两个,到第三个开始犹豫,到第五个就卡住了。
今天这篇文章就是要把这五个问题一个一个拆开——不只是给你"面试标准答案",而是告诉你为什么这样设计,以及生产环境里怎么用才不会翻车。
一、为什么不能随便 new 一个线程池?
先说一个朴素方案:大部分人的第一反应是Executors.newFixedThreadPool(10),10 个线程,简单粗暴。
看起来没问题对吧?但这个方案有一个致命缺陷:它的任务队列是无界的。
newFixedThreadPool底层用的是LinkedBlockingQueue<>(),默认容量是Integer.MAX_VALUE(约 21 亿)。也就是说,如果 10 个线程都在忙,新来的任务会全部塞进队列里,队列永远不会满。
那会怎样?
任务堆积、内存飙升、最终 OOM。
这不是危言耸听——线上确实出现过这种故障:某个接口用了newFixedThreadPool,高峰期请求量暴增,所有线程都在等数据库响应,任务疯狂堆积,最后整个服务因为 OOM 被系统 kill 掉。
所以问题来了:我们需要一个能控制任务堆积的线程池,而且要理解每个参数的含义和权衡。
线程池(ThreadPoolExecutor):一种线程复用机制,通过维护一组工作线程来异步执行提交的任务,避免频繁创建销毁线程的开销。
二、把线程池想象成高速收费站
你一定见过高速 ETC 收费站吧?
想象一下:一条高速有5 条收费车道(核心线程),平时这 5 条车道就够用了,车辆来了直接通过。但如果节假日车流量大增怎么办?
收费站会在旁边再开放5 条临时车道(非核心线程),总共 10 条车道同时工作。但这些临时车道是有条件的——如果空闲超过60 秒(keepAliveTime),就关闭节省成本。
如果 10 条车道都满了呢?车辆会在收费站入口处的等待区排队(任务队列)。等待区也是有容量的,比如只能停100 辆车。
如果等待区也满了?那就得启动拒绝策略了——要么让司机掉头走别的路(CallerRunsPolicy),要么直接不让上高速(AbortPolicy)。
翻译回技术语言:
| 收费站要素 | 线程池参数 | 作用 |
|---|---|---|
| 5 条常开车道 | corePoolSize | 核心线程数,即使空闲也不回收 |
| 5 条临时车道 | maximumPoolSize - corePoolSize | 最大额外线程数 |
| 空闲 60 秒关闭 | keepAliveTime + unit | 非核心线程的存活时间 |
| 等待区容量 100 | workQueue(BlockingQueue) | 存放等待执行的任务 |
| 拒绝入站规则 | RejectedExecutionHandler | 队列满且线程满时的处理方式 |
当然,实际上线程池还有两个参数:threadFactory(创建线程时用的工厂,可以自定义线程名便于排查)和handler(拒绝策略)。这两个虽然不常被提起,但在生产环境很重要。
三、代码实现:一个生产可用的线程池配置
下面这段代码可以直接复制到项目里用——模拟一个秒杀下单场景,展示线程池如何处理高并发任务。
importjava.util.concurrent.*;importjava.util.concurrent.atomic.AtomicInteger;publicclassThreadPoolDemo{publicstaticvoidmain(String[]args)throwsInterruptedException{// 自定义线程工厂:给线程起有意义的名字,方便排查问题ThreadFactorynamedThreadFactory=newThreadFactory(){privatefinalAtomicIntegerthreadNumber=newAtomicInteger(1);@OverridepublicThreadnewThread(Runnabler){Threadt=newThread(r,"seckill-pool-"+threadNumber.getAndIncrement());t.setDaemon(true);// 设置为守护线程,主线程结束时不阻塞JVM退出if(t.getPriority()!=Thread.NORM_PRIORITY){t.setPriority(Thread.NORM_PRIORITY);}returnt;}};// 创建线程池:核心参数逐个解释ThreadPoolExecutorexecutor=newThreadPoolExecutor(5,// corePoolSize: 核心线程数(常开车道)10,// maximumPoolSize: 最大线程数(含临时车道)60L,// keepAliveTime: 非核心线程空闲存活时间TimeUnit.SECONDS,// 时间单位newArrayBlockingQueue<>(100),// workQueue: 有界队列(等待区容量100)namedThreadFactory,// threadFactory: 自定义线程名newThreadPoolExecutor.CallerRunsPolicy()// handler: 调用者执行策略);// 模拟 1000 个秒杀请求AtomicIntegersuccessCount=newAtomicInteger(0);AtomicIntegerrejectCount=newAtomicInteger(0);CountDownLatchlatch=newCountDownLatch(1000);longstartTime=System.currentTimeMillis();for(inti=0;i<1000;i++){finalintorderId=i;try{executor.submit(()->{try{// 模拟下单操作:查库存→扣库存→创建订单mockSeckillOrder(orderId);successCount.incrementAndGet();}catch(Exceptione){System.err.println("订单 "+orderId+" 失败: "+e.getMessage());}finally{latch.countDown();}});}catch(RejectedExecutionExceptione){// 任务被拒绝rejectCount.incrementAndGet();latch.countDown();}}latch.await();// 等待所有任务完成或被拒绝longcostTime=System.currentTimeMillis()-startTime;System.out.println("========== 执行结果 ==========");System.out.println("成功订单数: "+successCount.get());System.out.println("被拒订单数: "+rejectCount.get());System.out.println("总耗时: "+costTime+"ms");System.out.println("活跃线程数: "+executor.getActiveCount());System.out.println("队列剩余容量: "+executor.getQueue().remainingCapacity());executor.shutdown();// 优雅关闭:不再接受新任务,等待已提交任务完成}privatestaticvoidmockSeckillOrder(intorderId){try{// 模拟数据库查询耗时:10-50msThread.sleep(ThreadLocalRandom.current().nextLong(10,50));// 模拟业务逻辑if(orderId%100==0){// 模拟1%的失败率thrownewRuntimeException("库存不足");}// System.out.println("订单 " + orderId + " 下单成功");}catch(InterruptedExceptione){Thread.currentThread().interrupt();}}}代码关键点解释:
为什么要用 ArrayBlockingQueue 而不是 LinkedBlockingQueue?ArrayBlockingQueue是基于数组的有界队列,初始化时指定容量为 100,队列满后就会触发拒绝策略。而LinkedBlockingQueue默认是无界的(除非你手动指定容量),会导致任务无限堆积。
为什么选 CallerRunsPolicy 作为拒绝策略?
当队列满且线程都忙时,CallerRunsPolicy会让提交任务的线程自己来执行这个任务。这样做的好处是:
- 不会丢失任务(不像 DiscardPolicy 直接丢弃)
- 不会抛异常中断流程(不像 AbortPolicy)
- 自然地降低了提交速度(调用者在忙着执行任务,没空提交新任务),起到削峰填谷的作用
为什么要自定义 ThreadFactory?
默认的线程工厂创建的线程名字类似pool-1-thread-1,看不出是哪个业务的线程池。自定义名称后,线上出问题时看 jstack 就能快速定位:"seckill-pool-3"说明是秒杀线程池的第 3 个线程出了问题。
运行结果示例:
========== 执行结果 ========== 成功订单数: 989 被拒订单数: 11 总耗时: 5234ms 活跃线程数: 5 队列剩余容量: 89可以看到:1000 个请求中,11 个被拒绝了(因为队列满了),其余 989 个成功处理。总耗时 5 秒左右,比串行快了约 200 倍(假设每个任务平均 50ms)。
四、看起来很完美了对吧?但这几个坑你可能踩过
坑一:核心线程也会被回收吗?
很多人以为设置了allowCoreThreadTimeOut(true)后,核心线程在空闲时也会被回收。但默认情况下,核心线程是不会被回收的——即使它们闲着没事干,也会一直占着内存。
这意味着什么?如果你的线程池配置了corePoolSize=100,即使系统已经没有任务了,这 100 个线程还活着,每个线程默认占用1MB 栈空间(取决于-Xss参数),光线程栈就吃了 100MB 内存。
解法:
- 如果你的业务有明显的高峰期和低谷期(比如白天忙晚上闲),考虑设置
executor.allowCoreThreadTimeOut(true) - 或者使用
ThreadPoolExecutor的setCorePoolSize()动态调整核心线程数
坑二:线程池里的线程挂了怎么办?
假设某个任务抛出了未捕获的异常(比如 NPE 或者数据库连接超时),执行这个任务的线程会怎样?
答案是:线程会终止,但线程池不会自动补充新的线程。
线程池只保证核心线程数和非核心线程数的上限,但不保证"始终有这么多活着的线程"。如果一个线程因为异常退出了,线程池的总线程数就会减少,直到有新任务提交时才会创建新线程。
后果:高并发下如果频繁出现异常,线程池的线程可能越来越少,最终导致吞吐量下降。
解法:
// 在自定义 ThreadFactory 里设置 UncaughtExceptionHandlernamedThreadFactory=r->{Threadt=newThread(r);t.setUncaughtExceptionHandler((thread,ex)->{System.err.println("线程 "+thread.getName()+" 异常终止: "+ex.getMessage());// 可以在这里做告警通知、日志记录等});returnt;};坑三:shutdown 和 shutdownNow 的区别
很多开发者不知道怎么优雅地关闭线程池,或者干脆不管(依赖 JVM 退出时强制终结)。
shutdown():不再接受新任务,但会等待已提交的任务执行完成。这是一个"温柔"的关闭方式。shutdownNow():不再接受新任务,尝试中断正在执行的任务,返回等待执行的任务列表。这是一个"暴力"的关闭方式。
最佳实践:
executor.shutdown();// 先温柔关闭if(!executor.awaitTermination(60,TimeUnit.SECONDS)){// 如果 60 秒还没关完,尝试暴力关闭List<Runnable>unfinishedTasks=executor.shutdownNow();System.warn("强制关闭,还有 "+unfinishedTasks.size()+" 个任务未执行");}五、从单机到分布式:线程池还够用吗?
到目前为止,我们讨论的都是单机 JVM 内的线程池。但如果你的服务部署了 10 个实例呢?
这时候线程池面临的新问题是:
每个实例都有自己的线程池,无法跨实例协调。
举例:你的服务限流逻辑是"最多同时处理 100 个请求",你在每个实例上配了maximumPoolSize=100。如果有 10 个实例,理论上系统能同时处理 1000 个请求——远超预期。
这就是单机限流 vs 分布式限流的区别。
解决方案:
- 分布式限流:使用 Redis + Lua 脚本或 Sentinel 实现全局流量控制
- 动态调整线程池参数:结合监控指标(CPU 使用率、队列积压数、响应时间),实时调整 corePoolSize 和 maximumPoolSize
- 线程池隔离:不同业务使用不同的线程池,避免慢请求拖垮核心业务
// 示例:基于 CPU 使用率动态调整核心线程数publicclassDynamicThreadPool{privatefinalThreadPoolExecutorexecutor;publicvoidadjustBasedOnCpu(){doublecpuUsage=getCpuUsage();// 通过 OperatingSystemMXBean 获取if(cpuUsage>80%){// CPU 高负载时减少线程数,降低上下文切换开销executor.setCorePoolSize(Math.max(2,executor.getCorePoolSize()/2));}elseif(cpuUsage<30%){// CPU 空闲时增加线程数,提高吞吐量executor.setCorePoolSize(Math.min(20,executor.getCorePoolSize()*2));}}}当然,实际生产中更推荐使用成熟框架如Hipid4j或Dynamic-Tp来实现线程池参数的动态调优和监控。
六、四种拒绝策略,到底该选哪个?
| 策略 | 行为 | 适用场景 | 风险 |
|---|---|---|---|
| AbortPolicy(默认) | 抛出 RejectedExecutionException | 需要让调用方感知任务被拒绝 | 不捕获会导致线程崩溃 |
| CallerRunsPolicy | 调用线程自己执行任务 | 削峰填谷、降级保护 | 调用线程可能被阻塞 |
| DiscardPolicy | 静默丢弃任务 | 允许丢数据的场景(日志、监控) | 数据丢失无感知 |
| DiscardOldestPolicy | 丢弃队列最老的任务,重新提交 | 只需要最新结果的场景 | 可能丢掉重要任务 |
一句话总结:
- 对数据一致性要求高 → AbortPolicy(让调用方决定怎么处理)
- 想自然限流 → CallerRunsPolicy(让调用方变慢)
- 可以容忍数据丢失 → DiscardPolicy(如监控上报)
- 只关心最新状态 → DiscardOldestPolicy(如实时价格更新)
我的推荐:大部分业务场景用CallerRunsPolicy,既不会丢数据,又能起到自保护作用。
七、面试官还会追问什么?
面试追问 1:corePoolSize 设成 0 行不行?
→ 回答方向:可以,但意味着所有线程都是"临时的",任务来才创建线程,空闲就被回收。适合任务量极低且不频繁的场景(如定时清理任务),不适合持续有任务的场景。
面试追问 2:任务队列应该用有界还是无界?
→ 回答方向:生产环境永远用有界队列。无界队列会导致 OOM(就像 Executors.newFixedThreadPool 的坑)。队列大小建议设成corePoolSize * 2 ~ corePoolSize * 5,具体值要通过压测确定。
面试追问 3:如何监控线程池的健康状态?
→ 回答方向:通过ThreadPoolExecutor提供的 API 获取指标:
getActiveCount():当前活跃线程数getQueue().size():队列中等待的任务数getCompletedTaskCount():已完成的任务总数getTaskCount():提交过的任务总数
可以把这些指标接入 Prometheus + Grafana 做可视化监控,或者集成到 Spring Boot Actuator 的/metrics端点。
面试追问 4:为什么阿里禁止用 Executors?除了无界队列还有什么问题?
→ 回答方向:三个原因:
- 无界队列导致 OOM(已讲过)
- 无法自定义 ThreadFactory,线程名没有业务语义,排查困难
- 无法灵活选择拒绝策略,默认的 AbortPolicy 可能不符合业务需求
面试追问 5:CPU 密集型和 IO 密集型任务的线程池参数怎么配?
→ 回答方向:
- CPU 密集型(加密、图像处理):
corePoolSize = CPU 核心数 + 1 - IO 密集型(数据库查询、RPC 调用):
corePoolSize = CPU 核心数 * 2,或者用公式N / (1 - 阻塞系数),阻塞系数一般取 0.8~0.9
但这只是起点,上线后要根据监控指标动态调整。
八、总结
线程池不是越大多好,是越合适越好。
读完这篇你应该能:
- 解释清楚线程池 7 个参数的含义和设计动机
- 手写一个生产可用的线程池配置(带自定义线程名、有界队列、合理拒绝策略)
- 避免 Executors 的三个坑(无界队列、无名线程、不可控策略)
- 在面试时说出"CallerRunsPolicy 削峰填谷"、“有界队列防 OOM”、“动态调参配合监控”——而不只是背 7 个参数的名字。
