設計模式之美 - 55 | 享元模式(下):剖析享元模式在Java Integer、String中的應用

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

設計模式之美 - 55 | 享元模式(下):剖析享元模式在Java Integer、String中的應用

上一節課,我們通過棋牌遊戲和文本編輯器這樣兩個實際的例子,學習了享元模式的原理、實現以及應用場景。用一句話總結一下,享元模式中的“享元”指被共享的單元。享元模式通過複用對象,以達到節省內存的目的。

今天,我再用一節課的時間帶你剖析一下,享元模式在 Java Integer、String 中的應用。如果你不熟悉 Java 編程語言,那也不用擔心看不懂,因爲今天的內容主要還是介紹設計思路,跟語言本身關係不大。

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

享元模式在 Java Integer 中的應用

我們先來看下面這樣一段代碼。你可以先思考下,這段代碼會輸出什麼樣的結果。

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

如果不熟悉 Java 語言,你可能會覺得,i1 和 i2 值都是 56,i3 和 i4 值都是 129,i1 跟i2 值相等,i3 跟 i4 值相等,所以輸出結果應該是兩個 true。這樣的分析是不對的,主要還是因爲你對 Java 語法不熟悉。要正確地分析上面的代碼,我們需要弄清楚下面兩個問題:

  • 如何判定兩個 Java 對象是否相等(也就代碼中的“==”操作符的含義)?

  • 什麼是自動裝箱(Autoboxing)和自動拆箱(Unboxing)?

在加餐一中,我們講到,Java 爲基本數據類型提供了對應的包裝器類型。具體如下所示:
在這裏插入圖片描述
所謂的自動裝箱,就是自動將基本數據類型轉換爲包裝器類型。所謂的自動拆箱,也就是自動將包裝器類型轉化爲基本數據類型。具體的代碼示例如下所示:

Integer i = 56; //自動裝箱
int j = i; //自動拆箱

數值 56 是基本數據類型 int,當賦值給包裝器類型(Integer)變量的時候,觸發自動裝箱操作,創建一個 Integer 類型的對象,並且賦值給變量 i。其底層相當於執行了下面這條語句:

Integer i = 59;底層執行了:Integer i = Integer.valueOf(59);

反過來,當把包裝器類型的變量 i,賦值給基本數據類型變量 j 的時候,觸發自動拆箱操作,將 i 中的數據取出,賦值給 j。其底層相當於執行了下面這條語句:

int j = i; 底層執行了:int j = i.intValue();

弄清楚了自動裝箱和自動拆箱,我們再來看,如何判定兩個對象是否相等?不過,在此之前,我們先要搞清楚,Java 對象在內存中是如何存儲的。我們通過下面這個例子來說明一下。

User a = new User(123, 23); // id=123, age=23

針對這條語句,我畫了一張內存存儲結構圖,如下所示。a 存儲的值是 User 對象的內存地址,在圖中就表現爲 a 指向 User 對象。
在這裏插入圖片描述
當我們通過“==”來判定兩個對象是否相等的時候,實際上是在判斷兩個局部變量存儲的地址是否相同,換句話說,是在判斷兩個局部變量是否指向相同的對象。

瞭解了 Java 的這幾個語法之後,我們重新看一下開頭的那段代碼。

Integer i1 = 56;
Integer i2 = 56;
Integer i3 = 129;
Integer i4 = 129;
System.out.println(i1 == i2);
System.out.println(i3 == i4);

前 4 行賦值語句都會觸發自動裝箱操作,也就是會創建 Integer 對象並且賦值給 i1、i2、i3、i4 這四個變量。根據剛剛的講解,i1、i2 儘管存儲的數值相同,都是 56,但是指向不同的 Integer 對象,所以通過“”來判定是否相同的時候,會返回 false。同理,i3i4 判定語句也會返回 false。

不過,上面的分析還是不對,答案並非是兩個 false,而是一個 true,一個 false。看到這裏,你可能會比較納悶了。實際上,這正是因爲 Integer 用到了享元模式來複用對象,才導致了這樣的運行結果。當我們通過自動裝箱,也就是調用 valueOf() 來創建Integer 對象的時候,如果要創建的 Integer 對象的值在 -128 到 127 之間,會從IntegerCache 類中直接返回,否則才調用 new 方法創建。看代碼更加清晰一些,Integer 類的 valueOf() 函數的具體代碼如下所示:

public static Integer valueOf(int i) {
	if (i >= IntegerCache.low && i <= IntegerCache.high)
		return IntegerCache.cache[i + (-IntegerCache.low)];
	return new Integer(i);
}

實際上,這裏的 IntegerCache 相當於,我們上一節課中講的生成享元對象的工廠類,只不過名字不叫 xxxFactory 而已。我們來看它的具體代碼實現。這個類是 Integer 的內部類,你也可以自行查看 JDK 源碼。

/**
* Cache to support the object identity semantics of autoboxing for values b
* -128 and 127 (inclusive) as required by JLS.
*
* The cache is initialized on first usage. The size of the cache
* may be controlled by the {@code -XX:AutoBoxCacheMax=<size>} option.
* During VM initialization, java.lang.Integer.IntegerCache.high property
* may be set and saved in the private system properties in the
* sun.misc.VM class.
*/
private static class IntegerCache {
	static final int low = -128;
	static final int high;
	static final Integer cache[];
	
	static {
		// high value may be configured by property
		int h = 127;
		String integerCacheHighPropValue =
				sun.misc.VM.getSavedProperty("java.lang.Integer.IntegerCache.hig
		if (integerCacheHighPropValue != null) {
			try {
				int i = parseInt(integerCacheHighPropValue);
				i = Math.max(i, 127);
				// Maximum array size is Integer.MAX_VALUE
				h = Math.min(i, Integer.MAX_VALUE - (-low) -1);
			} catch( NumberFormatException nfe) {
				// If the property cannot be parsed into an int, ignore it.
			}
		}
		high = h;
		
		cache = new Integer[(high - low) + 1];
		int j = low;
		for(int k = 0; k < cache.length; k++)
			cache[k] = new Integer(j++);
			
		// range [-128, 127] must be interned (JLS7 5.1.7)
		assert IntegerCache.high >= 127;
	}
	
	private IntegerCache() {}
}

爲什麼 IntegerCache 只緩存 -128 到 127 之間的整型值呢?

在 IntegerCache 的代碼實現中,當這個類被加載的時候,緩存的享元對象會被集中一次性創建好。畢竟整型值太多了,我們不可能在 IntegerCache 類中預先創建好所有的整型值,這樣既佔用太多內存,也使得加載 IntegerCache 類的時間過長。所以,我們只能選擇緩存對於大部分應用來說最常用的整型值,也就是一個字節的大小(-128 到 127 之間的數據)。

實際上,JDK 也提供了方法來讓我們可以自定義緩存的最大值,有下面兩種方式。如果你通過分析應用的 JVM 內存佔用情況,發現 -128 到 255 之間的數據佔用的內存比較多,你就可以用如下方式,將緩存的最大值從 127 調整到 255。不過,這裏注意一下,JDK 並沒有提供設置最小值的方法

//方法一:
-Djava.lang.Integer.IntegerCache.high=255
//方法二:
-XX:AutoBoxCacheMax=255

現在,讓我們再回到最開始的問題,因爲 56 處於 -128 和 127 之間,i1 和 i2 會指向相同的享元對象,所以 i1i2 返回 true。而 129 大於 127,並不會被緩存,每次都會創建一個全新的對象,也就是說,i3 和 i4 指向不同的 Integer 對象,所以 i3i4 返回 false。

實際上,除了 Integer 類型之外,其他包裝器類型,比如 Long、Short、Byte 等,也都利用了享元模式來緩存 -128 到 127 之間的數據。比如,Long 類型對應的 LongCache 享元工廠類及 valueOf() 函數代碼如下所示:

private static class LongCache {
	private LongCache(){}
	
	static final Long cache[] = new Long[-(-128) + 127 + 1];
	
	static {
		for(int i = 0; i < cache.length; i++)
			cache[i] = new Long(i - 128);
	}
}

public static Long valueOf(long l) {
	final int offset = 128;
	if (l >= -128 && l <= 127) { // will cache
		return LongCache.cache[(int)l + offset];
	}
	return new Long(l);
}

在我們平時的開發中,對於下面這樣三種創建整型對象的方式,我們優先使用後兩種。

Integer a = new Integer(123);
Integer a = 123;
Integer a = Integer.valueOf(123);

第一種創建方式並不會使用到 IntegerCache,而後面兩種創建方法可以利用 IntegerCache 緩存,返回共享的對象,以達到節省內存的目的。舉一個極端一點的例子,假設程序需要創建 1 萬個 -128 到 127 之間的 Integer 對象。使用第一種創建方式,我們需要分配 1 萬個 Integer 對象的內存空間;使用後兩種創建方式,我們最多只需要分配 256 個 Integer 對象的內存空間。

享元模式在 Java String 中的應用

剛剛我們講了享元模式在 Java Integer 類中的應用,現在,我們再來看下,享元模式在Java String 類中的應用。同樣,我們還是先來看一段代碼,你覺得這段代碼輸出的結果是什麼呢?

String s1 = "小爭哥";
String s2 = "小爭哥";
String s3 = new String("小爭哥");

System.out.println(s1 == s2);
System.out.println(s1 == s3);

上面代碼的運行結果是:一個 true,一個 false。跟 Integer 類的設計思路相似,String類利用享元模式來複用相同的字符串常量(也就是代碼中的“小爭哥”)。JVM 會專門開闢一塊存儲區來存儲字符串常量,這塊存儲區叫作“字符串常量池”。上面代碼對應的內存存儲結構如下所示:
在這裏插入圖片描述
不過,String 類的享元模式的設計,跟 Integer 類稍微有些不同。Integer 類中要共享的對象,是在類加載的時候,就集中一次性創建好的。但是,對於字符串來說,我們沒法事先知道要共享哪些字符串常量,所以沒辦法事先創建好,只能在某個字符串常量第一次被用到的時候,存儲到常量池中,當之後再用到的時候,直接引用常量池中已經存在的即可,就不需要再重新創建了。

重點回顧

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

在 Java Integer 的實現中,-128 到 127 之間的整型對象會被事先創建好,緩存在IntegerCache 類中。當我們使用自動裝箱或者 valueOf() 來創建這個數值區間的整型對象時,會複用 IntegerCache 類事先創建好的對象。這裏的 IntegerCache 類就是享元工廠類,事先創建好的整型對象就是享元對象。

在 Java String 類的實現中,JVM 開闢一塊存儲區專門存儲字符串常量,這塊存儲區叫作字符串常量池,類似於 Integer 中的 IntegerCache。不過,跟 IntegerCache 不同的是,它並非事先創建好需要共享的對象,而是在程序的運行期間,根據需要來創建和緩存字符串常量。

除此之外,這裏我再補充強調一下。

實際上,享元模式對 JVM 的垃圾回收並不友好。因爲享元工廠類一直保存了對享元對象的引用,這就導致享元對象在沒有任何代碼使用的情況下,也並不會被 JVM 垃圾回收機制自動回收掉。因此,在某些情況下,如果對象的生命週期很短,也不會被密集使用,利用享元模式反倒可能會浪費更多的內存。所以,除非經過線上驗證,利用享元模式真的可以大大節省內存,否則,就不要過度使用這個模式,爲了一點點內存的節省而引入一個複雜的設計模式,得不償失啊。

課堂討論

IntegerCache 只能緩存事先指定好的整型對象,那我們是否可以借鑑 String 的設計思路,不事先指定需要緩存哪些整型對象,而是在程序的運行過程中,當用到某個整型對象的時候,創建好放置到 IntegerCache,下次再被用到的時候,直接從 IntegerCache中返回呢?

如果可以這麼做,請你按照這個思路重新實現一下 IntegerCache 類,並且能夠做到在某個對象沒有任何代碼使用的時候,能被 JVM 垃圾回收機制回收掉。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章