Java高級編程基礎:多線程應用的高效安全,不可變對象設計很重要

2019-08-19   道以致遠

多線程設計之不可變性

實現多線程應用程式有時比乍一看要複雜得多。因此,在開始項目時,我們必須在頭腦中先有一個清晰的設計。

因為多線程應用程式,不可避免的存在共享資源並發訪問問題,所以在並發多線程應用設計時,我們一般情況下要儘量的少提供對共享資源的直接狀態改變操作。

因此,我們在多線程類和方法設計時,一個非常重要的原則是儘量保持對象狀態的不可變性,從而降低多線程應用程式設計的複雜性。

不可變的對象

顧名思義,對象本身的狀態在運行過程中不會被外部輕易改變。從而保證多線程訪問的一致性。

關於對象的是不可變性設計,它在這多線程並發應用設計中被認為是一個非常重要的設計規則。

如果在不同的線程之間共享對象實例,則必須注意兩個線程不會同時修改同一個對象。

怎樣做到讓不可修改對象不被用戶修改呢?這種設計很容易實現。

比如,當我們想要修改數據時,要求總是必須構造一個該類的新的實例。我們Java中的基本類java.lang.String就是不可變類的一個例子。

每次我們想改變一個字符串時,都會得到一個新的實例,而非在原來的字符串上做的修改:

String str = "abc";
String substr = str.substring(1);

雖然對象創建是一個不可能沒有成本的操作,但是這些對象的創建成本常常被我們誇大了。

我們在設計多線程應用時,如果使用不可變對象的簡單設計比不使用具有並發性錯誤風險的不可變對象更重要,那麼我們就必須權衡一下利弊了。

下面是我們在設計不可變對象時常用的一些規則,如果想讓一個類不可變的時候,這些規則可以應用:

  • 所有欄位都應該是final和private。
  • 不應該有setter方法。
  • 類本身應該聲明為final,以防止子類違反不變性原則。
  • 如果欄位不是原始類型,而是對另一個對象的引用:

-不應該有直接向調用者公開引用的getter方法。

-不要更改引用的對象(或至少更改這些引用對對象的客戶機不可見)。

以下類的實例表示包含主題、消息體和幾個鍵/值對的消息:

這裡定義的類是不可變的,因為它的所有欄位都是final和private。

它沒有定義公開的能夠在實例構造之後用來修改實例的狀態的方法。

因為String本身是一個不可變的類,所以該類返回對subject和message的引用都是安全的。

因此,獲得消息引用的調用者就不能直接修改它。

使用header的Map時,我們必須多加註意。如果我們僅直接返回對Map的引用那麼就允許調用者更改其內容。

因此,我們必須返回通過Collection.unmodifiableMap()調用獲得的不可修改Map。

該方法將返回Map的一個視圖,該視圖允許調用者讀取值(同樣是字符串),但不允許修改。

當試圖修改Map實例時,將會拋出一個UnsupportedOperationException異常。

在本例中,返回Map的特定鍵的值也是安全的,比如getHeader(String key)中,因為它返回的字符串String是不可變的。

但如果Map包含的對象本身不是不可變的,那麼這個操作就不是線程安全的了。

API設計時不可變對象

另外我們在設計對外應用程式接口時,也會大量使用不可變對象狀態。特別是在設計類的公共方法(即該類的API)時,我們也可以通過不可用對象設計來讓它適合多線程調用。

比如我們可以設置當對象處於某種狀態時,可能有一些方法不應該執行,從而避免在某個狀態下,對象被修改。

處理這種情況的一個簡單的解決方案是使用一個私有標誌變量,它指示我們處於什麼狀態,並在不應該調用特定方法時拋出一個IllegalStateException:

上面代碼示例中使用的模式也經常被稱為「阻塞模式」,因為一旦方法在錯誤的狀態下執行,它就會阻塞。

我們還可以使用靜態工廠方法來設計相同的功能,而不用檢查每個方法對象的狀態:

這裡我們的靜態工廠方法使用私有構造函數創建FactoryTask的一個新實例,並已經對該實例調用start()。

FactoryTask返回的引用已經處於要處理的正確狀態,因此只需要同步getResults()方法,但不需要檢查對象的狀態。

線程本地存儲

到目前為止,我們前面講的都是關於線程共享相同的內存。就性能而言,這是在線程之間共享數據的好方法。

如果我們使用單獨的進程來並行執行代碼,我們就會有更多繁重的數據交換方法,比如我們常見的遠程過程調用或文件系統或網絡級別上的同步操作。

但是,如果沒有正確地同步,在不同線程之間共享內存也很難處理。

通過Java為我們提供的java.lang.ThreadLocal類,我們可以定義和設置讓單個線程使用而不供其他線程使用的專用內存空間。

private static final ThreadLocal myThreadLocalInteger = new ThreadLocal();

應該存儲在ThreadLocal中的數據類型由泛型模板參數T給出。

在上面的例子中,我們只使用Integer,但是我們也可以使用其他任何數據類型。

執行結果如下:

這裡可能有一個小小的疑惑需要說明,雖然變量threadLocal被聲明為靜態的,但是每個線程都輸出它通過構造函數得到的值。

ThreadLocal的內部實現確保每次調用set()時,給定的值都存儲在只有當前線程才能訪問的內存區域中。

因此,儘管在此期間其他線程可能已經調用了set(),但當我們在調用get()時,還能檢索之前設置的值。

這個線程本地變量類很重要,比如我們的Java EE世界中的應用伺服器就大量使用ThreadLocal特性,因為我們有許多並行線程,但是每個線程都有自己的事務或安全上下文。

通過它我們可以不必在每個方法調用中傳遞這些對象,只需將其存儲在線程自己的內存中,然後在需要時訪問它即可。

總結

本文簡單介紹了我們在多線程設計時通常採用的一個重要原則,就是儘量使用不可變對象,因為不變代表著更低的風險。

特別要注意String字符串特性在設計不可變對象中的使用,還有就是在返回Map時,最好使用其提供的那些返回Map視圖的方法,從而保護Map內容不被修改。

在設計對外的公共方法API時,我們需要注意使用私有化構造方法,通過提供靜態工廠方法調用私有構造器等方式來消除對內部狀態的直接修改。

從而更好的支持多線程的調用安全性。當然這些對象設計都是在多線程應用中屬於共享資源的範疇,也就是說它們操作的是線程的共享內存空間。

為了更好的安全性,我們還介紹了映射到單個線程的內存空間的共享ThreadLocal類的使用。雖然它是一個靜態的變量,但是可實際的存儲內存空間則是位於單個線程內部,所以具備很好的安全性,不會被其它線程共享。