為什麼SimpleDateFormat不是線程安全的?

2021-03-02 小偉後端筆記

點擊上方小偉後端筆記關注公眾號

一、前言日期的轉換與格式化在項目中應該是比較常用的了,最近同事小剛出去面試實在是沒想到被 SimpleDateFormat 給擺了一道...

👨‍💻面試官:項目中的日期轉換怎麼用的?SimpleDateFormat 用過嗎?能說一下 SimpleDateFormat 線程安全問題嗎,以及如何解決?

🙋同事小剛:用過的,平時就是在全局定義一個 static 的 SimpleDateFormat,然後在業務處理方法中直接使用的,至於線程安全... 這個... 倒是沒遇到過線程安全問題。

哎,面試官的考察點真的是難以捉摸,吐槽歸吐槽,一起來看看這個類吧。

二、概述SimpleDateFormat 類主要負責日期的轉換與格式化等操作,在多線程的環境中,使用此類容易造成數據轉換及處理的不正確,因為 SimpleDateFormat 類並不是線程安全的,但在單線程環境下是沒有問題的。

SimpleDateFormat 在類注釋中也提醒大家不適用於多線程場景:

Date formats are not synchronized.
It is recommended to create separate format instances for each thread.
If multiple threads access a format concurrently, it must be synchronized
externally.

日期格式不同步。
建議為每個線程創建單獨的格式實例。 
如果多個線程同時訪問一種格式,則必須在外部同步該格式。

來看看阿里巴巴 java 開發規範是怎麼描述 SimpleDateFormat 的:

三、模擬線程安全問題無碼無真相,接下來我們創建一個線程來模擬 SimpleDateFormat 線程安全問題:

創建 MyThread.java 類:

public class MyThread extends Thread{
  
    private SimpleDateFormat simpleDateFormat;
  // 要轉換的日期字符串
    private String dateString;

    public MyThread(SimpleDateFormat simpleDateFormat, String dateString){
        this.simpleDateFormat = simpleDateFormat;
        this.dateString = dateString;
    }

    @Override
    public void run() {
        try {
            Date date = simpleDateFormat.parse(dateString);
            String newDate = simpleDateFormat.format(date).toString();
            if(!newDate.equals(dateString)){
                System.out.println("ThreadName=" + this.getName()
                    + " 報錯了,日期字符串:" + dateString
                    + " 轉換成的日期為:" + newDate);
            }
        }catch (ParseException e){
            e.printStackTrace();
        }
    }
}

創建執行類 Test.java 類:

public class Test {

    // 一般我們使用SimpleDateFormat的時候會把它定義為一個靜態變量,避免頻繁創建它的對象實例
    private static SimpleDateFormat simpleDateFormat = new SimpleDateFormat("YYYY-MM-dd");

    public static void main(String[] args) {

        String[] dateStringArray = new String[] { "2020-09-10", "2020-09-11", "2020-09-12", "2020-09-13", "2020-09-14"};

        MyThread[] myThreads = new MyThread[5];

        // 創建線程
        for (int i = 0; i < 5; i++) {
            myThreads[i] = new MyThread(simpleDateFormat, dateStringArray[i]);
        }

        // 啟動線程
        for (int i = 0; i < 5; i++) {
            myThreads[i].start();
        }
    }
}

執行截圖如下:

從控制臺列印的結果來看,使用單例的 SimpleDateFormat 類在多線程的環境中處理日期轉換,極易出現轉換異常(java.lang.NumberFormatException:multiple points)以及轉換錯誤的情況。

四、線程不安全的原因這個時候就需要看看源碼了,format() 格式轉換方法:
// 成員變量 Calendar
protected Calendar calendar;

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 ,這個 format 方法在執行過程中,會操作成員變量  calendar 來保存時間  calendar.setTime(date) 。

但由於在聲明 SimpleDateFormat 的時候,使用的是 static 定義的,那麼這個 SimpleDateFormat 就是一個共享變量,SimpleDateFormat 中的 calendar 也就可以被多個線程訪問到,所以問題就出現了,舉個例子:

假設線程 A 剛執行完 calendar.setTime(date) 語句,把時間設置為 2020-09-01,但線程還沒執行完,線程 B 又執行了 calendar.setTime(date) 語句,把時間設置為 2020-09-02,這個時候就出現幻讀了,線程 A 繼續執行下去的時候,拿到的 calendar.getTime 得到的時間就是線程B改過之後的。

除了 format() 方法以外,SimpleDateFormat 的 parse() 方法也有同樣的問題。

至此,我們發現了 SimpleDateFormat 的弊端,所以為了解決這個問題就是不要把 SimpleDateFormat 當做一個共享變量來使用。

五、如何解決線程安全1、每次使用就創建一個新的 SimpleDateFormat

創建全局工具類 DateUtils.java

public class DateUtils {
    public static Date parse(String formatPattern, String dateString) throws ParseException {
        return new SimpleDateFormat(formatPattern).parse(dateString);
    }

    public static String  format(String formatPattern, Date date){
        return new SimpleDateFormat(formatPattern).format(date);
    }
}

所有用到 SimpleDateFormat 的地方全部用 DateUtils 替換,然後看一下執行結果:

好傢夥,異常+錯誤終於是沒了,這種解決處理錯誤的原理就是創建了多個 SimpleDateFormat 類的實例,在需要用到的地方創建一個新的實例,就沒有線程安全問題,不過也加重了創建對象的負擔,會頻繁地創建和銷毀對象,效率較低。

2、synchronized 鎖

synchronized 就不展開介紹了,不了解的小夥伴請移步 > synchronized的底層原理?

變更一下 DateUtils.java

public class DateUtils {

    private static final SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static Date parse(String formatPattern, String dateString) throws ParseException {
        synchronized (simpleDateFormat){
            return simpleDateFormat.parse(dateString);
        }
    }

    public static String format(String formatPattern, Date date) {
        synchronized (simpleDateFormat){
            return simpleDateFormat.format(date);
        }
    }
}

簡單粗暴,synchronized 往上一套也可以解決線程安全問題,缺點自然就是並發量大的時候會對性能有影響,因為使用了 synchronized 加鎖後的多線程就相當於串行,線程阻塞,執行效率低。

3、ThreadLocal(最佳MVP)

ThreadLocal 是 java 裡一種特殊的變量,ThreadLocal 提供了線程本地的實例,它與普通變量的區別在於,每個使用該線程變量的線程都會初始化一個完全獨立的實例副本。

繼續改造 DateUtils.java

public class DateUtils {

    private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>(){
        @Override
        protected DateFormat initialValue() {
            return new SimpleDateFormat("yyyy-MM-dd");
        }
    };

    public static Date parse(String formatPattern, String dateString) throws ParseException {
        return threadLocal.get().parse(dateString);
    }

    public static String format(String formatPattern, Date date) {
        return threadLocal.get().format(date);
    }
}

ThreadLocal 可以確保每個線程都可以得到單獨的一個 SimpleDateFormat 的對象,那麼就不會存在競爭問題。

如果項目中還在使用 SimpleDateFormat 的話,推薦這種寫法,但這樣就結束了嗎?

顯然不是...

六、項目中推薦的寫法上邊提到的阿里巴巴 java 開發手冊給出了說明:如果是 JDK8 的應用,可以使用 Instant 代替 Date,LocalDateTime 代替 Calendar,DateTimeFormatter 代替 SimpleDateFormat,官方給出的解釋:simple beautiful strong immutable thread-safe。

日期轉換,SimpleDateFormat 固然好用,但是現在我們已經有了更好地選擇,Java 8 引入了新的日期時間 API,並引入了線程安全的日期類,一起來看看。

LocalDate:本地日期,不包含具體時間 例如:2014-01-14 可以用來記錄生日、紀念日、加盟日等。LocalDateTime:組合了日期和時間,但不包含時差和時區信息。ZonedDateTime:最完整的日期時間,包含時區和相對UTC或格林威治的時差。

新API還引入了 ZoneOffSet 和 ZoneId 類,使得解決時區問題更為簡便。

解析、格式化時間的 DateTimeFormatter 類也進行了全部重新設計。

例如,我們使用 LocalDate 代替 Date,使用 DateTimeFormatter 代替 SimpleDateFormat,如下所示:

// 當前日期和時間
String DateNow = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd HH:mm:ss")); 
System.out.println(DateNow);

這樣就避免了 SimpleDateFormat 的線程不安全問題啦。

此時的 DateUtils.java

public class DateUtils {

    public static final DateTimeFormatter DATE_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public static LocalDate parse(String dateString){
        return LocalDate.parse(dateString, DATE_TIME_FORMATTER);
    }

    public static String format(LocalDate target) {
        return target.format(DATE_TIME_FORMATTER);
    }
}

七、最後總結

SimpleDateFormart 線程不安全問題

SimpleDateFormart 繼承自 DateFormart,在 DataFormat 類內部有一個 Calendar 對象引用,SimpleDateFormat 轉換日期都是靠這個 Calendar 對象來操作的,比如 parse(String),format(date) 等類似的方法,Calendar 在用的時候是直接使用的,而且是改變了 Calendar 的值,這樣情況在多線程下就會出現線程安全問題,如果 SimpleDateFormart 是靜態的話,那麼多個 thread 之間就會共享這個 SimpleDateFormart,同時也會共享這個 Calendar 引用,那麼就出現數據賦值覆蓋情況,也就是線程安全問題。(現在項目中用到日期轉換,都是使用的 java 8 中的 LocalDate,或者 LocalDateTime,本質是這些類是不可變類,不可變一定程度上保證了線程安全)。

解決方式

在多線程下可以使用 ThreadLocal 修飾 SimpleDateFormart,ThreadLocal 可以確保每個線程都可以得到單獨的一個 SimpleDateFormat 的對象,那麼就不會存在競爭問題。

項目中推薦的寫法

java 8 中引入新的日期類 API,這些類是不可變的,且線程安全的。

以後面試官再問項目中怎麼使用日期轉換的,就不要說 SimpleDateFormat 了~

相關焦點

  • SimpleDateFormat 如何安全的使用?
    線程不安全在 SimpleDateFormat 類的 JavaDoc 中,描述了該類不能夠保證線程安全,建議為每個線程創建單獨的日期/時間格式實例,如果多個線程同時訪問一個日期/時間格式,它必須在外部進行同步。那麼在多線程環境下調用 format() 和 parse() 方法應該使用同步代碼來避免問題。
  • 面試官一步一步的套路你,為什麼SimpleDateFormat不是線程安全的
    小小白:如果不使用ThreadLocal包裝一下,直接創建一個SimpleDateFormat共享實例對象,在多線程並發的情況下使用這個對象的方法是線程不安全的,可能會拋出NumberFormatException或其它異常。
  • 為什麼阿里巴巴禁止把SimpleDateFormat定義為static類型的?
    當然,這不是顯示其他時區的唯一方法,不過本文主要為了介紹SimpleDateFormat,其他方法暫不介紹了。就是循環一百次,每次循環的時候都在當前時間基礎上增加一個天數(這個天數隨著循環次數而變化),然後把所有日期放入一個線程安全的、帶有去重功能的Set中,然後輸出Set中元素個數。上面的例子我特意寫的稍微複雜了一些,不過我幾乎都加了注釋。這裡面涉及到了線程池的創建、CountDownLatch、lambda表達式、線程安全的HashSet等知識。
  • 【107期】SimpleDateFormat 的線程安全問題與解決方案
    原因 SimpleDateFormat(下面簡稱sdf)類內部有一個Calendar對象引用,它用來儲存和這個sdf相關的日期信息,例如sdf.parse(dateStr), sdf.format(date) 諸如此類的方法參數傳入的日期相關String, Date等等, 都是交友Calendar引用來儲存的.
  • 不簡單的 SimpleDateFormat
    string ::... omittedFri Jan 11 00:00:00 IST 2019Sat Jul 11 00:00:00 IST 2111Fri Jan 11 00:00:00 IST 2019... omitted結果很有意思,不是麼?
  • 不簡單的 Java SimpleDateFormat
    string ::... omittedFri Jan 11 00:00:00 IST 2019Sat Jul 11 00:00:00 IST 2111Fri Jan 11 00:00:00 IST 2019... omitted結果很有意思,不是麼?
  • 2020 年,你還在使用 Java 中的 SimpleDateFormat 嗎?
    privatestatic SimpleDateFormat simpleDateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");但是這樣操作在並發量非常大的情況下,由於 SimpleDateFormat 是線程不安全的,這也是第二點原因,這個在JDK文檔中已經明確表明了SimpleDateFormat
  • 聽說你還在用SimpleDateFormat格式化日期
    = new SimpleDateFormat("yyyy-MM-dd");Date stationTime = dateFormat.parse(dateFormat.format(svcWorkOrderPo.getPayEndTime()));orderDailyStatisticsPo.setStatisticalDate(stationTime);而且項目中的時間和日期API用的也比較混亂
  • java中使用 SimpleDateFormat 格式化日期
    java.text.SimpleDateFormat;import java.util.Date;public class SimpleDateFormattime {public static void main(String[] args) {Date simpdate = new Date();SimpleDateFormat simpleDateFormattime
  • 【問答】MySQL DATE_FORMAT函數怎麼用?
    在我們平常使用MySQL時,有可能會對某些日期數據進行格式化,使它變為我們想要的格式,此時我們就會使用 DATE_FORMAT(date,format) 函數。年-月-日 的形式(格式)展示,那麼它格式化之後就是 2020-11-25DATE_FORMAT() 接收兩個參數:date
  • 線程安全之std::atomic探索
    什麼是原子數據類型簡單地說,原子數據類型能保證線程之間不會發生數據競爭(data race),因此能保證線程安全(thread safe)。對於我們用戶來說,就不需要對需要多線程訪問的數據添加互斥鎖。接下來,我們會針對這個結果進行改造,使之達到我們想要的「線程安全」的要求。測試環境工程1:不使用原子數據類型第一個工程,演示的是如果不使用原子數據類型,程序會產生一定的錯誤。
  • 面試懵了:StringBuilder為什麼線程不安全
    我:StringBuilder不是線程安全的,StringBuffer是線程安全的面試官:那StringBuilder不安全的點在哪兒?我:。。。(啞巴了)在這之前我只記住了StringBuilder不是線程安全的,StringBuffer是線程安全的這個結論,至於StringBuilder為什麼不安全從來沒有去想過。
  • 面試題:StringBuilder為什麼線程不安全?
    我:StringBuilder不是線程安全的,StringBuffer是線程安全的面試官:那StringBuilder不安全的點在哪兒?我:。。。(啞巴了)在這之前我只記住了StringBuilder不是線程安全的,StringBuffer是線程安全的這個結論,至於StringBuilder為什麼不安全從來沒有去想過。
  • 為什麼線程安全的List推薦用CopyOnWriteArrayList,而不是Vector
    註:本系列文章中用到的jdk版本均為java8相比很多同學在剛接觸Java集合的時候,線程安全的List用的一定是Vector。但是現在用到的線程安全的List一般都會用CopyOnWriteArrayList,很少有人再去用Vector了,至於為什麼,文章中會具體說到。接下來,我們先來簡單分析以下Vector的源碼。
  • String.format() 圖文詳解,寫得非常好!
    format()方法有兩種重載形式。重載// 使用當前本地區域對象(Locale.getDefault()),制定字符串格式和參數生成格式化的字符串String String.format(String fmt, Object... args);// 自定義本地區域對象,制定字符串格式和參數生成格式化的字符串String String.format
  • DATE
    當前日期所在月份的上一個月的第一天SELECT date_format(DATE_ADD( DATE_ADD(LAST_DAY(NOW()),INTERVAL 1 DAY ),INTERVAL -2 MONTH),'%Y-%m-%d %H:%i:%s');當期日期所在月份的上一個月的最後一天SELECT
  • 如何編寫線程安全的代碼?
    誇張了哈,總之,多線程程序有時就像一潭淤泥,走不進去退不出來。可這是為什麼呢?為什麼多線程代碼如此難以正確編寫呢?關於這個問題,本質上是有一個詞語你沒有透徹理解,這個詞就是所謂的線程安全,thread safe。如果你不能理解線程安全,那麼給你再多的方案也是無用武之地。
  • java中date日期計算使用方法
    很簡單,代碼如下:Date date = new Date();上述代碼便初始化了一個時間類,雖然簡單,但是裡面的坑不少。單純的這樣寫並不能輸出我們想要的數據。上面代碼將會輸出一個標準國際時間,如圖所示:
  • format,不只是格式化
    t "~@R~%" 123) ; 列印出CXXIII天曉得為什麼要內置這麼冷門的功能。(format t "|~8,,,'-<~;hello~>|" 5)上面的代碼運行後會列印出|---hello|:8 表示用於列印的最小寬度;三個逗號(,)之間為空,表示忽略~<的第二和第三個參數;第四個參數控制著列印結果中用於填充的字符,由於-不是數字