Java多執行緒程式設計模式實戰指南(二):Immutable Object模式

InfoQ - 黃文海發表於2015-01-18

多執行緒共享變數的情況下,為了保證資料一致性,往往需要對這些變數的訪問進行加鎖。而鎖本身又會帶來一些問題和開銷。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;
	}
}

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

清單 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;
	}
}

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

清單 3. 在使用不可變物件的情況下更新車輛的位置資訊

public class VehicleTracker {

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

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

}

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

Immutable Object模式的架構

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

圖 1. Immutable Object模式的類圖

  • ImmutableClass:負責儲存一組不可變狀態的類。該類不對外暴露任何可以修改其狀態的方法,其主要方法及職責如下:getStateXgetStateN:這些getter方法返回該類所維護的狀態相關變數的值。這些變數在物件例項化時通過其構造器的引數獲得值。getStateSnapshot:返回該類維護的一組狀態的快照。
  • Manipulator:負責維護ImmutableClass所建模的現實世界實體狀態的變更。當相應的現實世界實體狀態變更時,該類負責生成新的ImmutableClass的例項,以反映新的狀態。changeStateTo:根據新的狀態值生成新的ImmutableClass的例項。

不可變物件的使用主要包括以下幾種型別:

獲取單個狀態的值:呼叫不可變物件的相關getter方法即可實現。

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

生成新的不可變物件例項:當被建模物件的狀態發生變化的時候,建立新的不可變物件例項來反映這種變化。

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

圖 2. 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));
	}

}

而彩信中心的相關資料,如彩信中心裝置編號、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;
	}

}

彩信中心資訊變更的頻率也同樣不高。因此,當彩信閘道器係統通過網路(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());
	  		}
	  	}
	  	//省略其它程式碼
	  }
  }

}

上述程式碼會呼叫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模式的意圖及架構。並結合筆者工作經歷提供了一個實際的案例用於展示使用該模式的典型場景,在此基礎上對該模式進行了評價並分享在實際運用該模式時需要注意的事項。

相關文章