1. 程式人生 > >執行緒不安全的SimpleDateFormat

執行緒不安全的SimpleDateFormat

8.5 SimpleDateFormat是執行緒不安全的

SimpleDateFormat是Java提供的一個格式化和解析日期的工具類,日常開發中應該經常會用到,但是由於它是執行緒不安全的,多執行緒公用一個SimpleDateFormat例項對日期進行解析或者格式化會導致程式出錯,本節就討論下它為何是執行緒不安全的,以及如何避免。

問題復現

為了復現該問題,編寫如下程式碼:

 public class TestSimpleDateFormat {
    //(1)建立單例例項
    static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        //(2)建立多個執行緒,並啟動
        for (int i = 0; i <10 ; ++i) {
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    try {//(3)使用單例日期例項解析文字
                        System.out.println(sdf.parse("2017-12-13 15:17:27"));
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                }
            });
            thread.start();//(4)啟動執行緒
        }
    }
}

程式碼(1)建立了SimpleDateFormat的一個例項,程式碼(2)建立10個執行緒,每個執行緒都公用同一個sdf物件對文字日期進行解析,多執行幾次就會丟擲java.lang.NumberFormatException異常,加大執行緒的個數有利於該問題復現。

問題分析

為了便於分析首先奉上SimpleDateFormat的類圖結構:

image.png

可知每個SimpleDateFormat例項裡面有一個Calendar物件,從後面會知道其實SimpleDateFormat之所以是執行緒不安全的就是因為Calendar是執行緒不安全的,後者之所以是執行緒不安全的是因為其中存放日期資料的變數都是執行緒不安全的,比如裡面的fields,time等。

下面從程式碼層面看下parse方法做了什麼事情:

    public Date parse(String text, ParsePosition pos)
    {
       
        //(1)解析日期字串放入CalendarBuilder的例項calb中
        .....

        Date parsedDate;
        try {//(2)使用calb中解析好的日期資料設定calendar
            parsedDate = calb.establish(calendar).getTime();
            ...
        }
       
        catch (IllegalArgumentException e) {
           ...
            return null;
        }

        return parsedDate;
    }
Calendar establish(Calendar cal) {
   ...
   //(3)重置日期物件cal的屬性值
   cal.clear();
   //(4) 使用calb中中屬性設定cal
   ...
   //(5)返回設定好的cal物件
   return cal;
}
  • 程式碼(1)主要的作用是解析字串日期並把解析好的資料放入了 CalendarBuilder的例項calb中,CalendarBuilder是一個建造者模式,用來存放後面需要的資料。
  • 程式碼(3)重置Calendar物件裡面的屬性值,如下程式碼:

    public final void clear()
   {
       for (int i = 0; i < fields.length; ) {
           stamp[i] = fields[i] = 0; // UNSET == 0
           isSet[i++] = false;
       }
       areAllFieldsSet = areFieldsSet = false;
       isTimeSet = false;
   }
  • 程式碼(4)使用calb中解析好的日期資料設定cal物件
  • 程式碼(5) 返回設定好的cal物件

從上面步驟可知步驟(3)(4)(5)操作不是原子性操作,當多個執行緒呼叫parse
方法時候比如執行緒A執行了步驟(3)(4)也就是設定好了cal物件,在執行步驟(5)前執行緒B執行了步驟(3)清空了cal物件,由於多個執行緒使用的是一個cal物件,所以執行緒A執行步驟(5)返回的就可能是被執行緒B清空後的物件,當然也有可能執行緒B執行了步驟(4)被執行緒B修改後的cal物件。從而導致程式錯誤。

那麼怎麼解決那?

  • 第一種方式:每次使用時候new一個SimpleDateFormat的例項,這樣可以保證每個例項使用自己的Calendar例項,但是每次使用都需要new一個物件,並且使用後由於沒有其它引用,就會需要被回收,開銷會很大。
  • 第二種方式:究其原因是因為多執行緒下步驟(3)(4)(5)三個步驟不是一個原子性操作,那麼容易想到的是對其進行同步,讓(3)(4)(5)成為原子操作,可以使用synchronized進行同步,具體如下:
public class TestSimpleDateFormat {
    // (1)建立單例例項
    static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");

    public static void main(String[] args) {
        // (2)建立多個執行緒,並啟動
        for (int i = 0; i < 10; ++i) {
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    try {// (3)使用單例日期例項解析文字
                        synchronized (sdf) {
                            System.out.println(sdf.parse("2017-12-13 15:17:27"));
                        }
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                }
            });
            thread.start();// (4)啟動執行緒
        }
    }
}

使用同步意味著多個執行緒要競爭鎖,在高併發場景下會導致系統響應效能下降。

  • 第三種方式:使用ThreadLocal,這樣每個執行緒只需要使用一個SimpleDateFormat例項相比第一種方式大大節省了物件的建立銷燬開銷,並且不需要對多個執行緒直接進行同步,使用ThreadLocal方式程式碼如下:
public class TestSimpleDateFormat2 {
    // (1)建立threadlocal例項
    static ThreadLocal<DateFormat> safeSdf = new ThreadLocal<DateFormat>(){
        @Override 
        protected SimpleDateFormat initialValue(){
            return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        }
    };
    
    public static void main(String[] args) {
        // (2)建立多個執行緒,並啟動
        for (int i = 0; i < 10; ++i) {
            Thread thread = new Thread(new Runnable() {
                public void run() {
                    try {// (3)使用單例日期例項解析文字
                            System.out.println(safeSdf.get().parse("2017-12-13 15:17:27"));
                    } catch (ParseException e) {
                        e.printStackTrace();
                    }
                }
            });
            thread.start();// (4)啟動執行緒
        }
    }
}

程式碼(1)建立了一個執行緒安全的SimpleDateFormat例項,步驟(3)在使用的時候首先使用get()方法獲取當前執行緒下SimpleDateFormat的例項,在第一次呼叫ThreadLocal的get()方法適合會觸發其initialValue方法用來建立當前執行緒所需要的SimpleDateFormat物件。

總結

本節通過簡單介紹SimpleDateFormat的原理說明了SimpleDateFormat是執行緒不安全的,應該避免多執行緒下使用SimpleDateFormat的單個例項,多執行緒下使用時候最好使用ThreadLocal物件。更多併發程式設計中需要注意的情景以及解決方法敬請期待 Java中高併發程式設計必備基礎之併發包原始碼剖析 一書出版