SimpleDateFormat安全的時間格式化
一.引子
我們都是優秀的程式設計師,我們都知道在程式中我們應當儘量少的建立SimpleDateFormat 例項,因為建立這麼一個例項需要耗費很大的代價。在一個讀取資料庫資料匯出到excel檔案的例子當中,每次處理一個時間資訊的時候,就需要建立一個SimpleDateFormat例項物件,然後再丟棄這個物件。大量的物件就這樣被創建出來,佔用大量的記憶體和 jvm空間。程式碼如下:
package com.peidasoft.dateformat; import java.text.ParseException; import java.text.SimpleDateFormat;import java.util.Date; public class DateUtil { public static String formatDate(Date date)throws ParseException{ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.format(date); } public static Date parse(String strDate) throws ParseException{ SimpleDateFormat sdf= new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.parse(strDate); } }
你也許會說,OK,那我就建立一個靜態的simpleDateFormat例項,然後放到一個DateUtil類(如下)中,在使用時直接使用這個例項進行操作,這樣問題就解決了。改進後的程式碼如下:
package com.peidasoft.dateformat; import java.text.ParseException; import java.text.SimpleDateFormat; importjava.util.Date; public class DateUtil { private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static String formatDate(Date date)throws ParseException{ return sdf.format(date); } public static Date parse(String strDate) throws ParseException{ return sdf.parse(strDate); } }
當然,這個方法的確很不錯,在大部分的時間裡面都會工作得很好。但當你在生產環境中使用一段時間之後,你就會發現這麼一個事實:它不是執行緒安全的。在正常的測試情況之下,都沒有問題,但一旦在生產環境中一定負載情況下時,這個問題就出來了。他會出現各種不同的情況,比如轉化的時間不正確,比如報錯,比如執行緒被掛死等等。我們看下面的測試用例,那事實說話:
package com.peidasoft.dateformat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class DateUtil { private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static String formatDate(Date date)throws ParseException{ return sdf.format(date); } public static Date parse(String strDate) throws ParseException{ return sdf.parse(strDate); } }
package com.peidasoft.dateformat; import java.text.ParseException; import java.util.Date; public class DateUtilTest { public static class TestSimpleDateFormatThreadSafe extends Thread { @Override public void run() { while(true) { try { this.join(2000); } catch (InterruptedException e1) { e1.printStackTrace(); } try { System.out.println(this.getName()+":"+DateUtil.parse("2013-05-24 06:02:20")); } catch (ParseException e) { e.printStackTrace(); } } } } public static void main(String[] args) { for(int i = 0; i < 3; i++){ new TestSimpleDateFormatThreadSafe().start(); } } }
執行輸出如下:
Exception in thread "Thread-1" java.lang.NumberFormatException: multiple points at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1082) at java.lang.Double.parseDouble(Double.java:510) at java.text.DigitList.getDouble(DigitList.java:151) at java.text.DecimalFormat.parse(DecimalFormat.java:1302) at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1589) at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1311) at java.text.DateFormat.parse(DateFormat.java:335) at com.peidasoft.orm.dateformat.DateNoStaticUtil.parse(DateNoStaticUtil.java:17) at com.peidasoft.orm.dateformat.DateUtilTest$TestSimpleDateFormatThreadSafe.run(DateUtilTest.java:20) Exception in thread "Thread-0" java.lang.NumberFormatException: multiple points at sun.misc.FloatingDecimal.readJavaFormatString(FloatingDecimal.java:1082) at java.lang.Double.parseDouble(Double.java:510) at java.text.DigitList.getDouble(DigitList.java:151) at java.text.DecimalFormat.parse(DecimalFormat.java:1302) at java.text.SimpleDateFormat.subParse(SimpleDateFormat.java:1589) at java.text.SimpleDateFormat.parse(SimpleDateFormat.java:1311) at java.text.DateFormat.parse(DateFormat.java:335) at com.peidasoft.orm.dateformat.DateNoStaticUtil.parse(DateNoStaticUtil.java:17) at com.peidasoft.orm.dateformat.DateUtilTest$TestSimpleDateFormatThreadSafe.run(DateUtilTest.java:20) Thread-2:Mon May 24 06:02:20 CST 2021 Thread-2:Fri May 24 06:02:20 CST 2013 Thread-2:Fri May 24 06:02:20 CST 2013 Thread-2:Fri May 24 06:02:20 CST 2013
說明:Thread-1和Thread-0報java.lang.NumberFormatException: multiple points錯誤,直接掛死,沒起來;Thread-2 雖然沒有掛死,但輸出的時間是有錯誤的,比如我們輸入的時間是:2013-05-24 06:02:20 ,當會輸出:Mon May 24 06:02:20 CST 2021 這樣的靈異事件。
二.原因
作為一個專業程式設計師,我們當然都知道,相比於共享一個變數的開銷要比每次建立一個新變數要小很多。上面的優化過的靜態的SimpleDateFormat版,之所在併發情況下回出現各種靈異錯誤,是因為SimpleDateFormat和DateFormat類不是執行緒安全的。我們之所以忽視執行緒安全的問題,是因為從SimpleDateFormat和DateFormat類提供給我們的介面上來看,實在讓人看不出它與執行緒安全有何相干。只是在JDK文件的最下面有如下說明:
SimpleDateFormat中的日期格式不是同步的。推薦(建議)為每個執行緒建立獨立的格式例項。如果多個執行緒同時訪問一個格式,則它必須保持外部同步。
JDK原始文件如下:
Synchronization:
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.
下面我們通過看JDK原始碼來看看為什麼SimpleDateFormat和DateFormat類不是執行緒安全的真正原因:
SimpleDateFormat繼承了DateFormat,在DateFormat中定義了一個protected屬性的 Calendar類的物件:calendar。只是因為Calendar累的概念複雜,牽扯到時區與本地化等等,Jdk的實現中使用了成員變數來傳遞引數,這就造成在多執行緒的時候會出現錯誤。
在format方法裡,有這樣一段程式碼:
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.setTime(date)這條語句改變了calendar,稍後,calendar還會用到(在subFormat方法裡),而這就是引發問題的根源。想象一下,在一個多執行緒環境下,有兩個執行緒持有了同一個SimpleDateFormat的例項,分別呼叫format方法:
執行緒1呼叫format方法,改變了calendar這個欄位。
中斷來了。
執行緒2開始執行,它也改變了calendar。
又中斷了。
執行緒1回來了,此時,calendar已然不是它所設的值,而是走上了執行緒2設計的道路。如果多個執行緒同時爭搶calendar物件,則會出現各種問題,時間不對,執行緒掛死等等。
分析一下format的實現,我們不難發現,用到成員變數calendar,唯一的好處,就是在呼叫subFormat時,少了一個引數,卻帶來了這許多的問題。其實,只要在這裡用一個區域性變數,一路傳遞下去,所有問題都將迎刃而解。
這個問題背後隱藏著一個更為重要的問題--無狀態:無狀態方法的好處之一,就是它在各種環境下,都可以安全的呼叫。衡量一個方法是否是有狀態的,就看它是否改動了其它的東西,比如全域性變數,比如例項的欄位。format方法在執行過程中改動了SimpleDateFormat的calendar欄位,所以,它是有狀態的。
這也同時提醒我們在開發和設計系統的時候注意下一下三點:
1.自己寫公用類的時候,要對多執行緒呼叫情況下的後果在註釋裡進行明確說明
2.對執行緒環境下,對每一個共享的可變變數都要注意其執行緒安全性
3.我們的類和方法在做設計的時候,要儘量設計成無狀態的
三.解決辦法
1.需要的時候建立新例項:
package com.peidasoft.dateformat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class DateUtil { public static String formatDate(Date date)throws ParseException{ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.format(date); } public static Date parse(String strDate) throws ParseException{ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); return sdf.parse(strDate); } }
說明:在需要用到SimpleDateFormat 的地方新建一個例項,不管什麼時候,將有執行緒安全問題的物件由共享變為區域性私有都能避免多執行緒問題,不過也加重了建立物件的負擔。在一般情況下,這樣其實對效能影響比不是很明顯的。
2.使用同步:同步SimpleDateFormat物件
package com.peidasoft.dateformat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class DateSyncUtil { private static SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); public static String formatDate(Date date)throws ParseException{ synchronized(sdf){ return sdf.format(date); } } public static Date parse(String strDate) throws ParseException{ synchronized(sdf){ return sdf.parse(strDate); } } }
說明:當執行緒較多時,當一個執行緒呼叫該方法時,其他想要呼叫此方法的執行緒就要block,多執行緒併發量大的時候會對效能有一定的影響。
3.使用ThreadLocal:
package com.peidasoft.dateformat; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class ConcurrentDateUtil { private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>() { @Override protected DateFormat initialValue() { return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); } }; public static Date parse(String dateStr) throws ParseException { return threadLocal.get().parse(dateStr); } public static String format(Date date) { return threadLocal.get().format(date); } }
另外一種寫法:
package com.peidasoft.dateformat; import java.text.DateFormat; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; public class ThreadLocalDateUtil { private static final String date_format = "yyyy-MM-dd HH:mm:ss"; private static ThreadLocal<DateFormat> threadLocal = new ThreadLocal<DateFormat>(); public static DateFormat getDateFormat() { DateFormat df = threadLocal.get(); if(df==null){ df = new SimpleDateFormat(date_format); threadLocal.set(df); } return df; } public static String formatDate(Date date) throws ParseException { return getDateFormat().format(date); } public static Date parse(String strDate) throws ParseException { return getDateFormat().parse(strDate); } }
說明:使用ThreadLocal, 也是將共享變數變為獨享,執行緒獨享肯定能比方法獨享在併發環境中能減少不少建立物件的開銷。如果對效能要求比較高的情況下,一般推薦使用這種方法。
4.拋棄JDK,使用其他類庫中的時間格式化類:
1.使用Apache commons 裡的FastDateFormat,宣稱是既快又執行緒安全的SimpleDateFormat, 可惜它只能對日期進行format, 不能對日期串進行解析。
2.使用Joda-Time類庫來處理時間相關問題
做一個簡單的壓力測試,方法一最慢,方法三最快,但是就算是最慢的方法一效能也不差,一般系統方法一和方法二就可以滿足,所以說在這個點很難成為你係統的瓶頸所在。從簡單的角度來說,建議使用方法一或者方法二,如果在必要的時候,追求那麼一點效能提升的話,可以考慮用方法三,用ThreadLocal做快取。
Joda-Time類庫對時間處理方式比較完美,建議使用。