1. 程式人生 > >一次 SimpleDateFormat 引發的慘案

一次 SimpleDateFormat 引發的慘案

引子

最近手頭上的專案上了一個新功能,每天早上一到公司,就興致勃勃地登上伺服器去檢視日誌,“窺視”一下跑的正不正常。今天終於碰到“彩蛋”了:

Invalid Date in Date Math String:'2187-02-31T16:00:00Z'
...
Invalid Date in Date Math String:'0001-09-31T16:00:00Z'
複製程式碼

這是什麼鬼?怎麼會有這樣的日期?一會穿越到一百年後,一會穿越到原始社會,我想問那時的2月和9月都有31號了麼?

場景

冷靜~ 我們先來理一理業務場景:我這邊呼叫S團隊的服務,介面引數傳了String型別的開始日期和結束日期,格式:yyyy-MM-dd。既然報了“Invalid Date ...”錯誤,那是不是服務方對它們進行解析時出了問題呢?登上對方的伺服器看日誌去,發現很多 NumberFormatException:

2019-01-10 00:31:22 380 [com.xxx.xxx.xxx.xxx.util.DataTool]-[WARN] 2019-01-09 00:00:00 parse err
java.lang.NumberFormatException: For input string: ".109E2.109E2"
        at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:2043)
        at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110
) at java.lang.Double.parseDouble(Double.java:538) at java.text.DigitList.getDouble(DigitList.java:169) at java.text.DecimalFormat.parse(DecimalFormat.java:2056) at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:2162) at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514
) at java.text.DateFormat.parse(DateFormat.java:364) at com.xxx.xxx.xxx.xxx.util.DataTool.CCTToUTC(DataTool.java:29) 2019-01-10 00:31:22 415 [com.xxx.xxx.xxx.xxx.util.DataTool]-[WARN] 2019-01-10 00:00:00 parse err java.lang.NumberFormatException: For input string: "" at java.lang.NumberFormatException.forInputString(NumberFormatException.java:65) at java.lang.Long.parseLong(Long.java:601) at java.lang.Long.parseLong(Long.java:631) at java.text.DigitList.getLong(DigitList.java:195) at java.text.DecimalFormat.parse(DecimalFormat.java:2051) at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869) at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514) at java.text.DateFormat.parse(DateFormat.java:364) at com.xxx.xxx.xxx.xxx.util.DataTool.CCTToUTC(DataTool.java:29) 複製程式碼

嗯,"2019-01-09 00:00:00" 和 “2019-01-10 00:00:00” 是我傳過來的引數值,對應開始日期和結束日期。這應該沒什麼問題。那檢查一下 DataTool.java 類 CCTToUTC 這個方法的第29行:

public class DataTool {
	
	private static Logger logger = Logger.getLogger(DataTool.class);
	
	private static SimpleDateFormat dateSdf = new SimpleDateFormat("yyyy-MM-dd");
	
	private static SimpleDateFormat timezoneSdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
	
	public static String CCTToUTC(String timeString) {
		try {
			Date date = dateSdf.parse(timeString); // 第29行
			Calendar calendar = Calendar.getInstance();
			Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset());
			return timezoneSdf.format(tgtDate);
		} catch (Exception e) {
			logger.warn(timeString+" parse err", e);
			return timezoneSdf.format(new Date());
		}
	}
}
複製程式碼

程式碼很簡單,定義全域性變數 SimpleDateFormat,在 CCTToUTC(String timeString) 中用它對傳入的日期進行解析和格式化。但在第一行 parse 的時候就報錯了並被捕獲到,而後列印了一行 warn 日誌,並返回了當前時間 format 後的時間字串。這不是我們想要的結果。

我懷疑是不是我傳入的時間有問題,於是在本類寫了個 main 方法,簡單 sout 列印呼叫該方法後的結果,嘗試了幾個不同的時間串,發現始終得不到上面那些令我“穿越”的日期。

難道是別人也同時呼叫了該服務該方法?那為何在我這邊的伺服器日誌上打印出來了?不可能。

還是找找自身的問題吧,從我開始呼叫一步一來分析。。。咦?呼叫的時候,為了效能,我寫了一行很簡練的程式碼:

ids.parallelStream().forEach(id -> invokeMethod(id));
複製程式碼

哦,並行處理?-> 併發?-> 執行緒安全?-> parse?-> SimpleDateFormat類?

是不是找到點線索?如果要進一步真正找到“嫌疑人”,那就還原一下現場嘛。。

package com.jessehuang.dateformat;

import java.text.ParseException;
import java.util.Date;

public class DateUtilTest {
    
    public static class TestSimpleDateFormatThreadSafe extends Thread {
        @Override
        public void run() {
            while(true) {
                try {
                    this.join(2000);
                } catch (InterruptedException e1) {
                    e1.printStackTrace();
                }
                try {
                    System.out.println(this.getName() + ":" + DateUtil.parse("2019-01-10 00:00:00"));
                } catch (ParseException e) {
                    e.printStackTrace();
                }
            }
        }    
    }
    
    public static void main(String[] args) {
        for(int i = 0; i < 3; i++){
            new TestSimpleDateFormatThreadSafe().start();
        }
    }
}
複製程式碼

輸出結果:

Exception in thread "Thread-1" Exception in thread "Thread-0" java.lang.NumberFormatException: multiple points
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.jessehuang.SimpleDateFormatTest.parse(SimpleDateFormatTest.java:21)
	at com.jessehuang.SimpleDateFormatTest$TestSimpleDateFormatThreadSafe.run(SimpleDateFormatTest.java:34)
java.lang.NumberFormatException: multiple points
	at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1890)
	at sun.misc.FloatingDecimal.parseDouble(FloatingDecimal.java:110)
	at java.lang.Double.parseDouble(Double.java:538)
	at java.text.DigitList.getDouble(DigitList.java:169)
	at java.text.DecimalFormat.parse(DecimalFormat.java:2089)
	at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1869)
	at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
	at java.text.DateFormat.parse(DateFormat.java:364)
	at com.jessehuang.SimpleDateFormatTest.parse(SimpleDateFormatTest.java:21)
	at com.jessehuang.SimpleDateFormatTest$TestSimpleDateFormatThreadSafe.run(SimpleDateFormatTest.java:34)
Thread-2:Sat Jan 10 00:00:00 CST 2201
Thread-2:Thu Jan 10 00:00:00 CST 2019
Thread-2:Thu Jan 10 00:00:00 CST 2019
Thread-2:Thu Jan 10 00:00:00 CST 2019
複製程式碼

看到了嗎?2201這種年份出現了。Thread-1和Thread-0報java.lang.NumberFormatException: multiple points錯誤,直接掛死,沒起來;Thread-2 雖然沒有掛死,但輸出的時間是有錯誤的,比如我們輸入的時間是:2019-01-10 00:00:00 ,但會輸出:Sat Jan 10 00:00:00 CST 2201 這樣的令人“穿越”的日期。

是的,破案了,凶手就是你 —— SimpleDateFormat

分析

SimpleDateFormat 是 Java 中一個相當常用的類,該類用於對日期字串進行解析和格式化,但如果使用不當會導致非常微妙和難以除錯的問題,因為它不是執行緒安全的,在多執行緒環境下呼叫 format() 和 parse() 方法很容易產生問題。就像上面我一旦使用 JDK8 的 parallelStream() 來遍歷,它就不好使了。

“知其然,必知其所以然” 。我們來分析一下為什麼會輸出奇怪的“穿越”日期。

我們開啟 Dash 來查閱一下 JDK 文件 對於 SimpleDateFormat 的描述:

下面通過原始碼來看看為什麼 SimpleDateFormat 和 DateFormat 類不是執行緒安全的真正原因:

SimpleDateFormat 繼承自 DateFormat,在 DateFormat 中定義了一個 protected 屬性的 Calendar 類物件:calendar。因為 Calendar 類牽扯到了時區與本地化,JDK 的實現中使用了成員變數來傳遞引數,這就造成在多執行緒的時候會出現錯誤。

在 format() 方法裡,有這樣一段程式碼:

private StringBuffer format(Date date, StringBuffer toAppendTo, FieldDelegate delegate) {
        // Convert input date to time field list
        calendar.setTime(date);

    boolean useDateFormatSymbols = useDateFormatSymbols();

        for (int i = 0; i < compiledPattern.length; ) {
            int tag = compiledPattern[i] >>> 8;
        int count = compiledPattern[i++] & 0xff;
        if (count == 255) {
        count = compiledPattern[i++] << 16;
        count |= compiledPattern[i++];
        }

        switch (tag) {
        case TAG_QUOTE_ASCII_CHAR:
        toAppendTo.append((char)count);
        break;

        case TAG_QUOTE_CHARS:
        toAppendTo.append(compiledPattern, i, count);
        i += count;
        break;

        default:
                subFormat(tag, count, delegate, toAppendTo, useDateFormatSymbols);
        break;
        }
    }
        return toAppendTo;
    }
複製程式碼

calendar.setTime(date) 這條語句改變了 calendar ,然後,calendar 還在 subFormat() 方法裡被用到,而這就是引發問題的根源。想象一下,在一個多執行緒環境下,有兩個執行緒持有了同一個SimpleDateFormat 的例項,分別呼叫format方法:

  • 執行緒1呼叫 format 方法,改變了 calendar 這個欄位。
  • 中斷來了。
  • 執行緒2開始執行,它也改變了 calendar。
  • 又中斷了。
  • 執行緒1回來了,此時,calendar 已然不是它所設的值,而是走上了執行緒2設計的道路。如果多個執行緒同時爭搶 calendar 物件,則會出現各種問題。比如時間不對,執行緒掛死等等。

分析一下 format() 的實現,我們不難發現,用到成員變數 calendar,唯一的好處,就是在呼叫 subFormat() 時,少了一個引數,卻帶來了這許多的問題。其實,只要在這裡用一個區域性變數,一路傳遞下去,所有問題都將迎刃而解。

解決方案

方法一:

public class DataTool {

    private static Logger logger = Logger.getLogger(DataTool.class);

    public static String CCTToUTC(String timeString) {
        try {
            Date date = getDateSdf().parse(timeString);
            Calendar calendar = Calendar.getInstance();
            Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset());
            return getTimeZoneSdf().format(tgtDate);
        } catch (Exception e) {
            logger.warn(timeString + " parse err", e);
            return getTimeZoneSdf().format(new Date());
        }
    }

    private static SimpleDateFormat getTimeZoneSdf() {
        return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
    }

    private static SimpleDateFormat getDateSdf() {
        return new SimpleDateFormat("yyyy-MM-dd");
    }
}
複製程式碼

在需要用到 SimpleDateFormat 的地方就新建一個例項。不管什麼時候,將有執行緒安全問題的物件由共享變為區域性私有都能避免多執行緒問題,不過也加重了建立物件的負擔。在一般情況下,這樣其實對效能影響也不是那麼明顯。

方法二:

public class DateUtil {
    private static Logger logger = Logger.getLogger(DataTool.class);
    private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    private static SimpleDateFormat timeZoneSdf = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");

    public static String CCTToUTC(String timeString) {
        try {
            Date date = parse(timeString);
            Calendar calendar = Calendar.getInstance();
            Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset());
            return formatDate(tgtDate);
        } catch (Exception e) {
            logger.warn(timeString + " parse err", e);
            return formatDate(new Date());
        }
    }

    private static Date parse(String strDate) throws ParseException {
        synchronized(sdf){
            return sdf.parse(strDate);
        }
    }

    private static String formatDate(Date date) throws ParseException {
        synchronized(timeZoneSdf){
            return sdf.format(date);
        }  
    }
}
複製程式碼

當執行緒較多時,當一個執行緒呼叫該方法時,其他想要呼叫此方法的執行緒就要 block,多執行緒併發量大的時候會對效能有一定的影響。

方法三:

public class DateUtil {
    private static Logger logger = Logger.getLogger(DataTool.class);
    private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() {
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd");
        }
    };

    private static ThreadLocal<DateFormat> threadLocal2 = new ThreadLocal<DateFormat>() {
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");
        }
    };

    public static String CCTToUTC(String timeString) {
        try {
            Date date = parse(timeString);
            Calendar calendar = Calendar.getInstance();
            Date tgtDate = new Date(date.getTime() - calendar.getTimeZone().getRawOffset());
            return formatDate(tgtDate);
        } catch (Exception e) {
            logger.warn(timeString + " parse err", e);
            return formatDate(new Date());
        }
    }

    private static Date parse(String dateStr) throws ParseException {
        return threadLocal.get().parse(dateStr);
    }

    private static String formatDate(Date date) throws ParseException {
        return threadLocal2.get().format(date);
    }
}
複製程式碼

方法四:拋棄JDK,使用其他類庫中的時間格式化類:

  • 使用 Apache commons 裡的 FastDateFormat,“官宣”是既快又執行緒安全的 SimpleDateFormat, 可惜它只能對日期進行format(), 不能對日期串進行parse()
  • 使用 Joda-Time 類庫

其中,方法一和二,簡單好用,推薦;方法三效能更優。

總結

這也提醒我們在開發和設計系統的時候注意以下三點:

1、寫工具類的時候,要對多執行緒呼叫情況下的後果在註釋裡進行明確說明

2、多執行緒環境下,對每一個共享變數都要注意其執行緒安全性

3、我們的類和方法在做設計的時候,要儘量設計成無狀態的

(完)