Android 为什么不该使用Timer

2017/9/29 posted in  Android

概述

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

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

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

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

Timer的运行机制

20171104150978406359625.jpg
20171104150978406359625.jpg

  • TimerTask:任务类。内部持有nextExecutionTime变量,表示任务实际执行时间点,单位为毫秒,使用System.currentTimeMillis() + delay计算得出。
  • TimerQueue:使用小根堆实现的优先队列。按照TimerTask的实际执行时间点由小到大排序。
  • TimerThread:顾名思义,这是实际执行任务的线程。

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是差不多的。

20171104150978425468906.jpg
20171104150978425468906.jpg

  • ScheduledFutureTask:任务类。内部持有time变量,单位为纳秒,通过System.nanoTime() + delay计算得出。
  • DelayedWorkQueue:使用小根堆实现的优先阻塞队列,将ScheduledFutureTask按照从小到大的顺序排列,同时在take()方法内实现阻塞操作。
  • WorkerThread:这里为了简单起见,我将线程池的核心线程和临时线程统一写成WorkerThread,但需要注意的是ScheduledThreadPoolExecutor是线程池的一个子类,所以线程池的那一套东西在ScheduledThreadPoolExecutor里也是有的。

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

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

Leader/Follower模式

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

20171104150978440726061.png
20171104150978440726061.png

在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一个是基于绝对时间而另一个是基于相对时间的原因。下面我们写个例子来测试一下:

public static void main(String[] args) {
    System.out.println("Start:\t" + new Date());

    Executors.newSingleThreadScheduledExecutor().schedule(() -> {
        System.out.println("Executor:\t" + new Date());
    }, 60, TimeUnit.SECONDS);

    new Timer().schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("Timer:\t" + new Date());
        }
    }, 60000);
}

输出:

Start:    Sun Oct 08 10:51:44 CST 2017
Executor:    Sun Oct 08 10:51:41 CST 2017
Timer:    Sun Oct 08 10:52:45 CST 2017

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

单线程与多线程

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

public static void main(String[] args) {
    System.out.println("Start:\t\t\t" + new Date());

    ScheduledExecutorService service = Executors.newScheduledThreadPool(3);

    service.schedule(() -> {
        System.out.println("Executor 任务1:\t" + new Date());
        try {
            Thread.sleep(30000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, 60, TimeUnit.SECONDS);
    service.schedule(() -> {
        System.out.println("Executor 任务2:\t" + new Date());
        try {
            Thread.sleep(30000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }, 60, TimeUnit.SECONDS);

    Timer timer = new Timer();

    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("Timer 任务1:\t\t" + new Date());
            try {
                Thread.sleep(30000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }, 60000);
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            System.out.println("Timer 任务2:\t\t" + new Date());
            try {
                Thread.sleep(30000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }, 60000);
}

输出:

Start:            Sun Oct 08 11:10:34 CST 2017
Executor 任务1:    Sun Oct 08 11:11:34 CST 2017
Executor 任务2:    Sun Oct 08 11:11:34 CST 2017
Timer 任务1:        Sun Oct 08 11:11:34 CST 2017
Timer 任务2:        Sun Oct 08 11:12:04 CST 2017

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

异常处理

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

Timer

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

public static void main(String[] args) {
    System.out.println("Start:\t\t\t" + new Date());

    Timer timer = new Timer();

    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            throw new RuntimeException("Timer 任务1");
        }
    }, 60000);
    timer.schedule(new TimerTask() {
        @Override
        public void run() {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Timer 任务2:\t\t" + new Date());
        }
    }, 60000);
}

输出:

Start:            Sun Oct 08 11:53:05 CST 2017
Exception in thread "Timer-0" java.lang.RuntimeException: Timer 任务1
    at main.Main$1.run(Main.java:32)
    at java.util.TimerThread.mainLoop(Timer.java:555)
    at java.util.TimerThread.run(Timer.java:505)

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

ScheduledThreadPoolExecutor

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

public class ThreadPoolExecutor extends AbstractExecutorService {
    final void runWorker(Worker w) {
        ...
        Throwable thrown = null;
        try {
            task.run();
        } catch (RuntimeException x) {
            thrown = x; throw x;
        } catch (Error x) {
            thrown = x; throw x;
        } catch (Throwable x) {
            thrown = x; throw new Error(x);
        } finally {
            afterExecute(task, thrown);
        }
        ...
    }
}

我们来验证一下:

public static void main(String[] args) {
    System.out.println("Start:\t\t\t" + new Date());

    ScheduledExecutorService service = Executors.newSingleThreadScheduledExecutor();

    service.schedule(() -> {
        throw new RuntimeException("Executor 任务1");
    }, 60, TimeUnit.SECONDS);
    service.schedule(() -> {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Executor 任务2:\t" + new Date());
    }, 60, TimeUnit.SECONDS);
}

输出:

Start:            Sun Oct 08 11:33:35 CST 2017
Executor 任务2:    Sun Oct 08 11:34:36 CST 2017

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