背景
衆所周知,Java中的SimpleDateFormat不是線程安全的,在多線程下會出現意想不到的問題。本文將解析SimpleDateFormat線程不安全的具體原因,從而加深對線程安全的理解。
例子
簡單的測試代碼,當多個線程同時調用parse方法的時候會出問題:
public class SimpleDateFormatTest {
private static SimpleDateFormat format = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
public static void main(String[] args) {
for (int i = 0; i < 20; i++) {
new Thread(() -> {
try {
System.out.println(format.parse("2019/11/11 11:11:11"));
} catch (ParseException e) {
e.printStackTrace();
}
}).start();
}
}
}
部分輸出如下:
Mon Nov 11 11:11:11 GMT 2019
Thu Jan 01 00:00:00 GMT 1970
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:2162)
at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1514)
at java.text.DateFormat.parse(DateFormat.java:364)
at package1.SimpleDateFormatTest.lambda$0(SimpleDateFormatTest.java:17)
at package1.SimpleDateFormatTest
at java.lang.Thread.run(Thread.java:745)
java.lang.NumberFormatException: empty String
at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1842)
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 package1.SimpleDateFormatTest.lambda$0(SimpleDateFormatTest.java:17)
at package1.SimpleDateFormatTest
at java.lang.Thread.run(Thread.java:745)
不出意外,每次跑都會報錯,偶爾還會出現輸出初始時間Thu Jan 01 00:00:00 GMT 1970以及其他莫名其妙的時間。好的,記住這兩個錯誤,下面我們仔細分析。
分析
SimpleDateFormat繼承自DateFormat這個抽象類,UML圖如下:
DateFormat中有兩個全局變量需要注意
public abstract class DateFormat extends Format {
//日曆變量,作爲DateFormat的輔助
protected Calendar calendar;
//用來Format數字,默認爲DecimalFormat
protected NumberFormat numberFormat;
}
public class DecimalFormat extends NumberFormat {
//DecimalFormat中的全局變量,用來存放轉化好的數據
//digitList用科學技計數表示,如2019表示成0.2019x10^4
private transient DigitList digitList = new DigitList();
}
這兩個變量的初始化在SimpleDateFormat的構造方法裏初始化。
看了類結構,我們仔細分析一下DateFormat的parse方法,直接上代碼(省略掉了一些無關緊要的代碼):
public Date parse(String text, ParsePosition pos)
{
......
//注意這個變量calb,日期的轉化是通過CalendarBuilder這個類來完成的
CalendarBuilder calb = new CalendarBuilder();
//按照DateFormat的pattern逐個循環(年月日時分秒...)
for (int i = 0; i < compiledPattern.length; ) {
......
//最終調用subParse方法給calb賦值
start = subParse(text, start, tag, count, obeyCount, ambiguousYear, pos, useFollowingMinusSignAsDelimiter, calb);
}
Date parsedDate;
try {
//調用CalendarBuilder的establish方法,把值傳遞給變量calendar
//通過calendar來獲取最終返回的日期
//注意,這裏calendar是個全局變量
parsedDate = calb.establish(calendar).getTime();
}
......
return parsedDate;
}
主要分爲如下幾個步驟:
- 定義一個CalendarBuilder對象calb,用來臨時保存parse結果。
- 根據DateFormat定義的Pattern,for循環調用subParse方法,將目標字符串逐個(年月日時分秒…)轉化,並存儲在calb變量裏。
- 調用calb.establish(calendar)方法,把暫存在calb裏的數據設置到全局變量calendar裏。
- 現在calendar裏已經包含轉換過的日期數據,最後調用**Calendar.getTime()**方法返回日期。
問題之一
下面看一下subParse方法裏面做了什麼,實現上有什麼問題。先看代碼(省略掉了一些無關緊要的代碼):
public class SimpleDateFormat extends DateFormat {
private int subParse(String text, int start, int patternCharIndex, int count,
boolean obeyCount, boolean[] ambiguousYear,
ParsePosition origPos,
boolean useFollowingMinusSignAsDelimiter, CalendarBuilder calb) {
//一些變量初始化
......
//內部調用numberFormat的parse方法,轉化數字
//這裏的numberFormat就是上面分析過的那個全局變量,默認實例是DecimalFormat
//text是代轉字符串"2019/11/11 11:11:11", pos是位置,如2019會被轉化爲0.2019x10^4
number = numberFormat.parse(text, pos);
if (number != null) {
//轉化成int值,如0.2019x10^4會轉化成2019
value = number.intValue();
}
int index;
switch (patternCharIndex) {
case PATTERN_YEAR: // 'y'
//有年,月,日等等各種case,這裏只拿PATTERN_YEAR(年)這種情況舉例子
//將numberFormat parse出來的值set到calb裏面去
calb.set(field, value);
return pos.index;
}
......
// 轉義失敗
origPos.errorIndex = pos.index;
return -1;
}
}
//numberFormat.parse(text, pos)方法實現
public class DecimalFormat extends NumberFormat {
public Number parse(String text, ParsePosition pos) {
//內部調用subparse方法,將text的內容set到digitList上
if (!subparse(text, pos, positivePrefix, negativePrefix, digitList, false, status)) {
return null;
}
......
//將digitList轉變爲目標格式
if (digitList.fitsIntoLong(status[STATUS_POSITIVE], isParseIntegerOnly())) {
//parse爲Long型
longResult = digitList.getLong();
} else {
//parse爲double型
doubleResult = digitList.getDouble();
}
.....
return gotDouble ? (Number)new Double(doubleResult) : (Number)new Long(longResult);
}
private final boolean subparse(String text, ParsePosition parsePosition,
String positivePrefix, String negativePrefix,
DigitList digits, boolean isExponent,
boolean status[]) {
//一些判斷及變量初始化準備
......
//digitList在這個方法裏面叫digits,先對digits先清零處理。
//decimalAt指小數點位置,如0.2019x10^4中decimalAt就是4
//count指數字位數,如0.2019x10^4中count就是4
digits.decimalAt = digits.count = 0;
backup = -1;
for (; position < text.length(); ++position) {
//循環內部對digits一頓猛如虎的賦值操作,設置科學計數法各個部分的變量
//注意這個digits是一個全局變量
......
}
//還要對digits繼續操作
if (!sawDecimal) {
digits.decimalAt = digitCount; // Not digits.count!
}
digits.decimalAt += exponent;
......
return true;
}
}
看到這裏,有點併發編程經驗的同學估計就能看出問題了。在subparse這個方法裏面不加保護,當多個線程同時對全局變量digits(digitList)進行操作時,這個變量很可能是個無效的值。比如線程A把值設置了一半,另一個線程B把值又清零初始化了。於是線程A在後面調用digitList.getDouble和digitList.getLong方法的時候要麼得到意料之外的值,要麼直接報錯NumberFormatException。
問題之二
那麼後面的步驟有沒有問題呢?繼續往下看。
前面說到,方法會先把parse好的值放到CalendarBuilder型的臨時變量calb裏面,然後調用establish方法,將calb中緩存的值設置到SimpleDateFormat的calendar變量中,下面看看establish方法:
class CalendarBuilder {
Calendar establish(Calendar cal) {
......
//這個cal是SimpleDateFormat中的成員變量calendar
//先將cal中的數據清除初始化,跟上面digitList一樣的套路
cal.clear();
for (int stamp = MINIMUM_USER_STAMP; stamp < nextStamp; stamp++) {
for (int index = 0; index <= maxFieldIndex; index++) {
if (field[index] == stamp) {
//前面CalendarBuild暫存的值都放在field數組裏,
//這裏將數組中的值逐個賦給cal
cal.set(index, field[MAX_FIELD + index]);
break;
}
}
}
if (weekDate) {
//設置cal的weekdate field
cal.setWeekDate(field[MAX_FIELD + WEEK_YEAR], weekOfYear, dayOfWeek);
}
return cal;
}
}
還是同樣的問題,由於calendar(cal)是個全局變量,當多個線程同時調用establish方法的時候,會有線程安全問題。舉個簡單的例子,線程A原先賦值好了"2019/11/11 11:11:11",結果線程B調用了cal.clear方法將數據又給清掉了,於是線程A回到瞭解放前,輸出了日期"1970/01/01 00:00:00"。
解決辦法
對於線程安全的解決辦法,給方法加同步synchronize是最簡單的,相當於線程只能一個一個地訪問parse方法:
synchronize (this) {
System.out.println(format.parse("2019/11/11 11:11:11"));
}
當然更common的使用姿勢是配合ThreadLocal使用,相當於給每個線程都定義了一個format變量,線程間互不影響:
private ThreadLocal<SimpleDateFormat> format = new ThreadLocal<SimpleDateFormat>(){
@Override
protected SimpleDateFormat initialValue() {
return new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
}
};
System.out.println(format.get().parse("2019/11/11 11:11:11"));
不過最推薦的還是,不要用SimpleDateFormat,而是用Java8新引入的類LocalDateTime或者DateTimeFormatter,不僅線程安全,而且效率更高。
總結
本文從代碼層面分析了SimpleDateFormat線程不安全的原因。subparse和establish兩個方法都可能導致問題,前者還會拋出Exception。
總結下來,問題都是出在全局變量上。所以當我們定義全局變量的時候一定要謹慎,注意變量是不是線程安全。