SimpleDateFormat的線程安全性問題

問題:

public class Test {
    private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

    private static class Task implements Runnable {

        public void run() {
            try {
                System.out.println(sdf.parse("2016-03-21 12:00:00").getTime());
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }
    }

    public static void main(String[] args) {
        Task task = new Task();

        Thread t1 = new Thread(task);
        t1.start();
        Thread t2 = new Thread(task);
        t2.start();
        Thread t3 = new Thread(task);
        t3.start();
        Thread t4 = new Thread(task);
        t4.start();
        Thread t5 = new Thread(task);
        t5.start();
    }
}

平時經常都會用到SimpleDateFormat來對日期字符串進行解析和格式化。而且一般都是創建一個Util工具類,然后定義一個靜態的SimpleDateFormat實例變量。需要格式化時就直接使用這個變量進行操作。

在大多數正常的測試情況之下,出來的結果都是沒問題的。然而,我最近在看《Java并發編程實踐》的過程中才發現這樣做并不是線程安全的,當在并發量較高的生產環境中,問題就出現了,會出現各種不同的錯誤,例如線程被掛死,轉換的時間不正確。

下圖的輸出結果是線程被掛死了:

image

下圖是轉換的時間不對:


image

原因

以前之所以忽略這個問題,一來對多線程沒有很深入的理解;二是因為從這個類中完全看不出與線程安全有什么關系,因為SimpleDateFormat 實例變量已經是用final 修飾了,就一直以為是狀態不變的了。

而在jdk的官方文檔里面,也有提到

" 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."

就是說。Date formats不是同步的,建議每個線程都分別創建format實例變量;或者如果多個線共享一個format的話,必須保持在使用format時是同步的

而從源碼入手分析原因,會發現在SimpleDateFormat類的parse()方法有這么一段注釋

 This parsing operation uses the DateFormat#calendar
 to produce a Date. All of the
 calendar's date-time fields are Calendar#clear()
 cleared before parsing, and the calendar's default
 values of the date-time fields are used for any missing
 date-time information.

大概意思就是parse()方法使用calendar來生成返回的Date實例,而每次parse之前,都會先把calendar里的相關屬性清除掉。
問題是這個calendar是個全局變量,也就是線程共享的。因此就會出現一個線程剛把calendar設置好,另一個線程把它給清空了,
這時第一個線程再parse的話就會有問題了。

解決方案

需要的時候創建局部變量

public Date formatDate(Date d) {
    SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
    return sdf.parse(d);
}

創建一個共享的SimpleDateFormat實例變量,但是在使用的時候,需要對這個變量進行同步

private SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
public Date formatDate(Date d) {
    synchronized(sdf) {
        return sdf.parse(d);
    }
}

使用ThreadLocal為每個線程都創建一個線程獨享SimpleDateFormat變量

private ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>();
public Date formatDate(Date d) {
    SimpleDateFormat sdf = tl.get();
    if(sdf == null) {
        sdf = new SimpleDateFormat("yyyy-MM-dd");
        tl.set(sdf);
    }
    return sdf.parse(d);
}

性能對比

分別測試了一下上面三種方案,每種方案分別開了10000線程去進行時間格式化,記錄下消耗的時間。測試代碼如下:

public class Test {

    private final static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");

    /**
     * Option 1: Create local instances when required
     */
    public static Date parse1(String dateStr) {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
        try {
            return sdf.parse(dateStr);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return null;
    }

    /**
     * Option 2: Create an instance of SimpleDateFormat as a class variable but synchronize access to it.
     */
    public static Date parse2(String dateStr) {
        synchronized (sdf) {
            try {
                return sdf.parse(dateStr);
            } catch (ParseException e) {
                e.printStackTrace();
            }
        }
        return null;
    }

    /**
     * Option 3: Create a ThreadLocal to store a different instance of SimpleDateFormat for each thread.
     */
    private static ThreadLocal<SimpleDateFormat> tl = new ThreadLocal<SimpleDateFormat>();

    public static Date parse3(String dateStr) {
        SimpleDateFormat sdf = tl.get();
        if (sdf == null) {
            sdf = new SimpleDateFormat("yyyy-MM-hh");
            tl.set(sdf);
        }
        try {
            return sdf.parse(dateStr);
        } catch (ParseException e) {
            e.printStackTrace();
        }
        return null;
    }

    private static class Task implements Callable<Date> {
        public Date call() throws Exception {
            return parse1("2016-03-21 12:00:00");
        }
    }

    public static void main(String[] args) {
        ExecutorService executorService = Executors.newFixedThreadPool(50);
        List<Future<Date>> resultList = new ArrayList<Future<Date>>();

        long s = System.currentTimeMillis();
        //創建10000個任務并執行
        for (int i = 0; i < 10000; i++) {
            //使用ExecutorService執行Callable類型的任務,并將結果保存在future變量中
            Future<Date> future = executorService.submit(new Task());
            //將任務執行結果存儲到List中
            resultList.add(future);
        }

        //遍歷任務的結果
        for (Future<Date> fs : resultList) {
            try {
                while (!fs.isDone())
                    ;//Future返回如果沒有完成,則一直循環等待,直到Future返回完成
                System.out.println(fs.get().getTime());     //打印各個線程(任務)執行的結果
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (ExecutionException e) {
                e.printStackTrace();
            } finally {
                //啟動一次順序關閉,執行以前提交的任務,但不接受新任務
                executorService.shutdown();
            }
        }
        long e = System.currentTimeMillis();

        System.out.println("time elapse: " + (e - s));
    }
}

對于每種方案,我各執行了12次,然后去掉一個最高消耗,一個最低消耗,剩下的取平均值。測試結果如下:

方案1: 平均 410ms  

方案2: 平均 217ms 

方案3: 平均 300ms 

從結果看出,方案1性能最差,因為每次都需要new一個format實例。不推薦使用

方案2雖然看起來最優,但線程越來越多時,因為使用了同步塊,當一個線程調用format的方法時,其余線程就會阻塞,性能會優一定影響。

方案3,每個線程維護一個format實例,50個線程就有50個實例,對內存占用的消耗會比方案2要大,當影響不會太大,網上說法是:
對性能要求比較高的情況下,一般推薦使用這種方法

綜上所述,方案2和方案3都可以,至于具體使用哪一種,具體情況而定

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容