SimpleDateFormat線程安全問題深入解析

背景

衆所周知,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圖如下:
SimpleDateFormat 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的構造方法裏初始化。
看了類結構,我們仔細分析一下DateFormatparse方法,直接上代碼(省略掉了一些無關緊要的代碼):

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;
}

主要分爲如下幾個步驟:

  1. 定義一個CalendarBuilder對象calb,用來臨時保存parse結果。
  2. 根據DateFormat定義的Pattern,for循環調用subParse方法,將目標字符串逐個(年月日時分秒…)轉化,並存儲在calb變量裏。
  3. 調用calb.establish(calendar)方法,把暫存在calb裏的數據設置到全局變量calendar裏。
  4. 現在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.getDoubledigitList.getLong方法的時候要麼得到意料之外的值,要麼直接報錯NumberFormatException

問題之二

那麼後面的步驟有沒有問題呢?繼續往下看。
前面說到,方法會先把parse好的值放到CalendarBuilder型的臨時變量calb裏面,然後調用establish方法,將calb中緩存的值設置到SimpleDateFormatcalendar變量中,下面看看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線程不安全的原因。subparseestablish兩個方法都可能導致問題,前者還會拋出Exception
總結下來,問題都是出在全局變量上。所以當我們定義全局變量的時候一定要謹慎,注意變量是不是線程安全。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章