概述

在Java开发中,用过定时功能的同学一定不会对Timer感到陌生。不过,除了Timer,在Java 5之后又引入了一个定时工具ScheduledThreadPoolExecutor,那么我们应该如何在这两个定时工具之间进行选择呢?

一般情况下我们都建议使用ScheduledThreadPoolExecutor而不是Timer,主要原因有以下3点:

  1. Timer使用的是绝对时间,系统时间的改变会对Timer产生一定的影响;而ScheduledThreadPoolExecutor使用的是相对时间,所以不会有这个问题。
  2. Timer使用单线程来处理任务,长时间运行的任务会导致其他任务的延时处理,而ScheduledThreadPoolExecutor可以自定义线程数量。
  3. Timer没有对运行时异常进行处理,一旦某个任务触发运行时异常,会导致整个Timer崩溃,而ScheduledThreadPoolExecutor对运行时异常做了捕获(可以在afterExecute()回调方法中进行处理),所以更加安全。

下面我们就来通过了解Timer与ScheduledThreadPoolExecutor的运行原理来理解上面几个问题出现的原因。

Timer的运行机制

timer组件教程(为什么不建议使用Timer)(1)

TimerThread会在Timer初始化后启动,之后会进入mainLoop()方法,该方法会不断从TimerQueue中取出时间点最小的TimerTask。如果该TimerTask的执行时间点已到,则直接调用TimerTask.run()执行;否则,调用wait()方法,等待相应的时间。

而我们调用Timer.schedule()方法,实际上是通过TimerQueue.add()方法,将TimerTask加入任务等待队列。

这里还有一个需要注意的地方是:当加入任务的执行时间点是优先队列中最小的时,就调用notify()方法唤醒TimerThread,而TimerThread在被唤醒后会重新调用TimerQueue.getMin()方法,再次调用wait(),不过这次的等待时间就变成了新加入任务的时间点。

ScheduledThreadPoolExecutor的运行机制

ScheduledThreadPoolExecutor继承自ThreadPoolExecutor,对线程池的原理不了解的同学,可以看一下我的这篇文章:从零实现ImageLoader(三)—— 线程池详解。

ScheduledThreadPoolExecutor的实现比Timer要复杂一些,不过要是理解了线程池的运行原理,其实也不难。它只不过是在ThreadPoolExecutor的基础上使用自定义的阻塞队列DelayedWorkQueue来实现任务定时功能。所以ScheduledThreadPoolExecutor的运行流程其实和ThreadPoolExecutor是差不多的。

timer组件教程(为什么不建议使用Timer)(2)

光从这两个图上看,好像ScheduledThreadPoolExecutor和Timer的实现都大同小异,不过是换了一些名字,但实际上这两个的实现还是有很大的不同的,不止因为ScheduledThreadPoolExecutor使用的是多线程。

在Timer里定时功能的实现主要依靠TimerThread.mainLoop()的等待,而ScheduledThreadPoolExecutor使用的是多线程,在每个线程里都单独实现定时功能是不现实的,因此,ScheduledThreadPoolExecutor将定时功能放在了DelayedWorkQueue类里,而由于DelayedWorkQueue是阻塞队列,所以定时任务的实现实际上就在DelayedWorkQueue.take()方法中。下面我们就来分析一下DelayedWorkQueue.take()到底做了什么。

Leader/Follower模式

在多线程网络编程中,我们一般使用一个线程监听端口,在接收到事件后再使用其他的线程去完成操作。这种情况下,在两个线程之间的上下文切换开销其实是很大的,于是我们有了Leader/Follower模式:

timer组件教程(为什么不建议使用Timer)(3)

在Leader/Follower模式中,不存在一个专门用来监听的线程,所有的线程都是等价的,而这些线程会不断在Leader、Follower和Processor这三个状态之间来回切换。

在程序中会保证每个时刻有且只有一个Leader,这个Leader就暂时充当了之前用来监听端口线程的作用。而当有一个新的事件发生时,Leader不再是重新找一个线程去处理连接,而是自己转化为Processor处理事件,并且重新指定一个Follower作为新的Leader。当事件处理完毕后,Processor又会转化为Follower等待重新成为Leader。

take()方法的原理

这里的take()方法就借助了Leader/Follower模式的思想,同一时刻只有一个Leader线程,不过这里由于任务执行的时间点是已经确定了的,所以不再是等待一个触发事件,而是等待最小任务所对应的延迟时间。其他的Follower线程则处于无限等待的状态,直到当前Leader到达指定时间后转化为Processor去处理任务,这时就会唤醒一个Follower作为下一任的Leader。而Processor在处理完任务后又会重新加入Follower进行等待。

绝对时间与相对时间

了解了Timer与ScheduledThreadPoolExecutor的运行机制,下面我们就来看一下Timer的这些缺陷究竟是怎么回事。

首先是绝对时间与相对时间的问题,可能有人已经发现,不管是TimerTask还是ScheduledFutureTask都是存储的实际执行时间点,只不过一个是毫秒,一个是纳秒,难道时间单位还会对这些有影响?确实,时间单位是不会对任务的执行有影响的,不过这里的玄机就在于这个时间的计算方式:System.currentTimeMillis()与System.nanoTime()。

System.currentTimeMillis()大家已经很清楚了,就是当前时间与1970年1月1日午夜的时间差的毫秒数,而System.nanoTime()又是什么呢?官方文档里是这么说的:

此方法只能用于测量已过的时间,与系统或钟表时间的其他任何时间概念无关。返回值表示从某一固定但任意的时间算起的毫微秒数。

这就是Timer与ScheduledThreadPoolExecutor一个是基于绝对时间而另一个是基于相对时间的原因。下面我们写个例子来测试一下:

timer组件教程(为什么不建议使用Timer)(4)

输出:

timer组件教程(为什么不建议使用Timer)(5)

这里,我在启动之后将系统的时钟向后调了一分钟,所以实际的启动时间应该是10:50:44,由于ScheduledThreadPoolExecutor的等待时间与系统无关,所以在一分钟后执行;而Timer是基于绝对时间的所以在10:52:45执行,实际上这时已经过去两分钟了。

单线程与多线程

Timer的第二个缺陷是,由于它使用的是单线程,所以长时间执行的任务会对其他任务产生影响。

timer组件教程(为什么不建议使用Timer)(6)

timer组件教程(为什么不建议使用Timer)(7)

timer组件教程(为什么不建议使用Timer)(8)

timer组件教程(为什么不建议使用Timer)(9)

输出:

timer组件教程(为什么不建议使用Timer)(10)

可以看到ScheduledThreadPoolExecutor中的两个任务在等待一分钟之后同时执行;而在Timer中的任务2却因任务1长达半分钟的执行时间,总共等了一分半钟才得以执行。

异常处理

最后我们来看一下Timer与ScheduledThreadPoolExecutor对异常的处理情况:

Timer

Timer内部没有对异常做任何处理,如果任务执行发生运行时异常,整个TimerThread都会崩溃:

timer组件教程(为什么不建议使用Timer)(11)

输出:

timer组件教程(为什么不建议使用Timer)(12)

可以看到,任务1抛出的运行时异常导致整个Timer线程崩溃,任务2自然也没有执行。

ScheduledThreadPoolExecutor

ScheduledThreadPoolExecutor中对异常的处理实际上是ThreadPoolExecutor类完成的,ThreadPoolExecutor在任务运行时对异常做了捕获,并且将异常传入了afterExecute()方法:

timer组件教程(为什么不建议使用Timer)(13)

我们来验证一下:

timer组件教程(为什么不建议使用Timer)(14)

输出:

timer组件教程(为什么不建议使用Timer)(15)

可以看到这里虽然任务1抛出了运行时异常,但由于线程池内部完善的异常处理机制,任务2得以成功执行。

后记

看了这么多Timer的缺陷,你还在犹豫吗?赶快放弃Timer,投入ScheduledThreadPoolExecutor的怀抱吧!

,