Java定时器 Timer 和 ScheduledExecutorService 有什么区别

Java定时器 Timer 和 ScheduledExecutorService 有什么区别

Java定时器 Timer 和 ScheduledExecutorService 有什么区别

Java 中需要“过一会儿执行”或“周期执行”时,常见选择是 java.util.Timer 和 ScheduledExecutorService。二者都能安排延迟任务与周期任务,但线程模型、异常影响范围、时间语义和扩展能力并不相同。

简单说:Timer 适合已有代码中的轻量、单线程定时任务;新代码通常优先考虑 ScheduledExecutorService。后者支持线程池、任务句柄和更灵活的生命周期管理。不过,它不会自动保证准点执行,也不会替应用解决任务堆积、异常告警或资源限制问题。

一、核心区别一览

比较项TimerScheduledExecutorService
线程模型每个 Timer 使用一个后台线程串行执行任务通常由线程池执行;可配置核心线程数
一个任务变慢阻塞同一个 Timer 上所有后续任务可能占用一个工作线程;线程池中其他线程仍可执行任务
任务抛出未捕获异常通常导致 Timer 线程退出,后续任务无法继续执行周期任务停止;其他任务一般不因此全部停止
周期调度提供固定延迟和固定频率两种形式提供固定延迟和固定频率两种形式
取消与结果通过 TimerTask.cancel() 取消;不提供 Future 结果通过 ScheduledFuture 取消、查询状态或取得一次性任务结果
配置能力线程数固定为一个,能力较简单可配置线程工厂、线程数、关闭策略等

这里的“固定频率”和“固定延迟”是理解两类 API 的关键。它们描述的是计划时间的关系,不是实时系统意义上的准点承诺。线程繁忙、操作系统调度、垃圾回收、进程暂停等因素都可能造成延迟。

二、Timer 的工作方式与边界

Timer 内部由一个线程按计划取出任务并执行。该线程不会并行执行同一个 Timer 内的多个任务;一个任务运行期间,队列中其他任务只能等待。

因此,Timer 适合任务短小、相互串行且对隔离性要求不高的场景。若某项工作可能发起网络请求、等待锁、访问慢磁盘,或耗时不可预测,把它放进共享 Timer 可能拖延所有其他任务。

1. 固定延迟与固定频率

  • schedule(task, delay, period):固定延迟。首次执行在指定延迟之后,之后通常在上一次执行结束后再等待一个周期。
  • scheduleAtFixedRate(task, delay, period):固定频率。计划时间按初始时间加上周期递增;如果任务执行太久而错过计划点,Timer 可能连续执行补赶任务。它仍然是串行执行,不会并发重叠执行同一任务。

假设周期为 1 秒、某次任务花了 3 秒。固定延迟会在这次任务结束后再等约 1 秒;固定频率则会尝试追赶既定的计划时间。追赶不代表能恢复已经错过的业务事件,也可能让耗时任务连续运行,增加系统负载。对于轮询或维护任务,固定延迟往往更符合“做完后休息一段时间”的预期。

2. 异常可能让整个 Timer 停摆

Timer 的任务在 Timer 线程中执行。若 TimerTask.run() 抛出未捕获的运行时异常,Timer 线程会终止,队列中的其他任务也不会再被执行。排查时常见现象是:某个任务报错后,其他定时任务似乎“悄悄不跑了”。

不要为了让程序继续运行而无差别吞掉异常。应当记录异常、上报必要的上下文,并明确判断该任务是否可以安全重试。对于可恢复的单次故障,可以在任务边界捕获预期异常;程序错误则应保留足够日志和告警信息。

3. Timer 使用墙上时钟

Timer 的调度依赖系统当前时间。系统时间被校准、手动修改或发生跳变时,定时任务的实际等待可能受到影响。需要度量经过时间、希望不受日历时间调整影响时,不应把 Timer 当作单调计时器。

另外,默认构造的 Timer() 会创建非守护线程。即使主线程结束,该线程仍可能让 JVM 继续运行。若确实需要守护线程,可使用 new Timer(true),但守护线程只影响 JVM 退出行为,不等于任务可靠执行:JVM 退出时,守护线程中的任务可能被直接中止。

三、ScheduledExecutorService 的工作方式与边界

ScheduledExecutorService 是执行器接口,常见实现是 ScheduledThreadPoolExecutor。它能安排一次性延迟任务,也能安排周期任务。一个线程池可以让不同任务并行执行,同时避免由单个慢任务阻塞全部任务。

对于同一个周期任务,后一次执行不会与前一次重叠:固定频率任务若执行超过周期,后续执行会延迟;固定延迟任务则在前一次结束后再等待指定间隔。增加线程数能提高不同任务之间的并发能力,不能让同一个周期任务并发重叠。

1. 固定频率与固定延迟

  • scheduleAtFixedRate:按计划频率安排执行。任务运行时间超过周期时,后续执行会延后,不会并发运行同一个周期任务。
  • scheduleWithFixedDelay:每次执行结束后,再等待指定时长才开始下一次执行。

实际选择应对应业务含义。比如每分钟检查一次状态,若要求“上一轮完成一分钟后再查”,使用固定延迟;若要求尽量维持每分钟的计划节奏,使用固定频率,并处理任务变慢时的延迟和积压风险。

相对延迟任务通常基于经过时间来计算,而非要求在某个日历时刻绝对准点触发。需要“每天当地时间 9 点执行”的业务,还要考虑时区、夏令时、进程重启和补偿策略;通常应计算下一次触发时间,或使用具备持久化与集群协调能力的调度系统。

2. 异常不会自动重跑周期任务

如果周期任务的一次执行抛出异常,后续执行会被抑制,该周期任务不再继续。其他独立任务通常仍可运行。任务的异常状态可以从其 ScheduledFuture 中检查;一次性任务的异常也会保存在 Future 中。

有一个容易误解的细节:通过 submit 或调度 API 执行的任务,异常常常被任务句柄记录,而不是直接打印到调用线程。若只看控制台没有堆栈,不代表任务没有失败。应用应主动检查 Future,或在任务代码中做结构化日志与错误上报。

3. 线程池大小不是所有资源限制

ScheduledThreadPoolExecutor 使用延迟队列。它的核心线程数决定可同时处理任务的工作线程数量;将 maximumPoolSize 调大通常不能解决定时任务队列无限增长的问题,因为该执行器主要由核心线程数和延迟队列共同决定调度能力。

定时任务队列一般没有容量上限。若业务允许用户提交大量任务,或不断创建新的周期任务,必须在应用层限制任务数量、频率和生命周期,必要时采用持久化调度或外部队列。线程池不是任务数量的限流器。

四、参数限制:哪些能传,哪些不行

不同 API 对参数的约束不完全相同,不能把 Timer 的规则照搬到执行器 API。

API延迟参数周期或间隔
Timer.schedule(task, delay)毫秒数不能为负不适用
Timer.schedule(task, delay, period)不能为负必须大于零
Timer.scheduleAtFixedRate(task, delay, period)不能为负必须大于零
Timer.schedule(task, Date)过去的日期表示尽快执行,不是非法日期不适用
ScheduledExecutorService.schedule负延迟按立即执行处理;零延迟也表示可尽快执行不适用
scheduleAtFixedRate初始延迟可为负数或零,表示尽快开始周期必须大于零
scheduleWithFixedDelay初始延迟可为负数或零,表示尽快开始间隔必须大于零

执行器的调度参数单位由 TimeUnit 指定,例如秒、毫秒或纳秒;Timer 的相关参数通常以毫秒表示。传入非常大的数值时,不要假设系统能准确等待那么久或保证目标时间一定可达。应依据业务允许范围做校验,并测试使用的 Java 版本与执行环境。

无论采用哪种 API,周期都不能是零或负数。不要用“周期为零”表示尽可能快地重复执行;这既会被 API 拒绝,也不是合理的定时任务设计。需要持续消费工作时,应使用有背压或限流设计的工作队列,而不是无间隔地调度。

五、可运行示例:固定延迟与优雅关闭

下面是完整 Java 示例,使用固定延迟周期任务,并通过 shutdown 和 awaitTermination 等待任务结束。保存为 SchedulerDemo.java,用 JDK 编译运行即可。

import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class SchedulerDemo {
    public static void main(String[] args) throws InterruptedException {
        ScheduledExecutorService scheduler =
                Executors.newScheduledThreadPool(2);

        Runnable task = () -> {
            long start = System.currentTimeMillis();
            System.out.println("开始执行:" + start
                    + ",线程:" + Thread.currentThread().getName());

            try {
                // 模拟任务耗时
                Thread.sleep(1500);
            } catch (InterruptedException e) {
                // 响应中断,并让调用方知道线程已被要求停止
                Thread.currentThread().interrupt();
                System.out.println("任务收到中断");
                return;
            }

            System.out.println("执行结束:"
                    + System.currentTimeMillis());
        };

        // 首次延迟 1 秒;每次任务结束后再等待 2 秒
        scheduler.scheduleWithFixedDelay(
                task, 1, 2, TimeUnit.SECONDS);

        try {
            Thread.sleep(8000);
        } finally {
            scheduler.shutdown();

            if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
                // 尝试中断正在运行的任务,并取消尚未开始的任务
                scheduler.shutdownNow();

                if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) {
                    System.err.println("执行器未能及时终止");
                }
            }
        }
    }
}

如果任务不响应中断,shutdownNow() 也不能强制杀死正在运行的 Java 代码。它主要是向工作线程发出中断请求,并返回尚未开始的任务。任务实现应避免长时间忽略中断。

六、可运行示例:异常如何影响周期任务

下面的示例让周期任务第一次执行时抛出异常。后续不会继续执行,Future 会记录该任务已异常完成。示例也说明了为什么不能只依赖控制台观察任务状态。

import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class PeriodicFailureDemo {
    public static void main(String[] args) throws Exception {
        ScheduledExecutorService scheduler =
                Executors.newSingleThreadScheduledExecutor();

        try {
            ScheduledFuture<?> future =
                    scheduler.scheduleAtFixedRate(
                            new Runnable() {
                                private int count = 0;

                                @Override
                                public void run() {
                                    System.out.println("执行次数:" + (++count));
                                    if (count == 1) {
                                        throw new IllegalStateException(
                                                "模拟任务失败");
                                    }
                                }
                            },
                            0, 1, TimeUnit.SECONDS);

            Thread.sleep(1500);

            System.out.println("任务是否已结束:" + future.isDone());
            System.out.println("任务是否被取消:" + future.isCancelled());

            try {
                // 周期任务正常运行时,此调用会一直等待;
                // 本例中异常已发生,因此会抛出 ExecutionException。
                future.get();
            } catch (java.util.concurrent.ExecutionException e) {
                System.err.println("定时任务失败:"
                        + e.getCause());
            }
        } finally {
            scheduler.shutdownNow();
        }
    }
}

如果希望周期任务在可恢复异常后继续运行,可以在任务体内部捕获并处理明确可恢复的异常,同时记录失败。不要把所有 Throwable 都吞掉:错误可能表明程序处于不可预期状态,盲目继续会掩盖问题。

七、Timer 示例:简单但要注意线程

Timer 仍然适用于一些简单场景。下面的示例使用固定延迟,并在结束时取消 Timer。默认构造的 Timer 是非守护线程,因此显式取消可以避免它继续阻止 JVM 退出。

import java.util.Timer;
import java.util.TimerTask;

public class TimerDemo {
    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();

        TimerTask task = new TimerTask() {
            @Override
            public void run() {
                System.out.println("Timer 执行:"
                        + System.currentTimeMillis());
            }
        };

        // 首次延迟 1 秒;之后每 2 秒执行一次
        timer.schedule(task, 1000, 2000);

        try {
            Thread.sleep(5500);
        } finally {
            // cancel Timer 后不再接收新任务,并终止 Timer 线程
            timer.cancel();
        }
    }
}

TimerTask 是一次性对象:被安排执行或取消后,不能重新提交到另一个调度周期中。需要重新安排时应创建新的 TimerTask。调用 TimerTask.cancel() 取消单个任务;调用 Timer.cancel() 则取消整个 Timer 上尚未执行的任务并终止其线程。取消后若希望清理队列中的已取消任务,可在适用场景调用 purge()。

八、ScheduledThreadPoolExecutor 的取消与关闭细节

调度任务返回的 ScheduledFuture 可以取消。传入 true 表示允许对正在运行的任务发出中断请求,但任务是否停止取决于它是否响应中断。对于已经执行完成的一次性任务,取消不会撤销已经产生的副作用。

ScheduledThreadPoolExecutor 默认可能保留已取消、但原本尚未到期的延迟任务,直到其延迟时间过去。短延迟或任务量很大时,这可能占用不必要的内存。若确有需要,可在创建执行器后启用取消时移除任务:

import java.util.concurrent.ScheduledThreadPoolExecutor;

ScheduledThreadPoolExecutor scheduler =
        new ScheduledThreadPoolExecutor(2);

// 取消任务时将其从延迟队列移除
scheduler.setRemoveOnCancelPolicy(true);

不要因为这一设置就放松任务数量管理:正在运行的任务不会因从队列移除而终止,未取消的任务也仍会留在队列中。

关闭策略也值得检查。ScheduledThreadPoolExecutor 在调用 shutdown() 后,默认允许已经安排的一次性延迟任务继续执行,但不继续执行周期任务。应用可根据自身要求调用对应的策略设置方法;无论如何,都应在服务关闭时明确处理执行器,而不是依赖进程退出来清理。

九、常见错误与排查方法

错误一:用固定频率假设任务永远准点

固定频率只表达计划节奏,不保证实时性。任务变慢或系统繁忙时,执行会延迟。先明确需求究竟是“固定间隔”还是“按周期节拍”,再选择固定延迟或固定频率,并监控实际延迟与执行耗时。

错误二:在 Timer 中执行慢任务

一个慢任务会占住 Timer 唯一的工作线程。将网络调用、不可控锁等待或长时间处理放在 Timer 上,可能使所有任务排队。可以改用 ScheduledExecutorService,并为慢任务与短任务设置合适的执行器;不要把线程池大小设得很大就认为问题已经解决,还要控制任务并发和下游容量。

错误三:只捕获异常,不记录失败

空的 catch 会让任务表面上继续运行,却丢失故障信号。至少应记录任务标识、时间、异常和必要的业务上下文;可恢复错误还应有明确的重试次数、退避策略和幂等保障。

错误四:忘记关闭执行器

由执行器创建的线程通常不是守护线程。应用、测试或组件结束时,应调用 shutdown() 并在合理时间内等待;只有在确实需要中断未完成任务时,再使用 shutdownNow()。不要在共享执行器尚有其他使用者时随意关闭它。

错误五:重复注册周期任务

每次调用调度方法都会创建一个新的任务安排。若初始化逻辑重复执行而未取消旧任务,可能出现重复扣费、重复轮询或负载逐渐升高。保存任务句柄,保证注册操作幂等,并在组件停止时取消对应任务。

错误六:把内存定时器当成可靠任务系统

Timer 和 ScheduledExecutorService 的任务状态都在当前进程内。进程崩溃、机器重启或部署替换后,尚未执行的任务通常不会自动恢复。涉及订单到期、支付补偿、通知投递等不能丢失的业务,应使用持久化记录、可恢复的工作队列或专门的分布式调度方案,并设计去重和幂等处理。

十、参数校验与安全使用

定时 API 本身不是安全边界,也不提供身份认证、授权、审计、持久化或防重放能力。一个调度器不会因为使用了线程池就自动变得安全;守护线程也不是安全隔离机制。权限判断应由业务服务在任务创建和执行环节完成。

如果延迟、周期或任务数量来自请求参数、配置中心或其他不可信来源,应设置业务允许范围并校验边界:例如拒绝过短周期、限制单用户任务数量、限制最大排队量,避免有人创建大量高频任务造成资源耗尽。对执行外部命令、访问文件或调用远端服务的任务,应单独实施权限控制、超时和输入校验。

还应避免把敏感信息直接写入任务日志或异常消息。若任务的取消、重试或补偿可能重复产生副作用,应使用幂等键、状态机或去重机制。调度只负责“何时尝试运行”,业务正确性仍由应用设计保证。

十一、如何选择

  • 新项目中的普通延迟任务或周期任务:优先使用 ScheduledExecutorService,便于管理线程、取消任务和观察任务状态。
  • 任务彼此独立且需要有限并发:使用合适大小的调度线程池,并评估任务队列增长、下游并发承受能力与关闭流程。
  • 任务应在前一次完成后再等待一段时间:使用 scheduleWithFixedDelay,或 Timer 的固定延迟形式。
  • 需要尽量维持固定计划节奏:使用固定频率 API,并准备处理延迟、任务失败和超时;不要假设任务会并发追赶。
  • 已有简单 Timer 代码且确实只需要单线程串行调度:可继续使用,但要处理异常、取消任务并关闭 Timer。
  • 需要进程重启后恢复、集群协调或严格的日历调度:内存中的 Timer 和执行器都不够,应选择持久化调度方案。

归根结底,Timer 与 ScheduledExecutorService 的差别不只是“旧 API 和新 API”。Timer 把所有任务放在单一线程中,简单但故障与阻塞影响面大;ScheduledExecutorService 提供更灵活的执行与管理能力,但任务异常、资源上限、重启恢复和业务幂等仍需要应用自己设计。先明确周期语义、失败策略和生命周期,再选择工具,才能让定时任务可预测、可观察、可维护。

© 版权声明

相关文章