如何看待Spring下單例模式與執行緒安全的矛盾

lu_s發表於2018-10-22

前言

有多少人在使用Spring框架時,很多時候不知道或者忽視了多執行緒的問題?

  因為寫程式時,或做單元測試時,很難有機會碰到多執行緒的問題,因為沒有那麼容易模擬多執行緒測試的環境。那麼當多個執行緒呼叫同一個bean的時候就會存線上程安全問題。如果是Spring中bean的建立模式為非單例的,也就不存在這樣的問題了。

  但如果不去考慮潛在的漏洞,它就會變成程式的隱形殺手,在你不知道的時候爆發。而且,通常是程式交付使用時,在生產環境下觸發,會是很麻煩的事。


Spring使用ThreadLocal解決執行緒安全問題

  我們知道在一般情況下,只有無狀態的Bean才可以在多執行緒環境下共享,在Spring中,絕大部分Bean都可以宣告為singleton作用域。就是因為Spring對一些Bean(如RequestContextHolder、TransactionSynchronizationManager、LocaleContextHolder等)中非執行緒安全狀態採用ThreadLocal進行處理,讓它們也成為執行緒安全的狀態,因為有狀態的Bean就可以在多執行緒中共享了。

  一般的Web應用劃分為展現層、服務層和持久層三個層次,在不同的層中編寫對應的邏輯,下層通過介面向上層開放功能呼叫。在一般情況下,從接收請求到返回響應所經過的所有程式呼叫都同屬於一個執行緒

  ThreadLocal是解決執行緒安全問題一個很好的思路,它通過為每個執行緒提供一個獨立的變數副本解決了變數併發訪問的衝突問題。在很多情況下,ThreadLocal比直接使用synchronized同步機制解決執行緒安全問題更簡單,更方便,且結果程式擁有更高的併發性。

  如果你的程式碼所在的程式中有多個執行緒在同時執行,而這些執行緒可能會同時執行這段程式碼。如果每次執行結果和單執行緒執行的結果是一樣的,而且其他的變數的值也和預期的是一樣的,就是執行緒安全的。 或者說:一個類或者程式所提供的介面對於執行緒來說是原子操作或者多個執行緒之間的切換不會導致該介面的執行結果存在二義性,也就是說我們不用考慮同步的問題。執行緒安全問題都是由全域性變數及靜態變數引起的。

  若每個執行緒中對全域性變數、靜態變數只有讀操作,而無寫操作,一般來說,這個全域性變數是執行緒安全的;若有多個執行緒同時執行寫操作,一般都需要考慮執行緒同步,否則就可能影響執行緒安全。

  1) 常量始終是執行緒安全的,因為只存在讀操作。

  2)每次呼叫方法前都新建一個例項是執行緒安全的,因為不會訪問共享的資源。

  3)區域性變數是執行緒安全的。因為每執行一個方法,都會在獨立的空間建立區域性變數,它不是共享的資源。區域性變數包括方法的引數變數和方法內變數。

  有狀態就是有資料儲存功能。有狀態物件(Stateful Bean),就是有例項變數的物件 ,可以儲存資料,是非執行緒安全的。在不同方法呼叫間不保留任何狀態。

  無狀態就是一次操作,不能儲存資料。無狀態物件(Stateless Bean),就是沒有例項變數的物件 .不能儲存資料,是不變類,是執行緒安全的。

  有狀態物件:

  無狀態的Bean適合用不變模式,技術就是單例模式,這樣可以共享例項,提高效能。有狀態的Bean,多執行緒環境下不安全,那麼適合用Prototype原型模式。Prototype: 每次對bean的請求都會建立一個新的bean例項。

  Struts2預設的實現是Prototype模式。也就是每個請求都新生成一個Action例項,所以不存線上程安全問題。需要注意的是,如果由Spring管理action的生命週期, scope要配成prototype作用域


執行緒安全案例

  SimpleDateFormat( 下面簡稱 sdf) 類內部有一個 Calendar 物件引用 , 它用來儲存和這個 sdf 相關的日期資訊 , 例如 sdf.parse(dateStr), sdf.format(date)  諸如此類的方法引數傳入的日期相關 String, Date 等等 ,  都是交友 Calendar 引用來儲存的 . 這樣就會導致一個問題 , 如果你的 sdf 是個 static 的 ,  那麼多個 thread  之間就會共享這個 sdf, 同時也是共享這個 Calendar 引用 ,  並且 ,  觀察  sdf.parse()  方法 , 你會發現有如下的呼叫 :

 Date parse() {
   calendar.clear(); // 清理calendar
   ... // 執行一些操作, 設定 calendar 的日期什麼的
   calendar.getTime(); // 獲取calendar的時間
 }
複製程式碼

  這裡會導致的問題就是 ,  如果 執行緒 A  呼叫了  sdf.parse(),  並且進行了 calendar.clear() 後還未執行 calendar.getTime() 的時候 , 執行緒 B 又呼叫了 sdf.parse(), 這時候執行緒 B 也執行了 sdf.clear() 方法 ,  這樣就導致執行緒 A 的的 calendar 資料被清空了 ( 實際上 A,B 的同時被清空了 ).  又或者當  A  執行了 calendar.clear()  後被掛起 ,  這時候 B  開始呼叫 sdf.parse() 並順利 i 結束 ,  這樣  A  的  calendar 記憶體儲的的 date 變成了後來 B 設定的 calendar 的 date

  這個問題背後隱藏著一個更為重要的問題 -- 無狀態:無狀態方法的好處之一,就是它在各種環境下,都可以安全的呼叫。衡量一個方法是否是有狀態的,就看它是否改動了其它的東西,比如全域性變數,比如例項的欄位。 format 方法在執行過程中改動了SimpleDateFormat 的 calendar 欄位,所以,它是有狀態的。

  這也同時提醒我們在開發和設計系統的時候注意下以下三點 :

  • 自己寫公用類的時候,要對多執行緒呼叫情況下的後果在註釋裡進行明確說明

  • 對執行緒環境下,對每一個共享的可變變數都要注意其執行緒安全性

  • 我們的類和方法在做設計的時候,要儘量設計成無狀態的


解決辦法

1. 需要的時候建立新例項:

  說明:在需要用到 SimpleDateFormat  的地方新建一個例項,不管什麼時候,將有執行緒安全問題的物件由共享變為區域性私有都能避免多執行緒問題,不過也加重了建立物件的負擔。在一般情況下,這樣其實對效能影響比不是很明顯的。

2. 使用同步:同步 SimpleDateFormat 物件

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 :

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);
    }
}
複製程式碼

  或

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 ,使用其他類庫中的時間格式化類:

  • 使用 Apache commons  裡的 FastDateFormat ,宣稱是既快又執行緒安全的SimpleDateFormat,  可惜它只能對日期進行 format,  不能對日期串進行解析。

  • 使用 Joda-Time 類庫來處理時間相關問題

  做一個簡單的壓力測試,方法一最慢,方法三最快,但是就算是最慢的方法一效能也不差,一般系統方法一和方法二就可以滿足,所以說在這個點很難成為你係統的瓶頸所在。從簡單的角度來說,建議使用方法一或者方法二,如果在必要的時候,追求那麼一點效能提升的話,可以考慮用方法三,用 ThreadLocal 做快取。

  Joda-Time 類庫對時間處理方式比較完美,建議使用。

總結

  回到文章開頭的問題:《有多少人在使用Spring框架時,很多時候不知道或者忽視了多執行緒的問題?》

  其實程式碼誰都會寫,為什麼架構師寫的程式碼效果和你的天差地別呢?應該就是此類你沒考慮到的小問題而架構師都考慮到了。因此我給大家推薦一個Java架構群:895244712,裡面有分散式,微服務,效能優化等技術點底層原理的視訊,讓你拓寬自己的知識面,養成架構師的思維方式和習慣。歡迎大家加群一起交流學習。

  架構師知識面更廣,見識到的具體情況更多,解決各類問題的經驗更豐富。只要你養成架構師的思維和習慣,那你離架構師還會遠嗎?

相關文章