当前位置: 首页 > 后端技术 > Java

ScheduledThreadPoolExecutor踩过最痛的坑

时间:2023-04-01 21:20:20 Java

概述最近项目上报了一个重要的定时任务突然不执行了,很头疼。开发环境和测试环境都没有出现过这个问题。定时任务使用ScheduledThreadPoolExecutor。后来看代码的时候发现踩了个大坑。还原“大坑”的坑是,如果ScheduledThreadPoolExecutor中执行的任务失败,抛出异常,不仅不会打印异常堆栈信息,同时还会取消后续的调度,看看就好在例子中。@TestpublicvoidtestException()throwsInterruptedException{//创建一个定时任务线程池,有1个线程ScheduledExecutorServicescheduledExecutorService=Executors.newSingleThreadScheduledExecutor();//创建一个任务Runnablerunnable=newRunnable(){volatileintnum=0;@Overridepublicvoidrun(){num++;//模拟执行错误if(num>5){thrownewRuntimeException("执行错误");}log.info("execnum:[{}].....",num);}};//每1秒执行一次任务scheduledExecutorService.scheduleAtFixedRate(runnable,0,1,TimeUnit.SECONDS);Thread.sleep(10000);}运行结果:只执行了5次,就不会打印或执行了,因为报错任务,没有打印一次栈,导致调度任务取消,后果很严重。解决方案解决方案也很简单,只要用trycatch捕获异常即可。运行结果:不仅打印了异常栈,还进行了周期调度。比较推荐的做法是更好的建议在自己的项目中封装一个包装类,要求所有的调度都通过我们统一的包装类提交,如下代码:@Slf4jpublicclassRunnableWrapperimplementsRunnable{//真正要执行的线程任务私有可运行任务;//线程任务创建时间privatelongcreateTime;//线程池运行线程任务时的开始时间privatelongstartTime;//线程池运行线程任务的结束时间privatelongendTime;//线程信息privateStringtaskInfo;私人布尔showWaitLog;/***执行间隔多长时间,打印日志*/privatelongdurMs=1000L;//创建这个任务的时候,会设置它的创建时间//但是之后有可能这个任务提交到线程池之后,就会进入线程池的队列,排队publicRunnableWrapper(Runnabletask,StringtaskInfo){this.task=task;this.taskInfo=taskInfo;this.createTime=System.currentTimeMillis();}publicvoidsetShowWaitLog(booleanshowWaitLog){this.showWaitLog=showWaitLog;}publicvoidsetDurMs(longdurMs){this.durMs=durMs;}//当任务在线程池中排队时,这个run方法不会被运行//但是当任务完成排队并有机会在线程池中运行时,这个方法就会被调用//此时,可以设置线程任务的开始运行时间@Overridepublicvoidrun(){this.startTime=System.currentTimeMillis();//这里可以调用监控系统的API上报监控指标//使用线程任务的startTime-createTime,其实就是任务排队时间//这个在打印日志输出的同时,也可以输出到监控系统if(showWaitLog){log.info("任务信息:[{}],任务排队时间:[{}]ms",taskInfo,startTime-createTime);}//然后就可以调用被包裹的实际任务的run方法了try{task.run();}catch(Exceptione){log.error("运行任务错误",e);扔e;}//任务运行后This.endTime=System.currentTimeMillis()会设置任务运行的结束时间;//这里可以调用监控系统的API,上报监控指标//使用线程任务的endTime-startTime,其实就是任务运行时间//这里打印任务执行时间,也可以输出到监控系统}}}用途:我们也可以在封装类中封装各种监控行为,比如本例中的打印日志执行时间等原理。有没有想过为什么任务出错会导致异常打印失败,甚至调度失败?取消?让我们从源码入手一探究竟。下面是调度任务的入口方法。//ScheduledThreadPoolExecutor#scheduleAtFixedRatepublicSc??heduledFuturescheduleAtFixedRate(Runnablecommand,longinitialDelay,longperiod,TimeUnitunit){if(command==null||unit==null)thrownewNullPointerException();}if(period<=0)thrownewIllegalArgumentException();//将执行任务和参数打包到ScheduledFutureTask对象中ScheduledFutureTasksft=newScheduledFutureTask(command,null,triggerTime(initialDelay,unit),unit.toNanos(period));RunnableScheduledFuturet=decorateTask(command,sft);sft.outerTask=t;//延迟执行delayedExecute(t);returnt;}这个方法主要做了两件事:将执行任务和参数包装成一个ScheduledFutureTask对象,调用delayedExecute方法延迟执行任务延迟或周期性任务的主要执行方式主要是将任务丢入队列,然后由工作线程获取执行())拒绝(任务);else{//将任务丢入阻塞队列super.getQueue().add(task);如果(isShutdown()&&!canRunInCurrentRunState(task.isPeriodic())&&remove(task))task.cancel(false);else//启动工作线程执行任务,或者从队列中获取任务执行ensurePrestart();}}现在任务已经在队列中了,我们来看看任务执行是怎样的,还记得之前的封装对象ScheduledFutureTask类,它的实现类是ScheduledFutureTask,它继承了Runnable类。//ScheduledFutureTask#runmethodpublicvoidrun(){//是否是周期性任务booleanperiodic=isPeriodic();如果(!canRunInCurrentRunState(定期))取消(假);//如果不是周期性任务,则调用一次下面的runelseif(!periodic)ScheduledFutureTask.super.run();//如果是周期任务,调用runAndReset方法,如果返回true,继续执行elseif(ScheduledFutureTask.super.runAndReset()){//设置下一次调度时间setNextRunTime();//重新执行调度任务reExecutePeriodic(outerTask);}}这里的关键是看ScheduledFutureTask.super.runAndReset()方法是否返回true,如果为true则继续调度。runAndReset方法也很简单,关键是如何处理异常。//FutureTask#runAndResetprotectedbooleanrunAndReset(){if(state!=NEW||!UNSAFE.compareAndSwapObject(this,runnerOffset,null,Thread.currentThread()))returnfalse;//是否继续下一次调度,默认falsebooleanran=false;ints=状态;尝试{Callablec=callable;if(c!=null&&s==NEW){try{//执行任务c.call();//如果执行成功,设置为trueran=true;//异常处理,重点}catch(Throwableex){//不会修改ran的值,最终为false,不会打印异常栈setException(ex);}}}finally{//在状态稳定之前,runner必须是非空的//以防止并发调用run()runner=null;//在清零运行器后必须重新读取状态以防止//泄漏中断s=state;如果(s>=中断)handlePossibleCancellationInterrupt(s);}//返回结果returnran&&s==NEW;}关键是ran变量,最后返回下一次是否继续调度执行。如果抛出异常,可以看到ran不会被修改为true。总结Java如果ScheduledThreadPoolExecutor线程池调度的任务抛出异常,没有捕获异常直接抛给框架,ScheduledThreadPoolExecutor调度任务不会被调度。希望大家一定要记住这个结论,不然会很混乱。关键是有时候测试环境和开发环境无法复现,有一定的随机性,到了生产就真的完了。对于这些知识点,我们不仅要知其然,更要知其所以然,这样才能牢牢记住,否则很容易遗忘。