SimpleDateFormat 是 Java 提供的一個格式化和解析日期的工具類,日常開發中應該經常會用到,但是由於它是線程不安全的,多線程公用一個 SimpleDateFormat 實例對日期進行解析或者格式化會導致程序出錯,本節就討論下它為何是線程不安全的,以及如何避免。
為了復現上面所說的不安全,我們要用一個例子來突出這個不安全,例子如下:
package com.hjc; import java.text.ParseException; import java.text.SimpleDateFormat; /** * Created by cong on 2018/7/12. */ public class SimpleDateFormatTest { //(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("2018-07-12 15:18:00")); } catch (ParseException e) { e.printStackTrace(); } } }); thread.start();//(4)啟動線程 } } }
運行結果如下:
代碼(1)創建了 SimpleDateFormat 的一個實例,代碼(2)創建 10 個線程,每個線程都公用同一個 sdf 對象對文本日期進行解析,多運行幾次就會拋出 java.lang.NumberFormatException 異常,加大線程的個數有利於該問題復現。
為什么會出現這樣的問題呢?
那么接下來我們就要進入到SimpleDateFormat 源碼一探究竟,為了便於分析首先查看 SimpleDateFormat 的類圖結構,類圖如下所示:
可知每個 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 對象。從而導致程序錯誤。
那么,讓我們思考一個問題,如何解決SimpleDateFormat 的線程安全性問題呢?
1.第一種方式:每次使用時候 new 一個 SimpleDateFormat 的實例,這樣可以保證每個實例使用自己的 Calendar 實例, 但是每次使用都需要 new 一個對象,並且使用后由於沒有其它引用,就會需要被回收,開銷會很大。
2.第二種方式:究其原因是因為多線程下代碼(3)(4)(5)三個步驟不是一個原子性操作,那么容易想到的是對其進行同步,讓(3)(4)(5)成為原子操作,可以使用 synchronized 進行同步,例子改造如下所示:
/** * Created by cong on 2018/7/12. */ public class SimpleDateFormatTest1 { //(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("2018-07-12 15:18:00")); } } catch (ParseException e) { e.printStackTrace(); } } }); thread.start();//(4)啟動線程 } } }
運行結果如下:
3.第三種方式:使用 ThreadLocal,這樣每個線程只需要使用一個 SimpleDateFormat 實例相比第一種方式大大節省了對象的創建銷毀開銷,並且不需要對多個線程直接進行同步,使用 ThreadLocal 方式來保證線程安全,例子如下:
/** * Created by cong on 2018/7/12. */ public class SimpleDateFormatTest2 { // (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("2018-07-12 15:18:00")); } catch (ParseException e) { e.printStackTrace(); }finally { //(4)使用完畢記得清除,避免內存泄露 safeSdf.remove(); } } }); thread.start();// (4)啟動線程 } } }
運行結果如下:
代碼(1)創建了一個線程安全的 SimpleDateFormat 實例,代碼(3)在使用的時候首先使用 get() 方法獲取當前線程下 SimpleDateFormat 的實例,在第一次調用 ThreadLocal 的 get()方法適合會觸發其 initialValue 方法用來創建當前線程所需要的 SimpleDateFormat 對象。另外需要注意的是代碼(4)使用完畢線程變量后要記得進行清理,以避免內存泄露。