一次 SimpleDateFormat 引起的慘案

引子

最近手頭上的項目上了一個新功能,天天早上一到公司,就興致勃勃地登上服務器去查看日誌,「窺視」一下跑的正不正常。今天終於碰到「彩蛋」了:java

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號了麼?git

場景

冷靜~ 咱們先來理一理業務場景:我這邊調用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 打印調用該方法後的結果,嘗試了幾個不一樣的時間串,發現始終得不到上面那些令我「穿越」的日期。併發

難道是別人也同時調用了該服務該方法?那爲什麼在我這邊的服務器日誌上打印出來了?不可能。app

仍是找找自身的問題吧,從我開始調用一步一來分析。。。咦?調用的時候,爲了性能,我寫了一行很簡練的代碼:ide

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 類庫

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

總結

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

一、寫工具類的時候,要對多線程調用狀況下的後果在註釋裏進行明確說明

二、多線程環境下,對每個共享變量都要注意其線程安全性

三、咱們的類和方法在作設計的時候,要儘可能設計成無狀態的

(完)
相關文章
相關標籤/搜索