Java 定时器 Timer 源码分析和使用建议「终于解决」

Java (51) 2023-08-13 09:12

Hi,大家好,我是编程小6,很荣幸遇见你,我把这些年在开发过程中遇到的问题或想法写出来,今天说一说Java 定时器 Timer 源码分析和使用建议「终于解决」,希望能够帮助你!!!。

Timer 定时器相信都不会陌生,之所以拿它来做源码分析,是发现整个控制流程可以体现很多有意思的东西。

在业务开发中经常会遇到执行一些简单定时任务的需求,通常为了避免做一些看起来复杂的控制逻辑,一般考虑使用 Timer 来实现定时任务的执行,下面先给出一个最简单用法的例子:

Timer timer = new Timer();
TimerTask timerTask = new TimerTask() {
    @Override
    public void run() {
        // scheduledExecutionTime() 返回此任务最近开始执行的时间
        Date date = new Date(this.scheduledExecutionTime());
        System.out.println("timeTask run " + date);
    }
};

// 从现在开始每间隔 1000 ms 计划执行一个任务
timer.schedule(timerTask, 0, 1000);

Timer 概述

Timer 可以按计划执行重复的任务或者定时执行指定任务,这是因为 Timer 内部利用了一个后台线程 TimerThread 有计划地执行指定任务。

  • **Timer:**是一个实用工具类,该类用来调度一个线程(schedule a thread),使它可以在将来某一时刻执行。 Java 的 Timer 类可以调度一个任务运行一次或定期循环运行。 Timer tasks should complete quickly. 即定时器中的操作要尽可能花费短的时间。

  • **TimerTask:**一个抽象类,它实现了 Runnable 接口。我们需要扩展该类以便创建自己的 TimerTask ,这个 TimerTask 可以被 Timer 调度。

一个 Timer 对象对应的是单个后台线程,其内部维护了一个 TaskQueue,用于顺序执行计时器任务 TimeTask 。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第1张

Timer 中优先队列的实现

TaskQueue 队列,内部用一个 TimerTask[] 数组实现优先队列(二叉堆),默认最大任务数是 128 ,当添加定时任务超过当前最大容量时会这个数组会拓展到原来 2 倍。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第2张

优先队列主要目的是为了找出、返回并删除优先队列中最小的元素,这里优先队列是通过数组实现了平衡二叉堆,TimeQueue 实现的二叉堆用数组表示时,具有最小 nextExecutionTime 的 TimerTask 在队列中为 queue[1] ,所以堆中根节点在数组中的位置是 queue[1] ,那么第 n 个位置 queue[n] 的子节点分别在 queue[2n] 和 queue[2n+1] 。关于优先队列的数据结构实现,这里推荐一篇文章:数据结构与算法学习笔记 - 优先队列、二叉堆、左式堆。

按照 TaskQueue 的描述:This class represents a timer task queue: a priority queue of TimerTasks, ordered on nextExecutionTime.这是一个优先队列,队列的优先级按照 nextExecutionTime 进行调度。 也就说 TaskQueue 按照 TimerTask 的 nextExecutionTime 属性界定优先级,优先级高的任务先出队列,也就先执行任务调度。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第3张

如上图所示,列举了优先队列中部分操作的实现,优先队列插入和删除元素的复杂度都是O(logn),所以add, removeMin 和 rescheduleMin方法的性能都是不错的。从上图可以知道,获取下一个计划执行任务时,取队列的头出列即可,为了减少额外性能消耗,移除队列头部元素的操作是先把队尾元素赋值到队首后,再把队尾置空,队列数量完成减一后进行优先权值操作。再下面看看保证优先队列最核心的两个方法fixUpfixDown

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第4张

两个方法的核心思路都是通过向上或向下调整二叉堆中元素所在位置,保持堆的有序性: fixUp 是将元素值小于父节点的子节点与父节点交换位置,保持堆有序。交换位置后,原来的子节点可能仍然比更上层的父节点小, 所以整个过程需要循环进行。这样一来,原来的子节点有可能升级为层级更高的父节点,类似于一个轻的物体从湖底往上浮直到达到其重力与浮力相平衡的过程。 fixDown 将元素值大于子节点的父节点与子节点交换位置,交换位置后, 原来的父节点仍然有可能比其下面的子节点大, 所以还需要继续进行类相同的操作,以便保持堆的有序性。所以整个过程循环进行。 这类似于一个重的物体从湖面下沉到距离湖底的某个位置,直到达到其重力与浮力相平衡为止。 总的来说,就是调整大的元素下沉,小的元素上浮,反复调整后堆顶一直保持是堆中最小的元素,父节点元素要一直小于或等于子节点。

TimerTask 的调度

前面说完 Timer 源码中优先队列的实现,下面我们来看看其如果操作优先队列,实现 TimerTask 的计划调度的:

Timer 提供了四个构造方法,每个构造方法都启动了一个后台线程(默认不是守护线程,除非主动指定)。所以对于每一个 Timer 对象而言,其内部都是对应着单个后台线程,这个线程用于顺序执行优先队列中所有的计时器任务。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第5张

当初始化完成 Timer 后,我们就可以往 Timer 中添加定时任务,然后定时任务就会按照我们设定的时间交由 Timer 取调度执行。Timer 提供了 schedule 方法,该方法依靠多次重载的方式来适应不同的情况,具体如下:

  • **schedule(TimerTask task, Date time):**安排在指定的时间执行指定的任务。

  • **schedule(TimerTask task, long delay) :**安排在指定延迟后执行指定的任务。

  • **schedule(TimerTask task, Date firstTime, long period) :**安排指定的任务在指定的时间开始进行重复的固定延迟执行。

  • **schedule(TimerTask task, long delay, long period) :**安排指定的任务从指定的延迟后开始进行重复的固定延迟执行。

  • scheduleAtFixedRate :,scheduleAtFixedRate 方法与 schedule 相同,只不过他们的侧重点不同,区别后面分析。

  • **scheduleAtFixedRate(TimerTask task, Date firstTime, long period):**安排指定的任务在指定的时间开始进行重复的固定速率执行。

  • **scheduleAtFixedRate(TimerTask task, long delay, long period):**安排指定的任务在指定的延迟后开始进行重复的固定速率执行。

首先来看 schedule(TimerTask task, Date time)schedule(TimerTask task, long delay) ,第一个参数传入是定时任务的实例,区别在于方法的第二个参数,date 是在指定的时间点,delay 是当前时间延后多少毫秒。这就引出了 Timer 具有的两个特性:定时(在指定时间点执行任务)和延迟(延迟多少秒后执行任务)。 值得大家注意的是:这里所说时间都是跟系统时间相关的绝对时间,而不是相对时间,基于这点,Timer 对任务的调度计划和系统时间息息相关,所以它对系统时间的改变非常敏感。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第6张

下面在来看看 schedule(TimerTask task, Date time)schedule(TimerTask task, Date firstTime, long period) 的区别。对比方法中新增的 period 参数,period 作用区别在于 Timer 的另一个特性:周期性地执行任务(一次任务结束后,可以每隔个 period 豪秒后再执行任务,如此反复)。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第7张

从上面 schedule 的方法重载来看,最终都是调用了 sched(TimerTask task, long time, long period) 方法,只是传入的参数不同,下面就再来看就看关于 schedule 和 scheduleAtFixedRate 的区别:

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第8张

从调用方法来看,他们的区别仅仅是传入 sched 方法 period 参数正负数的差别,所以具体的就要看 sched 方法的实现。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第9张

可以看到 sched 方法主要是设置 TimerTask 属性和状态,比如 nextExecutionTime 等,然后将任务添加到队列中。能看出来,设置定时任务 task 属性时是加了锁的,而且在添加任务到队列时,这里使用 Timer 内 TaskQueue 实例作为对象锁,并且使用 wait 和 notify 方法来通知任务调度。Timer 类可以保证多个线程可以共享单个 Timer 对象而无需进行外部同步,所以 Timer 类是线程安全的。

这里注意区分开: 前面一个 Timer 对象中用于处理任务调度的后台线程TimerThread 实例和 schedule 方法传入后被加入到 TaskQueue 的 TimerTask 任务的实例,两者是不一样的。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第10张

要想知道为 TimerTask 设置属性和状态的作用,那就得进一步看看 TimerTask 类的具体实现了。

TimerTask 类是一个抽象类,可以由 Timer 安排为一次执行或重复执行的任务。它有一个抽象方法 run() 方法,用于子类实现 Runnale 接口。可以在 run 方法中写定时任务的具体业务逻辑。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第11张

可以看到下图中 TimerTask 类中的文档描述,如果任务是按计划执行,那么 nextExecutionTime 属性是指下次任务的执行时间,时间格式是按照 System.currentTimeMillis 返回的。对于需要重复进行的任务,每个任务执行之前会更新这一属性。

而 period 属性是用来表示以毫秒为时间单位的重复任务。period 为正值时表示固定速率执行,负值表示固定延迟执行,值 0 表示一个非重复性的任务。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第12张

所谓固定速率执行和固定延迟执行,固定延迟指的是定时任务会因为前一个任务的延迟而导致其后面的定时任务延时,而固定速率执行则不会有这个问题,它是直接按照计划的速率重复执行,不会考虑前面任务是否执行完。

这也是 scheduleAtFixedRate 与 schedule 方法的区别,两者侧重点不同,schedule 方法侧重保存间隔时间的稳定,而 scheduleAtFixedRate 方法更加侧重于保持执行频率的稳定。

另外 TimerTask 还有两个非抽象方法:

  • **boolean cancel():**取消此计时器任务。
  • **long scheduledExecutionTime():**返回此任务最近实际执行的安排执行时间。
Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第13张

说完这些,下面就来看看 Timer 的后台线程具体是如何调度队列中的定时任务,可以看到 TimerThread 是持有任务队列进行操作的,也就具有了任务调度功能了。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第14张

下面就来看看后台线程的 run 方法调用 mainLoop 具体做了什么:

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第15张

前面说到每个 Timer 对象内部包含一个 TaskQueue 实例,在执行定时任务时,TimerThread 中将这个 taskqueue 对象作为锁,在任何时刻只能有一个线程执行 TimerTask 。Timer 类为了保证线程安全的,是不需要外部同步机制就可以共享同一个 Timer 对象。

可以看到 Timer 是不会捕获异常的,如果 TimerTask 抛出的了未检查异常则会导致 Timer 线程终止,同时 Timer 也不会重新恢复线程的执行,它会错误的认为整个 Timer 线程都会取消。同时,已经被安排但尚未执行的 TimerTask 也不会再执行了,新的任务也不能被调度。所以,如果 TimerTask 抛出未检查的异常,Timer 将会产生无法预料的行为。

注意看计划安排任务的核心代码,包括任务计划执行时间的设置,也有优先队列保持二叉堆序性地操作。下面代码很好地体现了 period 属性作用,period 为正值时表示固定速率执行,负值表示固定延迟执行,值 0 表示一个非重复性的任务。

currentTime = System.currentTimeMillis();
executionTime = task.nextExecutionTime;
if (taskFired = (executionTime<=currentTime)) {
    if (task.period == 0) { // Non-repeating, remove
        queue.removeMin();
        task.state = TimerTask.EXECUTED;
    } else { // Repeating task, reschedule
        queue.rescheduleMin(
          task.period<0 ? currentTime   - task.period
                        : executionTime + task.period);
    }
}

前面提过 Timer 使用 schedule (TimerTask task, Date firstTime, long period) 方法执行的计时器任务可能会因为前一个任务执行时间较长而延时。每一次执行的 task 的计划时间会随着前一个 task 的实际时间而发生改变,也就是 scheduledExecutionTime(n+1) = realExecutionTime(n) + periodTime。也就是说如果第 n 个 task 由于某种情况导致这次的执行时间过程,最后导致 systemCurrentTime>= scheduledExecutionTime(n+1),这是第 n+1 个 task 并不会因为到时了而执行,他会等待第 n 个 task 执行完之后再执行,那么这样势必会导致 n+2 个的执行时间 scheduledExecutionTime 发生改变。所以 schedule 方法更加注重保存间隔时间的稳定。

而对于 scheduleAtFixedRate(TimerTask task, Date firstTime, long period),在前面也提过 scheduleAtFixedRate 与 schedule 方法的侧重点不同,schedule 方法侧重保存间隔时间的稳定,而 scheduleAtFixedRate 方法更加侧重于保持执行频率的稳定。在 schedule 方法中会因为前一个任务的延迟而导致其后面的定时任务延时,而 scheduleAtFixedRate 方法则不会,如果第 n 个 task 执行时间过长导致 systemCurrentTime >= scheduledExecutionTime(n+1),则不会做任何等待他会立即执行第 n+1 个 task,所以 scheduleAtFixedRate 方法执行时间的计算方法不同于 schedule,而是 scheduledExecutionTime(n)=firstExecuteTime +n*periodTime,该计算方法永远保持不变。所以 scheduleAtFixedRate 更加侧重于保持执行频率的稳定。

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第16张

说完了 Timer 的源码分析,相信大致上也能明白定时集整个流程是怎样的。下面根据上面这些内容,说一些实际使用建议。

使用建议

最近使用阿里 Java 开发编码规约插件,可以看到提示是建议使用 ScheduledExecutorService 代替 Timer :

Java 定时器 Timer 源码分析和使用建议「终于解决」_https://bianchenghao6.com/blog_Java_第17张

那为什么要使用 ScheduledExecutorService 代替 Timer :

  1. 前面我们也有提到,Timer 是基于绝对时间的,对系统时间比较敏感,而 ScheduledThreadPoolExecutor 则是基于相对时间;

  2. Timer 是内部是单一线程,而 ScheduledThreadPoolExecutor 内部是个线程池,所以可以支持多个任务并发执行。

  3. Timer 运行多个 TimeTask 时,只要其中之一没有捕获抛出的异常,其它任务便会自动终止运行,使用 ScheduledExecutorService 则没有这个问题。

  4. 使用 ScheduledExecutorService 更容易明确任务实际执行策略,更方便自行控制。

  5. 默认 Timer 执行线程不是 daemon 线程, 任务执行完,主线程(或其他启动定时器的线程)结束时,task 线程并没有结束。需要注意潜在内存泄漏问题

下面给出一个实际使用 ScheduledExecutorService 代替 Timer 的例子:

import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/** * ImprovedTimer 改进过的定时器 * 多线程并行处理定时任务时,Timer运行多个TimeTask时,只要其中之一没有捕获抛出的异常,其它任务便会自动终止运行, * 使用ScheduledExecutorService则没有这个问题。 * * @author baishixian * @date 2017/10/16 * */

public class ImprovedTimer {


    /** * 线程池不允许使用Executors去创建,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。 说明:Executors各个方法的弊端: * 1)newFixedThreadPool和newSingleThreadExecutor: *   主要问题是堆积的请求处理队列可能会耗费非常大的内存,甚至OOM。 * 2)newCachedThreadPool和newScheduledThreadPool: *   主要问题是线程数最大数是Integer.MAX_VALUE,可能会创建数量非常多的线程,甚至OOM。 * * 线程池能按时间计划来执行任务,允许用户设定计划执行任务的时间,int类型的参数是设定 * 线程池中线程的最小数目。当任务较多时,线程池可能会自动创建更多的工作线程来执行任务 */
    private final ScheduledExecutorService executorService = new ScheduledThreadPoolExecutor(1, new ImprovedTimer.DaemonThreadFactory());
    private ScheduledFuture<?> improvedTimerFuture = null;

    public ImprovedTimer() {
    }

    /** * 周期性重复执行定时任务 * @param command 执行 Runnable * @param initialDelay 单位 MILLISECONDS * @param period 单位 MILLISECONDS */
    public void schedule(Runnable command, long initialDelay, long period){
        // initialDelay 毫秒后开始执行任务,以后每隔 period 毫秒执行一次

        // schedule方法被用来延迟指定时间来执行某个指定任务。
        // 如果你需要周期性重复执行定时任务可以使用scheduleAtFixedRate或者scheduleWithFixedDelay方法,它们不同的是前者以固定频率执行,后者以相对固定频率执行。
        // 不管任务执行耗时是否大于间隔时间,scheduleAtFixedRate和scheduleWithFixedDelay都不会导致同一个任务并发地被执行。
        // 唯一不同的是scheduleWithFixedDelay是当前一个任务结束的时刻,开始结算间隔时间,如0秒开始执行第一次任务,任务耗时5秒,任务间隔时间3秒,那么第二次任务执行的时间是在第8秒开始。

        improvedTimerFuture = executorService.scheduleAtFixedRate(command, initialDelay, period, TimeUnit.MILLISECONDS);
    }

    /** * 周期性重复执行定时任务 * @param command 执行 Runnable * @param initialDelay 单位 MILLISECONDS */
    public void schedule(Runnable command, long initialDelay){
        // initialDelay 毫秒后开始执行任务

        improvedTimerFuture = executorService.schedule(command, initialDelay, TimeUnit.MILLISECONDS);
    }


    private void cancel() {
        if (improvedTimerFuture != null) {
            improvedTimerFuture.cancel(true);
            improvedTimerFuture = null;
        }
    }

    public void shutdown() {
        cancel();
        executorService.shutdown();
    }


    /** * 守护线程工厂类,用于生产后台运行线程 */
    private static final class DaemonThreadFactory implements ThreadFactory {
        private AtomicInteger atoInteger = new AtomicInteger(0);

        @Override
        public Thread newThread(Runnable runnable) {
            Thread thread = new Thread(runnable);
            thread.setName("schedule-pool-Thread-" + atoInteger.getAndIncrement());
            thread.setDaemon(true);
            return thread;
        }
    }
}

参考: 详解 Java 定时任务 Java多线程总结(3)— Timer 和 TimerTask深入分析

OVER...

今天的分享到此就结束了,感谢您的阅读,如果确实帮到您,您可以动动手指转发给其他人。

发表回复