1. 程式人生 > >spring cron表示式及解析過程

spring 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