ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java定时器原理与实战:从Timer到ScheduledExecutorService的选型与避坑

Java定时器原理与实战:从Timer到ScheduledExecutorService的选型与避坑 JavaSE阶段学多线程绕不开定时器。定时器表面上是“按时间触发任务”本质上却是多线程编程的浓缩战场任务调度、线程生命周期、并发状态共享、异常隔离全都能在这么一个小点上体现出来。这篇分享不打算讲分布式调度框架只讲JavaSE里最常用也最容易翻车的两个方案Timer/ScheduledExecutorService顺便把底层原理、实操细节、常见坑一次性说透。适合刚学到多线程、想动手写定时任务的初中级Java开发也适合正在准备面试的朋友用来串知识点。1. 先从需求说起定时器到底解决什么问题1.1 一个最朴素的定时方案为什么不行刚开始学Java的人十有八九会这么写定时任务while (true) { Thread.sleep(1000); System.out.println(每秒执行一次); }这段代码确实能“定时”但稍微往深处想一下就发现问题很多主线程被sleep死死占住其他逻辑没法并行如果任务本身执行超过1秒下一次任务会立即补偿执行还是跳过完全不可控最终程序只能靠强制kill停掉没有任何优雅取消的手段。说白了这不是定时器是一个阻塞循环。真实的定时任务需求其实可以拆成四个维度延迟执行任务不是立即跑而是等到指定时间再触发。周期执行任务跑完一次后隔一段固定时间再跑下一次一直持续。并发隔离多个定时任务之间不能互相影响某个任务卡住其他任务不能跟着遭殃。生命周期管理能取消任务、能优雅关闭调度器进程退出时不会留下悬空的线程。Timer和ScheduledExecutorService之所以能成为JavaSE里的经典方案就是因为它们在语言层面把上面这些需求做成了现成的API。新手往往只记得“new Timer().schedule(task, 1000)”这行代码却没意识到背后有一个完整的调度线程、任务队列和时间计算逻辑。1.2 不要一上来就选型先理解单线程与多线程的差异选型之前要先明白一个关键差异Timer是单线程调度器ScheduledExecutorService是多线程调度器。这句话在面试里能展开一大篇在实际项目里更是决定生死。Timer内部只有一个TimerThread所有TimerTask都由这个线程串行执行。好处是简单、不会出现并发竞争坏处也很致命如果某个任务执行时间过长后面排队的任务全都会被延迟更糟的是任务一旦抛出未检查异常这个唯一的工作线程会直接退出整个定时器从此“哑火”后续任务永远不再执行。ScheduledExecutorService底层基于线程池核心线程数可以配置为多个。多个定时任务可以并行执行一个任务抛出异常只会影响当前这个线程的这次执行调度线程本身不会死掉。从Java 5开始这个类就是官方推荐替换Timer的替代方案Timer到现在仍然是教学和面试题常客但工程上已经很少直接用了。选型逻辑其实可以归纳成一句话任务少、执行快、互相独立用Timer图省事任务多、执行慢、要求稳定必须用ScheduledExecutorService。我在实际项目中见过用Timer做心跳上报导致整个服务假死的情况问题不在Timer这个类本身而在于使用的人没意识到“所有任务共享一个线程”这个前提。2. Timer/TimerTask源码级拆解它到底怎么工作2.1 三个核心成员TimerTask、TaskQueue、TimerThread很多人用Timer多年只知道schedule(task, delay)却不知道它背后是三个角色在协作。看一遍源码能记住一辈子。TimerTask是一个抽象类实现了Runnable接口。它内部维护了一个状态字段用来表示任务当前处于什么阶段VIRGIN任务刚创建还没被调度。SCHEDULED已经被安排进队列等待执行。EXECUTED已经执行完成。CANCELLED被取消了。每次调用schedule都会检查任务状态是不是VIRGIN如果不是就抛出IllegalStateException。所以同一个TimerTask只能被调度一次想重复执行必须再new一个任务对象。TaskQueue是一个用数组实现的小顶堆binary heap按照任务的nextExecutionTime从小到大排序队首永远是下一次执行时间最早的任务。这里的思想和后续要讲的延迟队列是一脉相承的都是为了在新增任务、取出任务时保持O(log n)的复杂度。TimerThread是Timer内部创建的工作线程它的run方法里有一个死循环从TaskQueue取出队首任务。如果任务还没到执行时间就调用wait等待。如果时间到了把任务从队列移除并执行task.run()。如果队列变空了继续wait等待新任务被添加。值得注意的一个细节是TimerThread在创建时可以被指定为守护线程。new Timer()默认是非守护线程这就意味着如果某个类创建了Timer却没有显式cancel即使所有业务线程都结束了JVM进程也无法退出。以前我在一个桌面程序里踩过这个坑关掉主窗口进程还在后台挂着查了半天才发现是Timer没cancel。看一个最小Demopublic class TimerDemo { public static void main(String[] args) throws InterruptedException { Timer timer new Timer(MyTimer, true); // 第二个参数指定为守护线程 TimerTask task new TimerTask() { Override public void run() { System.out.println(任务执行时间 System.currentTimeMillis()); } }; timer.schedule(task, 1000, 2000); // 1秒后首次执行之后每2秒执行一次 Thread.sleep(5000); timer.cancel(); System.out.println(定时器已取消); } }这里把TimerThread设为守护线程是刻意为之只是为了demo演示时进程能正常退出。真实项目中我反而建议认真思考生命周期不要随手设daemon。2.2 schedule、scheduleAtFixedRate的区别到底在哪Timer类里有几个schedule重载方法面试问“Timer和ScheduledExecutorService的区别”之前通常还会先问“schedule和scheduleAtFixedRate有什么区别”。这两个方法的差异非常subtle但理解透了才能选对。假设任务第一次执行时间是01:00每次耗时为30秒。schedule方法按“固定延迟”执行它会在上一次任务执行完成后再等待period时间所以时间轴是这样的任务开始01:00 任务结束01:00:30 下一次开始01:02:00结束时间 2分钟period 如果任务延误后续全部顺延scheduleAtFixedRate方法按“固定频率”执行它以上一次任务开始时间为基准加上period来安排下一次时间轴是这样的任务开始01:00 任务结束01:00:30 下一次开始01:02:00开始时间 2分钟period 但如果任务在01:03才结束原定01:02的那次已经错过会紧接着立刻补偿执行一次用大白话说schedule是“我干完活歇够两分钟再干下一次”scheduleAtFixedRate是“我按表打卡这次晚了但下次依然按原计划来”。补偿机制看似贴心实际使用中很容易引发“雪崩式补任务”。我举个例子你在做数据同步每天凌晨1点跑一次全量任务单次耗时可能超过20分钟。如果使用scheduleAtFixedRate任务只要跑慢了即使你设定的周期是一天它也可能在一天内连续补跑好几次最糟糕的情况下服务重启后直接把数据库压垮。反之如果只是想做一个固定间隔的轮询比如每5秒检查一次队列用schedule就够了下一次执行严格以上一次结束时间往后推。2.3 Timer的经典陷阱一个任务拖垮全部Timer最大的设计缺陷概括起来就是两个词串行和无异常隔离。串行意味着所有TimerTask都跑在一个线程里。假设你往同一个Timer里塞了两个任务任务A执行需要10秒任务B设定的是每2秒执行一次那么在这10秒内任务B一次都跑不了。更难受的是如果任务A是个死循环任务B就永远没机会跑了。无异常隔离意味着如果某个任务在run方法里抛出了RuntimeExceptionTimerThread会被杀掉Timer变成僵尸。代码示例如下Timer timer new Timer(); timer.schedule(new TimerTask() { Override public void run() { throw new RuntimeException(boom); } }, 1000); timer.schedule(new TimerTask() { Override public void run() { System.out.println(我还能执行吗); } }, 3000);运行这段代码第二个任务的输出永远不会出现因为TimerThread在第一次执行时已经退出。关键是JVM进程不会崩溃只是什么日志都没有静态代码扫描也发现不了非常隐蔽。这也是我在文章开头说“Timer是教学和面试题常客工程上慎用”的原因。3. 多线程定时任务的正确姿势ScheduledExecutorService3.1 为什么线程池能让定时任务“活”起来Java 5之后JUC包提供ScheduledExecutorService它的核心实现类是ScheduledThreadPoolExecutor。初次接触时可以把它理解为“线程池 延迟队列”的组合。线程池提供多个工作线程让不同任务可以并发执行。延迟队列负责按时间优先级调度工作线程空闲时会从队列头拿到期任务没到期就阻塞等待。内核线程大小的配置直接影响定时任务并行度ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2);我建议每个不同类型的任务使用独立的ScheduledExecutorService而不是所有任务共用一个线程池。原因很简单如果核心线程数是2一个运行10分钟的重任务会把其中一个线程占满另一个线程还要处理其他任务一旦任务总数大于线程数轻量任务依然会被重量任务拖累。把不同业务属性的任务拆到不同调度器里可以做到真正的故障隔离。3.2 scheduleAtFixedRate与scheduleWithFixedDelay的代码对比ScheduledExecutorService提供了两个最常用的周期调度方法scheduleAtFixedRate(command, initialDelay, period, unit)固定频率调度和Timer的scheduleAtFixedRate思想一致基于任务开始时间计算下一次执行。scheduleWithFixedDelay(command, initialDelay, delay, unit)固定延迟调度基于上一次任务结束时间计算下一次执行。看两段代码感受一下差别public class ScheduleRateVsDelay { public static void main(String[] args) throws InterruptedException { ScheduledExecutorService scheduler Executors.newScheduledThreadPool(2); // 固定频率每2秒触发一次如果任务耗时超过2秒可能马上补偿执行 scheduler.scheduleAtFixedRate(() - { try { System.out.printf(FixedRate开始 %s%n, System.currentTimeMillis()); Thread.sleep(3000); // 模拟耗时3秒 System.out.printf(FixedRate结束 %s%n, System.currentTimeMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, 0, 2, TimeUnit.SECONDS); Thread.sleep(10000); scheduler.shutdownNow(); } }这段代码里任务耗时3秒周期却是2秒。实际输出会看到任务连续执行中间几乎没有间隔因为上一次任务还没结束下一次触发时间就已经到了线程池会在上一次任务结束后立即补偿跑一次。换成scheduleWithFixedDelayscheduler.scheduleWithFixedDelay(() - { try { System.out.printf(FixedDelay开始 %s%n, System.currentTimeMillis()); Thread.sleep(3000); System.out.printf(FixedDelay结束 %s%n, System.currentTimeMillis()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }, 0, 2, TimeUnit.SECONDS);这次输出会是任务结束时间 2秒后才开始下一次任务。整个执行时间轴是“3秒干活 2秒休息”节奏非常固定。我个人的经验是凡是涉及外部IO轮询场景优先用scheduleWithFixedDelay因为它天然避免了任务还没结束就重复触发的问题只有当你非常确定每次执行耗时都远小于周期时才考虑scheduleAtFixedRate。3.3 提交任务时几个关键参数的思考初次使用ScheduledExecutorService的人常把initialDelay、period、delay这三个时间参数搞混。initialDelay首次延迟多久执行给系统一个预热时间。比如监听外部配置变更可以设置初始延迟5秒等Spring容器完全初始化完成再开始轮询。periodscheduleAtFixedRate里的周期从上一次任务“开始时间”算起。delayscheduleWithFixedDelay里的间隔从上一次任务“结束时间”算起。有个很常见的场景需要自己去算一遍业务要求“每秒拉取一次上游状态”但拉取操作本身可能要花500到800毫秒。如果选择scheduleAtFixedRateperiod设为1秒那么任务执行时间会被强行压缩到1秒内一旦上游慢到1.2秒就会出现“上一次还没结束下一次紧接着补跑”的连环触发。如果选择scheduleWithFixedDelaydelay设为200毫秒就能确保两次拉取之间至少有200毫秒的喘息整体压力更平滑。参数计算没有绝对标准但有一个建议周期任务的参数不要只看业务字面需求一定要把任务最大执行时间、抖动余量一起估算进去。给delay或period留出至少20%的冗余在负载升高时不会立刻变成灾难。4. 实操落地从Demo到项目里的定时任务4.1 一个干净的定时任务管理工具类怎么写即使不做分布式定时调度本地定时任务也该有个统一的管理入口。我习惯写一个小工具类把提交、取消、优雅关闭都封装在一起public class TaskScheduler { private final ScheduledExecutorService executor; public TaskScheduler(int corePoolSize, String threadNamePrefix) { ThreadFactory factory new ThreadFactory() { private final AtomicInteger seq new AtomicInteger(); Override public Thread newThread(Runnable r) { Thread t new Thread(r, threadNamePrefix - seq.incrementAndGet()); t.setDaemon(true); // 视业务情况决定 return t; } }; this.executor Executors.newScheduledThreadPool(corePoolSize, factory); } public ScheduledFuture? scheduleAtFixedRate(Runnable task, long initialDelay, long period, TimeUnit unit) { return executor.scheduleAtFixedRate(wrap(task), initialDelay, period, unit); } public ScheduledFuture? scheduleWithFixedDelay(Runnable task, long initialDelay, long delay, TimeUnit unit) { return executor.scheduleWithFixedDelay(wrap(task), initialDelay, delay, unit); } public void shutdownGracefully(long timeout, TimeUnit unit) throws InterruptedException { executor.shutdown(); if (!executor.awaitTermination(timeout, unit)) { executor.shutdownNow(); } } private Runnable wrap(Runnable task) { return () - { try { task.run(); } catch (Throwable t) { // 统一记录异常不让线程池静默吞掉错误 System.err.println(定时任务异常 t.getMessage()); } }; } }这个工具类干了三件重要的事一是给线程起了有业务含义的名字排查问题时jstack里一眼就能看出来二是把任务包了一层try-catch防止单个任务的异常直接终结线程三是提供了graceful shutdown关停时预留时间处理正在执行的任务。4.2 优雅关闭和守护线程的取舍关于关闭定时线程池很多初学者只记得一个shutdown()但shutdown只是拒绝新任务已提交任务还会继续执行。如果希望等待正在执行的任务跑完再接退出必须配合awaitTermination使用scheduler.shutdown(); // 不再接受新任务 scheduler.awaitTermination(30, TimeUnit.SECONDS); // 等待已有任务完成 scheduler.shutdownNow(); // 30秒还没完成强制打断在Spring等框架环境里应用关闭时会调用DisposableBean或PreDestroy方法这一步必须做好。否则定时线程池可能让JVM进程迟迟无法退出严重时还会在开发环境把端口一直占着。守护线程怎么取舍我的建议是如果定时任务是应用内的一次性辅助任务比如临时统计、短时监控设daemontrue避免拖住进程退出。如果定时任务是应用核心功能的一部分比如消息队列消费者、心跳上报默认非守护线程反而更安全因为它能确保任务在进程退出前有机会执行完成不会被JVM强制中断。不要一概而论地“全部设daemon”要根据任务性质来。4.3 任务耗时监控与线程隔离定时任务跑得久了“慢”和“挂”是两回事。我建议在包装层里加入耗时统计用最朴素的System.currentTimeMillis()就可以不要引入重量级监控long start System.currentTimeMillis(); try { task.run(); } finally { long cost System.currentTimeMillis() - start; if (cost 5000) { System.err.printf(任务执行超过5秒耗时%d ms%n, cost); } }这里的阈值根据业务调整但作用是实实在在的你可以在问题发生之前就发现某个任务在恶化。线程隔离的原则也值得单独说。定时任务里如果还要并行处理一批子任务不要复用调度线程池而是再创建一个普通线程池来跑子任务避免调度线程被长期占用。比如定时器每5分钟触发一次批处理批处理内部需要并发请求多个接口这时候可以让调度线程只负责任务启动真正的活交给另一个Executors.newFixedThreadPool去干。5. 常见问题排查与面试高频考点5.1 定时任务“没跑”或者“跑了一次就不跑了”这是项目里最常遇到的诡异问题。按照经验我建议按以下顺序排查确认定时器是否还活着线程池是否被shutdown了排查有没有代码调用了shutdown或shutdownNow很多服务在重启或热加载时误关了调度器。确认任务是否抛异常ScheduledThreadPoolExecutor在任务抛出异常后会影响这个任务的下一次调度但如果单独用execute提交异常会被线程池的UncaughtExceptionHandler处理。使用schedule方法时需要检查ScheduledFuture的get方法get会抛出ExecutionException拿到具体异常。确认任务是否被阻塞长时间不执行不一定调度器死了可能是线程池所有线程都被别的长任务占满。用jstack查看线程栈重点看“pool-*-thread”在跑什么。确认时间单位是否弄错period是数字和TimeUnit不匹配比如把1秒写成了1分钟这类肉眼排查确实很费劲。5.2 理解ScheduledThreadPoolExecutor的调度流程ScheduledThreadPoolExecutor的核心是一个延迟队列DelayedWorkQueue队列的元素是ScheduledFutureTask它实现了Delayed接口。工作机制大体是提交任务时按照任务下一次执行时间排序放进队列队首是最近的待执行任务。工作线程循环从队列头部take元素take会阻塞直到队首任务的delay时间到了。任务执行完后如果是周期任务会重新计算下次执行时间再次放回队列。这里就牵扯出一个高频面试题tick算法和任务的“漂移”。ScheduledThreadPoolExecutor在计算固定频率任务的nextExecutionTime时用的是“上次执行开始时间 period”。如果任务执行时间超过period看似会马上补偿执行但补偿机制并不会为了追上原计划而批量积压多个任务它只会把下一次执行时间设为“当前时间”保证执行顺序正确但节奏被打乱。这也是我前面建议优先用scheduleWithFixedDelay的原因之一因为它在任务耗时波动时能天然维持稳定节奏。5.3 题库里常出现的几个追问追问一Timer和ScheduledExecutorService的区别核心差异在单线程/多线程、异常处理、关闭方式。Timer一个工作任务异常会让唯一调度线程退出ScheduledExecutorService理论上线程池线程挂了可以自动补新线程任务异常不会终止整个调度器。追问二scheduleAtFixedRate和scheduleWithFixedDelay的区别一句话前者以上次开始时间为基准后者以上次结束时间为基准。前者会补偿执行后者不会。追问三如果要实现高精度的定时调度有什么改进思路可以从时间轮HashedWheelTimer、延迟队列、堆结构这几个方向去答。最低限度要能说出TimerTaskQueue是二叉堆ScheduledThreadPoolExecutor是延迟队列。追问四定时任务如何做分布式这是JavaSE阶段之外的问题思路方向包括DB锁、ZK/etcd选主、分布式调度框架。JavaSE里的本地定时器只能保证单机内调度多实例部署时必须考虑重复执行问题本地定时器只适合做辅助任务。5.4 快速参数参考表参数/概念含义常见注意点initialDelay首次延迟时间应用启动后需要预热建议不要设为0period固定频率周期基于上次开始时间任务超时会补偿delay固定延迟间隔基于上次结束时间节奏稳定corePoolSize调度线程核心数不同业务任务用不同线程池隔离daemon是否为守护线程涉及JVM退出和任务可靠性我在实际项目中的体会是定时器学起来十分钟踩坑踩一年。很多人觉得“定时任务嘛到点执行就行”结果线上出现“任务偶尔不跑”“任务重复跑”的时候才意识到真正复杂的不是触发而是任务状态、执行耗时、异常处理和生命周期管理。最后分享一个小技巧给每个定时任务都加一个任务ID日志里统一带上taskId排查问题时能把“哪个任务没跑”和“为什么没跑”在几分钟内定位清楚这比事后翻代码省力太多。
返回列表