1. 线程池核心机制与参数解读先聊点实在的。接手过几个线上服务之后你会发现线程池这玩意儿写起来简单跑起来要命。很多人背了八股文知道ThreadPoolExecutor有七个参数但真到了生产环境线程池一满任务一丢接口超时日志里连个报错都找不到那时候才明白什么叫“基础不牢地动山摇”。这篇文章我把线程池从参数到底层流转、从队列选型到生产配置、从常见故障到排查手法完整过一遍。不是面试八股是真正能在线上环境扛住压力的那种理解。1.1 七个参数各自扮演什么角色ThreadPoolExecutor的构造方法里那七个参数任何一个设错了都不是“性能差一点”的问题而是直接导致线上故障的问题。先把它们逐个拆开看。corePoolSize核心线程数线程池里常驻的线程数量。就算闲着这些线程也不会销毁除非设置了allowCoreThreadTimeOut。它决定了线程池处理突发流量的“最低火力”。maximumPoolSize最大线程数线程池允许存在的最大线程数量。当核心线程都在忙队列又满了线程池才会创建额外线程直到这个上限。keepAliveTime存活时间非核心线程空闲多久后被回收。默认情况下只对超出核心线程数的那部分线程生效。这个参数如果设得太小流量抖动时频繁创建销毁线程反而增加开销。unit时间单位配合keepAliveTime用的秒、毫秒、微秒按需选择。workQueue任务队列核心线程都在忙的时候新任务先在队列里排队。这个队列选择的学问最大后面单独用一节细讲。threadFactory线程工厂创建线程用的工厂。生产环境必须自定义给线程起有意义的名称否则排查问题时你看到的全是pool-1-thread-1这种毫无信息量的名字。handler拒绝策略线程池和队列都满的时候新任务怎么处理。默认的AbortPolicy是直接抛异常但如果你没接住任务就没了。从JDK源码的角度看ThreadPoolExecutor内部就一个核心状态变量用ctl这个AtomicInteger同时保存workerCount工作线程数和runState运行状态。高3位存状态低29位存线程数用位运算来保证状态切换和线程数增减的原子性。这不只是面试考点理解了这一步你才能理解为什么线程池关闭时要先shutdown()再awaitTermination()——那是为了让状态有机会从RUNNING平滑过渡到TIDYING。1.2 任务提交后线程池内部是怎么流转的很多资料会画一张复杂的流程图其实你记住四个步骤就够了。这是execute()方法的真实执行逻辑顺序错了就全错了核心线程未满线程池收到新任务先判断当前工作线程数是否小于corePoolSize。如果小于直接创建核心线程执行这个任务。注意不是先丢队列。这个顺序很多人搞反了。队列未满核心线程已经全部在忙新任务被塞进阻塞队列等待。线程池不会立刻创建新线程而是让任务排队。队列已满线程未达上限队列放不下了线程池才会创建非核心线程去处理任务。这一步是“先塞队列再扩线程”这个机制决定了你单纯调大maximumPoolSize不一定能提升吞吐量还要看队列策略。队列满且线程满触发拒绝策略。四种策略分别是抛异常Abort、丢弃Discard、丢弃最老的DiscardOldest、由调用线程自己执行CallerRuns。这里有个关键点线程池创建非核心线程后如果这个线程空闲时间超过了keepAliveTime就会被回收。所以你的线程池容量是动态变化的别以为maximumPoolSize设了10就一直有10个线程。生活类比线程池就像一家银行。柜台窗口是核心线程大厅等候区是队列行长决定要不要临时加开窗口非核心线程。等候区坐满了窗口也全开着再来客户就只能拒之门外拒绝策略。1.3 拒绝策略不是随便选的默认的AbortPolicy直接抛RejectedExecutionException如果你的业务代码没有捕获任务静默丢失上游看到的只是接口超时或数据缺失。我在生产环境见过最典型的案例一个消息推送服务用了默认策略流量高峰时大量消息被丢弃用户没收到推送排查了半天才在日志里找到零星的RejectedExecutionException。CallerRunsPolicy反而是我比较推荐的兜底策略。它不会丢任务而是让提交任务的线程通常是业务线程自己执行这个任务。这就形成了天然限流——提交线程被占用提交速度自然慢下来。坏处是接口响应时间可能飙升你要在监控上接受这个代价。DiscardPolicy和DiscardOldestPolicy基本只适合丢得起消息的场景比如实时日志、监控指标丢了也不心疼。凡是涉及订单、支付、消息通知的别用。经验之谈拒绝策略不要只写在代码注释里要在监控大盘上加上“线程池拒绝次数”这个指标。一旦出现拒绝告警说明你的线程池容量或队列已经到极限了这是容量规划的报警信号。2. 阻塞队列选型最容易踩坑的环节线程池的参数里workQueue的选择大概是生产环境最容易被忽视、又最容易出问题的一项。队列选错不只是性能问题可能直接把内存打爆。2.1 三种常见队列的底层差异无界队列LinkedBlockingQueue默认容量是Integer.MAX_VALUE任务永远进得来maximumPoolSize直接变成摆设。你设了最大线程数10但因为队列永远不满非核心线程永远不会被创建。这个队列最大的风险是——如果生产速度持续快过消费速度队列无限膨胀最终触发OOM内存溢出。有界队列ArrayBlockingQueue必须指定容量比如new ArrayBlockingQueue(1000)。这是生产环境最推荐的选择它会强制你思考“队列最多能堆积多少任务”。队列满了之后线程池才会扩容到maximumPoolSize一切行为都可预测。同步队列SynchronousQueue这个队列比较特殊它内部不存储任何任务。每个任务进来必须立刻有一个线程接走否则就阻塞。配合maximumPoolSize设得比较大时相当于完全没有缓冲任务直达线程。适合处理“必须立刻执行不缓存”的场景比如即时通信的消息转发。优先级队列PriorityBlockingQueue任务按优先级排序。适合有明确优先级的业务但要注意——如果任务一直有高优先级的进来低优先级任务可能会饿死永远得不到执行。从底层实现看LinkedBlockingQueue用两个锁分别控制队头和队尾读写可以并行ArrayBlockingQueue只有一个锁读写互斥。所以理论上LinkedBlockingQueue的吞吐量更高但这在业务层面往往不是主要矛盾——容量可控性和溢出风险才是。生活类比先把任务全部装进一个大仓库无界队列再让工人慢慢搬。表面看效率高但仓库如果无限大积压的货物会把仓库撑爆。有界队形就像是限定了仓库面积满了就得加人或者不接单。2.2 不同业务场景该选哪个队列拿我实际见过的几个场景来说。场景一Web接口异步处理任务。用户请求进来把任务丢线程池响应立刻返回。这种场景选有界队列比如ArrayBlockingQueue容量设1000~5000。为什么因为你需要一个明确的任务积压上限。如果队列无界网络抖动导致任务暴增时内存会先扛不住服务OOM挂掉那可比拒绝几个请求严重多了。场景二日志异步写入。日志数据量大、允许丢弃、对实时性要求不高。这种场景可以用无界队列或者干脆用DiscardPolicy因为日志丢几条不影响核心业务。但要注意如果你用logback或log4j2的异步队列它们内部已经做了有界处理和丢弃策略你配置线程池时不需要重复造轮子。场景三即时性要求极高的消息推送。用户在线状态判断、IM消息转发这类任务延迟敏感。用SynchronousQueue配合较大的maximumPoolSize让任务能立刻分配到线程执行。代价是线程池会频繁创建线程keepAliveTime要设短一点比如60秒避免线程堆积。2.3 队列长度怎么定队列长度没有万能公式但有一个可以参考的估算思路。假设你的核心线程数是4每个任务平均耗时200ms那么线程池每秒大约能消费4 * (1000/200) 20个任务。如果你的业务高峰期每秒提交50个任务那每秒积压30个任务。如果你能接受任务最多积压1分钟队列长度就设为30 * 60 1800。这个估算方式背后是经典的利特尔法则Littles Law系统中的任务数等于到达速率乘以处理时间。用这个思路算出来的队列长度至少是“有依据”的比拍脑袋强得多。算完再在压测环境验证一下看看积压和拒绝是否在可接受范围内。注意队列长度不能只算平均值要按峰值的3~5倍预留。线上流量不是匀速的秒杀、活动、定时任务触发重叠可能瞬间就是10倍流量别让队列在正常波动下就撑满。3. 生产环境线程池配置实践3.1 核心线程数到底怎么算线程池的线程数设置业界流传的经验公式基本是CPU密集型设为CPU核心数 1IO密集型设为CPU核心数 * 2。先解释为什么。CPU密集型任务计算、加密、序列化基本不阻塞线程数接近CPU核心数就能跑满。多加一个线程是为了防止某个线程偶尔因页缺失、上下文切换而空闲时能补位。IO密集型任务数据库访问、远程调用、文件读写大部分时间在等IO返回线程处于阻塞状态。这时候线程数可以多一些让等待的线程和运行的线程重叠。$CPU核心数 * 2$ 是一个经验起点更准确的计算是$$线程数 CPU核心数 \times (1 \frac{等待时间}{计算时间})$$比如一个任务计算耗时20msIO等待80ms那等待时间与计算时间的比值是4理想线程数就是核心数乘以14。但要注意这个公式适合单任务独立的情况。如果多个任务共享同一个下游连接池比如数据库连接池只有50个连接你的线程数设200也没用都被连接池挡住了。实操建议核心线程数先按经验公式定一个初值然后压测。观察线程池活跃度、队列积压速度、任务耗时这三个指标不断调整直到吞吐量和延迟达到平衡。没有哪一组参数是“绝对正确”的只有“适合你当前业务”的。3.2 不要在代码里硬编码线程池新手最容易犯的错就是每次用到线程池就现场new一个ExecutorService executor Executors.newFixedThreadPool(10);这条代码有两个问题。第一Executors提供的快捷方法里newFixedThreadPool和newSingleThreadExecutor用的是无界队列前面说过了有OOM风险。第二每次new一个线程池用完不关闭线程资源一直在泄漏。我有一个项目就因为这个原因上线一周后线程数涨到几千个最后服务直接不响应了。正确做法是全局统一管理线程池。可以用Spring管理在配置类里声明Configuration public class ThreadPoolConfig { Bean(businessExecutor) public ThreadPoolExecutor businessExecutor() { ThreadPoolExecutor executor new ThreadPoolExecutor( 8, 16, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(2000), new NamedThreadFactory(biz-exec), new ThreadPoolExecutor.CallerRunsPolicy() ); // 预启动核心线程避免流量突刺时冷启动 executor.prestartAllCoreThreads(); return executor; } }注意我用了NamedThreadFactory——这是Guava或自定义的目的就是给线程起名字。出了故障你在jstack里能看到biz-exec-thread-5在干什么而不是一头雾水地猜pool-3-thread-1是哪来的。prestartAllCoreThreads()这个方法也很实用。默认情况下核心线程是懒加载的收到第一个任务才创建。对高并发服务来说首次请求可能会因为线程创建而变慢预启动可以消除这个问题。3.3 动态调整生产环境的神器静态参数配得再好也应对不了流量突变。比如运营活动来了任务量翻倍或者大促结束任务量骤降。Java提供了一个很实用的方法ThreadPoolExecutor#setCorePoolSize()和setMaximumPoolSize()。你可以把线程池参数配置到配置中心Nacos、Apollo改动实时生效Component public class DynamicThreadPoolManager { private final ThreadPoolExecutor executor; public void updatePoolSize(int coreSize, int maxSize) { executor.setCorePoolSize(coreSize); executor.setMaximumPoolSize(maxSize); log.info(线程池参数动态调整完成, core{}, max{}, coreSize, maxSize); } }这套方案有两个细节。第一setCorePoolSize如果设得比当前线程数小多余的线程会在空闲后逐渐回收不会立刻中断正在执行的任务所以是安全的。第二配置中心推送新值后最好加一个版本号日志方便排查“是不是参数被谁改过”。遇到的真实案例我曾经维护过一个推送服务每次大促前都要手动改线程池参数再重启后来接入了动态配置大促前提前半小时改配置再也不用重启了。而且加了告警之后线程池如果发生拒绝马上能感知到手里有动态参数这招改起来也不用慌张。4. 常见问题与排查技巧实录4.1 线程池里任务异常被吞掉这是最隐蔽的一个坑。execute()方法提交的任务如果执行过程中抛出异常这个异常会被线程池捕获打印到System.err或者线程池内部的UncaughtExceptionHandler但不会自动传给提交任务的调用方。后果就是——任务失败了你完全无感日志也不一定有记录数据就这么悄悄丢了。复现现场public class ThreadPoolExceptionDemo { public static void main(String[] args) { ThreadPoolExecutor executor new ThreadPoolExecutor( 2, 4, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue(100) ); executor.execute(() - { throw new RuntimeException(任务执行失败); }); // 主线程继续跑完全感知不到上面那个异常 System.out.println(主线程执行完毕); } }你跑一下会发现异常在子线程里是打出来了但主线程毫无察觉。解决办法有几种一是在任务内部自己捕获所有异常并记录日志executor.execute(() - { try { doBusiness(); } catch (Exception e) { log.error(任务执行异常, e); // 如果业务需要可以在这里做补偿或重试 } });二是用submit()代替execute()返回值是Future调用future.get()时异常会重新抛出来。代价是如果主线程不做get()异常依然是无感的。三是给线程池设置UncaughtExceptionHandlerThreadFactory factory new ThreadFactoryBuilder() .setNameFormat(biz-pool-%d) .setUncaughtExceptionHandler((t, e) - log.error(线程 {} 发生未捕获异常, t.getName(), e)) .build();我个人的习惯是任务内部必须try-catch并且把异常上下文任务ID、参数、耗时打全。线程池是异步的异步意味着你失去了原有的调用栈不好好记日志出了问题就是大海捞针。4.2 ThreadLocal内存泄漏线程池和ThreadLocal搭配使用是经典的内存泄漏触发点。逻辑是这样的ThreadLocal的特点是“线程本地变量”每个线程维护一个ThreadLocalMap。正常业务中线程用完就销毁ThreadLocalMap随之被GC回收。但线程池里的线程长期存活如果某个任务往ThreadLocal里写了一个大对象用完后没有手动remove()那这个对象就会被后续所有任务共同看到。轻则数据串号A用户的数据跑到B用户那里重则老年代持续增长触发Full GC。真实事故一个订单服务在拦截器里把用户信息放进了ThreadLocal线程池里的线程处理完一个请求后没有清理。结果用户A查订单跳出了用户B的订单信息。这个事故的直接原因就是ThreadLocal值在不同请求间串了。规矩很简单用ThreadLocal的地方finally块里必须remove()。public void handleRequest(User user) { // 假设 UserContext 内部用 ThreadLocal 存用户信息 UserContext.set(user); try { doBusiness(); } finally { UserContext.remove(); } }这行remove()在面试里很好答但在生产环境你少写一行线上就会炸。线程池的复用特性决定了ThreadLocal必须是“用完即扔”的一次性工具绝不能依赖GC帮你清理。4.3 线程池用完没关闭导致的线程泄漏线程池不像普通对象不会因为你不再引用它就自动销毁。ThreadPoolExecutor的核心线程默认是常驻的即使业务已经不再提交任务那些线程依然活着。最常见的一个场景批处理任务里循环处理一批数据每次都创建一个线程池处理处理完没有调用shutdown()。第一批数据处理的线程池还在第二批又创建了新线程池久而久之线程数只增不减直到操作系统再也创建不了线程报出OutOfMemoryError: unable to create new native thread。关闭线程池的正确姿势executor.shutdown(); // 不再接受新任务但已提交的任务继续执行 try { // 等待60秒让已提交的任务执行完 if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { // 如果还没执行完强制关闭 executor.shutdownNow(); } } catch (InterruptedException e) { executor.shutdownNow(); Thread.currentThread().interrupt(); }shutdown()和shutdownNow()的区别是前者等已经提交的任务跑完后者直接中断正在执行的任务并返回未执行的任务列表。生产环境一般先shutdown()再awaitTermination()给任务一个优雅退出的窗口。如果等不及再shutdownNow()。4.4 常见问题排查速查表现象排查思路处理方式接口超时、任务不见查看拒绝策略是否触发监控线程池拒绝次数日志搜索RejectedExecutionException调整队列或最大线程数内存持续增长、OOM检查队列是否无界、任务积压换成有界队列给队列容量设置上限监控队列深度线程数持续上涨不下降确认allowCoreThreadTimeOut、检查线程是否阻塞在外部调用上jstack查看线程栈确认是否有线程因IO等待而长期不释放多个请求间数据串号检查ThreadLocal是否用了没清理在finally里调用remove()重启验证核心线程数为0时行为诡异了解corePoolSize0的特殊逻辑任务提交时先入队有任务被消费后线程才创建实际场景不建议设0业务无法预测响应行为线上任务执行超时但无明显报错查任务里是否有远程调用下游超时时间是否合理在任务内部记录耗时日志拆解耗时构成排查线程池问题最常用的命令# 查看进程中最耗CPU的线程 top -Hp pid # 打印线程堆栈 jstack pid thread_dump.txt # 用 jstack 搜索自定义线程名前缀快速定位 grep biz-exec thread_dump.txt排查时有个小技巧如果你用了自定义线程池jstack里每个线程名都带业务前缀一眼就能定位。这一行命名习惯省下来的排查时间无法估算。5. 自己动手实现一个简化版线程池别急着跳过这部分是理解Java线程池的最佳途径。面试的时候背诵参数是低级的真正的高级是能用自己的话说出线程池是怎么跑起来的。自己写一个简化版比看十遍源码都管用。5.1 核心结构设计一个简化线程池需要四个部件任务队列、工作线程集合、线程池状态、任务调度逻辑。public class SimpleThreadPool { // 任务队列存放提交的任务 private final BlockingQueueRunnable taskQueue; // 工作线程集合 private final ListWorker workers new ArrayList(); // 线程池状态 private volatile boolean running true; // 核心线程数 private final int corePoolSize; public SimpleThreadPool(int corePoolSize, int queueCapacity) { this.corePoolSize corePoolSize; this.taskQueue new ArrayBlockingQueue(queueCapacity); } // 启动核心线程 public void start() { for (int i 0; i corePoolSize; i) { Worker worker new Worker(thread- i); workers.add(worker); worker.start(); } } // 提交任务入队失败就拒绝 public void execute(Runnable task) { if (!running) { throw new IllegalStateException(线程池已停止); } // 这里真正生产环境要考虑是否创建额外线程 taskQueue.offer(task); } // 关闭线程池 public void shutdown() { running false; workers.forEach(Worker::interrupt); } // 工作线程 private class Worker extends Thread { public Worker(String name) { super(name); } Override public void run() { while (running || !taskQueue.isEmpty()) { try { Runnable task taskQueue.poll(1, TimeUnit.SECONDS); if (task ! null) { task.run(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } }这个版本极其简化没有最大线程数概念没有拒绝策略但核心骨架已经到位了一个队列存任务一组线程消费队列。理解了这一点再看官方ThreadPoolExecutor你就知道它是在这个骨架上加了状态机、动态扩容、拒绝策略、线程回收等细节。5.2 关键实现细节我发明的这个简化版有个细节值得品味while (running || !taskQueue.isEmpty())。这个循环条件保证了关闭线程池时已经在队列里没执行的任务也会被消费完不会因为shutdown()调用就立刻丢任务。这其实就是官方shutdown()和shutdownNow()的区别本质好的关闭策略不是戛然而止而是给排队任务一个“消化完”的窗口。taskQueue.poll(1, TimeUnit.SECONDS)这个细节也很关键。它让空闲线程每秒钟醒一次检查状态而不是用take()死等这样既能及时响应关闭信号也不会浪费CPU。实际生产中你把keepAliveTime理解成类似的东西它是线程从“等不到新任务”到“被回收”的等待时间。5.3 用测试验证自己的线程池写完了必须跑一下验证三个核心能力能执行任务、能排队、能优雅关闭。public class SimpleThreadPoolTest { public static void main(String[] args) throws InterruptedException { SimpleThreadPool pool new SimpleThreadPool(2, 5); pool.start(); for (int i 0; i 10; i) { final int taskId i; pool.execute(() - { System.out.println(Thread.currentThread().getName() 执行任务 taskId); try { Thread.sleep(200); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } // 等待任务执行 Thread.sleep(2000); pool.shutdown(); System.out.println(线程池已关闭); } }观察输出你会看到两个核心线程轮流执行10个任务且线程创建是“懒启动”——第一个任务执行时才真正创建线程这也印证了前面说核心线程为什么最好提前预启动的原因。我自己写完这个小demo后对官方ThreadPoolExecutor的理解确实上了一个台阶。一个“会思考”的线程池无非是在这几个部件上做了很多精细化控制源码也就不再是背出来的八股文了。我个人在实际项目中的体会是线程池的参数没有金科玉律每个服务都要根据自身的任务类型和下游依赖能力压测调优。先写对参数合理、队列有界、拒绝策略合适再看紧线程数、队列深度、拒绝次数、任务耗时全上监控最后能用动态参数对抗流量突变。做好这三步线程池才能从“能跑”变成“扛得住”。如果遇到线程池问题排查无从下手先从jstack看线程在干什么再回来看队列积压和线程池参数90%的问题能快速定位。
阅读完成 · 觉得有帮助?