設計模式之美 - 47 | 原型模式:如何最快速地clone一個HashMap散列表?

這系列相關博客,參考 設計模式之美

設計模式之美 - 47 | 原型模式:如何最快速地clone一個HashMap散列表?

對於創建型模式,前面我們已經講了單例模式、工廠模式、建造者模式,今天我們來講最後一個:原型模式。

對於熟悉 JavaScript 語言的前端程序員來說,原型模式是一種比較常用的開發模式。這是因爲,有別於 Java、C++ 等基於類的面向對象編程語言,JavaScript 是一種基於原型的面向對象編程語言。即便 JavaScript 現在也引入了類的概念,但它也只是基於原型的語法糖而已。不過,如果你熟悉的是 Java、C++ 等這些編程語言,那在實際的開發中,就很少用到原型模式了。

今天的講解跟具體某一語言的語法機制無關,而是通過一個 clone 散列表的例子帶你搞清楚:原型模式的應用場景,以及它的兩種實現方式:深拷貝和淺拷貝。雖然原型模式的原理和代碼實現非常簡單,但今天舉的例子還是稍微有點複雜的,你要跟上我的思路,多動腦思考一下。

話不多說,讓我們正式開始今天的學習吧!

原型模式的原理與應用

如果對象的創建成本比較大,而同一個類的不同對象之間差別不大(大部分字段都相同),在這種情況下,我們可以利用對已有對象(原型)進行復制(或者叫拷貝)的方式來創建新對象,以達到節省創建時間的目的。這種基於原型來創建對象的方式就叫作原型設計模式(Prototype Design Pattern),簡稱原型模式

那何爲“對象的創建成本比較大”?

實際上,創建對象包含的申請內存、給成員變量賦值這一過程,本身並不會花費太多時間,或者說對於大部分業務系統來說,這點時間完全是可以忽略的。應用一個複雜的模式,只得到一點點的性能提升,這就是所謂的過度設計,得不償失。

但是,如果對象中的數據需要經過複雜的計算才能得到(比如排序、計算哈希值),或者需要從 RPC、網絡、數據庫、文件系統等非常慢速的 IO 中讀取,這種情況下,我們就可以利用原型模式,從其他已有對象中直接拷貝得到,而不用每次在創建新對象的時候,都重複執行這些耗時的操作。

這麼說還是比較理論,接下來,我們通過一個例子來解釋一下剛剛這段話。

假設數據庫中存儲了大約 10 萬條“搜索關鍵詞”信息,每條信息包含關鍵詞、關鍵詞被搜索的次數、信息最近被更新的時間等。系統 A 在啓動的時候會加載這份數據到內存中,用於處理某些其他的業務需求。爲了方便快速地查找某個關鍵詞對應的信息,我們給關鍵詞建立一個散列表索引。

如果你熟悉的是 Java 語言,可以直接使用語言中提供的 HashMap 容器來實現。其中,HashMap 的 key 爲搜索關鍵詞,value 爲關鍵詞詳細信息(比如搜索次數)。我們只需要將數據從數據庫中讀取出來,放入 HashMap 就可以了。

不過,我們還有另外一個系統 B,專門用來分析搜索日誌,定期(比如間隔 10 分鐘)批量地更新數據庫中的數據,並且標記爲新的數據版本。比如,在下面的示例圖中,我們對 v2 版本的數據進行更新,得到 v3 版本的數據。這裏我們假設只有更新和新添關鍵詞,沒有刪除關鍵詞的行爲。
在這裏插入圖片描述
爲了保證系統 A 中數據的實時性(不一定非常實時,但數據也不能太舊),系統 A 需要定期根據數據庫中的數據,更新內存中的索引和數據。

我們該如何實現這個需求呢?

實際上,也不難。我們只需要在系統 A 中,記錄當前數據的版本 Va 對應的更新時間Ta,從數據庫中撈出更新時間大於 Ta 的所有搜索關鍵詞,也就是找出 Va 版本與最新版本數據的“差集”,然後針對差集中的每個關鍵詞進行處理。如果它已經在散列表中存在了,我們就更新相應的搜索次數、更新時間等信息;如果它在散列表中不存在,我們就將它插入到散列表中。

按照這個設計思路,我給出的示例代碼如下所示:

public class Demo {
	private ConcurrentHashMap<String, SearchWord> currentKeywords = new Concur
	private long lastUpdateTime = -1;
	
	public void refresh() {
		// 從數據庫中取出更新時間>lastUpdateTime的數據,放入到currentKeywords中
		List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime)
		long maxNewUpdatedTime = lastUpdateTime;
		for (SearchWord searchWord : toBeUpdatedSearchWords) {
			if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
				maxNewUpdatedTime = searchWord.getLastUpdateTime();
			}
			if (currentKeywords.containsKey(searchWord.getKeyword())) {
				currentKeywords.replace(searchWord.getKeyword(), searchWord);
			} else {
				currentKeywords.put(searchWord.getKeyword(), searchWord);
			}
		}
		lastUpdateTime = maxNewUpdatedTime;
	}
	
	private List<SearchWord> getSearchWords(long lastUpdateTime) {
		// TODO: 從數據庫中取出更新時間>lastUpdateTime的數據
		return null;
	}
}

不過,現在,我們有一個特殊的要求:任何時刻,系統 A 中的所有數據都必須是同一個版本的,要麼都是版本 a,要麼都是版本 b,不能有的是版本 a,有的是版本 b。那剛剛的更新方式就不能滿足這個要求了。除此之外,我們還要求:在更新內存數據的時候,系統 A 不能處於不可用狀態,也就是不能停機更新數據。

那我們該如何實現現在這個需求呢?

實際上,也不難。我們把正在使用的數據的版本定義爲“服務版本”,當我們要更新內存中的數據的時候,我們並不是直接在服務版本(假設是版本 a 數據)上更新,而是重新創建另一個版本數據(假設是版本 b 數據),等新的版本數據建好之後,再一次性地將服務版本從版本 a 切換到版本 b。這樣既保證了數據一直可用,又避免了中間狀態的存在。

按照這個設計思路,我給出的示例代碼如下所示:

public class Demo {
	private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
	
	public void refresh() {
		HashMap<String, SearchWord> newKeywords = new LinkedHashMap<>();
		// 從數據庫中取出所有的數據,放入到newKeywords中
		List<SearchWord> toBeUpdatedSearchWords = getSearchWords();
		for (SearchWord searchWord : toBeUpdatedSearchWords) {
			newKeywords.put(searchWord.getKeyword(), searchWord);
		}
		currentKeywords = newKeywords;
	}
	
	private List<SearchWord> getSearchWords() {
		// TODO: 從數據庫中取出所有的數據
		return null;
	}
}

不過,在上面的代碼實現中,newKeywords 構建的成本比較高。我們需要將這 10 萬條數據從數據庫中讀出,然後計算哈希值,構建 newKeywords。這個過程顯然是比較耗時。爲了提高效率,原型模式就派上用場了。

我們拷貝 currentKeywords 數據到 newKeywords 中,然後從數據庫中只撈出新增或者有更新的關鍵詞,更新到 newKeywords 中。而相對於 10 萬條數據來說,每次新增或者更新的關鍵詞個數是比較少的,所以,這種策略大大提高了數據更新的效率。

按照這個設計思路,我給出的示例代碼如下所示:

public class Demo {
	private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
	private long lastUpdateTime = -1;
	
	public void refresh() {
		// 原型模式就這麼簡單,拷貝已有對象的數據,更新少量差值
		HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>)
		
		// 從數據庫中取出更新時間>lastUpdateTime的數據,放入到newKeywords中
		List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime)
		long maxNewUpdatedTime = lastUpdateTime;
		for (SearchWord searchWord : toBeUpdatedSearchWords) {
			if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
				maxNewUpdatedTime = searchWord.getLastUpdateTime();
			}
			if (newKeywords.containsKey(searchWord.getKeyword())) {
				SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
				oldSearchWord.setCount(searchWord.getCount());
				oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
			} else {
				newKeywords.put(searchWord.getKeyword(), searchWord);
			}
		}
		
		lastUpdateTime = maxNewUpdatedTime;
		currentKeywords = newKeywords;
	}
	
	private List<SearchWord> getSearchWords(long lastUpdateTime) {
		// TODO: 從數據庫中取出更新時間>lastUpdateTime的數據
		return null;
	}
}

這裏我們利用了 Java 中的 clone() 語法來複制一個對象。如果你熟悉的語言沒有這個語法,那把數據從 currentKeywords 中一個個取出來,然後再重新計算哈希值,放入到newKeywords 中也是可以接受的。畢竟,最耗時的還是從數據庫中取數據的操作。相對於數據庫的 IO 操作來說,內存操作和 CPU 計算的耗時都是可以忽略的。

不過,不知道你有沒有發現,實際上,剛剛的代碼實現是有問題的。要弄明白到底有什麼問題,我們需要先了解另外兩個概念:深拷貝(Deep Copy)和淺拷貝(Shallow Copy)。

原型模式的實現方式:深拷貝和淺拷貝

我們來看,在內存中,用散列表組織的搜索關鍵詞信息是如何存儲的。我畫了一張示意圖,大致結構如下所示。從圖中我們可以發現,散列表索引中,每個結點存儲的 key 是搜索關鍵詞,value 是 SearchWord 對象的內存地址。SearchWord 對象本身存儲在散列表之外的內存空間中。
在這裏插入圖片描述
淺拷貝和深拷貝的區別在於,淺拷貝只會複製圖中的索引(散列表),不會複製數據(SearchWord 對象)本身。相反,深拷貝不僅僅會複製索引,還會複製數據本身。淺拷貝得到的對象(newKeywords)跟原始對象(currentKeywords)共享數據(SearchWord 對象),而深拷貝得到的是一份完完全全獨立的對象。具體的對比如下圖所示:
在這裏插入圖片描述
在這裏插入圖片描述
在 Java 語言中,Object 類的 clone() 方法執行的就是我們剛剛說的淺拷貝。它只會拷貝對象中的基本數據類型的數據(比如,int、long),以及引用對象(SearchWord)的內存地址,不會遞歸地拷貝引用對象本身。

在上面的代碼中,我們通過調用 HashMap 上的 clone() 淺拷貝方法來實現原型模式。當我們通過 newKeywords 更新 SearchWord 對象的時候(比如,更新“設計模式”這個搜索關鍵詞的訪問次數),newKeywords 和 currentKeywords 因爲指向相同的一組SearchWord 對象,就會導致 currentKeywords 中指向的 SearchWord,有的是老版本的,有的是新版本的,就沒法滿足我們之前的需求:currentKeywords 中的數據在任何時刻都是同一個版本的,不存在介於老版本與新版本之間的中間狀態。

現在,我們又該如何來解決這個問題呢?

我們可以將淺拷貝替換爲深拷貝。newKeywords 不僅僅複製 currentKeywords 的索引,還把 SearchWord 對象也複製一份出來,這樣 newKeywords 和 currentKeywords 就指向不同的 SearchWord 對象,也就不存在更新 newKeywords 的數據會導致 currentKeywords 的數據也被更新的問題了。

那如何實現深拷貝呢?總結一下的話,有下面兩種方法。

第一種方法:遞歸拷貝對象、對象的引用對象以及引用對象的引用對象……直到要拷貝的對象只包含基本數據類型數據,沒有引用對象爲止。根據這個思路對之前的代碼進行重構。重構之後的代碼如下所示:

public class Demo {
	private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
	private long lastUpdateTime = -1;
	
	public void refresh() {
		// Deep copy
		HashMap<String, SearchWord> newKeywords = new HashMap<>();
		for (HashMap.Entry<String, SearchWord> e : currentKeywords.entrySet()) {
			SearchWord searchWord = e.getValue();
			SearchWord newSearchWord = new SearchWord(
					searchWord.getKeyword(), searchWord.getCount(), searchWord.get
			newKeywords.put(e.getKey(), newSearchWord);
		}
		
		// 從數據庫中取出更新時間>lastUpdateTime的數據,放入到newKeywords中
		List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime)
		long maxNewUpdatedTime = lastUpdateTime;
		for (SearchWord searchWord : toBeUpdatedSearchWords) {
			if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
				maxNewUpdatedTime = searchWord.getLastUpdateTime();
			}
			if (newKeywords.containsKey(searchWord.getKeyword())) {
				SearchWord oldSearchWord = newKeywords.get(searchWord.getKeyword());
				oldSearchWord.setCount(searchWord.getCount());
				oldSearchWord.setLastUpdateTime(searchWord.getLastUpdateTime());
			} else {
				newKeywords.put(searchWord.getKeyword(), searchWord);
			}
		}
		
		lastUpdateTime = maxNewUpdatedTime;
		currentKeywords = newKeywords;
	}
	
	private List<SearchWord> getSearchWords(long lastUpdateTime) {
		// TODO: 從數據庫中取出更新時間>lastUpdateTime的數據
		return null;
	}
}

第二種方法:先將對象序列化,然後再反序列化成新的對象。具體的示例代碼如下所示:

public Object deepCopy(Object object) {
	ByteArrayOutputStream bo = new ByteArrayOutputStream();
	ObjectOutputStream oo = new ObjectOutputStream(bo);
	oo.writeObject(object);
	
	ByteArrayInputStream bi = new ByteArrayInputStream(bo.toByteArray());
	ObjectInputStream oi = new ObjectInputStream(bi);
	
	return oi.readObject();
}

剛剛的兩種實現方法,不管採用哪種,深拷貝都要比淺拷貝耗時、耗內存空間。針對我們這個應用場景,有沒有更快、更省內存的實現方式呢?

我們可以先採用淺拷貝的方式創建 newKeywords。對於需要更新的 SearchWord 對象,我們再使用深度拷貝的方式創建一份新的對象,替換 newKeywords 中的老對象。畢竟需要更新的數據是很少的。這種方式即利用了淺拷貝節省時間、空間的優點,又能保證 currentKeywords 中的中數據都是老版本的數據。具體的代碼實現如下所示。這也是標題中講到的,在我們這個應用場景下,最快速 clone 散列表的方式。

public class Demo {
	private HashMap<String, SearchWord> currentKeywords=new HashMap<>();
	private long lastUpdateTime = -1;
	
	public void refresh() {
		// Shallow copy
		HashMap<String, SearchWord> newKeywords = (HashMap<String, SearchWord>)
	
		// 從數據庫中取出更新時間>lastUpdateTime的數據,放入到newKeywords中
		List<SearchWord> toBeUpdatedSearchWords = getSearchWords(lastUpdateTime)
		long maxNewUpdatedTime = lastUpdateTime;
		for (SearchWord searchWord : toBeUpdatedSearchWords) {
			if (searchWord.getLastUpdateTime() > maxNewUpdatedTime) {
				maxNewUpdatedTime = searchWord.getLastUpdateTime();
			}
			if (newKeywords.containsKey(searchWord.getKeyword())) {
				newKeywords.remove(searchWord.getKeyword()); 主要是這行代碼
			}
			newKeywords.put(searchWord.getKeyword(), searchWord);
		}
		lastUpdateTime = maxNewUpdatedTime;
		currentKeywords = newKeywords;
	}
	
	private List<SearchWord> getSearchWords(long lastUpdateTime) {
		// TODO: 從數據庫中取出更新時間>lastUpdateTime的數據
		return null;
	}
}

重點回顧

好了,今天的內容到此就講完了。我們一塊來總結回顧一下,你需要重點掌握的內容。

1. 什麼是原型模式?

如果對象的創建成本比較大,而同一個類的不同對象之間差別不大(大部分字段都相同),在這種情況下,我們可以利用對已有對象(原型)進行復制(或者叫拷貝)的方式,來創建新對象,以達到節省創建時間的目的。這種基於原型來創建對象的方式就叫作原型設計模式,簡稱原型模式。

2. 原型模式的兩種實現方法

原型模式有兩種實現方法,深拷貝和淺拷貝。淺拷貝只會複製對象中基本數據類型數據和引用對象的內存地址,不會遞歸地複製引用對象,以及引用對象的引用對象……而深拷貝得到的是一份完完全全獨立的對象。所以,深拷貝比起淺拷貝來說,更加耗時,更加耗內存空間。

如果要拷貝的對象是不可變對象,淺拷貝共享不可變對象是沒問題的,但對於可變對象來說,淺拷貝得到的對象和原始對象會共享部分數據,就有可能出現數據被修改的風險,也就變得複雜多了。除非像我們今天實戰中舉的那個例子,需要從數據庫中加載 10萬條數據並構建散列表索引,操作非常耗時,比較推薦使用淺拷貝,否則,沒有充分的理由,不要爲了一點點的性能提升而使用淺拷貝。

課堂討論

  1. 在今天的應用場景中,如果不僅往數據庫中添加和更新關鍵詞,還刪除關鍵詞,這種情況下,又該如何實現呢?

  2. 在第 7 講中,爲了讓 ShoppingCart 的 getItems() 方法返回不可變對象,我們如下來實現代碼。當時,我們指出這樣的實現思路還是有點問題。因爲當調用者通過ShoppingCart 的 getItems() 獲取到 items 之後,我們還是可以修改容器中每個對象(ShoppingCartItem)的數據。學完本節課之後,現在你有沒有解決方法了呢?

public class ShoppingCart {
	// ...省略其他代碼...
	public List<ShoppingCartItem> getItems() {
		return Collections.unmodifiableList(this.items);
	}
}
// Testing Code in main method:
ShoppingCart cart = new ShoppingCart();
List<ShoppingCartItem> items = cart.getItems();
items.clear();//try to modify the list
// Exception in thread "main" java.lang.UnsupportedOperationExceptio

ShoppingCart cart = new ShoppingCart();
cart.add(new ShoppingCartItem(...));
List<ShoppingCartItem> items = cart.getItems();
ShoppingCartItem item = items.get(0);
item.setPrice(19.0); // 這裏修改了item的價格屬性
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章