1. 一个JDK Timer的例子。
  2. JDK Timer包含的主要对象。
  3. Timer对象分析。
  4. TimerTask对象分析。
  5. 任务调度:一次性定时任务。
  6. 任务调度:多次执行的定时任务(固定时间点或固定时间间隔)。
  7. JDK Timer是单线程的吗?
  8. Thread和Runable的区别。
  9. 优缺点。

一个JDK Timer的例子

项目中其实是经常需要定时任务的,JDK就提供了一个定时任务的实现Timer,但是由于JDK Timer不是很灵活(比如不能支持cron表达式的执行计划),所以项目中实际用的应该比较少。

不过JDK Timer的使用非常简单。

第一步:创建Timer对象。
第二步:扩展TimerTask实现其run方法。
第三步:调用Timer对象的schedule方法创建执行计划。

@Slf4j
public class TimerTaskDemo {
    public void runTimerA(){
        Timer timer1=new Timer("Timer");
        long delay=10000L;
        timer1.schedule(new TimerTask(){
            @Override
            public void run(){
                log.info("This is timerTaskA:" +  Thread.currentThread().getId());
                timer1.cancel();
            }},delay);
    }

    public static void main(String[] args) {
        TimerTaskDemo timerTaskDemo=new TimerTaskDemo();
        log.info("There we come:" + Thread.currentThread().getId());
        timerTaskDemo.runTimerA();
    }

运行结果:主线程输出,10秒后定时任务执行。

21:27:17.313 [main] INFO com.example.demo.task.TimerTaskDemo - There we come:1
21:27:27.340 [Timer] INFO com.example.demo.task.TimerTaskDemo - This is timerTaskA:11

JDK Timer包含的主要对象

JDK提供了定时控制器Timer,主要包括:

  1. Timer:定时控制器。
  2. TimerTask:定时控制器被触发以后要执行的任务,是一个实现了Runable的抽象类,应用需要扩展实现TimerTask从而执行我们的定时任务。
  3. Schedule:执行计划,实际是Timer的一个方法,按照一定的规则绑定TimerTask到Timer。
  4. TaskQueue:任务队列,每一个Timer都包含一个任务队列保存任务,以便Timer一个个取出并执行任务。

Timer对象分析

JDK 定时任务的主要对象就是这个定时器,负责定时任务的创建及调度执行。

包含两个重要属性:

private final TaskQueue queue = new TaskQueue();

private final TimerThread thread = new TimerThread(queue);

一个是任务队列TaskQueue,另外一个是定时器线程TimerThread,两个对象都是Timer对象初始化的时候直接创建,定时器线程TimerThread持有任务队列。

Timer#TaskQueue

TaskQueue是Timer的内部类,顾名思义,是任务队列。

任务队列以数组保存,初始化长度128。

  private TimerTask[] queue = new TimerTask[128];

通过add方法将任务加入任务队列,如果队列已满则扩充队列容量(2倍),之后通过fixUp调整队列顺序,确保队列尽可能按照执行时间的先后顺序排列。

void add(TimerTask task) {
        // Grow backing store if necessary
        if (size + 1 == queue.length)
            queue = Arrays.copyOf(queue, 2*queue.length);

        queue[++size] = task;
        fixUp(size);
    }

TaskQueue的其他方法我们后面在调用到的时候再做分析。

Timer#TimerThread

TimerThread也是Timer的内部类。

TimerThread是一个线程类,扩展了Thread并覆盖了他的run方法。

class TimerThread extends Thread {
  boolean newTasksMayBeScheduled = true;

    private TaskQueue queue;

    TimerThread(TaskQueue queue) {
        this.queue = queue;
    }

    public void run() {
        try {
            mainLoop();
        } finally {
            // Someone killed this Thread, behave as if Timer cancelled
            synchronized(queue) {
                newTasksMayBeScheduled = false;
                queue.clear();  // Eliminate obsolete references
            }
        }
    }

对象实例的同时初始化了任务列表,并初始化newTasksMayBeScheduled为true。

run方法调用了一个叫mainLoop()的方法,方法名告诉我们应该是一个主循环。

我们知道定时任务是启动一个新线程执行任务,他能够不断定时执行的原因就是新线程启动之后不退出,等待任务计划的调度。这个mainLoop应该就是线程启动之后的等待方法,一直等待任务调度,非必要不退出。

mainLoop代码稍后分析。

Timer对象创建

从前面的例子我们知道,JDK Timer定时任务首先要创建一个Timer对象,看一下Timer的实例化方法:

 public Timer(String name) {
        thread.setName(name);
        thread.start();
    }

给TimerThread设置name,之后直接启动TimerThread。

Timer对象创建的时候就直接启动的线程,我们上面说过的mainLoop方法就开始运行了。但是这个时候Timer的任务队列还是空的,所以mainLoop应该是只能空转。

我们先简单看一眼mainLoop方法,验证一下:

private void mainLoop() {
        while (true) {
            try {
                TimerTask task;
                boolean taskFired;
                synchronized(queue) {
                    // Wait for queue to become non-empty
                    while (queue.isEmpty() && newTasksMayBeScheduled)
                        queue.wait();
                    if (queue.isEmpty())
                        break; // Queue is empty and will forever remain; die
                       //省略代码...

队列是空的,并且newTasksMayBeScheduled=true,所以调用queue.wait()方法挂起队列,等待被再次唤醒。

如果队列一直为空并且newTasksMayBeScheduled一直为true,并且也不通过其他手段结束当前线程的话,他就会一直等待下去。

好了,是时候给任务队列喂点东西了。

TimerTask

顾名思义,TimerTask,就是任务、或者叫定时任务。

TimerTask是一个实现了Runable接口的虚拟类,他并没有实现Runable的run方法,需要我们应用去实现。

TimerTask才是我们业务需要关注的主要目标,比如我们需要每天晚上2点钟跑批进行账户余额的更新,那这个更新账户余额的业务方法就是要在业务对象(扩展TimerTask)的run方法中去调用。

除了需要实现run方法去调用我们的业务逻辑之外,还有两个属性了解一下:一个lock,一个state。我们知道Timer是线程安全的,lock是用来在线程执行过程中更新任务状态的时候锁定任务的,state是任务状态,包括:

  1. VIRGIN:任务尚未被调度。
  2. SCHEDULED:被调度但是尚未执行。
  3. EXECUTED:已经执行完成。
  4. CANCELLED:被取消。

任务调度:一次性任务

一次性任务指的是任务执行一次之后就结束,我们上面的例子就是一个一次性任务。

一次性任务通过Timer的schedule方法调度:

Params:
task – task to be scheduled.
delay – delay in milliseconds before task is to be executed.
public void schedule(TimerTask task, long delay)

schedule方法接收两个参数:

  1. TimerTask:要执行的任务。
  2. delay:任务在delay毫秒之后触发

schedule方法执行如下动作:

  1. 为确保Timer定时任务的线程安全行,同步Timer的任务队列。
  2. 同步TimerTask的lock,并设置TimerTask的执行时间为当前系统时间+delay,设置任务状态为SCHEDULED,设置任务的period=0。
  3. TimerTask加入任务队列。
  4. 从任务队列中获取待执行的任务(队首的任务),如果队首任务就是当前任务的话,调用任务队列quene的notify()方法唤醒队列。

我们看到schedule只是将当前任务加入队列,加入队列之后任务什么时间被执行就和schedule没有关系了,其实加入任务队列之后,schedule就完成使命了。

任务具体什么时候被执行就是我们上面所说的那个mainLoop的事情了,我们前面说过,在Timer被创建之后,任务队列是空的,mainLoop通过调用队列queue.wait处于无限期待命状态。

在此状态下应用通过schedule方法加入一个任务到任务队列中,并且当前队列如果只有刚被加入的这一个任务的话,就会调用notify唤醒队列。

我们继续分析mainLoop的剩余代码,看一下任务队列被唤醒之后的逻辑。

private void mainLoop() {
        while (true) {
            try {
                TimerTask task;
                boolean taskFired;
                synchronized(queue) {
                    // Wait for queue to become non-empty
                    while (queue.isEmpty() && newTasksMayBeScheduled)
                        queue.wait();
                    //从这儿开始...
                    
                    if (queue.isEmpty())
                        break; // Queue is empty and will forever remain; die

                    // Queue nonempty; look at first evt and do the right thing
                    long currentTime, executionTime;
                    task = queue.getMin(); 
                    synchronized(task.lock) {
                        if (task.state == TimerTask.CANCELLED) {
                            queue.removeMin();
                            continue;  // No action required, poll queue again
                        }
                        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);
                            }
                        }
                    }
                    if (!taskFired) // Task hasn't yet fired; wait
                        queue.wait(executionTime - currentTime);
                }
                if (taskFired)  // Task fired; run it, holding no locks
                    task.run();
            } catch(InterruptedException e) {
            }
        }
    }

代码其实比较简单:

  1. 如果newTasksMayBeScheduled=false,其实就是接收到了当前任务执行器要结束执行的信号了,此时如果任务队列空了,就结束mainLoop,就相当于当前任务执行器线程要结束了。
  2. 否则,从任务队列中获取队首任务。
  3. 任务上锁。
  4. 检查当前任务,如果已经被取消的话,将任务移除队列,啥也不干了(当前任务不需要被执行)。
  5. 比较任务执行时间nextExecutionTime如果小于当前系统时间的话,执行以下步骤:
    5.1 设置taskFired=true,检查period=0则表明是一次性任务,将该任务移除队列,并设置任务状态为EXECUTED。
    5.2 否则,是周期性任务,执行queue.rescheduleMin对当前任务重排。
  6. 如果taskFired=false,则表明还没有到当前任务的执行时间,则限时挂起当前任务队列。
  7. 否则taskFired=true则调用TimeTask的run方法执行任务。

所以我们现在明白一次性任务之所以执行一次后就不会被再次调度的原因是,任务执行后就被移出了任务队列。周期性任务能被多次执行的原因是每次执行后都会对该任务在任务队列中的位置进行重排!

另外,我们还需要搞清楚一个问题:如何结束定时控制器?

这个问题其实我们在分析mainLoop的代码是已经获得的一半答案:newTasksMayBeScheduled=false并且任务队列为空。

答案的另一半就是要知道如何满足上述条件?需要从Timer控制器提供的方法中寻找,Timer提供了一个cancel方法,cancel方法在设置newTasksMayBeScheduled为false并清空任务队列之后,立刻调用任务队列的notify唤醒队列、结束Timer控制器。

    public void cancel() {
        synchronized(queue) {
            thread.newTasksMayBeScheduled = false;
            queue.clear();
            queue.notify();  // In case queue was already empty.
        }
    }

任务调度:周期性任务

JDK Timer支持两种类型的周期性任务,一种是fixRate周期性任务,通过定时控制器Timer的scheduleAtFixedRate方法调度,另外一种是非fixRate的,通过带有period的普通的schedule方法调度。

两者有什么区别呢?

搞清楚两者区别之前,我们需要首先了解一个概念,就是定时控制器Timer在调度任务的时候是无法保证严格按照调度计划执行任务的(不考虑任务执行时长对调度周期的影响,比如我们假设任务被调度后瞬间就可以执行完成),什么意思呢?

比如我们通过调度方法schedule安排在当前时间10秒后执行一个period=10秒的定时任务,比如当前时间正好是12:00正,那么我们的期望是从12点10秒执行一次任务,后续每隔10秒执行一次,理想的执行时间就是 10秒 20秒 30秒 40秒 50秒...以此类推。

但是这个执行时间其实Timer是没有办法保证的,因为线程挂起之后再次被唤醒是依赖于CPU的调度的,CPU在10秒执行了一次任务之后,下次任务不一定能在20秒被唤醒,有可能是22秒或者23秒的时候才会被唤醒。

假设22秒的时候任务被唤醒,Timer在安排执行下次任务计划的时候提供了两个选项:

  1. fixRate:如果是通过scheduleAtFixedRate方法进行调度的(此时调度器内部的period>0),下次任务安排在30秒执行。
  2. 如果是普通的schedule方法调度的(此时调度器内部的period<0),下次任务安排在当前系统时间+10秒,也就是被安排在第32秒执行。

所以两者的区别就一目了然了。

另外,既然Timer内部是通过period大于或小于0来控制周期性任务的执行策略的,那我们是不是可以在调用调度方法schedule的时候通过period来控制执行策略呢?答案当然是不可以,否则理解起来就会乱套了:

   public void schedule(TimerTask task, long delay, long period) {
        if (delay < 0)
            throw new IllegalArgumentException("Negative delay.");
        if (period <= 0)
            throw new IllegalArgumentException("Non-positive period.");
        sched(task, System.currentTimeMillis()+delay, -period);
    }

Timer的几个变形调度方法schedule都不允许period小于0。

JDK Timer是单线程?

JDK Timer是单线程执行这种说法其实比较模糊,需要加以解释,否则容易混淆。

对于主线程来说,JDK Timer的调度以及任务执行是在新启动的线程中执行的,调度和任务执行线程是和主线程独立的线程。所以从这个角度来看的话,说JDK Timer是单线程貌似不太合理。

但是JDK Timer的任务调度是在TimerThread线程中进行的,在TimerTread的mainLoop方法中检查到任务队列中的当前任务应该被调度的时候,通过TimerTask的run方法执行任务。我们知道TimerTask虽然实现了Runable接口,但是在TimerThread线程中直接调用TimerTask的run方法执行任务、而不是将TimerTask再次封装在一个新的Thread中通过Thread的start方法执行任务,这样的话TimerTaks其实就是在TimerThread线程中执行,而并不会开启一个新的线程。

所以我们的结论就是:JDK Timer通过TimerThread线程调度任务,同时也是通过TimerThread线程执行任务,调度任务和执行任务是在同一个线程中完成的。从这个角度来讲,我们可以说JDK Timer是单线程的。

因此,如果一个TimerTask的执行时间太长,超过了周期性任务的period的话,任务的下次执行时间将会受到影响!

Thread和Runable的区别

以上讨论过程中其实涉及到了一个Thread和Runable区别的问题,我们今天也不是专门讨论这个问题的,但既然涉及到了,就简单说一下。

这个问题虽然被大家广泛讨论,也有可能是一个比较普遍的Java基础知识的面试问题。但是,个人理解,这个问题根本就不应该成为一个问题,因为两者其实没有什么可比性。

Thread几乎可以认为是我们启动线程的唯一选择,只通过Runable而不借助Thread的话,我们是没有办法启动一个新线程的。

Runable其实只是一个简单的接口,定义了一个run方法。Thread实现了Runable接口,而且Thread有一个定义为Runable的属性target。因此可以说Thread和Runable是有联系的。

我们启动一个线程的唯一方法还是通过Thread,我们可以继承Thread并覆盖他的run方法,这个时候从应用的角度看,整个启动线程的过程就和Runable没有半毛钱的关系。

另外我们还可以自定义一个业务类,实现Runable接口,然后new一个Thread对象并且把我们自定义的业务类作为参数送给Thread对象的targe。这种方式下从应用的角度看,启动线程的过程才和Runable有了关系。

如果我们只是自定义了一个类实现了Runable接口,但是不通过Thread绑定这个自定义的类,不是通过Thread的start方法调用自定义类的run方法、而是直接调用run方法的话(这个过程就和TimerThread通过调用TimerTask的run方法执行任务有点类似)。那么我们虽然实现了Runable接口,也调用了run方法,但是整个过程都和“启动新线程执行任务”没有半毛钱的关系。这种情况下我们虽然调用了Runable的run方法但是却并没有启动新线程,run方法依然还是在原来的线程下运行!这种情况下的Runable接口就和其他普通接口没有任何区别了,他只是个定义了一个run方法的普通接口。

也看到过很多关于两者区别的讨论中提到了两种方式下对于变量是共享还是隔离访问的说法,个人认为完全是跑偏了。这类观点认为Thread方式实现的多线程是独占成员变量的、而通过Runable实现的多线程是共享成员变量的。看过了他们列举的例子,其实是因为Thread方式下是new了多个Thread对象所以成员变量当然隔离的,因为他们根本就分别属于不同的对象。而Runable方式下就只是new了一个Runable对象,然后new了多个Thread、启动多个线程执行的时候是把这个唯一的Runable对象传递给了Thread的target,不同线程持有的是相同的Runable对象作为他们的target,同一个对象的成员变量当然是共享的。

JDK Timer的优缺点

优点只有一个,就是JDK自带,不需要引入外部包,使用比较简单。

缺点是使用简单,调度策略比较单一,不能支持cron表达式,貌似也不能支持“几点开始、执行10次”这样的需求,这类需求都需要应用层想办法控制。

如果任务执行时长大于period的话,会影响到调度时间。

调度策略比较简单、不灵活,可能也就是导致JDK Timer不被广泛应用的原因。

上一篇 基于Mybatis的分页控制 - PageHelper分页控制底层原理


45 声望17 粉丝