spring cron表达式及解析过程

1.cron表达式

cron表达式是用来配置spring定时任务执行时间的字符串,由5个空格分隔成的6个域构成,格式如下:

{秒}  {分}  {时}  {日}  {月}  {周}

每一个域的含义解释:
1)秒:表示在指定的秒数触发定时任务,范围0-59。例如,"*"表示任何秒都触发,"0,3"表示0秒和3秒触发。
2)分:表示在指定的分钟触发定时任务,范围0-59。例如,"0-3"表示0分钟到3分钟每分钟都触发,"0/2"表示只有偶数分钟触发。
3)时:表示在指定的小时触发定时任务,范围0-23。例如,"3-15/2"表示上午3点到下午3点每隔2个小时触发。
4)日:表示在指定的日期触发定时任务,范围1-31(可以写0,但不会生效)。例如,"1"表示1号出发,"5,15"表示5号和15号出发。需要注意的是,日期可以写0,不会报错但也不会生效。
5)月:表示在指定的月份触发定时任务,范围1-12。例如,"1-4,12"表示1月到4月以及12月触发。
6)周:表示在指定的星期触发定时任务,范围0-7(0和7都表示周日)。例如,"?"表示一周都触发,"6,7"表示周六日触发。
注意,1月到12月可以用对应的英文缩写JAN-DEC代替,周日到周六可以用对应的英文缩写SUN-SAT代替。但是,周日的缩写SUN只会被替换为0,因此在cron表达式的周域,我们可以写6-7,却不能写SAT-SUN。

表1-1总结了cron表达式中域的范围和可能出现的特殊符号:

  表1-1

范围
特殊字符
是否必需
0-59
, - * /
Y
0-59
, - * /
Y
0-23
, - * /
Y
1-31
, - * / ?
Y
1-12或JAN-DEC
, - * /
Y
0-7或SUN-SAT
, - * / ?
Y

特殊字符的含义说明如下:
1)"*":匹配该域的任意值,例如在日域上使用"*",则表示每天都触发该定时任务。
2)"?":只能在日和周域使用,表示非明确的值,实际作用等同"*",即匹配任意值。一般在日和周上会出现一次,当然,如果你对日和周两个域都使用"?"或者都使用其他值也没什么问题。
3)"-":表示范围,例如在分域上使用5-10表示从5分钟到10分钟每分钟触发一次。
4)"/":表示起始时间触发一次,然后每隔固定时间触发一次。例如,在分钟域使用"10/2"表示从10分钟开始每隔2分钟触发一次,直    到58分钟。也可以和字符"-"连用,例如在分钟域使用"10-30/2"表示从10分钟开始每隔2分钟触发一次,直到30分钟。
5)",":表示枚举多个值,这些值之间是"或"的关系。例如,在月份上使用"1-3,10,12"表示1月到3月,10月,12月都触发。

下面是一些cron表达式和对应的含义:
"0 15 10 ? * *"  每天上午10:15触发
"0 0/5 14 * * ?"  在每天下午2点到下午2:55期间的每5分钟触发
"0 0-5 14 * * ?"  每天下午2点到下午2:05期间的每1分钟触发
"0 10,44 14 ? 3 WED"  三月的星期三的下午2:10和2:44触发
"0 15 10 ? * MON-FRI"  周一至周五的上午10:15触发

2.cron定时任务的调度

   在说cron表达式的解析过程之前,先了解一下spring的cron定时任务调度大体框架。图2-1是cron定时任务涉及的主要类及他们之间的关系。左边的红色部分包括三个类Trigger,CronTrigger,CronsequenceGenerator,它们解决的问题是如何根据任务的上一次执行时间,计算出符合cron表达式的下一次执行时间,即nextExcutionTime接口。

CronSequenceGenerator负责解析用户配置的cron表达式,并提供next接口,即根据给定时间获取符合cron表达式规则的最近的下一个时间。CronTrigger实现Trigger的nextExecutionTime接口,根据定时任务执行的上下文环境(最近调度时间和最近完成时间)决定查找下一次执行时间的左边界,之后调用CronSequenceGenerator的next接口从左边界开始找下一次的执行时间。

右边的橙色部分包括四个类Runnable,ReschedulingRunable,ScheduledExecutorService,ScheduledThreadPoolExecutor。解决的问题是当计算出定时任务的执行时间序列之后,如何沿着这个时间序列不断的执行定时任务。ReschedulingRunnable的主要接口包括schedule方法和run方法。schedule方法根据CronTrigger的nextExecutionTime接口返回的下一次执行时间,计算与当前时间的相对延迟时间delay,然后调用ScheduledExecutorService的schedule延迟执行方法对当前任务延调度。当该任务真正被执行时,运行ReschedulingRunnable的run方法。run方法首先执行用户任务,当本次用户任务执行完成之后,再调用schedule方法,继续调度当前任务。这样以来,用户任务就能够沿着计算出的执行时间序列,一次又一次的执行。

                                                        图2-1

3.cron表达式解析过程

在图2-1中,CronsequenceGenerator负责解析cron表达式并提供next接口。

3.1 cron位数组

   cron表达式本身是一个字符串,虽然对于我们人来说直观易懂,但是对于计算机却并不十分友好。因此,在CronSequenceGenerator中使用与cron表达式含有等价信息的cron位数组来表示匹配规则,如下图所示。对于cron表达式中的秒,分,时,日,月,周六个域,CronSequenceGenerator分别对应设置了seconds,minutes,hours,daysOfMonth,months,daysOfWeek六个位数组。大体思路是:对于某个域,如果数字value是一个匹配值,则将位数组的第value位设置为1,否则设置0。
(注:为什么使用位数组,而不使用list,set之类的容易的,一方面是空间效率,更重要的是接下来的操作主要是判断某个值是否匹配和从某个值开始找最近的下一个能够匹配的值,这两个操作对于list和set并不是很简单)

              图3-1  cron位数组,灰色表示无效位

    CronSequenceGenerator的parse方法具体负责将cron表达式解析成cron位数组。首先根据空格分隔cron表达式,得到秒分时日月周6个域分别对应的子cron表达式。对于秒分时三个域的解析使用基础解析算法处理,基础解析算法只处理","、"*"、"-"、"/"四个字符,如图3-2所示:

                               图3-2  基础解析算法

基础解析算法源码:

private void setNumberHits(BitSet bits, String value, int min, int max) {
   String[] fields = StringUtils.delimitedListToStringArray(value, ",");
   for (String field : fields) {
      if (!field.contains("/")) {
         // Not an incrementer so it must be a range (possibly empty)
         int[] range = getRange(field, min, max);
         bits.set(range[0], range[1] + 1);
      }
      else {
         String[] split = StringUtils.delimitedListToStringArray(field, "/");
         if (split.length > 2) {
            throw new IllegalArgumentException("Incrementer has more than two fields: '" +
                  field + "' in expression \"" + this.expression + "\"");
         }
         int[] range = getRange(split[0], min, max);
         if (!split[0].contains("-")) {
            range[1] = max - 1;
         }
         int delta = Integer.valueOf(split[1]);
         for (int i = range[0]; i <= range[1]; i += delta) {
            bits.set(i);
         }
      }
   }
}
 
private int[] getRange(String field, int min, int max) {
   int[] result = new int[2];
   if (field.contains("*")) {
      result[0] = min;
      result[1] = max - 1;
      return result;
   }
   if (!field.contains("-")) {
      result[0] = result[1] = Integer.valueOf(field);
   }
   else {
      String[] split = StringUtils.delimitedListToStringArray(field, "-");
      if (split.length > 2) {
         throw new IllegalArgumentException("Range has more than two fields: '" +
               field + "' in expression \"" + this.expression + "\"");
      }
      result[0] = Integer.valueOf(split[0]);
      result[1] = Integer.valueOf(split[1]);
   }
   if (result[0] >= max || result[1] >= max) {
      throw new IllegalArgumentException("Range exceeds maximum (" + max + "): '" +
            field + "' in expression \"" + this.expression + "\"");
   }
   if (result[0] < min || result[1] < min) {
      throw new IllegalArgumentException("Range less than minimum (" + min + "): '" +
            field + "' in expression \"" + this.expression + "\"");
   }
   return result;
}

对于日期,先将该域的子cron表达式中出现的字符"?"替换成"*",然后使用基础解析算法进行处理。日期的范围是1-31,因此位数组的第0位是用不到的,在基础解析算法之后进行清除。位数组的第0位最后会清除。

源码:

private void setDaysOfMonth(BitSet bits, String field) {
   int max = 31;
   // Days of month start with 1 (in Cron and Calendar) so add one
   setDays(bits, field, max + 1);
   // ... and remove it from the front
   bits.clear(0);
}
 
private void setDays(BitSet bits, String field, int max) {
   if (field.contains("?")) {
      field = "*";
   }
   setNumberHits(bits, field, 0, max);

}

对于月份,先将该域的英文缩写JAN-DEC替换成对应的数字(1-12),然后使用基础解析算法进行处理。但是由于cron表达式中配置的月份范围是1-12,Calendar中的月份范围是0-11,所以为了后续算法使用方便,在基础解析算法处理完之后将months位数组整体左移1位。

源码:

private void setMonths(BitSet bits, String value) {
   int max = 12;
   value = replaceOrdinals(value, "FOO,JAN,FEB,MAR,APR,MAY,JUN,JUL,AUG,SEP,OCT,NOV,DEC");
   BitSet months = new BitSet(13);
   // Months start with 1 in Cron and 0 in Calendar, so push the values first into a longer bit set
   setNumberHits(months, value, 1, max + 1);
   // ... and then rotate it to the front of the months
   for (int i = 1; i <= max; i++) {
      if (months.get(i)) {
         bits.set(i - 1);
      }
   }
}

对于星期,先将该域的英文缩写SUN-SAT替换成对应的数字(0-6),接着将该域中的字符"?"替换成"*",然后使用基础解析算法处理。最后,由于周日对应的值有两个0和7,因此对daysOfWeek位数组的第0位和第7位取或,将结果保存到第0位,并清除第7位。(Calendar的星期范围是1-7,为什么使用第0-6位,不使用1-7位呢)

源码:

setDays(this.daysOfWeek, replaceOrdinals(fields[5], "SUN,MON,TUE,WED,THU,FRI,SAT"), 8);
if (this.daysOfWeek.get(7)) {
   // Sunday can be represented as 0 or 7
   this.daysOfWeek.set(0);
   this.daysOfWeek.clear(7);
}
  
private void setDays(BitSet bits, String field, int max) {
   if (field.contains("?")) {
      field = "*";
   }
   setNumberHits(bits, field, 0, max);
}

举个例子,图3-3是cron表达式"0 59 21 ? * MON-FRI"(周一至周五的下午21:59:00触发)解析后得到的位数组,红色表示1,白色表示0,灰色表示用不到。

                                      图3-3

3.2 doNext算法

CronSequenceGenerator的doNext算法从指定时间开始(包括指定时间)查找符合cron表达式规则下一个匹配的时间。如图3-4所示,其整体思路是:

沿着秒→分→时→日→月逐步检查指定时间的值。如果所有域上的值都已经符合规则那么指定时间符合cron表达式,算法结束。否则,必然有某个域的值不符合规则,调整该域到下一个符合规则的值(可能调整更高的域),并将较低域的值调整到最小值,然后从秒开始重新检查和调整。(假如需要多次调整日月的话,秒分时岂不是要做很多次无用功?)

                         图3-4 doNext算法

具体实现上,对于秒,分,时,月四个范围固定的四个域,调用findNext方法从对应的位数组中从当前值开始(包括当前值)查找下一个匹配值,有三种情况:

1)下一个匹配值就是当前值,则匹配通过,如果当前域是月则算法结束,否则继续处理下一个更高的域。
2)下一个匹配值不是当前值但也不是-1,则将当前域设置为下一个匹配值,将比当前域低的所有域设置为最小值,递归调度本算法(如果是月份且年份超过原始年份4年以上则抛异常)。(递归之后不知道为什么没有return,其实递归调度结束后当前的执行过程就可以结束了)
3)下一个匹配值是-1,则将对更高的域做加1操作,从0开始查找下一个匹配值(肯定能找到,要不cron表达式不合法,解析阶段就抛异常了),将当前域
   设置为下一个匹配值,重置比当前域低的所有域设置为最小值,递归调度本算法(如果是月份且年份超过原始年份4年以上则抛异常)。
对于时间中的日,则情况比较复杂,比如从2016年1月31日开始找下一个30日的周五(虽然同时设置日和周的情况比较少见),则仅仅调整一次月份是无法找到下一个匹配的日期的。
spring的实现方案是从当前时间开始连续搜索366天,匹配规则是日期和周同时匹配,有三种结果:
1)找不到下一个匹配的日期,则抛异常。
2)找到下一个匹配的日期且与当前日期相等,则继续处理月份。(应该多判断一下月份和年份,万一月份或年份被调整了呢?)
3)找到下一个匹配的日期且与当前日期不等,则重置比日期低的域为最小值,并递归调度doNext算法。
(日期的处理略粗糙,总感觉打开的方式不对..)

doNext算法源码:

private void doNext(Calendar calendar, int dot) {
   List<Integer> resets = new ArrayList<Integer>();
 
   int second = calendar.get(Calendar.SECOND);
   List<Integer> emptyList = Collections.emptyList();
   int updateSecond = findNext(this.seconds, second, calendar, Calendar.SECOND, Calendar.MINUTE, emptyList);
   if (second == updateSecond) {
      resets.add(Calendar.SECOND);
   }
 
   int minute = calendar.get(Calendar.MINUTE);
   int updateMinute = findNext(this.minutes, minute, calendar, Calendar.MINUTE, Calendar.HOUR_OF_DAY, resets);
   if (minute == updateMinute) {
      resets.add(Calendar.MINUTE);
   }
   else {
      doNext(calendar, dot);
   }
 
   int hour = calendar.get(Calendar.HOUR_OF_DAY);
   int updateHour = findNext(this.hours, hour, calendar, Calendar.HOUR_OF_DAY, Calendar.DAY_OF_WEEK, resets);
   if (hour == updateHour) {
      resets.add(Calendar.HOUR_OF_DAY);
   }
   else {
      doNext(calendar, dot);
   }
 
   int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
   int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
   int updateDayOfMonth = findNextDay(calendar, this.daysOfMonth, dayOfMonth, daysOfWeek, dayOfWeek, resets);
   if (dayOfMonth == updateDayOfMonth) {
      resets.add(Calendar.DAY_OF_MONTH);
   }
   else {
      doNext(calendar, dot);
   }
 
   int month = calendar.get(Calendar.MONTH);
   int updateMonth = findNext(this.months, month, calendar, Calendar.MONTH, Calendar.YEAR, resets);
   if (month != updateMonth) {
      if (calendar.get(Calendar.YEAR) - dot > 4) {
         throw new IllegalArgumentException("Invalid cron expression \"" + this.expression +
               "\" led to runaway search for next trigger");
      }
      doNext(calendar, dot);
   }
 
}
 
private int findNextDay(Calendar calendar, BitSet daysOfMonth, int dayOfMonth, BitSet daysOfWeek, int dayOfWeek,
      List<Integer> resets) {
 
   int count = 0;
   int max = 366;
   // the DAY_OF_WEEK values in java.util.Calendar start with 1 (Sunday),
   // but in the cron pattern, they start with 0, so we subtract 1 here
   while ((!daysOfMonth.get(dayOfMonth) || !daysOfWeek.get(dayOfWeek - 1)) && count++ < max) {
      calendar.add(Calendar.DAY_OF_MONTH, 1);
      dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
      dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK);
      reset(calendar, resets);
   }
   if (count >= max) {
      throw new IllegalArgumentException("Overflow in day for expression \"" + this.expression + "\"");
   }
   return dayOfMonth;
}
 
/**
 * Search the bits provided for the next set bit after the value provided,
 * and reset the calendar.
 * @param bits a {@link BitSet} representing the allowed values of the field
 * @param value the current value of the field
 * @param calendar the calendar to increment as we move through the bits
 * @param field the field to increment in the calendar (@see
 * {@link Calendar} for the static constants defining valid fields)
 * @param lowerOrders the Calendar field ids that should be reset (i.e. the
 * ones of lower significance than the field of interest)
 * @return the value of the calendar field that is next in the sequence
 */
private int findNext(BitSet bits, int value, Calendar calendar, int field, int nextField, List<Integer> lowerOrders) {
   int nextValue = bits.nextSetBit(value);
   // roll over if needed
   if (nextValue == -1) {
      calendar.add(nextField, 1);
      reset(calendar, Arrays.asList(field));
      nextValue = bits.nextSetBit(0);
   }
   if (nextValue != value) {
      calendar.set(field, nextValue);
      reset(calendar, lowerOrders);
   }
   return nextValue;
}
 
/**
 * Reset the calendar setting all the fields provided to zero.
 */
private void reset(Calendar calendar, List<Integer> fields) {
   for (int field : fields) {
      calendar.set(field, field == Calendar.DAY_OF_MONTH ? 1 : 0);
   }
}

(注:源码中对秒的处理与图3-4不一致,当下一个匹配的秒数与当前值不等时没有递归调用。当cron表达式为"0/2 1 * * * * *",指定时间为2016-12-25 18:00:45时,doNext算法计算出的下一个匹配时间为2016-12-25 18:01:46,正确的结果是2016-12-25 18:01:00。可能是源码少写了一行代码)

3.3 next接口

next接口首先调用doNext方法从指定时间开始(包括该指定时间)计算出下一个符合cron表达式规则的时间,如果doNext的结果和指定时间不等则直接返回,如果相等则对指定时间加一秒,然后重新调用doNext算法计算下一个时间并返回(重新计算出的时间肯定和指定时间不等了)。
(注:为什么不直接先加1秒,然后doNext呢(addSecond→doNext)?原因是虽然这样代码更简洁而且能得到正确结果但是效率相对更低。原因对Calendar表示的时间加1秒来说其实是个相对复杂的工作。另外,一般情况下指定时间不符合cron表达式的概率很大(毕竟配置6个*号也不多见),所以只执行doNext的概率比执行doNext→addSecond→doNet的概率要大得多(类似6个*这种cron配置情况除外)。另外当执行doNext→addSecond→doNext时,说明指定时间是匹配cron表达式的,当指定时间匹配cron表达式的时候,doNext仅仅对6个域分别做了一次check而已,没有递归调用,耗时可以忽略不计。这样算下来,doNext→addSecond→doNext虽然代码看起来更复杂,但效率更高一些。)

next接口源码:

/**
 * Get the next {@link Date} in the sequence matching the Cron pattern and
 * after the value provided. The return value will have a whole number of
 * seconds, and will be after the input value.
 * @param date a seed value
 * @return the next value matching the pattern
 */
public Date next(Date date) {
   /*
   The plan:
 
   1 Round up to the next whole second
 
   2 If seconds match move on, otherwise find the next match:
   2.1 If next match is in the next minute then roll forwards
 
   3 If minute matches move on, otherwise find the next match
   3.1 If next match is in the next hour then roll forwards
   3.2 Reset the seconds and go to 2
 
   4 If hour matches move on, otherwise find the next match
   4.1 If next match is in the next day then roll forwards,
   4.2 Reset the minutes and seconds and go to 2
 
   ...
   */
 
   Calendar calendar = new GregorianCalendar();
   calendar.setTimeZone(this.timeZone);
   calendar.setTime(date);
 
   // First, just reset the milliseconds and try to calculate from there...
   calendar.set(Calendar.MILLISECOND, 0);
   long originalTimestamp = calendar.getTimeInMillis();
   doNext(calendar, calendar.get(Calendar.YEAR));
 
   if (calendar.getTimeInMillis() == originalTimestamp) {
      // We arrived at the original timestamp - round up to the next whole second and try again...
      calendar.add(Calendar.SECOND, 1);
      doNext(calendar, calendar.get(Calendar.YEAR));
   }
 
   return calendar.getTime();
}

4.spring解析算法存在的问题

当前的cron解析算法,主要是doNext算法,存在的问题总结如下表:

                                                                    表4-1

编号
问题
后果
1 对秒的处理有漏洞,当秒域调整之后,没有递归调度doNext算法。 导致bug,见3.2最后的问题说明。
2 在递归调用doNext方法结束之后,时间已经调整到预期值,但当前方法还会继续执行 影响效率,虽然不是很严重。 全部
3 找下一个匹配的日期,最多查找366天 方法略粗糙,而且多了一个限制
4 找到下一个匹配日期后,只判断日期域是否和指定时间的日期相等,而没有判断月份和年份是否修改。 当月份和年份被修改,而日期不变的情况下,不会递归调用doNext方法
5 从低域(秒)到高域(月)的处理过程 如果日月调整次数比较多,则秒分时上的无效调整会做很多无用功,并影响效率。 全部

5.新的doNext算法

新的doNext算法的思路主要是按照月→日→时→分→秒的顺序,对指定时间按照规则进行调整,如图5-1所示。主要思路是:当执行到某一个域时,先判断是否有更高的域已经调整过,如果更高的域调整过则我们只需要将该域设置为符合规则的最小值即可。如果更高的域都没有调整过,则判断当前域的值是否符合匹配规则。如果不匹配则调整该域的值,并通知更低的域其已经被调整过;如果匹配则进入下一个域的执行逻辑。

             图5-1

图5-1可以看出,关键是如何判断某个域的值是否匹配cron表达式,以及当某个域的值不匹配时如何调整该域到下一个最近匹配的值,这两个操作称为检查操作和调整操作。
在检查操作中,假如某个域的值是value。对于月时分秒四个域只需要判断位数组的第value位是否为1即可,而对于日期,除了判断daysOfMonth的第value位之外,还要判断daysOfWeek的第value位,同时为1才算匹配。
在调整操作中,对于月,时,分,秒四个域可以直接通过对应位数组查找下一个匹配的值,有三种情况:
1)下一个匹配值是当前值,说明当前值已经符合cron表达式,不调整。
2)下一个匹配值不是当前值也不是-1,则将当前域设置为下一个匹配值。
3)下一个匹配值是-1,则先对更高一级的域做加1操作,然后调整更高一级的域使其符合cron表达式(可能涉及调整所有其他更高的域)。然后从0开始找匹配值,并设置为当前域的值(只要更高的域调整过,当前域只需要设置为最小匹配值)。
对于日期的调整稍微复杂一些,可能需要调整多次:
1).如果daysOfMonth和daysOfWeek中当前日期的对应位都是1,则不需要调整,否则进入步骤2。
2)获取当前月份的实际最大天数(考虑月份和是否闰年),根据daysOfMonth从当前日期+1开始查找下一个匹配日期(当前日期已经在第1步证明不匹配了,所以从当前日期+1处查找)。如果下一个匹配日期正常,则将月设置为下一个匹配值即可。否则,即下一个匹配日期是-1或者超过该月的实际最大天数,则将月份加1并调整月到下一个符合规则的月并设置日期为1,然后回到步骤1(为什么不走其他域的类似逻辑,即从0找到最小匹配值然后将当前域设置为这个最小值?考虑这种情况:月份不限,日期限制在30号,如果当前时间是1月31号,那么月份调整后是2,我们会设置一个不存在的2月30号)。

新的doNext算法源码:

//从calendar开始寻找下一个匹配cron表达式的时间
private void doNextNew(Calendar calendar) {
    //calendar中比当前更高的域是否调整过
    boolean changed = false;
    List<Integer> fields = Arrays.asList(Calendar.MONTH, Calendar.DAY_OF_MONTH,
            Calendar.HOUR_OF_DAY, Calendar.MINUTE, Calendar.SECOND);
 
    //依次调整月,日,时,分,秒
    for (int field : fields) {
        if (changed) {
            calendar.set(field, field == Calendar.DAY_OF_MONTH ? 1 : 0);
        }
        if (!checkField(calendar, field)) {
            changed = true;
            findNext(calendar, field);
        }
    }
}
 
//检查某个域是否匹配cron表达式
private boolean checkField(Calendar calendar, int field) {
    switch (field) {
        case Calendar.MONTH: {
            int month = calendar.get(Calendar.MONTH);
            return this.months.get(month);
        }
        case Calendar.DAY_OF_MONTH: {
            int dayOfMonth = calendar.get(Calendar.DAY_OF_MONTH);
            int dayOfWeek = calendar.get(Calendar.DAY_OF_WEEK) - 1;
            return this.daysOfMonth.get(dayOfMonth) && this.daysOfWeek.get(dayOfWeek);
        }
        case Calendar.HOUR_OF_DAY: {
            int hour = calendar.get(Calendar.HOUR_OF_DAY);
            return this.hours.get(hour);
        }
        case Calendar.MINUTE: {
            int minute = calendar.get(Calendar.MINUTE);
            return this.minutes.get(minute);
        }
        case Calendar.SECOND: {
            int second = calendar.get(Calendar.SECOND);
            return this.seconds.get(second);
        }
        default:
            return true;
    }
}
 
//调整某个域到下一个匹配值,使其符合cron表达式
private void findNext(Calendar calendar, int field) {
    switch (field) {
        case Calendar.MONTH: {
            if (calendar.get(Calendar.YEAR) > 2099) {
                throw new IllegalArgumentException("year exceeds 2099!");
            }
            int month = calendar.get(Calendar.MONTH);
            int nextMonth = this.months.nextSetBit(month);
            if (nextMonth == -1) {
                calendar.add(Calendar.YEAR, 1);
                calendar.set(Calendar.MONTH, 0);
                nextMonth = this.months.nextSetBit(0);
            }
            if (nextMonth != month) {
                calendar.set(Calendar.MONTH, nextMonth);
            }
            break;
        }
        case Calendar.DAY_OF_MONTH: {
            while (!this.daysOfMonth.get(calendar.get(Calendar.DAY_OF_MONTH))
                    || !this.daysOfWeek.get(calendar.get(Calendar.DAY_OF_WEEK) - 1)) {
                int max = calendar.getActualMaximum(Calendar.DAY_OF_MONTH);
                int nextDayOfMonth = this.daysOfMonth.nextSetBit(calendar.get(Calendar.DAY_OF_MONTH) + 1);
                if (nextDayOfMonth == -1 || nextDayOfMonth > max) {
                    calendar.add(Calendar.MONTH, 1);
                    findNext(calendar, Calendar.MONTH);
                    calendar.set(Calendar.DAY_OF_MONTH, 1);
                } else {
                    calendar.set(Calendar.DAY_OF_MONTH, nextDayOfMonth);
                }
            }
            break;
        }
        case Calendar.HOUR_OF_DAY: {
            int hour = calendar.get(Calendar.HOUR_OF_DAY);
            int nextHour = this.hours.nextSetBit(hour);
            if (nextHour == -1) {
                calendar.add(Calendar.DAY_OF_MONTH, 1);
                findNext(calendar, Calendar.DAY_OF_MONTH);
                calendar.set(Calendar.HOUR_OF_DAY, 0);
                nextHour = this.hours.nextSetBit(0);
            }
            if (nextHour != hour) {
                calendar.set(Calendar.HOUR_OF_DAY, nextHour);
            }
            break;
        }
        case Calendar.MINUTE: {
            int minute = calendar.get(Calendar.MINUTE);
            int nextMinute = this.minutes.nextSetBit(minute);
            if (nextMinute == -1) {
                calendar.add(Calendar.HOUR_OF_DAY, 1);
                findNext(calendar, Calendar.HOUR_OF_DAY);
                calendar.set(Calendar.MINUTE, 0);
                nextMinute = this.minutes.nextSetBit(0);
            }
            if (nextMinute != minute) {
                calendar.set(Calendar.MINUTE, nextMinute);
            }
            break;
        }
        case Calendar.SECOND: {
            int second = calendar.get(Calendar.SECOND);
            int nextSecond = this.seconds.nextSetBit(second);
            if (nextSecond == -1) {
                calendar.add(Calendar.MINUTE, 1);
                findNext(calendar, Calendar.MINUTE);
                calendar.set(Calendar.SECOND, 0);
                nextSecond = this.seconds.nextSetBit(0);
            }
            if (nextSecond != second) {
                calendar.set(Calendar.SECOND, nextSecond);
            }
            break;
        }
    }
}

6.试验结果

试验手动生成了10个cron表达式以及对应的10个指定日期,分别使用新旧算法从指定时间查找符合cron表达式规则的下一个时间。试验结果如下所示:
第一,从执行时间上看,新的doNext算法比spring自带的doNext算法效率更高,而且多数情况下能提升一半以上的效率。
第二,从第8组试验结果来看,新算法客服了老算法秒数调整存在的问题(3.2节最后的注)。
第三,第4组试验的目的是找2016年5月23号之后,找第一个星期是周五的2月29号。原doNext算法耗时9000多us,没有计算出下一个匹配时间(实际抛出了异常,因为年份差不能大于4,会抛出运行时异常)。而新的doNext算法仅耗时600多us,并且找到了结果-2036-02-29 01:00:00。

测试程序源码:

public class Test {
    private static void testCronAlg(Map<String, String> map) throws Exception {
        int count = 0;
        for (Map.Entry<String, String> entry : map.entrySet()) {
            System.out.println(++count);
            System.out.println("cron = "+entry.getKey());
            System.out.println("date = "+entry.getValue());
            CronSequenceGenerator cronSequenceGenerator = new CronSequenceGenerator(entry.getKey());
            SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            Date date = sdf.parse(entry.getValue());
 
            long nanoTime1 = System.nanoTime();
            Date date1 = null;
            try {
                date1 = cronSequenceGenerator.next(date);
            } catch (Exception e) {
            }
            long nanoTime2 = System.nanoTime();
            String str1 = null;
            if (date1 != null) {
                str1 = sdf.format(date1);
            }
            System.out.println("old method : result date = " + str1
                    + " , consume " + (nanoTime2 - nanoTime1)/1000 + "us");
 
 
            long nanoTime3 = System.nanoTime();
            Date date2 = null;
            try {
                date2 = cronSequenceGenerator.nextNew(date);
            } catch (Exception e) {
                e.printStackTrace();
            }
            long nanoTime4 = System.nanoTime();
            String str2 = null;
            if (date2 != null) {
                str2 = sdf.format(date2);
            }
            System.out.println("new method : result date = " + str2
                    + " , consume " + (nanoTime4 - nanoTime3)/1000 + "us");
        }
    }
 
    public static void main(String[] args) throws Exception {
        Map<String, String> map = new HashMap<>();
        map.put("0 0 8 * * *", "2011-03-25 13:22:43");
        map.put("0/2 1 * * * *", "2016-12-25 18:00:45");
        map.put("0 0/5 14,18 * * ?", "2016-01-29 04:01:12");
        map.put("0 15 10 ? * MON-FRI", "2022-08-31 23:59:59");
        map.put("0 26,29,33 * * * ?", "2013-09-12 03:04:05");
        map.put("10-20/4 10,44,30/2 10 ? 3 WED", "1999-10-18 12:00:00");
        map.put("0 0 0 1/2 MAR-AUG ?", "2008-09-11 19:19:19");
        map.put("0 10-50/3,57-59 * * * WED-FRI", "2003-02-09 06:17:19");
        map.put("0/2 0 1 29 2 FRI ", "2016-05-23 09:13:53");
        map.put("0/2 0 1 29 2 5 ", "2016-05-23 09:13:53");
        map.put("0 10,44 14 ? 3 WED", "2016-12-28 19:01:35");
        testCronAlg(map);
    }
}
新旧算法测试结果对比:

1
cron = 0 15 10 ? * MON-FRI
date = 2022-08-31 23:59:59
old method : result date = 2022-09-01 10:15:00 , consume 403us
new method : result date = 2022-09-01 10:15:00 , consume 115us
2
cron = 0 0/5 14,18 * * ?
date = 2016-01-29 04:01:12
old method : result date = 2016-01-29 14:00:00 , consume 106us
new method : result date = 2016-01-29 14:00:00 , consume 74us
3
cron = 10-20/4 10,44,30/2 10 ? 3 WED
date = 1999-10-18 12:00:00
old method : result date = 2000-03-01 10:10:10 , consume 382us
new method : result date = 2000-03-01 10:10:10 , consume 132us
4
cron = 0/2 0 1 29 2 FRI
date = 2016-05-23 09:13:53
old method : result date = null , consume 9418us
new method : result date = 2036-02-29 01:00:00 , consume 658us
5
cron = 0 10,44 14 ? 3 WED
date = 2016-12-28 19:01:35
old method : result date = 2017-03-01 14:10:00 , consume 302us
new method : result date = 2017-03-01 14:10:00 , consume 69us
6
cron = 0 0 0 1/2 MAR-AUG ?
date = 2008-09-11 19:19:19
old method : result date = 2009-03-01 00:00:00 , consume 99us
new method : result date = 2009-03-01 00:00:00 , consume 45us
7
cron = 0 0 8 * * *
date = 2011-03-25 13:22:43
old method : result date = 2011-03-26 08:00:00 , consume 116us
new method : result date = 2011-03-26 08:00:00 , consume 58us
8
cron = 0/2 1 * * * *
date = 2016-12-25 18:00:45
old method : result date = 2016-12-25 18:01:46 , consume 35us
new method : result date = 2016-12-25 18:01:00 , consume 28us
9
cron = 0/2 0 1 29 2 5
date = 2016-05-23 09:13:53
old method : result date = null , consume 3270us
new method : result date = 2036-02-29 01:00:00 , consume 346us
10
cron = 0 26,29,33 * * * ?
date = 2013-09-12 03:04:05
old method : result date = 2013-09-12 03:26:00 , consume 53us
new method : result date = 2013-09-12 03:26:00 , consume 42us
11
cron = 0 10-50/3,57-59 * * * WED-FRI
date = 2003-02-09 06:17:19
old method : result date = 2003-02-12 00:10:00 , consume 63us
new method : result date = 2003-02-12 00:10:00 , consume 44us
评论 6
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值