从频繁OOM到系统稳如老狗,线程池 ExecutorService 救了我三次

📅 2026/6/30 11:29:14
从频繁OOM到系统稳如老狗,线程池 ExecutorService 救了我三次
搞 Java 后端八年我见过太多因为线程池没用好导致的线上事故。CPU 飙到 100%、任务堆积把内存打爆、服务重启后 JVM 退不出去……这些问题我全踩过。今天把 ExecutorService 的所有关键知识点结合我的实战踩坑经历一次性讲透。大家好我是老张。先说一个让我至今难忘的事故。2019 年我们的订单导出功能突然挂了。用户一点导出页面就转圈然后超时。我上去一看日志RejectedExecutionException满屏都是。线程池满了队列也满了新任务进不去。更惨的是我连shutdown()都没调用服务重启了三次进程都退不干净。那次我加了三天班才把整个线程池的使用逻辑彻底重构。从那以后我对 ExecutorService 有了敬畏之心。今天我就把这个 JDK 提供的异步任务执行框架从头到尾捋一遍——不讲废话只讲能落地的东西。一、ExecutorService 是什么一句话说清楚简单说ExecutorService 是 JDK 帮我们封装好的一套线程池管理 API。你不用自己new Thread()不用操心线程什么时候创建、什么时候销毁也不用管任务队列怎么设计。把这些脏活累活交给 ExecutorService你只负责往里面扔任务就行了。它的核心价值就四个字解耦 复用。二、怎么创建 ExecutorService两种方式我建议你用第二种2.1 方式一Executors 工厂方法简单但有隐患这是新手最常用的方式一行代码搞定javaExecutorService executor Executors.newFixedThreadPool(10);Executors 还提供了好几种工厂方法方法特点坑newFixedThreadPool(int n)固定大小线程池队列无界任务多时可能 OOMnewCachedThreadPool()按需创建空闲回收线程数无上限可能创建太多newSingleThreadExecutor()单线程同样是无界队列newScheduledThreadPool(int n)支持定时/延迟任务同上真实经历我刚入行时用了newCachedThreadPool()做批量推送结果某个时间段任务暴增线程数瞬间飙到几千个直接把服务器打挂了。从此我再也没在生产环境用过newCachedThreadPool()。2.2 方式二直接实例化 ThreadPoolExecutor强烈推荐这是我最常用的方式参数全在你掌控之中javaExecutorService executorService new ThreadPoolExecutor( 4, // corePoolSize: 核心线程数 8, // maximumPoolSize: 最大线程数 60L, // keepAliveTime: 空闲线程存活时间 TimeUnit.SECONDS, new LinkedBlockingQueue(1000), // 有界队列防止 OOM new ThreadFactoryBuilder().setNameFormat(order-pool-%d).build(), new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略 );这里是我的血泪教训必须用有界队列无界队列在高并发下会导致内存无限增长直到 OOM。必须给线程起名字不然排查问题时堆栈里全是pool-1-thread-1你根本分不清是哪个业务。必须设置拒绝策略CallerRunsPolicy会让提交任务的线程自己执行虽然慢但不会丢任务。5 个核心参数一次记牢corePoolSize核心线程数即使空闲也不会被回收maximumPoolSize最大线程数任务暴增时可以扩容到的上限keepAliveTime非核心线程空闲多久后被回收workQueue任务队列强烈建议用有界队列RejectedExecutionHandler队列满了之后的处理策略三、提交任务Runnable vs Callable三种方式用对场景先定义两个示例任务java// 无返回值适合执行后不需要结果的场景 Runnable runnableTask () - { // 比如写日志、发通知、清理缓存 }; // 有返回值适合执行完需要拿到结果的场景 CallableString callableTask () - { // 比如查询数据、调用远程接口 return 执行完成; };execute() —— 只管执行不管结果只支持Runnable不返回任何东西。javaexecutorService.execute(runnableTask);适用场景日志记录、埋点上报、缓存预热——这些你不需要知道结果的任务。submit() —— 要结果的用这个支持Runnable和Callable返回Future对象。javaFutureString future executorService.submit(callableTask);适用场景几乎所有的业务场景特别是你需要拿到异步执行结果的时候。invokeAny() / invokeAll() —— 批量任务javaListCallableString tasks Arrays.asList(callableTask, callableTask, callableTask); // 谁先完成返回谁最快的那个 String result executorService.invokeAny(tasks); // 全部完成才返回所有结果 ListFutureString futures executorService.invokeAll(tasks);适用场景invokeAny()调用多个第三方接口谁先返回用谁的竞速模式invokeAll()并行处理多条数据全部完成后再汇总四、关闭 ExecutorService这里踩过的坑比写代码还多先记住一句话ExecutorService 不会自动关闭即使没有任务在执行。如果你不手动关闭JVM 可能永远退不出去。因为线程池里的线程默认不是守护线程会一直等待新任务。shutdown() vs shutdownNow()方法行为适用场景shutdown()停止接收新任务等已有任务执行完优雅关闭不丢任务shutdownNow()立即尝试停止所有任务返回未执行的任务列表紧急关闭能丢任务⚠️ 重点shutdownNow() 不保证任务能停下来。它只是给正在执行的线程发中断信号如果你的任务没处理InterruptedException它可能还是会继续跑。官方推荐的优雅关闭模板直接用javaexecutorService.shutdown(); try { // 最多等待 800ms超时则强制关闭 if (!executorService.awaitTermination(800, TimeUnit.MILLISECONDS)) { executorService.shutdownNow(); } } catch (InterruptedException e) { executorService.shutdownNow(); }真实经历有一次我忘记调用shutdown()服务发版后旧的进程一直卡着端口被占用新进程起不来。运维找我的时候我都懵了。从此以后所有线程池必须在 finally 块里关闭。五、Future异步结果的快递单号Future是你提交任务后拿到的那个凭证可以用来查询任务是否完成isDone()取消任务cancel()获取执行结果get()获取结果注意get()是阻塞的javaFutureString future executorService.submit(callableTask); String result future.get(); // 会一直等到任务完成⚠️ 又一个大坑get()会无限阻塞当前线程直到任务执行完毕。如果任务卡住了你的主线程也卡住了。必须设置超时这条救了我三次javatry { String result future.get(200, TimeUnit.MILLISECONDS); } catch (TimeoutException e) { // 超时了做降级处理 future.cancel(true); return 默认值; }真实经历有一次调用外部支付接口对方服务挂了get()一直没返回导致我的线程池被占满整个订单服务不可用。加了超时 降级之后再也没出现过这个问题。取消任务javaboolean canceled future.cancel(true); // true 表示尝试中断正在执行的线程 boolean isCancelled future.isCancelled(); boolean isDone future.isDone();六、ScheduledExecutorService定时任务的正确姿势如果你需要延迟执行或周期性执行任务用ScheduledExecutorService比Timer强一万倍。创建javaScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor();延迟执行java// 1 秒后执行 scheduler.schedule(callableTask, 1, TimeUnit.SECONDS);固定频率执行scheduleAtFixedRatejava// 每 450ms 执行一次 scheduler.scheduleAtFixedRate(runnableTask, 100, 450, TimeUnit.MILLISECONDS);⚠️ 注意如果任务执行时间超过 450ms下次执行不会并行而是等当前任务执行完立即开始下一次。它保证的是频率不是间隔。固定延迟执行scheduleWithFixedDelayjava// 每次执行完等 150ms 再执行下一次 scheduler.scheduleWithFixedDelay(task, 100, 150, TimeUnit.MILLISECONDS);两者区别一句话scheduleAtFixedRate 按固定时钟执行不关心任务耗时scheduleWithFixedDelay 按固定间隔执行等任务干完再休息一会儿真实场景数据同步我用scheduleAtFixedRate因为需要每隔 10 分钟同步一次消息重试用scheduleWithFixedDelay因为失败后需要等一段时间再重试避免一直失败一直重试。七、ExecutorService vs Fork/Join别选错场景Java 7 引入了 Fork/Join 框架但别什么都往里套。ExecutorServiceFork/Join适用场景独立任务每个请求一个线程可递归拆分的任务分治、归并典型例子Web 请求处理、批量导出并行排序、大数据计算核心机制任务队列 线程池工作窃取Work-Stealing一句话选型如果你处理的任务可以大拆小、小再拆更小用 Fork/Join否则老老实实用 ExecutorService。八、踩坑总结每一行都是加班换来的坑后果解决方案忘记关闭线程池JVM 无法退出端口被占用shutdown()awaitTermination()模板无界队列任务堆积导致 OOM用LinkedBlockingQueue(capacity)get()不设超时下游故障导致线程池占满所有get()强制带超时参数线程池大小设置不当过小排队、过大浪费资源根据 CPU 核数和任务类型动态计算newCachedThreadPool()滥用线程数暴涨打挂服务器生产环境禁用手动创建 ThreadPoolExecutor不处理InterruptedExceptionshutdownNow()失效任务中正确处理中断线程不命名排查问题找不到对应业务ThreadFactoryBuilder().setNameFormat()九、一句话总结ExecutorService 是 Java 并发编程的必修课。它管任务的调度和执行帮你屏蔽线程管理的复杂性。用好它的关键就四条创建时手动new ThreadPoolExecutor()别用 Executors 工厂方法除了极简单场景提交时区分execute()vssubmit()批量用invokeAll()获取结果时get()必须带超时用完时必须关闭用官方推荐的优雅关闭模板并发工具只是剑剑法还得自己悟。上面每个坑都是我熬夜加班换来的希望你能绕过去。如果觉得有用点个赞、收个藏下次遇到线程池问题回来看一眼可能就找到答案了。