Java多線程編程模式實戰指南(二):Immutable Object模式

jopen 9年前發布 | 18K 次閱讀 Java開發 JAVA多線程

多線程共享變量的情況下,為了保證數據一致性,往往需要對這些變量的訪問進行加鎖。而鎖本身又會帶來一些問題和開銷。Immutable Object模式使得我們可以在不使用鎖的情況下,既保證共享變量訪問的線程安全,又能避免引入鎖可能帶來的問題和開銷。

Immutable Object模式簡介

多線程環境中,一個對象常常會被多個線程共享。這種情況下,如果存在多個線程并發地修改該對象的狀態或者一個線程讀取該對象的狀態而另外一個線程試 圖修改該對象的狀態,我們不得不做一些同步訪問控制以保證數據一致性。而這些同步訪問控制,如顯式鎖和CAS操作,會帶來額外的開銷和問題,如上下文切 換、等待時間和ABA問題等。Immutable Object模式的意圖是通過使用對外可見的狀態不可變的對象(即Immutable Object),使得被共享對象“天生”具有線程安全性,而無需額外的同步訪問控制。從而既保證了數據一致性,又避免了同步訪問控制所產生的額外開銷和問 題,也簡化了編程。

所謂狀態不可變的對象,即對象一經創建其對外可見的狀態就保持不變,例如Java中的String和Integer。這點固然容易理解,但這還不足 以指導我們在實際工作中運用Immutable Object模式。下面我們看一個典型應用場景,這不僅有助于我們理解它,也有助于在實際的環境中運用它。

一個車輛管理系統要對車輛的位置信息進行跟蹤,我們可以對車輛的位置信息建立如清單1所示的模型。

清單 1. 狀態可變的位置信息模型(非線程安全)

public class Location {

private double x;
private double y;

public Location(double x, double y) {
    this.x = x;
    this.y = y;
}

public double getX() {
    return x;
}

public double getY() {
    return y;
}

public void setXY(double x, double y) {
    this.x = x;
    this.y = y;
}

}</pre>

當系統接收到新的車輛坐標數據時,需要調用Location的setXY方法來更新位置信息。顯然,清單1中setXY是非線程安全的,因為對坐標 數據x和y的寫操作不是一個原子操作。setXY被調用時,如果在x寫入完畢,而y開始寫之前有其它線程來讀取位置信息,則該線程可能讀到一個被追蹤車輛 根本不曾經過的位置。為了使setXY方法具備線程安全性,我們需要借助鎖進行訪問控制。雖然被追蹤車輛的位置信息總是在變化,但是我們也可以將位置信息 建模為狀態不可變的對象,如清單2所示。

</div> </div> </div>

清單 2. 狀態不可變的位置信息模型

public final class Location {
    public final double x;
    public final double y;

public Location(double x, double y) {
    this.x = x;
    this.y = y;
}

}</pre>

使用狀態不可變的位置信息模型時,如果車輛的位置發生變動,則更新車輛的位置信息是通過替換整個表示位置信息的對象(即Location實例)來實現的。如清單3所示。

清單 3. 在使用不可變對象的情況下更新車輛的位置信息

public class VehicleTracker {

private Map<String, Location> locMap 
    = new ConcurrentHashMap();

public void updateLocation(String vehicleId, Location newLocation) {
    locMap.put(vehicleId, newLocation);
}

}</pre>

因此,所謂狀態不可變的對象并非指被建模的現實世界實體的狀態不可變,而是我們在建模的時候的一種決策:現實世界實體的狀態總是在變化的,但我們可以用狀態不可變的對象來對這些實體進行建模。

Immutable Object模式的架構

Immutable Object模式的主要參與者有以下幾種。其類圖如圖1所示。

圖 1. Immutable Object模式的類圖

 Java多線程編程模式實戰指南(二):Immutable Object模式

  • ImmutableClass:負責存儲一組不可變狀態的類。該類不對外暴露任何可以修改其狀態的方法,其主要方法及職責如下:

    getStateXgetStateN:這些getter方法返回該類所維護的狀態相關變量的值。這些變量在對象實例化時通過其構造器的參數獲得值。

    getStateSnapshot:返回該類維護的一組狀態的快照。

    </li>

  • Manipulator:負責維護ImmutableClass所建模的現實世界實體狀態的變更。當相應的現實世界實體狀態變更時,該類負責生成新的ImmutableClass的實例,以反映新的狀態。

    changeStateTo:根據新的狀態值生成新的ImmutableClass的實例。

  • </ul>

    不可變對象的使用主要包括以下幾種類型:

    獲取單個狀態的值:調用不可變對象的相關getter方法即可實現。

    獲取一組狀態的快照:不可變對象可以提供一個getter方法,該方法需要對其返回值做防御性拷貝或者返回一個只讀的對象,以避免其狀態對外泄露而被改變。

    生成新的不可變對象實例:當被建模對象的狀態發生變化的時候,創建新的不可變對象實例來反映這種變化。

    Immutable Object模式的典型交互場景如圖2所示:

    圖 2. Immutable Object模式的序列圖

     Java多線程編程模式實戰指南(二):Immutable Object模式

    1~4、客戶端代碼獲取ImmutableClass的各個狀態值。

    5、客戶端代碼調用Manipulator的changeStateTo方法來更新應用的狀態。

    6、Manipulator創建新的ImmutableClass實例以反映應用的新狀態。

    7~9、客戶端代碼獲取新的ImmutableClass實例的狀態快照。

    一個嚴格意義上不可變對象要滿足以下所有條件:

    1) 類本身使用final修飾:防止其子類改變其定義的行為;

    2) 所有字段都是用final修飾的:使用final修飾不僅僅是從語義上說明被修飾字段的引用不可 改變。更重要的是這個語義在多線程環境下由JMM(Java Memory Model)保證了被修飾字段的所引用對象的初始化安全,即final修飾的字段在其它線程可見時,它必定是初始化完成的。相反,非final修飾的字段 由于缺少這種保證,可能導致一個線程“看到”一個字段的時候,它還未被初始化完成,從而可能導致一些不可預料的結果。

    3) 在對象的創建過程中,this關鍵字沒有泄露給其它類:防止其它類(如該類的匿名內部類)在對象創建過程中修改其狀態。

    4) 任何字段,若其引用了其它狀態可變的對象(如集合、數組等),則這些字段必須是private修飾的,并且這些字段值不能對外暴露。若有相關方法要返回這些字段值,應該進行防御性拷貝(Defensive Copy)。

    Immutable Object模式實戰案例

    某彩信網關系統在處理由增值業務提供商(VASP,Value-Added Service Provider)下發給手機終端用戶的彩信消息時,需要根據彩信接收方號碼的前綴(如1381234)選擇對應的彩信中心 (MMSC,Multimedia Messaging Service Center),然后轉發消息給選中的彩信中心,由其負責對接電信網絡將彩信消息下發給手機終端用戶。彩信中心相對于彩信網關系統而言,它是一個獨立的部 件,二者通過網絡進行交互。這個選擇彩信中心的過程,我們稱之為路由(Routing)。而手機號前綴和彩信中心的這種對應關系,被稱為路由表。路由表在 軟件運維過程中可能發生變化。例如,業務擴容帶來的新增彩信中心、為某個號碼前綴指定新的彩信中心等。雖然路由表在該系統中是由多線程共享的數據,但是這 些數據的變化頻率并不高。因此,即使是為了保證線程安全,我們也不希望對這些數據的訪問進行加鎖等并發訪問控制,以免產生不必要的開銷和問題。這 時,Immutable Object模式就派上用場了。

    維護路由表可以被建模為一個不可變對象,如清單4所示。

    清單 4. 使用不可變對象維護路由表

    public final class MMSCRouter {
        // 用volatile修飾,保證多線程環境下該變量的可見性
        private static volatile MMSCRouter instance = new MMSCRouter();
             //維護手機號碼前綴到彩信中心之間的映射關系
        private final Map<String, MMSCInfo> routeMap;

    public MMSCRouter() {
        // 將數據庫表中的數據加載到內存,存為Map
        this.routeMap = MMSCRouter.retrieveRouteMapFromDB();
    }
    
    private static Map<String, MMSCInfo> retrieveRouteMapFromDB() {
        Map<String, MMSCInfo> map = new HashMap<String, MMSCInfo>();
        // 省略其它代碼
        return map;
    }
    
    public static MMSCRouter getInstance() {
    
        return instance;
    }
    
    /**
     * 根據手機號碼前綴獲取對應的彩信中心信息
     * 
     * @param msisdnPrefix
     *          手機號碼前綴
     * @return 彩信中心信息
     */
    public MMSCInfo getMMSC(String msisdnPrefix) {
        return routeMap.get(msisdnPrefix);
    
    }
    
    /**
     * 將當前MMSCRouter的實例更新為指定的新實例
     * 
     * @param newInstance
     *          新的MMSCRouter實例
     */
    public static void setInstance(MMSCRouter newInstance) {
        instance = newInstance;
    }
    
    private static Map<String, MMSCInfo> deepCopy(Map<String, MMSCInfo> m) {
        Map<String, MMSCInfo> result = new HashMap<String, MMSCInfo>();
        for (String key : m.keySet()) {
            result.put(key, new MMSCInfo(m.get(key)));
        }
        return result;
    }
    
    public Map<String, MMSCInfo> getRouteMap() {
        //做防御性拷貝
        return Collections.unmodifiableMap(deepCopy(routeMap));
    }
    
    

    }</pre>

    而彩信中心的相關數據,如彩信中心設備編號、URL、支持的最大附件尺寸也被建模為一個不可變對象。如清單5所示。

    清單 5. 使用不可變對象表示彩信中心信息

    public final class MMSCInfo {
        /**

     * 設備編號
     */
    private final String deviceID;
    /**
     * 彩信中心URL
     */
    private final String url;
    /**
     * 該彩信中心允許的最大附件大小
     */
    private final int maxAttachmentSizeInBytes;
    
    public MMSCInfo(String deviceID, String url, int maxAttachmentSizeInBytes) {
        this.deviceID = deviceID;
        this.url = url;
        this.maxAttachmentSizeInBytes = maxAttachmentSizeInBytes;
    }
    
    public MMSCInfo(MMSCInfo prototype) {
        this.deviceID = prototype.deviceID;
        this.url = prototype.url;
        this.maxAttachmentSizeInBytes = prototype.maxAttachmentSizeInBytes;
    }
    
    public String getDeviceID() {
        return deviceID;
    }
    
    public String getUrl() {
        return url;
    }
    
    public int getMaxAttachmentSizeInBytes() {
        return maxAttachmentSizeInBytes;
    }
    
    

    }</pre>

    彩信中心信息變更的頻率也同樣不高。因此,當彩信網關系統通過網絡(Socket連接)被通知到這種彩信中心信息本身或者路由表變更時,網關系統會重新生成新的MMSCInfo和MMSCRouter來反映這種變更。如清單6所示。

    清單 6. 處理彩信中心、路由表的變更

    /**

    • 與運維中心(Operation and Maintenance Center)對接的類 / public class OMCAgent extends Thread{

      @Override public void run() { boolean isTableModificationMsg=false; String updatedTableName=null; while(true){

       //省略其它代碼
       /*
        * 從與OMC連接的Socket中讀取消息并進行解析,
        * 解析到數據表更新消息后,重置MMSCRouter實例。
        */
       if(isTableModificationMsg){
           if("MMSCInfo".equals(updatedTableName)){
               MMSCRouter.setInstance(new MMSCRouter());
           }
       }
       //省略其它代碼
      

      } }

    }</pre>

    上述代碼會調用MMSCRouter的setInstance方法來替換MMSCRouter的實例為新創建的實例。而新創建的MMSCRouter實例通過其構造器會生成多個新的MMSCInfo的實例。

    本案例中,MMSCInfo是一個嚴格意義上的不可變對象。雖然MMSCRouter對外提供了setInstance方法用于改變其靜態字段 instance的值,但它仍然可視作一個等效的不可變對象。這是因為,setInstance方法僅僅是改變instance變量指向的對象,而 instance變量采用volatile修飾保證了其在多線程之間的內存可見性,這意味著setInstance對instance變量的改變無需加鎖 也能保證線程安全。而其它代碼在調用MMSCRouter的相關方法獲取路由信息時也無需加鎖。

    從圖1的類圖上看,OMCAgent類(見清單6)是一個Manipulator參與者實例,而MMSCInfo、MMSCRouter是一個 ImmutableClass參與者實例。通過使用不可變對象,我們既可以應對路由表、彩信中心這些不是非常頻繁的變更,又可以使系統中使用路由表的代碼 免于并發訪問控制的開銷和問題。

    Immutable Object模式的評價與實現考量

    不可變對象具有天生的線程安全性,多個線程共享一個不可變對象的時候無需使用額外的并發訪問控制,這使得我們可以避免顯式鎖(Explicit Lock)等并發訪問控制的開銷和問題,簡化了多線程編程。

    Immutable Object模式特別適用于以下場景。

    被建模對象的狀態變化不頻繁:正如本文案例所展示的,這種場景下可以設置一個專門的線程(Manipulator參與者所在的線程) 用于在被建模對象狀態變化時創建新的不可變對象。而其它線程則只是讀取不可變對象的狀態。此場景下的一個小技巧是Manipulator對不可變對象的引 用采用volatile關鍵字修飾,既可以避免使用顯式鎖(如synchronized),又可以保證多線程間的內存可見性。

    同時對一組相關的數據進行寫操作,因此需要保證原子性:此場景為了保證操作的原子性,通常的做法是使用顯式鎖。但若采用 Immutable Object模式,將這一組相關的數據“組合”成一個不可變對象,則對這一組數據的操作就可以無需加顯式鎖也能保證原子性,既簡化了編程,又提高了代碼運 行效率。本文開頭所舉的車輛位置跟蹤的例子正是這種場景。

    使用某個對象作為安全的HashMap的Key:我們知道,一個對象作為HashMap的Key被“放入”HashMap之后,若該 對象狀態變化導致了其Hash Code的變化,則會導致后面在用同樣的對象作為Key去get的時候無法獲取關聯的值,盡管該HashMap中的確存在以該對象為Key的條目。相反, 由于不可變對象的狀態不變,因此其Hash Code也不變。這使得不可變對象非常適于用作HashMap的Key。

    Immutable Object模式實現時需要注意以下幾個問題:

    被建模對象的狀態變更比較頻繁:此時也不見得不能使用Immutable Object模式。只是這意味著頻繁創建新的不可變對象,因此會增加GC(Garbage Collection)的負擔和CPU消耗,我們需要綜合考慮:被建模對象的規模、代碼目標運行環境的JVM內存分配情況、系統對吞吐率和響應性的要求。 若這幾個方面因素綜合考慮都能滿足要求,那么使用不可變對象建模也未嘗不可。

    使用等效或者近似的不可變對象:有時創建嚴格意義上的不可變對象比較難,但是盡量向嚴格意義上的不可變對象靠攏也有利于發揮不可變對象的好處。

    防御性拷貝:如果不可變對象本身包含一些狀態需要對外暴露,而相應的字段本身又是可變的(如HashMap),那么在返回這些字段的方法還是需要做防御性拷貝,以避免外部代碼修改了其內部狀態。正如清單4的代碼中的getRouteMap方法所展示的那樣。

    總結

    本文介紹了Immutable Object模式的意圖及架構。并結合筆者工作經歷提供了一個實際的案例用于展示使用該模式的典型場景,在此基礎上對該模式進行了評價并分享在實際運用該模式時需要注意的事項。

    參考資源

 本文由用戶 jopen 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!