1. 线程池到底解决了什么问题刚入行那会儿写并发代码我的做法特别直接需要异步执行任务就new Thread(runnable).start()用完拉倒。小打小闹的脚本这么写确实没问题可一旦放到真实项目里问题就全冒出来了。最典型的一次是做一个批量文件处理工具循环里给每个文件开一个线程本地测试几十个文件跑得好好的到了生产环境几千个文件一上来机器直接卡死线程创建和上下文切换的开销把CPU吃满了程序响应慢得像蜗牛。后来才明白线程这东西不是免费的。每创建一个线程JVM都要为它分配栈空间、向操作系统申请内核资源线程销毁时又要回收这一来一回的成本相当可观。更要命的是线程数量不受控任务量一大就无限膨胀轻则性能骤降重则直接把内存撑爆。ExecutorService就是来解决这个问题的它把线程的创建、复用、调度、销毁这一整套生命周期管理起来我们只需要把任务丢进去剩下的交给它。打个比方new Thread像是每次要搬东西都临时去雇一个搬运工搬完就辞退而ExecutorService像是一个固定的搬运团队有活儿就派一个人去干干完这个人回到队伍里等下一个活儿。团队规模可以控制活儿多了排队活儿少了也不养闲人。这个比喻虽然糙但基本把线程池的核心价值说清楚了复用线程、控制并发规模、统一管理任务队列。这篇文章我打算把ExecutorService从里到外捋一遍包括它为什么这么设计、几种常见线程池的适用场景、参数怎么算、踩过哪些坑、出问题怎么排查。适合已经会写基础Java、但对并发工具还停留在“能用”阶段的同学也适合想系统梳理一遍线程池知识的老手。下面这些内容都是我在实际项目里反复验证过的能直接抄作业。2. ExecutorService的整体设计与选型思路2.1 为什么是接口而不是具体类ExecutorService本身是个接口继承自更顶层的Executor。这个设计很有意思Executor只定义了一个execute(Runnable)方法抽象级别极高就是“把任务交出去执行”这一件事。ExecutorService在此基础上扩展了生命周期管理shutdown、awaitTermination、任务提交submit返回Future、批量执行invokeAll、invokeAny等能力。为什么用接口因为不同的并发场景需要不同的实现策略。有的场景需要固定大小的线程池有的需要可缓存的弹性池有的需要定时调度还有的只需要单线程串行执行。如果设计成一个庞大的具体类用继承去扩展会非常笨重。接口加实现类的组合让每种策略都能独立演化我们使用时面向接口编程切换实现只改一行代码。实际开发中我们几乎不会直接new一个ExecutorService的实现类而是通过Executors这个工厂类来创建。这个工厂类提供了几个静态方法每个方法对应一种典型的线程池配置。理解这几个方法的差异是选型的第一步。2.2 四种常见线程池的适用场景对比Executors工厂里最常用的有四个方法我整理成一张表方便对照工厂方法线程数量策略任务队列适用场景风险点newFixedThreadPool(n)固定n个无界LinkedBlockingQueue负载稳定、需要限制并发数的场景队列无界任务积压可能OOMnewCachedThreadPool()0到Integer.MAX_VALUESynchronousQueue大量短生命周期异步任务线程数无上限可能耗尽资源newSingleThreadExecutor()固定1个无界LinkedBlockingQueue需要串行执行、保证顺序的场景同样有队列积压风险newScheduledThreadPool(n)固定n个DelayedWorkQueue定时、周期性任务周期任务异常会静默停止这张表看着简单但每一条背后都有故事。newFixedThreadPool是最常用的因为大多数业务场景的并发量是可预估的固定线程数能提供稳定的吞吐。但它的无界队列是个隐患如果任务提交速度持续大于处理速度队列会一直涨直到内存耗尽。我见过一个日志上报的模块网络抖动时任务堆积了几十万个最后OOM了排查半天才发现是线程池队列的问题。newCachedThreadPool适合那种“来一个任务开一个线程空闲60秒回收”的场景比如处理大量短暂的HTTP回调。但它最大线程数是Integer.MAX_VALUE理论上可以创建无数线程一旦任务量突增线程数失控系统资源会被迅速榨干。所以生产环境我基本不用它除非能确保任务量绝对可控。newSingleThreadExecutor保证所有任务按提交顺序串行执行适合需要严格顺序的场景比如写同一个文件、操作同一个非线程安全的对象。它的队列也是无界的同样要注意积压问题。newScheduledThreadPool用于定时任务比如每隔几分钟同步一次数据。这里有个大坑如果周期任务内部抛了未捕获的异常后续的周期执行会直接停止而且不会有任何提示。这个坑我后面会详细讲怎么规避。2.3 为什么生产环境更推荐手动创建ThreadPoolExecutor阿里那本Java开发手册里有一条强制规约线程池不允许使用Executors去创建要通过ThreadPoolExecutor的方式手动创建。刚开始我觉得这是不是太教条了工厂方法用着多方便。直到踩了几次坑才理解背后的深意。Executors的工厂方法把很多关键参数隐藏了用的是默认值。比如newFixedThreadPool的队列是无界的newCachedThreadPool的最大线程数是无界的这些默认值在极端情况下都会出问题。手动创建ThreadPoolExecutor你需要显式指定核心线程数、最大线程数、空闲存活时间、时间单位、任务队列、线程工厂、拒绝策略这七个参数每一个都得想清楚虽然麻烦但把风险暴露在了明面上。更重要的是手动创建时你可以自定义线程工厂给线程起有意义的名字方便排查问题可以自定义拒绝策略在任务过载时做降级处理而不是直接抛异常。这些在工厂方法里要么做不到要么需要额外包装。所以我的建议是学习和简单场景可以用Executors生产环境一律手动new ThreadPoolExecutor。3. 核心参数与运行机制拆解3.1 七个构造参数逐个说透ThreadPoolExecutor最完整的构造函数有七个参数我按重要性一个个拆public ThreadPoolExecutor( int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueueRunnable workQueue, ThreadFactory threadFactory, RejectedExecutionHandler handler )corePoolSize核心线程数线程池维持的最小线程数量。即使这些线程空闲默认也不会被回收除非设置allowCoreThreadTimeOut(true)。新任务提交时如果当前线程数小于核心线程数会直接创建新线程执行不管有没有空闲线程。maximumPoolSize最大线程数线程池允许创建的最大线程数量。当核心线程都在忙、任务队列也满了才会创建新线程直到达到这个上限。keepAliveTime unit空闲存活时间超过核心线程数的那部分线程空闲时间超过这个值就会被回收。如果设置了allowCoreThreadTimeOut(true)核心线程也会被回收。workQueue任务队列存放等待执行任务的阻塞队列。常用的有ArrayBlockingQueue有界数组队列、LinkedBlockingQueue链表队列可指定容量、SynchronousQueue不存储元素的交接队列、PriorityBlockingQueue优先级队列。threadFactory线程工厂创建线程的工厂可以自定义线程名称、优先级、是否为守护线程等。强烈建议自定义给线程起个能看出业务含义的名字。handler拒绝策略当线程数达到最大、队列也满了新任务如何处理。JDK内置了四种AbortPolicy抛异常默认、CallerRunsPolicy调用者线程执行、DiscardPolicy静默丢弃、DiscardOldestPolicy丢弃最老的任务。3.2 任务提交后的完整流转路径理解线程池的工作流程比死记参数重要得多。一个任务通过execute或submit提交后会经历这样的判断链条当前线程数 corePoolSize是则创建新线程执行任务即使有空闲线程也不管。否则尝试把任务放入workQueue。入队成功则等待空闲线程来取。如果队列满了且当前线程数 maximumPoolSize创建新线程执行。如果线程数已达maximumPoolSize且队列满触发拒绝策略。这个流程有个反直觉的点线程池优先把任务塞进队列而不是先创建新线程。也就是说只有当队列满了才会动用“最大线程数”这个额度。这跟很多人想象的“任务多了就多开线程”不一样。理解这一点才能明白为什么有时候线程池明明没到最大线程数任务却在排队。我画个文字版的示意方便记忆提交任务 | v 线程数 core? --是-- 创建核心线程执行 |否 v 入队成功? --是-- 等待空闲线程 |否队列满 v 线程数 max? --是-- 创建非核心线程执行 |否 v 执行拒绝策略3.3 线程复用是怎么实现的线程池最核心的价值是复用那它是怎么做到一个线程执行完任务不销毁、继续等下一个任务的答案在runWorker方法里。工作线程启动后会进入一个循环从队列里取任务getTask取到就执行执行完继续取直到取不到任务且满足退出条件才跳出循环。getTask里有个关键的阻塞逻辑如果队列为空线程会通过workQueue.take()或workQueue.poll(keepAliveTime, unit)阻塞等待。核心线程用take()一直等非核心线程用poll带超时地等超时了还没任务就返回null线程退出。这就是空闲回收的实现原理。这个机制带来的一个实践启示是线程池里的线程是“懒创建”的。核心线程默认不会在池子初始化时就全部创建而是来一个任务创建一个直到达到核心数。如果想让核心线程提前就绪可以调用prestartAllCoreThreads()。对于启动后马上会有大量请求的服务提前预热能避免第一波请求时的创建开销。4. 实操从零搭建一个可用的线程池4.1 参数计算核心线程数到底设多少这是被问得最多的问题。网上流传的公式是CPU密集型任务设N1IO密集型任务设2NN是CPU核数。这个公式有参考价值但不能照搬因为它没考虑任务的实际耗时和到达率。我更倾向于用利特尔法则Littles Law来估算线程数 任务到达率 × 平均任务处理时间。比如每秒来100个任务每个任务平均处理50毫秒那么需要的并发线程数大约是100 × 0.05 5个。当然这是理论值实际要留一定余量再结合压测调整。对于IO密集型任务线程大部分时间在等网络或磁盘CPU是空闲的所以线程数可以设得多一些让CPU在等待间隙去处理其他线程。对于CPU密集型任务线程太多反而增加上下文切换开销一般设成核数或核数1比较合适。我的实操做法是先按公式估一个初值然后压测观察CPU利用率、任务队列积压情况、平均响应时间逐步调整。没有一劳永逸的公式只有不断逼近的调优。4.2 一个完整的手动创建示例下面是我在一个数据处理项目里用的线程池配置可以直接参考import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class DataProcessPool { private static final int CPU_CORES Runtime.getRuntime().availableProcessors(); private static final int CORE_POOL_SIZE CPU_CORES * 2; private static final int MAX_POOL_SIZE CPU_CORES * 4; private static final long KEEP_ALIVE_SECONDS 60L; private static final int QUEUE_CAPACITY 500; private final ThreadPoolExecutor executor; public DataProcessPool() { this.executor new ThreadPoolExecutor( CORE_POOL_SIZE, MAX_POOL_SIZE, KEEP_ALIVE_SECONDS, TimeUnit.SECONDS, new ArrayBlockingQueue(QUEUE_CAPACITY), new NamedThreadFactory(data-process), new ThreadPoolExecutor.CallerRunsPolicy() ); this.executor.allowCoreThreadTimeOut(true); } public FutureString submitTask(CallableString task) { return executor.submit(task); } public void shutdown() { executor.shutdown(); try { if (!executor.awaitTermination(30, TimeUnit.SECONDS)) { executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); } } static class NamedThreadFactory implements ThreadFactory { private final AtomicInteger counter new AtomicInteger(1); private final String prefix; NamedThreadFactory(String prefix) { this.prefix prefix; } Override public Thread newThread(Runnable r) { Thread t new Thread(r, prefix -thread- counter.getAndIncrement()); t.setDaemon(false); t.setUncaughtExceptionHandler((thread, ex) - System.err.println(线程 thread.getName() 抛出异常: ex)); return t; } } }这段代码有几个设计点值得说明。队列用了ArrayBlockingQueue并指定容量500避免无界队列导致内存问题。拒绝策略用了CallerRunsPolicy当池子满时让提交任务的线程自己执行起到天然的背压作用减缓任务提交速度。线程工厂给线程起了带业务前缀的名字排查问题时一眼就能看出是哪个池子的线程。还设置了allowCoreThreadTimeOut(true)让核心线程在空闲时也能回收节省资源。4.3 提交任务的两种方式与Future的使用ExecutorService提交任务有两个入口execute(Runnable)和submit(Callable/Runnable)。区别在于execute没有返回值任务里抛的异常会直接冒到线程的未捕获异常处理器submit返回一个Future异常被封装在Future里只有调用get()时才会以ExecutionException的形式抛出。这个差异非常关键。如果你用submit提交任务却从来不调用get()那么任务里抛的异常会被静默吞掉你根本不知道出错了。我踩过这个坑一个批量任务用submit提交某个子任务抛了空指针但主流程毫无感知最后数据对不上才发现。所以用submit时要么在合适的地方get()检查结果要么在任务内部自己try-catch兜底。Future的get()是阻塞的会一直等到任务完成。如果不想无限等待用带超时的get(timeout, unit)。批量提交时可以用invokeAll一次性提交一组任务它会返回所有Future的列表并且等所有任务完成才返回。invokeAny则是只要有一个任务成功返回就返回适合“多个数据源取最快结果”的场景。5. 常见问题与排查技巧实录5.1 线程池任务丢失的几种原因任务提交了却没执行这是最让人头疼的问题之一。我总结了几种常见原因做成速查表现象可能原因排查方法任务静默消失用了DiscardPolicy或DiscardOldestPolicy检查拒绝策略配置任务抛异常无感知用submit但没调get()改用execute或在任务内try-catch周期任务只执行一次任务内抛了未捕获异常给周期任务整体包try-catch任务一直排队不执行核心线程被阻塞、队列积压打印线程栈看线程状态线程池已关闭调用了shutdown后继续提交提交前检查isShutdown其中周期任务静默停止这个坑特别隐蔽。ScheduledExecutorService的scheduleAtFixedRate和scheduleWithFixedDelay如果任务内部抛出异常且没被捕获这个任务后续的调度会被取消而且不会有任何日志。解决办法很简单在周期任务的最外层包一层try-catch把异常吃掉并记录日志保证任务不会因为一次异常而永久停止。5.2 线程池监控怎么知道池子健不健康线程池出问题往往是渐进的等业务报警时已经晚了。所以主动监控很重要。ThreadPoolExecutor提供了一组getter方法可以拿到实时状态getPoolSize()当前线程总数getActiveCount()正在执行任务的线程数getQueue().size()队列中等待的任务数getCompletedTaskCount()已完成任务总数getLargestPoolSize()历史峰值线程数我通常的做法是写一个定时任务每隔一段时间把这些指标打点到监控系统重点看队列大小和活跃线程数的趋势。如果队列持续增长说明处理能力跟不上提交速度需要扩容或优化任务逻辑。如果活跃线程数长期等于最大线程数说明池子一直处于饱和状态也该考虑调整了。还有一个更细粒度的办法重写ThreadPoolExecutor的beforeExecute和afterExecute方法在任务执行前后记录耗时这样能定位到具体是哪些任务慢。afterExecute还能捕获任务抛出的异常弥补submit吞异常的缺陷。5.3 优雅关闭别让线程池成为僵尸应用关闭时如果线程池没处理好可能导致任务执行到一半被中断数据不一致。正确的关闭流程是先调shutdown()它不再接受新任务但会把已提交的任务执行完然后调awaitTermination等待一段时间如果超时还没结束再调shutdownNow()强制中断。executor.shutdown(); try { if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { ListRunnable dropped executor.shutdownNow(); System.err.println(强制关闭丢弃任务数: dropped.size()); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }这里有个细节shutdownNow()返回的是队列里还没执行的任务列表可以记录下来做补偿处理。另外awaitTermination被中断时要恢复中断标志这是处理InterruptedException的标准姿势别把中断信号吞了。5.4 几个我踩过的坑和对应经验第一个坑是在任务里调用线程池自身的submit导致死锁。如果一个任务提交了子任务并等待子任务结果而线程池的线程都被这类父任务占满了子任务永远排不上队就死锁了。解决办法是父子任务用不同的线程池或者避免在任务内同步等待子任务。第二个坑是**ThreadLocal在线程池里的脏数据**。线程被复用上一个任务往ThreadLocal里塞的值会留给下一个任务。如果任务依赖ThreadLocal一定要在任务结束时remove()清理。这个坑在Web应用里特别常见用户A的请求数据串到用户B的请求上排查起来很费劲。第三个坑是核心线程数设得过大导致资源浪费。有次我把核心线程数设成了200结果大部分时间只有几个线程在干活其余190多个线程白白占着内存。后来改成按实际并发量设置配合allowCoreThreadTimeOut资源占用降了一大截。第四个坑是用SynchronousQueue时任务直接被拒绝。SynchronousQueue不存储任务来一个必须马上有线程接否则就触发拒绝策略。如果线程数已达上限任务会被直接拒绝没有缓冲。用这个队列要确保最大线程数足够大或者拒绝策略能兜住。6. 进阶让线程池更贴合业务6.1 自定义拒绝策略做降级处理JDK内置的四种拒绝策略AbortPolicy抛异常、DiscardPolicy静默丢弃在生产环境都不太友好。我一般会自定义一个拒绝策略在任务被拒时做降级要么把任务落库稍后重试要么返回一个兜底结果要么打日志告警。public class DegradeRejectPolicy implements RejectedExecutionHandler { Override public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { System.err.println(任务被拒绝队列大小: executor.getQueue().size() , 活跃线程: executor.getActiveCount()); // 这里可以做落库、告警、降级返回等处理 throw new RejectedExecutionException(系统繁忙请稍后重试); } }关键是要在拒绝时留下足够的现场信息方便事后分析是哪个环节过载了。光抛个异常什么信息都没有排查时两眼一抹黑。6.2 用CompletableFuture组合异步任务ExecutorService配合CompletableFuture能把异步编排玩得很顺。比如一个接口需要并行调用三个下游服务然后合并结果可以这样写CompletableFutureString f1 CompletableFuture.supplyAsync(() - callServiceA(), executor); CompletableFutureString f2 CompletableFuture.supplyAsync(() - callServiceB(), executor); CompletableFutureString f3 CompletableFuture.supplyAsync(() - callServiceC(), executor); CompletableFutureVoid all CompletableFuture.allOf(f1, f2, f3); all.thenRun(() - { String result f1.join() f2.join() f3.join(); // 合并处理 });这里要注意CompletableFuture默认用的是ForkJoinPool.commonPool()如果不显式传入自己的executor任务会跑到公共池里可能影响其他使用公共池的代码。所以生产环境一定要显式指定线程池。另外join()和get()的区别是join()抛的是非受检异常用起来更顺手但异常处理要自己兜住。6.3 线程池隔离不同业务用不同池子一个常见的反模式是所有业务共用一个线程池。这样做的后果是某个业务的任务突然暴增把池子占满其他业务全部被拖累这就是所谓的“故障扩散”。正确的做法是按业务维度做线程池隔离每个核心业务独享一个池子互不影响。隔离的粒度要权衡。池子太多管理成本高资源也分散池子太少隔离效果差。我的经验是按“故障影响范围”来划分影响面大的核心链路单独一个池非核心的、可降级的业务共用一个池。这样核心业务不会被边缘业务拖垮。7. 一些零散但重要的经验关于线程池的关闭还有个容易忽略的点如果线程池是在Spring容器里管理的记得配置销毁方法让容器关闭时自动调用shutdown。用Bean(destroyMethod shutdown)或者在配置类里实现DisposableBean都行。不然应用重启时正在执行的任务可能被硬生生掐断。关于队列的选择LinkedBlockingQueue如果不指定容量默认是Integer.MAX_VALUE等于无界。很多人用new LinkedBlockingQueue()以为是有界的其实不是。要用有界队列必须显式传容量比如new LinkedBlockingQueue(1000)。这个细节坑过不少人。关于线程命名别小看这件事。线上排查问题时jstack打出来的线程栈里如果线程名都是pool-1-thread-1这种根本分不清是哪个业务。给线程起个order-process-thread-1这样的名字一眼就能定位。自定义ThreadFactory的成本很低收益很高。关于submit和execute的选择我的习惯是不需要返回值、且希望异常能被全局处理器捕获的用execute需要返回值或需要精细控制异常的用submit并确保处理Future。混用的时候要特别小心别让异常在某个环节悄悄溜走。最后说个心态上的体会。线程池的参数调优没有标准答案它跟你的硬件、业务特征、流量模式都相关。别人的最佳实践放到你这里可能完全不适用。所以别迷信任何公式老老实实压测、监控、迭代用数据说话。我调过的每个池子参数都不一样但方法论是通用的先估算、再压测、后监控、持续调。