
Java 用 ScheduledExecutorService 做定时任务:比 Timer 强在哪、异常吞没与优雅关闭项目里要跑个定时任务:每 5 秒拉一次配置、每分钟清一次缓存。很多人上手就new Timer().schedule(...),能跑就没再管。直到某天线上定时任务莫名其妙全停了——查了半天才发现是某次任务抛了个异常,把整个 Timer 线程干掉了,后面所有任务再也不执行。这篇讲清楚三件事:为什么该用ScheduledExecutorService取代Timer、任务里的异常是怎么被偷偷吃掉的、以及关闭时怎么不丢任务。先看 Timer 的两个致命伤TimertimernewTimer();timer.scheduleAtFixedRate(newTimerTask(){Overridepublicvoidrun(){if(Math.random()0.5){thrownewRuntimeException(模拟任务异常);}System.out.println(任务执行:LocalTime.now());}},0,1000);跑几秒你就会看到:一旦某次run()抛出未捕获异常,整个 Timer 直接崩溃,控制台再无输出。原因是Timer内部只有一个执行线程,异常会终止这个线程,后续所有任务陪葬。第二个坑:Timer 用的是绝对时间。如果系统时钟被回拨(NTP 校时、运维手动改时间),任务调度会错乱。正确写法:ScheduledExecutorServiceScheduledExecutorService用线程池,基于相对延迟调度,是java.util.concurrent里的标准方案。importjava.util.concurrent.*;ScheduledExecutorServiceschedulerExecutors.newScheduledThreadPool(2);// 固定频率:每 1 秒执行一次(从上次开始时间算起)scheduler.scheduleAtFixedRate(()-{System.out.println(fixedRate 任务:LocalTime.now());},0,1,TimeUnit.SECONDS);// 固定延迟:上次执行结束后再等 1 秒(适合任务耗时不稳定的场景)scheduler.scheduleWithFixedDelay(()-{System.out.println(fixedDelay 任务:LocalTime.now());},0,1,TimeUnit.SECONDS);scheduleAtFixedRate和scheduleWithFixedDelay的区别是新手必踩的点:fixedRate:每隔固定周期触发,不管上次跑多久。如果任务耗时超过周期,下次会紧接着执行(任务会堆积)。fixedDelay:等上一次执行完再计时。任务之间永远有固定间隔,不会堆积。拉配置、发心跳这种必须匀速的用 fixedRate;清理、同步这种别重叠就行的用 fixedDelay。关键坑:异常会被静默吞掉这是ScheduledExecutorService最反直觉的地方。它比 Timer 强——一次任务抛异常不会影响别的任务,但它对同一个周期任务的处理是:抛异常后,这个周期任务就再也不调度了,而且异常被默默吞掉,连日志都没有。scheduler.scheduleAtFixedRate(()-{System.out.println(执行前);thrownewRuntimeException(炸了);// 你在控制台看不到这个异常},0,1,TimeUnit.SECONDS);// 只会打印一次执行前,之后这个任务静默消失,程序还继续跑异常去哪了?被包在返回的ScheduledFuture里了,你不get()就永远看不到。所以周期任务里必须自己 try-catch 兜住所有异常,否则任务会悄无声息地阵亡:scheduler.scheduleAtFixedRate(()-{try{doBusiness();// 真正的业务逻辑}catch(Throwablet){// 兜住一切,包括 Error,保证任务能继续下一轮log.error(定时任务执行失败,本轮跳过,t);}},0,1,TimeUnit.SECONDS);记住这条铁律:放进 scheduleAtFixedRate 的 Runnable,内部必须是不抛异常的。任何漏出来的异常都会让这个周期任务永久停摆。优雅关闭:别用 shutdownNow 一刀切程序退出时直接System.exit或者不管线程池,可能导致正在执行的任务被硬中断。正确的关闭姿势分三步:scheduler.shutdown();// 1. 不再接受新任务,已提交的继续执行try{// 2. 给正在跑的任务一点时间收尾if(!scheduler.awaitTermination(10,TimeUnit.SECONDS)){// 3. 超时还没结束,强制中断scheduler.shutdownNow();}}catch(InterruptedExceptione){scheduler.shutdownNow();Thread.currentThread().interrupt();// 恢复中断状态,别吞掉}shutdown():温柔关闭,等已有任务跑完,但周期任务会停止下一次调度。shutdownNow():发中断信号并清空等待队列,适合超时后的兜底。awaitTermination:阻塞等待,给收尾留窗口。Spring 项目里更简单:把 bean 声明成Bean(destroyMethod shutdown),或用PreDestroy触发上面的关闭逻辑,容器停机时自动回收。一个能直接用的封装publicclassSafeScheduler{privatefinalScheduledExecutorServicescheduler;publicSafeScheduler(intpoolSize){// 用带名字的线程工厂,排查问题时能一眼认出是哪个池的线程this.schedulerExecutors.newScheduledThreadPool(poolSize,r-{ThreadtnewThread(r);t.setName(safe-scheduler-t.getId());t.setDaemon(true);// 守护线程,不阻止 JVM 退出returnt;});}publicvoidscheduleAtFixedRate(Runnabletask,longperiod,TimeUnitunit){scheduler.scheduleAtFixedRate(()-{try{task.run();}catch(Throwablet){System.err.println([scheduler] 任务异常,本轮跳过: t.getMessage());}},0,period,unit);}publicvoidshutdown(){scheduler.shutdown();try{if(!scheduler.awaitTermination(10,TimeUnit.SECONDS)){scheduler.shutdownNow();}}catch(InterruptedExceptione){scheduler.shutdownNow();Thread.currentThread().interrupt();}}}小结别再用Timer:单线程、异常会连坐整个调度器、依赖绝对时间。ScheduledExecutorService全面替代它。scheduleAtFixedRate按周期匀速触发(任务会堆积),scheduleWithFixedDelay按上次结束后计时(不重叠),按业务语义选。周期任务内部必须 try-catch 兜住一切异常——漏出来的异常会让这个任务静默停摆,连日志都不给你留。关闭走shutdown()→awaitTermination→ 超时shutdownNow()三段式,给任务收尾留窗口。一句话记忆:ScheduledExecutorService 不会因为一次异常崩掉整个池,但会因为一次异常悄悄弄死那一个周期任务——所以异常绝不能漏出 Runnable。