计算机程序的思惟逻辑 (80) - 定时任务的那些坑

本系列文章经补充和完善,已修订整理成书《Java编程的逻辑》(马俊昌著),由机械工业出版社华章分社出版,于2018年1月上市热销,读者好评如潮!各大网店和书店有售,欢迎购买:京东自营连接 html

本节探讨定时任务,定时任务的应用场景是很是多的,好比:java

  • 闹钟程序或任务提醒,指定时间叫床或在指定日期提醒还信用卡
  • 监控系统,每隔一段时间采集下系统数据,对异常事件报警
  • 统计系统,通常凌晨必定时间统计昨日的各类数据指标

在Java中,有两种方式实现定时任务:git

  • 使用java.util包中的Timer和TimerTask
  • 使用Java并发包中的ScheduledExecutorService

它们的基本用法都是比较简单的,但若是对它们没有足够的了解,则很容易陷入其中的一些陷阱,下面,咱们就来介绍它们的用法、原理以及那些坑。github

Timer和TimerTask

基本用法

TimerTask表示一个定时任务,它是一个抽象类,实现了Runnable,具体的定时任务须要继承该类,实现run方法。编程

Timer是一个具体类,它负责定时任务的调度和执行,它有以下主要方法:swift

//在指定绝对时间time运行任务task
public void schedule(TimerTask task, Date time) //在当前时间延时delay毫秒后运行任务task public void schedule(TimerTask task, long delay) //固定延时重复执行,第一次计划执行时间为firstTime,后一次的计划执行时间为前一次"实际"执行时间加上period public void schedule(TimerTask task, Date firstTime, long period) //一样是固定延时重复执行,第一次执行时间为当前时间加上delay public void schedule(TimerTask task, long delay, long period) //固定频率重复执行,第一次计划执行时间为firstTime,后一次的计划执行时间为前一次"计划"执行时间加上period public void scheduleAtFixedRate(TimerTask task, Date firstTime, long period) //一样是固定频率重复执行,第一次计划执行时间为当前时间加上delay public void scheduleAtFixedRate(TimerTask task, long delay, long period) 复制代码

须要注意固定延时(fixed-delay)与固定频率(fixed-rate)的区别,都是重复执行,但后一次任务执行相对的时间是不同的,对于固定延时,它是基于上次任务的"实际"执行时间来算的,若是因为某种缘由,上次任务延时了,则本次任务也会延时,而固定频率会尽可能补够运行次数bash

另外,须要注意的是,若是第一次计划执行的时间firstTime是一个过去的时间,则任务会当即运行,对于固定延时的任务,下次任务会基于第一次执行时间计算,而对于固定频率的任务,则会从firstTime开始算,有可能加上period后仍是一个过去时间,从而连续运行不少次,直到时间超过当前时间。微信

咱们经过一些简单的例子具体来看下。多线程

基本示例

看一个最简单的例子:并发

public class BasicTimer {
    static class DelayTask extends TimerTask {
        
        @Override
        public void run() {
            System.out.println("delayed task");
        }
    }
    
    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();
        timer.schedule(new DelayTask(), 1000);
        Thread.sleep(2000);
        timer.cancel();
    }
}
复制代码

建立一个Timer对象,1秒钟后运行DelayTask,最后调用Timer的cancel方法取消全部定时任务。

看一个固定延时的简单例子:

public class TimerFixedDelay {

    static class LongRunningTask extends TimerTask {
        @Override
        public void run() {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
            }
            System.out.println("long running finished");
        }
    }

    static class FixedDelayTask extends TimerTask {
        @Override
        public void run() {
            System.out.println(System.currentTimeMillis());
        }
    }

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

        timer.schedule(new LongRunningTask(), 10);
        timer.schedule(new FixedDelayTask(), 100, 1000);
    }
}
复制代码

有两个定时任务,第一个运行一次,但耗时5秒,第二个是重复执行,1秒一次,第一个先运行。运行该程序,会发现,第二个任务只有在第一个任务运行结束后才会开始运行,运行后1秒一次。

若是替换上面的代码为固定频率,即代码变为:

public class TimerFixedRate {

    static class LongRunningTask extends TimerTask {
        @Override
        public void run() {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
            }
            System.out.println("long running finished");
        }
    }

    static class FixedRateTask extends TimerTask {

        @Override
        public void run() {
            System.out.println(System.currentTimeMillis());
        }
    }

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

        timer.schedule(new LongRunningTask(), 10);
        timer.scheduleAtFixedRate(new FixedRateTask(), 100, 1000);
    }
}
复制代码

运行该程序,第二个任务一样只有在第一个任务运行结束后才会运行,但它会把以前没有运行的次数补过来,一会儿运行5次,输出相似下面这样:

long running finished
1489467662330
1489467662330
1489467662330
1489467662330
1489467662330
1489467662419
1489467663418
复制代码

基本原理

Timer内部主要由两部分组成,任务队列和Timer线程。任务队列是一个基于堆实现的优先级队列,按照下次执行的时间排优先级。Timer线程负责执行全部的定时任务,须要强调的是,一个Timer对象只有一个Timer线程,因此,对于上面的例子,任务才会被延迟。

Timer线程主体是一个循环,从队列中拿任务,若是队列中有任务且计划执行时间小于等于当前时间,就执行它,若是队列中没有任务或第一个任务延时还没到,就睡眠。若是睡眠过程当中队列上添加了新任务且新任务是第一个任务,Timer线程会被唤醒,从新进行检查。

在执行任务以前,Timer线程判断任务是否为周期任务,若是是,就设置下次执行的时间并添加到优先级队列中,对于固定延时的任务,下次执行时间为当前时间加上period,对于固定频率的任务,下次执行时间为上次计划执行时间加上period。

须要强调是,下次任务的计划是在执行当前任务以前就作出了的,对于固定延时的任务,延时相对的是任务执行前的当前时间,而不是任务执行后,这与后面讲到的ScheduledExecutorService的固定延时计算方法是不一样的,后者的计算方法更合乎通常的指望。

另外一方面,对于固定频率的任务,它老是基于最早的计划计划的,因此,颇有可能会出现前面例子中一会儿执行不少次任务的状况。

死循环

一个Timer对象只有一个Timer线程,这意味着,定时任务不能耗时太长,更不能是无限循环,看个例子:

public class EndlessLoopTimer {
    static class LoopTask extends TimerTask {

        @Override
        public void run() {
            while (true) {
                try {
                    // ... 执行任务
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    // 永远也没有机会执行
    static class ExampleTask extends TimerTask {
        @Override
        public void run() {

            System.out.println("hello");
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();
        timer.schedule(new LoopTask(), 10);
        timer.schedule(new ExampleTask(), 100);
    }
}
复制代码

第一个定时任务是一个无限循环,其后的定时任务ExampleTask将永远没有机会执行。

异常处理

关于Timer线程,还须要强调很是重要的一点,在执行任何一个任务的run方法时,一旦run抛出异常,Timer线程就会退出,从而全部定时任务都会被取消。咱们看个简单的示例:

public class TimerException {

    static class TaskA extends TimerTask {
        
        @Override
        public void run() {
            System.out.println("task A");
        }
    }
    
    static class TaskB extends TimerTask {
        
        @Override
        public void run() {
            System.out.println("task B");
            throw new RuntimeException();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Timer timer = new Timer();
        timer.schedule(new TaskA(), 1, 1000);
        timer.schedule(new TaskB(), 2000, 1000);
    }
}
复制代码

指望TaskA每秒执行一次,但TaskB会抛出异常,致使整个定时任务被取消,程序终止,屏幕输出为:

task A
task A
task B
Exception in thread "Timer-0" java.lang.RuntimeException
    at laoma.demo.timer.TimerException$TaskB.run(TimerException.java:21)
    at java.util.TimerThread.mainLoop(Timer.java:555)
    at java.util.TimerThread.run(Timer.java:505)
复制代码

因此,若是但愿各个定时任务不互相干扰,必定要在run方法内捕获全部异常

小结

能够看到,Timer/TimerTask的基本使用是比较简单的,但咱们须要注意:

  • 背后只有一个线程在运行
  • 固定频率的任务被延迟后,可能会当即执行屡次,将次数补够
  • 固定延时任务的延时相对的是任务执行前的时间
  • 不要在定时任务中使用无限循环
  • 一个定时任务的未处理异常会致使全部定时任务被取消

ScheduledExecutorService

接口和类定义

因为Timer/TimerTask的一些问题,Java并发包引入了ScheduledExecutorService,它是一个接口,其定义为:

public interface ScheduledExecutorService extends ExecutorService {
    //单次执行,在指定延时delay后运行command
    public ScheduledFuture<?> schedule(Runnable command, long delay, TimeUnit unit);
    //单次执行,在指定延时delay后运行callable
    public <V> ScheduledFuture<V> schedule(Callable<V> callable, long delay, TimeUnit unit);
    //固定频率重复执行
    public ScheduledFuture<?> scheduleAtFixedRate(Runnable command, long initialDelay, long period, TimeUnit unit);
    //固定延时重复执行
    public ScheduledFuture<?> scheduleWithFixedDelay(Runnable command, long initialDelay, long delay, TimeUnit unit);
}
复制代码

它们的返回类型都是ScheduledFuture,它是一个接口,扩展了Future和Delayed,没有定义额外方法。这些方法的大部分语义与Timer中的基本是相似的。对于固定频率的任务,第一次执行时间为initialDelay后,第二次为initialDelay+period,第三次initialDelay+2*period,依次类推。不过,对于固定延时的任务,它是从任务执行后开始算的,第一次为initialDelay后,第二次为第一次任务执行结束后再加上delay。与Timer不一样,它不支持以绝对时间做为首次运行的时间。

ScheduledExecutorService的主要实现类是ScheduledThreadPoolExecutor,它是线程池ThreadPoolExecutor的子类,是基于线程池实现的,它的主要构造方法是:

public ScheduledThreadPoolExecutor(int corePoolSize) 复制代码

此外,还有构造方法能够接受参数ThreadFactory和RejectedExecutionHandler,含义与ThreadPoolExecutor同样,咱们就不赘述了。

它的任务队列是一个无界的优先级队列,因此最大线程数对它没有做用,即便corePoolSize设为0,它也会至少运行一个线程。

工厂类Executors也提供了一些方便的方法,以方便建立ScheduledThreadPoolExecutor,以下所示:

//单线程的定时任务执行服务
public static ScheduledExecutorService newSingleThreadScheduledExecutor() public static ScheduledExecutorService newSingleThreadScheduledExecutor(ThreadFactory threadFactory) //多线程的定时任务执行服务 public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize) public static ScheduledExecutorService newScheduledThreadPool(int corePoolSize, ThreadFactory threadFactory) 复制代码

基本示例

因为能够有多个线程执行定时任务,通常任务就不会被某个长时间运行的任务所延迟了,好比,对于前面的TimerFixedDelay,若是改成:

public class ScheduledFixedDelay {
    static class LongRunningTask implements Runnable {
        @Override
        public void run() {
            try {
                Thread.sleep(5000);
            } catch (InterruptedException e) {
            }
            System.out.println("long running finished");
        }
    }

    static class FixedDelayTask implements Runnable {
        @Override
        public void run() {
            System.out.println(System.currentTimeMillis());
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ScheduledExecutorService timer = Executors.newScheduledThreadPool(10);
        timer.schedule(new LongRunningTask(), 10, TimeUnit.MILLISECONDS);
        timer.scheduleWithFixedDelay(new FixedDelayTask(), 100, 1000,
                TimeUnit.MILLISECONDS);
    }
}
复制代码

再次执行,第二个任务就不会被第一个任务延迟了。

另外,与Timer不一样,单个定时任务的异常不会再致使整个定时任务被取消了,即便背后只有一个线程执行任务,咱们看个例子:

public class ScheduledException {

    static class TaskA implements Runnable {

        @Override
        public void run() {
            System.out.println("task A");
        }
    }

    static class TaskB implements Runnable {

        @Override
        public void run() {
            System.out.println("task B");
            throw new RuntimeException();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ScheduledExecutorService timer = Executors
                .newSingleThreadScheduledExecutor();
        timer.scheduleWithFixedDelay(new TaskA(), 0, 1, TimeUnit.SECONDS);
        timer.scheduleWithFixedDelay(new TaskB(), 2, 1, TimeUnit.SECONDS);
    }
}
复制代码

TaskA和TaskB都是每秒执行一次,TaskB两秒后执行,但一执行就抛出异常,屏幕的输出相似以下:

task A
task A
task B
task A
task A
...
复制代码

这说明,定时任务TaskB被取消了,但TaskA不受影响,即便它们是由同一个线程执行的。不过,须要强调的是,与Timer不一样,没有异常被抛出来,TaskB的异常没有在任何地方体现。因此,与Timer中的任务相似,应该捕获全部异常

基本原理

ScheduledThreadPoolExecutor的实现思路与Timer基本是相似的,都有一个基于堆的优先级队列,保存待执行的定时任务,它的主要不一样是:

  • 它的背后是线程池,能够有多个线程执行任务
  • 它在任务执行后再设置下次执行的时间,对于固定延时的任务更为合理
  • 任务执行线程会捕获任务执行过程当中的全部异常,一个定时任务的异常不会影响其余定时任务,但发生异常的任务也再也不被从新调度,即便它是一个重复任务

小结

本节介绍了Java中定时任务的两种实现方式,Timer和ScheduledExecutorService,须要特别注意Timer的一些陷阱,实践中建议使用ScheduledExecutorService。

它们的共同局限是,不太胜任复杂的定时任务调度,好比,每周一和周三晚上18:00到22:00,每半小时执行一次。对于相似这种需求,能够利用咱们以前在32节33节介绍的日期和时间处理方法,或者利用更为强大的第三方类库,好比Quartz

在并发应用程序中,通常咱们应该尽可能利用高层次的服务,好比前面章节介绍的各类并发容器、任务执行服务线程池等,避免本身管理线程和它们之间的同步,但在个别状况下,本身管理线程及同步是必需的,这时,除了利用前面章节介绍的synchronized, wait/notify, 显示锁条件等基本工具,Java并发包还提供了一些高级的同步和协做工具,以方便实现并发应用,让咱们下一节来了解它们。

(与其余章节同样,本节全部代码位于 github.com/swiftma/pro…)


未完待续,查看最新文章,敬请关注微信公众号“老马说编程”(扫描下方二维码),从入门到高级,深刻浅出,老马和你一块儿探索Java编程及计算机技术的本质。用心原创,保留全部版权。

相关文章
相关标签/搜索