如何使用ScheduledExecutorService在特定时间每天运行特定任务?

108

我想每天早上5点运行某个任务,因此决定使用ScheduledExecutorService,但是目前我只看到了每几分钟运行任务的示例。

我找不到任何示例显示如何在每天特定时间(早上5点)运行任务,并考虑夏令时的因素。

以下是我的代码,它将每15分钟运行一次 -

public class ScheduledTaskExample {
    private final ScheduledExecutorService scheduler = Executors
        .newScheduledThreadPool(1);

    public void startScheduleTask() {
    /**
    * not using the taskHandle returned here, but it can be used to cancel
    * the task, or check if it's done (for recurring tasks, that's not
    * going to be very useful)
    */
    final ScheduledFuture<?> taskHandle = scheduler.scheduleAtFixedRate(
        new Runnable() {
            public void run() {
                try {
                    getDataFromDatabase();
                }catch(Exception ex) {
                    ex.printStackTrace(); //or loggger would be better
                }
            }
        }, 0, 15, TimeUnit.MINUTES);
    }

    private void getDataFromDatabase() {
        System.out.println("getting data...");
    }

    public static void main(String[] args) {
        ScheduledTaskExample ste = new ScheduledTaskExample();
        ste.startScheduleTask();
    }
}

有没有一种方法可以使用ScheduledExecutorService来计划每天早上5点运行任务,同时考虑夏令时的因素?

在这种情况下,TimerTaskScheduledExecutorService哪个更好?


使用类似于Quartz的东西。 - millimoose
13个回答

135

就像当前的Java SE 8版本一样,它具有出色的日期时间API——java.time,这种计算可以更轻松地完成,而不是使用java.util.Calendarjava.util.Date

  • 使用新API的日期时间类,例如LocalDateTime (教程)
  • 使用ZonedDateTime类处理特定于时区的计算,包括夏令时问题。您可以在这里找到相关的教程和示例

现在以一个样本示例来说明如何安排任务:

ZonedDateTime now = ZonedDateTime.now(ZoneId.of("America/Los_Angeles"));
ZonedDateTime nextRun = now.withHour(5).withMinute(0).withSecond(0);
if(now.compareTo(nextRun) > 0)
    nextRun = nextRun.plusDays(1);

Duration duration = Duration.between(now, nextRun);
long initialDelay = duration.getSeconds();

ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);            
scheduler.scheduleAtFixedRate(new MyRunnableTask(),
    initialDelay,
    TimeUnit.DAYS.toSeconds(1),
    TimeUnit.SECONDS);

initialDelay被计算为要求调度程序延迟TimeUnit.SECONDS执行。对于单位毫秒及以下的时间差问题,对于这种情况似乎可以忽略不计。但是,您仍然可以利用duration.toMillis()TimeUnit.MILLISECONDS来处理以毫秒为单位的调度计算。

在这种情况下,TimerTask还是ScheduledExecutorService更好?

否:ScheduledExecutorService似乎比TimerTask更好。 StackOverflow已经为您提供了答案

根据@PaddyD的说法,

如果您希望它以正确的本地时间运行,则每年需要重新启动两次。除非您满意全年使用相同的UTC时间,否则无法使用scheduleAtFixedRate。

由于这是正确的,并且@PaddyD已经给出了解决方法(+1),我将提供一个使用Java8日期时间API和ScheduledExecutorService的工作示例。使用守护线程是危险的

class MyTaskExecutor
{
    ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
    MyTask myTask;
    volatile boolean isStopIssued;

    public MyTaskExecutor(MyTask myTask$) 
    {
        myTask = myTask$;
        
    }
    
    public void startExecutionAt(int targetHour, int targetMin, int targetSec)
    {
        Runnable taskWrapper = new Runnable(){

            @Override
            public void run() 
            {
                myTask.execute();
                startExecutionAt(targetHour, targetMin, targetSec);
            }
            
        };
        long delay = computeNextDelay(targetHour, targetMin, targetSec);
        executorService.schedule(taskWrapper, delay, TimeUnit.SECONDS);
    }

    private long computeNextDelay(int targetHour, int targetMin, int targetSec) 
    {
        LocalDateTime localNow = LocalDateTime.now();
        ZoneId currentZone = ZoneId.systemDefault();
        ZonedDateTime zonedNow = ZonedDateTime.of(localNow, currentZone);
        ZonedDateTime zonedNextTarget = zonedNow.withHour(targetHour).withMinute(targetMin).withSecond(targetSec);
        if(zonedNow.compareTo(zonedNextTarget) > 0)
            zonedNextTarget = zonedNextTarget.plusDays(1);
        
        Duration duration = Duration.between(zonedNow, zonedNextTarget);
        return duration.getSeconds();
    }
    
    public void stop()
    {
        executorService.shutdown();
        try {
            executorService.awaitTermination(1, TimeUnit.DAYS);
        } catch (InterruptedException ex) {
            Logger.getLogger(MyTaskExecutor.class.getName()).log(Level.SEVERE, null, ex);
        }
    }
}

注意:

  • MyTask 是一个带有 execute 函数的接口。
  • 在停止 ScheduledExecutorService 时,始终在调用其上的 shutdown 后使用 awaitTermination: 您的任务可能会被卡住或死锁,用户会永远等待。

我之前给出的关于日历的示例只是一个想法,我提到了这一点,避免了确切的时间计算和夏令时问题。根据@PaddyD的抱怨更新了解决方案。


谢谢您的建议,您能详细解释一下 intDelayInHour 是如何表示我将在早上5点运行任务的吗? - AKIWEB
aDate的目的是什么? - José Andias
但是如果你在HH:mm开始这个任务,它将会在05:mm运行,而不是上午5点?它也没有考虑到OP所请求的夏令时。如果你在整点后立即启动它,或者如果你满意在5点到6点之间的任何时间,或者如果你不介意在每年两次更改时钟后重启应用程序的深夜中进行操作,那就可以了... - PaddyD
3
你仍然有一个问题,如果你想让它在正确的本地时间运行,你需要每年重启两次。scheduleAtFixedRate不会解决这个问题,除非你愿意一整年都使用相同的UTC时间。 - PaddyD
4
为什么下面的例子(第二个)会触发 n 次执行或者直到第二个时间过去?代码不应该每天只触发一次任务吗? - krizajb
显示剩余4条评论

30

在 Java 8 中:

scheduler = Executors.newScheduledThreadPool(1);

//Change here for the hour you want ----------------------------------.at()       
Long midnight=LocalDateTime.now().until(LocalDate.now().plusDays(1).atStartOfDay(), ChronoUnit.MINUTES);
scheduler.scheduleAtFixedRate(this, midnight, 1440, TimeUnit.MINUTES);

21
为了提高可读性,我建议使用TimeUnit.DAYS.toMinutes(1)代替"魔法数字"1440。 - philonous
1
谢谢,维克托。这样的话,如果我想让它在正确的当地时间运行,一年需要重新启动两次? - invzbl3
1
为了可读性,我建议使用数字1代替1440或TimeUnit.DAYS.toMinutes(1),然后使用时间单位TimeUnit.DAYS。;-) - Kelly Denehy
2
@invzbl3 — 你说得对,因为这并没有考虑到每年在夏令时切换时发生的23/25小时日子(或者无论夏令时转移量是多少)。 - M. Justin
1
说实话,这应该是被接受的答案,相比其他答案,它更加清晰简单。 - Alessandro Parisi
显示剩余7条评论

7
如果您无法使用Java 8,以下内容可以满足您的需求:
public class DailyRunnerDaemon
{
   private final Runnable dailyTask;
   private final int hour;
   private final int minute;
   private final int second;
   private final String runThreadName;

   public DailyRunnerDaemon(Calendar timeOfDay, Runnable dailyTask, String runThreadName)
   {
      this.dailyTask = dailyTask;
      this.hour = timeOfDay.get(Calendar.HOUR_OF_DAY);
      this.minute = timeOfDay.get(Calendar.MINUTE);
      this.second = timeOfDay.get(Calendar.SECOND);
      this.runThreadName = runThreadName;
   }

   public void start()
   {
      startTimer();
   }

   private void startTimer();
   {
      new Timer(runThreadName, true).schedule(new TimerTask()
      {
         @Override
         public void run()
         {
            dailyTask.run();
            startTimer();
         }
      }, getNextRunTime());
   }


   private Date getNextRunTime()
   {
      Calendar startTime = Calendar.getInstance();
      Calendar now = Calendar.getInstance();
      startTime.set(Calendar.HOUR_OF_DAY, hour);
      startTime.set(Calendar.MINUTE, minute);
      startTime.set(Calendar.SECOND, second);
      startTime.set(Calendar.MILLISECOND, 0);

      if(startTime.before(now) || startTime.equals(now))
      {
         startTime.add(Calendar.DATE, 1);
      }

      return startTime.getTime();
   }
}

它不需要任何外部库,并且会考虑夏令时。只需将要运行任务的时间作为Calendar对象传递,以及作为Runnable的任务即可。例如:

Calendar timeOfDay = Calendar.getInstance();
timeOfDay.set(Calendar.HOUR_OF_DAY, 5);
timeOfDay.set(Calendar.MINUTE, 0);
timeOfDay.set(Calendar.SECOND, 0);

new DailyRunnerDaemon(timeOfDay, new Runnable()
{
   @Override
   public void run()
   {
      try
      {
        // call whatever your daily task is here
        doHousekeeping();
      }
      catch(Exception e)
      {
        logger.error("An error occurred performing daily housekeeping", e);
      }
   }
}, "daily-housekeeping");

请注意,计时器任务在守护线程中运行,不建议用于执行任何IO操作。如果您需要使用用户线程,则需要添加另一个方法来取消计时器。
如果必须使用ScheduledExecutorService,请将startTimer方法更改为以下内容:
private void startTimer()
{
   Executors.newSingleThreadExecutor().schedule(new Runnable()
   {
      Thread.currentThread().setName(runThreadName);
      dailyTask.run();
      startTimer();
   }, getNextRunTime().getTime() - System.currentTimeMillis(),
   TimeUnit.MILLISECONDS);
}

我不确定具体行为,但如果您采用 ScheduledExecutorService 的方式,可能需要一个停止方法来调用 shutdownNow,否则在尝试停止应用程序时可能会出现挂起的情况。


我明白你的意思。+1,谢谢你。然而,最好不要使用守护线程(即new Timer(runThreadName, true))。 - Sage
@Sage 不用担心。如果你不进行任何IO操作,守护线程就可以了。我编写这个用例只是为了创建一个简单的“点火并忘记”类,以启动一些线程执行一些日常维护任务。我想,如果你在计时器任务线程中执行数据库读取,正如OP的请求所示,那么你不应该使用守护进程,并且需要一种停止方法,你必须调用它来使你的应用程序终止。https://dev59.com/Emw05IYBdhLWcg3wuUKY - PaddyD
@PaddyD 最后一部分,也就是使用ScheduledExecutorSerive的那一部分,是正确的吗?匿名类的创建方式在语法上看起来不正确。而且newSingleThreadExecutor()没有schedule方法,对吧? - Freeze Francis
1
如果有人卡在Java 6和7上,我建议他们使用大多数java.time功能的后移版本。请参阅ThreeTen-Backport项目。 - Basil Bourque

6

您是否考虑使用类似Quartz Scheduler的工具?该库具有使用类似于cron表达式的机制来安排任务每天定期运行的功能(请查看CronScheduleBuilder)。

以下是一些示例代码(未经测试):

public class GetDatabaseJob implements InterruptableJob
{
    public void execute(JobExecutionContext arg0) throws JobExecutionException
    {
        getFromDatabase();
    }
}

public class Example
{
    public static void main(String[] args)
    {
        JobDetails job = JobBuilder.newJob(GetDatabaseJob.class);

        // Schedule to run at 5 AM every day
        ScheduleBuilder scheduleBuilder = 
                CronScheduleBuilder.cronSchedule("0 0 5 * * ?");
        Trigger trigger = TriggerBuilder.newTrigger().
                withSchedule(scheduleBuilder).build();

        Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();
        scheduler.scheduleJob(job, trigger);

        scheduler.start();
    }
}

有些额外的工作需要完成,您可能需要重写作业执行代码,但这会让您更加掌控作业运行方式。此外,如果需要更改时间表,这也会更加容易。


6

Java8:
从最佳答案的升级版本:

  1. 修复了Web应用程序服务器由于具有空闲线程的线程池而不想停止的情况
  2. 无需递归
  3. 在您自定义的本地时间下运行任务,在我的情况下,是白俄罗斯明斯克


/**
 * Execute {@link AppWork} once per day.
 * <p>
 * Created by aalexeenka on 29.12.2016.
 */
public class OncePerDayAppWorkExecutor {

    private static final Logger LOG = AppLoggerFactory.getScheduleLog(OncePerDayAppWorkExecutor.class);

    private ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);

    private final String name;
    private final AppWork appWork;

    private final int targetHour;
    private final int targetMin;
    private final int targetSec;

    private volatile boolean isBusy = false;
    private volatile ScheduledFuture<?> scheduledTask = null;

    private AtomicInteger completedTasks = new AtomicInteger(0);

    public OncePerDayAppWorkExecutor(
            String name,
            AppWork appWork,
            int targetHour,
            int targetMin,
            int targetSec
    ) {
        this.name = "Executor [" + name + "]";
        this.appWork = appWork;

        this.targetHour = targetHour;
        this.targetMin = targetMin;
        this.targetSec = targetSec;
    }

    public void start() {
        scheduleNextTask(doTaskWork());
    }

    private Runnable doTaskWork() {
        return () -> {
            LOG.info(name + " [" + completedTasks.get() + "] start: " + minskDateTime());
            try {
                isBusy = true;
                appWork.doWork();
                LOG.info(name + " finish work in " + minskDateTime());
            } catch (Exception ex) {
                LOG.error(name + " throw exception in " + minskDateTime(), ex);
            } finally {
                isBusy = false;
            }
            scheduleNextTask(doTaskWork());
            LOG.info(name + " [" + completedTasks.get() + "] finish: " + minskDateTime());
            LOG.info(name + " completed tasks: " + completedTasks.incrementAndGet());
        };
    }

    private void scheduleNextTask(Runnable task) {
        LOG.info(name + " make schedule in " + minskDateTime());
        long delay = computeNextDelay(targetHour, targetMin, targetSec);
        LOG.info(name + " has delay in " + delay);
        scheduledTask = executorService.schedule(task, delay, TimeUnit.SECONDS);
    }

    private static long computeNextDelay(int targetHour, int targetMin, int targetSec) {
        ZonedDateTime zonedNow = minskDateTime();
        ZonedDateTime zonedNextTarget = zonedNow.withHour(targetHour).withMinute(targetMin).withSecond(targetSec).withNano(0);

        if (zonedNow.compareTo(zonedNextTarget) > 0) {
            zonedNextTarget = zonedNextTarget.plusDays(1);
        }

        Duration duration = Duration.between(zonedNow, zonedNextTarget);
        return duration.getSeconds();
    }

    public static ZonedDateTime minskDateTime() {
        return ZonedDateTime.now(ZoneId.of("Europe/Minsk"));
    }

    public void stop() {
        LOG.info(name + " is stopping.");
        if (scheduledTask != null) {
            scheduledTask.cancel(false);
        }
        executorService.shutdown();
        LOG.info(name + " stopped.");
        try {
            LOG.info(name + " awaitTermination, start: isBusy [ " + isBusy + "]");
            // wait one minute to termination if busy
            if (isBusy) {
                executorService.awaitTermination(1, TimeUnit.MINUTES);
            }
        } catch (InterruptedException ex) {
            LOG.error(name + " awaitTermination exception", ex);
        } finally {
            LOG.info(name + " awaitTermination, finish");
        }
    }

}

1
我有类似的需求。我需要在给定时间安排一天的任务。一旦安排的任务完成,就在下一天的指定时间安排任务。这应该继续进行。我的问题是如何找出已安排的任务是否已完成。只有知道已安排的任务已完成,我才能安排下一天的任务。 - amitwdh

2

如果当天时间在当前时间之前,您可以使用简单的日期解析,让我们从明天开始:

  String timeToStart = "12:17:30";
  SimpleDateFormat format = new SimpleDateFormat("yyyy-MM-dd 'at' HH:mm:ss");
  SimpleDateFormat formatOnlyDay = new SimpleDateFormat("yyyy-MM-dd");
  Date now = new Date();
  Date dateToStart = format.parse(formatOnlyDay.format(now) + " at " + timeToStart);
  long diff = dateToStart.getTime() - now.getTime();
  if (diff < 0) {
    // tomorrow
    Date tomorrow = new Date();
    Calendar c = Calendar.getInstance();
    c.setTime(tomorrow);
    c.add(Calendar.DATE, 1);
    tomorrow = c.getTime();
    dateToStart = format.parse(formatOnlyDay.format(tomorrow) + " at " + timeToStart);
    diff = dateToStart.getTime() - now.getTime();
  }

  ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);            
  scheduler.scheduleAtFixedRate(new MyRunnableTask(), TimeUnit.MILLISECONDS.toSeconds(diff) ,
                                  24*60*60, TimeUnit.SECONDS);

2
如果你的服务器在凌晨4:59宕机并在5:01恢复,会发生什么?我认为它会跳过这个任务。我建议使用像Quartz这样的持久调度程序,它会将其调度数据存储在某个地方。然后它会发现这个任务还没有执行,会在5:01AM执行。

2

我也曾遇到过类似的问题。需要使用 ScheduledExecutorService 安排一天中要执行的任务。解决方法是一个任务从早上 3:30 开始,根据他的当前时间相对地安排其他所有任务的时间,并在第二天早上 3:30 再次安排自己。

这样做就不再受夏令时的影响了。


2
您可以使用以下类来安排每天特定时间的任务。
package interfaces;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;

public class CronDemo implements Runnable{

    public static void main(String[] args) {

        Long delayTime;

        ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);

        final Long initialDelay = LocalDateTime.now().until(LocalDate.now().plusDays(1).atTime(12, 30), ChronoUnit.MINUTES);

        if (initialDelay > TimeUnit.DAYS.toMinutes(1)) {
            delayTime = LocalDateTime.now().until(LocalDate.now().atTime(12, 30), ChronoUnit.MINUTES);
        } else {
            delayTime = initialDelay;
        }

        scheduler.scheduleAtFixedRate(new CronDemo(), delayTime, TimeUnit.DAYS.toMinutes(1), TimeUnit.MINUTES);

    }

    @Override
    public void run() {
        System.out.println("I am your job executin at:" + new Date());
    }
}

2
请勿在2019年使用过时的DateTimeUnit - Mark Jeronimus

1

补充一下Victor的回答

我建议添加一个检查,看看变量(在他的情况下是长整型midnight)是否大于1440。如果是,则省略.plusDays(1),否则任务将在后天才运行。

我只是简单地这样做:

Long time;

final Long tempTime = LocalDateTime.now().until(LocalDate.now().plusDays(1).atTime(7, 0), ChronoUnit.MINUTES);
if (tempTime > 1440) {
    time = LocalDateTime.now().until(LocalDate.now().atTime(7, 0), ChronoUnit.MINUTES);
} else {
    time = tempTime;
}

如果您使用truncatedTo(),它会变得更简单。 - Mark Jeronimus

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接