最近手頭上的項目上了一個新功能,天天早上一到公司,就興致勃勃地登上服務器去查看日誌,「窺視」一下跑的正不正常。今天終於碰到「彩蛋」了: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方法:
分析一下 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,使用其餘類庫中的時間格式化類:
其中,方法一和二,簡單好用,推薦;方法三性能更優。
這也提醒咱們在開發和設計系統的時候注意如下三點:
一、寫工具類的時候,要對多線程調用狀況下的後果在註釋裏進行明確說明
二、多線程環境下,對每個共享變量都要注意其線程安全性
三、咱們的類和方法在作設計的時候,要儘可能設計成無狀態的